fix watch.py labels churn, edges/links schema, shrink-check duplication, and skill.md ID edge cases

This commit is contained in:
Safi
2026-05-14 00:29:54 +01:00
parent 7d1c1096bc
commit f7c9a9ad82
3 changed files with 42 additions and 32 deletions
+1 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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"])