diff --git a/graphify/skill.md b/graphify/skill.md index 4f90c0c1e..228af3837 100644 --- a/graphify/skill.md +++ b/graphify/skill.md @@ -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} diff --git a/graphify/watch.py b/graphify/watch.py index 5fe3f24e7..c01734245 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -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 diff --git a/tests/test_cli_export.py b/tests/test_cli_export.py index 35cfa6453..3125ab822 100644 --- a/tests/test_cli_export.py +++ b/tests/test_cli_export.py @@ -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"])