mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-24 06:26:11 +00:00
fix watch.py labels churn, edges/links schema, shrink-check duplication, and skill.md ID edge cases
This commit is contained in:
+1
-1
@@ -357,7 +357,7 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
|
||||
the edge AMBIGUOUS rather than picking 0.4 or below.
|
||||
- AMBIGUOUS edges: 0.1-0.3
|
||||
|
||||
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is `{parent_dir}_{filename_without_ext}` (the immediate parent directory name + the filename stem, both lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Examples: `src/auth/session.py` + `ValidateToken` → `auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or the full path (e.g., `src_auth_session_validatetoken`) will create orphan ghost-duplicate nodes. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
|
||||
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is `{parent_dir}_{filename_without_ext}` (the **immediate** parent directory name + the filename stem, both lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Only one level of parent is used — not the full path. Examples: `src/auth/session.py` + `ValidateToken` → `auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or the full path (e.g., `src_auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project that had ghost duplicates under the old format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
|
||||
|
||||
Generate the extraction JSON matching this schema exactly:
|
||||
{"nodes":[{"id":"session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"relative/path","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"relative/path","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":"relative/path"}],"input_tokens":0,"output_tokens":0}
|
||||
|
||||
+40
-30
@@ -170,6 +170,12 @@ def _canonical_topology_for_compare(graph_data: dict) -> dict:
|
||||
if not isinstance(edge, dict):
|
||||
continue
|
||||
e = dict(edge)
|
||||
# to_json writes _src/_tgt as the canonical directed endpoints and
|
||||
# overwrites source/target with them before serialising, so the
|
||||
# on-disk graph has no _src/_tgt. The candidate topology (fresh from
|
||||
# node_link_data) still has them. Popping and reassigning here makes
|
||||
# both sides comparable: existing gets no-op pops (None), candidate
|
||||
# gets source/target overwritten from _src/_tgt — same result.
|
||||
true_src = e.pop("_src", None)
|
||||
true_tgt = e.pop("_tgt", None)
|
||||
if true_src is not None and true_tgt is not None:
|
||||
@@ -202,6 +208,29 @@ def _topology_from_graph(G) -> dict:
|
||||
return data
|
||||
|
||||
|
||||
def _check_shrink(force: bool, existing_data: dict, new_data: dict, tmp: "Path | None" = None) -> bool:
|
||||
"""Return True (ok to proceed) or False (shrink refused).
|
||||
|
||||
When False, cleans up *tmp* if provided and prints a warning to stderr.
|
||||
"""
|
||||
if force or not existing_data:
|
||||
return True
|
||||
existing_n = len(existing_data.get("nodes", []))
|
||||
new_n = len(new_data.get("nodes", []))
|
||||
if new_n < existing_n:
|
||||
if tmp is not None:
|
||||
tmp.unlink(missing_ok=True)
|
||||
print(
|
||||
f"[graphify] WARNING: new graph has {new_n} nodes but existing "
|
||||
f"graph.json has {existing_n}. Refusing to overwrite — you may be "
|
||||
f"missing chunk files from a previous session. "
|
||||
f"Pass --force to override.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _report_for_compare(report_text: str) -> str:
|
||||
return re.sub(r"^- Built from commit: `[^`]+`\n?", "", report_text, flags=re.MULTILINE)
|
||||
|
||||
@@ -363,13 +392,16 @@ def _rebuild_code(
|
||||
(out / ".graphify_root").write_text(str(watch_root), encoding="utf-8")
|
||||
|
||||
if no_cluster:
|
||||
candidate_graph_data = dict(result)
|
||||
# Normalise to "links" key so schema is consistent with the full clustered path.
|
||||
candidate_graph_data = {
|
||||
**{k: v for k, v in result.items() if k != "edges"},
|
||||
"links": result.get("edges", []),
|
||||
}
|
||||
candidate_graph_text = _json_text(candidate_graph_data)
|
||||
existing_text = existing_graph.read_text(encoding="utf-8") if existing_graph.exists() else ""
|
||||
same_graph = False
|
||||
if existing_graph.exists():
|
||||
try:
|
||||
existing_payload = json.loads(existing_text)
|
||||
existing_payload = json.loads(existing_graph.read_text(encoding="utf-8"))
|
||||
same_graph = (
|
||||
json.dumps(_canonical_graph_for_compare(existing_payload), sort_keys=True, ensure_ascii=False)
|
||||
== json.dumps(_canonical_graph_for_compare(candidate_graph_data), sort_keys=True, ensure_ascii=False)
|
||||
@@ -377,18 +409,8 @@ def _rebuild_code(
|
||||
except Exception:
|
||||
same_graph = False
|
||||
if not same_graph:
|
||||
if (not force) and existing_graph_data:
|
||||
existing_n = len(existing_graph_data.get("nodes", []))
|
||||
new_n = len(candidate_graph_data.get("nodes", []))
|
||||
if new_n < existing_n:
|
||||
print(
|
||||
f"[graphify] WARNING: new graph has {new_n} nodes but existing "
|
||||
f"graph.json has {existing_n}. Refusing to overwrite — you may be "
|
||||
f"missing chunk files from a previous session. "
|
||||
f"Pass force=True to override.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
if not _check_shrink(force, existing_graph_data, candidate_graph_data):
|
||||
return False
|
||||
existing_graph.write_text(candidate_graph_text, encoding="utf-8")
|
||||
|
||||
try:
|
||||
@@ -487,23 +509,11 @@ def _rebuild_code(
|
||||
graph_tmp.unlink(missing_ok=True)
|
||||
print("[graphify watch] No code-graph changes detected; graph.json/GRAPH_REPORT.md left untouched.")
|
||||
else:
|
||||
if (not force) and existing_graph_data:
|
||||
existing_n = len(existing_graph_data.get("nodes", []))
|
||||
new_n = len(candidate_graph_data.get("nodes", []))
|
||||
if new_n < existing_n:
|
||||
graph_tmp.unlink(missing_ok=True)
|
||||
print(
|
||||
f"[graphify] WARNING: new graph has {new_n} nodes but existing "
|
||||
f"graph.json has {existing_n}. Refusing to overwrite — you may be "
|
||||
f"missing chunk files from a previous session. "
|
||||
f"Pass force=True to override.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
if not _check_shrink(force, existing_graph_data, candidate_graph_data, tmp=graph_tmp):
|
||||
return False
|
||||
graph_tmp.replace(existing_graph)
|
||||
report_path.write_text(report, encoding="utf-8")
|
||||
|
||||
labels_file.write_text(labels_json, encoding="utf-8")
|
||||
labels_file.write_text(labels_json, encoding="utf-8")
|
||||
|
||||
try:
|
||||
from graphify.detect import save_manifest
|
||||
|
||||
@@ -248,5 +248,5 @@ def test_update_no_cluster_writes_raw_graph(tmp_path):
|
||||
graph_path = tmp_path / "graphify-out" / "graph.json"
|
||||
assert graph_path.exists()
|
||||
data = json.loads(graph_path.read_text(encoding="utf-8"))
|
||||
assert "nodes" in data and "edges" in data
|
||||
assert "nodes" in data and "links" in data
|
||||
assert all("community" not in node for node in data["nodes"])
|
||||
|
||||
Reference in New Issue
Block a user