mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-30 18:25:50 +00:00
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.
376 lines
16 KiB
Python
376 lines
16 KiB
Python
"""`graphify extract --code-only` indexes code without an LLM key (#1734).
|
|
|
|
A mixed repo (code + docs) with no API key configured used to hard-fail on the
|
|
doc/paper/image files. `--code-only` skips the semantic pass so the code graph
|
|
still builds, and the no-key error now points users at the flag.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
PYTHON = sys.executable
|
|
_KEY_VARS = ("GEMINI_API_KEY", "GOOGLE_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL",
|
|
"ANTHROPIC_API_KEY", "MOONSHOT_API_KEY", "DEEPSEEK_API_KEY")
|
|
|
|
|
|
def _mixed_repo(tmp_path: Path) -> Path:
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
(repo / "app.py").write_text("def hello():\n return 1\n")
|
|
(repo / "README.md").write_text("# Design\n\nHow it works.\n")
|
|
(repo / "NOTES.txt").write_text("Architecture notes and rationale.\n")
|
|
return repo
|
|
|
|
|
|
def _run(repo: Path, *extra: str):
|
|
env = {k: v for k, v in os.environ.items() if k not in _KEY_VARS}
|
|
env["GRAPHIFY_OUT"] = str(repo / "graphify-out")
|
|
return subprocess.run(
|
|
[PYTHON, "-m", "graphify", "extract", ".", *extra],
|
|
cwd=repo, capture_output=True, text=True, env=env,
|
|
)
|
|
|
|
|
|
def test_code_only_succeeds_without_key(tmp_path):
|
|
repo = _mixed_repo(tmp_path)
|
|
r = _run(repo, "--code-only")
|
|
assert r.returncode == 0, f"--code-only should succeed with no key: {r.stderr}"
|
|
out = r.stdout + r.stderr
|
|
assert "--code-only: skipping" in out
|
|
graph = repo / "graphify-out" / "graph.json"
|
|
assert graph.exists(), "code graph must still be written"
|
|
import json
|
|
g = json.loads(graph.read_text())
|
|
labels = [n.get("label") for n in g["nodes"]]
|
|
assert any(str(l).startswith("hello") for l in labels), "code was indexed"
|
|
|
|
|
|
def test_mixed_repo_without_key_errors_and_points_at_code_only(tmp_path):
|
|
repo = _mixed_repo(tmp_path)
|
|
r = _run(repo) # no --code-only, no key
|
|
assert r.returncode != 0, "mixed repo with no key should still error without the flag"
|
|
assert "--code-only" in r.stderr, "the no-key error must point users at --code-only"
|
|
|
|
|
|
def test_extract_usage_advertises_code_only(tmp_path):
|
|
"""#2071: --code-only must be discoverable in the extract usage text, not only
|
|
by triggering the no-key error. `graphify extract` with no path prints usage."""
|
|
r = subprocess.run(
|
|
[PYTHON, "-m", "graphify", "extract"],
|
|
cwd=tmp_path, capture_output=True, text=True,
|
|
)
|
|
assert r.returncode != 0
|
|
assert "--code-only" in r.stdout + r.stderr, (
|
|
"extract usage must advertise --code-only (#2071)"
|
|
)
|
|
|
|
|
|
def _run_relative_out(repo: Path, *extra: str):
|
|
"""Like _run but with a RELATIVE GRAPHIFY_OUT so --out/--output controls the
|
|
parent dir (an absolute GRAPHIFY_OUT would override the flag)."""
|
|
env = {k: v for k, v in os.environ.items() if k not in _KEY_VARS}
|
|
env["GRAPHIFY_OUT"] = "graphify-out"
|
|
return subprocess.run(
|
|
[PYTHON, "-m", "graphify", "extract", ".", *extra],
|
|
cwd=repo, capture_output=True, text=True, env=env,
|
|
)
|
|
|
|
|
|
def test_output_flag_is_alias_of_out(tmp_path):
|
|
"""#2004 part 3: `--output DIR` was silently ignored on extract (output went
|
|
to the default `<path>/graphify-out/`). It is now an alias of `--out`."""
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
(repo / "app.py").write_text("def hello():\n return 1\n")
|
|
custom = tmp_path / "elsewhere"
|
|
|
|
r = _run_relative_out(repo, "--code-only", "--no-cluster", "--output", str(custom))
|
|
assert r.returncode == 0, r.stderr
|
|
assert (custom / "graphify-out" / "graph.json").exists(), "--output was ignored (#2004)"
|
|
assert not (repo / "graphify-out").exists(), "output must not go to the default dir"
|
|
|
|
|
|
def test_output_flag_inline_form(tmp_path):
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
(repo / "app.py").write_text("def hello():\n return 1\n")
|
|
custom = tmp_path / "out2"
|
|
r = _run_relative_out(repo, "--code-only", "--no-cluster", f"--output={custom}")
|
|
assert r.returncode == 0, r.stderr
|
|
assert (custom / "graphify-out" / "graph.json").exists()
|
|
|
|
|
|
def test_no_gitignore_indexes_vcs_ignored_code_but_keeps_graphifyignore(tmp_path):
|
|
repo = tmp_path / "repo"
|
|
generated = repo / "proj" / "deep" / "generated"
|
|
generated.mkdir(parents=True)
|
|
(repo / ".git" / "info").mkdir(parents=True)
|
|
(repo / ".git" / "info" / "exclude").write_text("local/\n")
|
|
(repo / "proj" / ".gitignore").write_text("generated/\n")
|
|
(repo / "proj" / ".graphifyignore").write_text("hidden/\n")
|
|
(generated / "Gen.cs").write_text("namespace N { public class Gen {} }\n")
|
|
local = repo / "local"
|
|
local.mkdir()
|
|
(local / "Local.cs").write_text("namespace N { public class Local {} }\n")
|
|
hidden = repo / "proj" / "hidden"
|
|
hidden.mkdir()
|
|
(hidden / "Hidden.cs").write_text("namespace N { public class Hidden {} }\n")
|
|
|
|
result = _run(repo, "--no-gitignore", "--no-cluster")
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
graph = json.loads((repo / "graphify-out" / "graph.json").read_text())
|
|
sources = {Path(str(node.get("source_file", ""))).as_posix() for node in graph["nodes"]}
|
|
assert any(source.endswith("proj/deep/generated/Gen.cs") for source in sources)
|
|
assert any(source.endswith("local/Local.cs") for source in sources)
|
|
assert not any(source.endswith("proj/hidden/Hidden.cs") for source in sources)
|
|
|
|
|
|
def test_no_gitignore_setting_persists_across_flagless_extract(tmp_path):
|
|
"""#1971 persistence: once --no-gitignore is set, a later flag-less
|
|
`graphify extract` must NOT clobber it back to honoring .gitignore (which
|
|
would make the git-ignored code silently disappear again)."""
|
|
repo = tmp_path / "repo"
|
|
gen = repo / "generated"
|
|
gen.mkdir(parents=True)
|
|
(repo / ".gitignore").write_text("generated/\n")
|
|
(repo / "app.py").write_text("def hello():\n return 1\n")
|
|
(gen / "Gen.py").write_text("def gen():\n return 2\n")
|
|
|
|
def _sources():
|
|
g = json.loads((repo / "graphify-out" / "graph.json").read_text())
|
|
return {Path(str(n.get("source_file", ""))).as_posix() for n in g["nodes"]}
|
|
|
|
r1 = _run(repo, "--no-gitignore", "--code-only", "--no-cluster")
|
|
assert r1.returncode == 0, r1.stderr
|
|
assert any(s.endswith("generated/Gen.py") for s in _sources())
|
|
|
|
# A plain flag-less re-extract must keep the git-ignored file (setting persisted).
|
|
r2 = _run(repo, "--code-only", "--no-cluster")
|
|
assert r2.returncode == 0, r2.stderr
|
|
assert any(s.endswith("generated/Gen.py") for s in _sources()), (
|
|
"flag-less re-extract clobbered the persisted --no-gitignore setting (#1971)"
|
|
)
|
|
|
|
|
|
def test_exclude_setting_persists_across_flagless_extract(tmp_path):
|
|
repo = tmp_path / "repo"
|
|
vendor = repo / "vendor"
|
|
vendor.mkdir(parents=True)
|
|
(repo / "app.py").write_text("def app():\n return 1\n")
|
|
(vendor / "lib.py").write_text("def vendor():\n return 2\n")
|
|
|
|
def _sources():
|
|
graph = json.loads((repo / "graphify-out" / "graph.json").read_text())
|
|
return {
|
|
Path(str(node.get("source_file", ""))).as_posix()
|
|
for node in graph["nodes"]
|
|
}
|
|
|
|
first = _run(
|
|
repo, "--exclude", "vendor", "--code-only", "--no-cluster"
|
|
)
|
|
assert first.returncode == 0, first.stderr
|
|
assert any(source.endswith("app.py") for source in _sources())
|
|
assert not any(source.endswith("vendor/lib.py") for source in _sources())
|
|
|
|
second = _run(repo, "--code-only", "--no-cluster")
|
|
assert second.returncode == 0, second.stderr
|
|
assert any(source.endswith("app.py") for source in _sources())
|
|
assert not any(source.endswith("vendor/lib.py") for source in _sources())
|
|
|
|
|
|
def test_explicit_exclude_replaces_persisted_setting_with_custom_out(tmp_path):
|
|
repo = tmp_path / "repo"
|
|
vendor = repo / "vendor"
|
|
generated = repo / "generated"
|
|
vendor.mkdir(parents=True)
|
|
generated.mkdir()
|
|
(repo / "app.py").write_text("def app():\n return 1\n")
|
|
(vendor / "lib.py").write_text("def vendor():\n return 2\n")
|
|
(generated / "gen.py").write_text("def generated():\n return 3\n")
|
|
out_root = tmp_path / "custom-output"
|
|
|
|
env = {key: value for key, value in os.environ.items() if key not in _KEY_VARS}
|
|
env["GRAPHIFY_OUT"] = "graphify-out"
|
|
|
|
def _run_extract(*extra: str):
|
|
return subprocess.run(
|
|
[
|
|
PYTHON,
|
|
"-m",
|
|
"graphify",
|
|
"extract",
|
|
".",
|
|
"--out",
|
|
str(out_root),
|
|
"--code-only",
|
|
"--no-cluster",
|
|
*extra,
|
|
],
|
|
cwd=repo,
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
|
|
first = _run_extract("--exclude", "vendor")
|
|
assert first.returncode == 0, first.stderr
|
|
|
|
graph_out = out_root / "graphify-out"
|
|
def _sources():
|
|
graph = json.loads((graph_out / "graph.json").read_text())
|
|
return {
|
|
Path(str(node.get("source_file", ""))).as_posix()
|
|
for node in graph["nodes"]
|
|
}
|
|
|
|
persisted = _run_extract("--force")
|
|
assert persisted.returncode == 0, persisted.stderr
|
|
assert any(source.endswith("app.py") for source in _sources())
|
|
assert not any(source.endswith("vendor/lib.py") for source in _sources())
|
|
assert any(source.endswith("generated/gen.py") for source in _sources())
|
|
|
|
replacement = _run_extract("--exclude", "generated", "--force")
|
|
assert replacement.returncode == 0, replacement.stderr
|
|
sources = _sources()
|
|
assert any(source.endswith("app.py") for source in sources)
|
|
assert any(source.endswith("vendor/lib.py") for source in sources)
|
|
assert not any(source.endswith("generated/gen.py") for source in sources)
|
|
assert json.loads((graph_out / ".graphify_build.json").read_text()) == {
|
|
"excludes": ["generated"]
|
|
}
|
|
|
|
|
|
def test_extract_names_skipped_sensitive_files(tmp_path):
|
|
"""#2106 traceability: a file dropped by the sensitive-file filter is reported
|
|
by NAME (not just a count), so a wrongly-flagged file is visible."""
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
(repo / "app.py").write_text("def hello():\n return 1\n")
|
|
(repo / "github_token.txt").write_text("ghp_secretvalue\n") # real secret -> skipped
|
|
r = _run(repo, "--code-only", "--no-cluster")
|
|
assert r.returncode == 0, r.stderr
|
|
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)"
|
|
)
|