mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-04 04:35:50 +00:00
v0.6.6: Windows skill bash rewrite, wiki fixes, rationale-node fix, hidden allowlist, --no-viz cluster-only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
d40e1c0cef
commit
36e894aa62
@@ -2,6 +2,19 @@
|
||||
|
||||
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
|
||||
|
||||
## 0.6.6 (2026-05-02)
|
||||
|
||||
- Fix: `skill-windows.md` rewritten from PowerShell to bash — Claude Code on Windows uses git-bash so PowerShell syntax (`$null`, `$LASTEXITCODE`, `Select-Object`, `& (Get-Content ...)`, `Remove-Item`) caused exit code 49 failures; now mirrors `skill.md` structure with `python` added as fallback after `python3` for Windows Conda (#39)
|
||||
- Fix: wiki `to_wiki()` now clears stale articles before regenerating, preventing orphan .md accumulation (#558)
|
||||
- Fix: `_safe_filename()` in wiki.py now strips Windows-reserved characters (`< > : " / \ | ? *`) and caps length at 200 chars (#594)
|
||||
- Fix: rationale-node leakage in cross-file INFERRED call resolution — rationale nodes now excluded from name lookup; edge direction (`calls`, `rationale_for`) preserved correctly at JSON export (#576)
|
||||
- Feat: `.graphifyinclude` hidden path allowlist — opt specific hidden dirs into traversal (e.g. `.hermes/plans/**/*.md`) (#583)
|
||||
- Feat: `--no-viz` flag wired in `cluster-only`; `GRAPHIFY_VIZ_NODE_LIMIT` env var overrides 5000-node HTML threshold (#565)
|
||||
- Fix: stray colon SyntaxError in `skill-trae.md` `--cluster-only` block (#603)
|
||||
- Docs: skill INFERRED confidence score guidance changed to discrete rubric (0.55/0.65/0.75/0.85/0.95) backed by calibration data (#546)
|
||||
- Docs: skill `--update` prune output clarified — splits no-drift vs drift cases (#544)
|
||||
- Docs: skill `--update` merge step now calls `save_manifest` to prevent deleted files reappearing (#545)
|
||||
|
||||
## 0.6.5 (2026-05-02)
|
||||
|
||||
- Fix: Kotlin call-walker now accepts both `simple_identifier` and `identifier` node types — PyPI's `tree_sitter_kotlin` grammar uses `identifier` while older forks use `simple_identifier`, causing zero `calls` edges to be emitted (#659)
|
||||
|
||||
+21
-5
@@ -1008,6 +1008,7 @@ def main() -> None:
|
||||
print(" --force overwrite graph.json even if the rebuild has fewer nodes")
|
||||
print(" (also: GRAPHIFY_FORCE=1 env var; use after refactors that delete code)")
|
||||
print(" cluster-only <path> rerun clustering on an existing graph.json and regenerate report")
|
||||
print(" --no-viz skip graph.html generation (useful for >5000 node graphs / CI)")
|
||||
print(" query \"<question>\" BFS traversal of graph.json for a question")
|
||||
print(" --dfs use depth-first instead of breadth-first")
|
||||
print(" --budget N cap output at N tokens (default 2000)")
|
||||
@@ -1385,6 +1386,7 @@ def main() -> None:
|
||||
|
||||
elif cmd == "cluster-only":
|
||||
watch_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(".")
|
||||
no_viz = "--no-viz" in sys.argv
|
||||
graph_json = watch_path / "graphify-out" / "graph.json"
|
||||
if not graph_json.exists():
|
||||
print(f"error: no graph found at {graph_json} — run /graphify first", file=sys.stderr)
|
||||
@@ -1414,11 +1416,25 @@ def main() -> None:
|
||||
out = watch_path / "graphify-out"
|
||||
(out / "GRAPH_REPORT.md").write_text(report, encoding="utf-8")
|
||||
to_json(G, communities, str(out / "graph.json"))
|
||||
try:
|
||||
to_html(G, communities, str(out / "graph.html"), community_labels=labels or None)
|
||||
except ValueError as _viz_err:
|
||||
print(f"[graphify] Skipped graph.html: {_viz_err}")
|
||||
print(f"Done — {len(communities)} communities. GRAPH_REPORT.md, graph.json and graph.html updated.")
|
||||
|
||||
# Mirror watch.py pattern: gate to_html so core outputs (graph.json +
|
||||
# GRAPH_REPORT.md) always land. Honor --no-viz explicitly; otherwise
|
||||
# fall back to ValueError handling so an oversized graph doesn't crash
|
||||
# the CLI mid-write and leave a stale graph.html on disk.
|
||||
html_target = out / "graph.html"
|
||||
if no_viz:
|
||||
if html_target.exists():
|
||||
html_target.unlink()
|
||||
print(f"Done — {len(communities)} communities. GRAPH_REPORT.md and graph.json updated (--no-viz; graph.html removed).")
|
||||
else:
|
||||
try:
|
||||
to_html(G, communities, str(html_target), community_labels=labels or None)
|
||||
print(f"Done — {len(communities)} communities. GRAPH_REPORT.md, graph.json and graph.html updated.")
|
||||
except ValueError as viz_err:
|
||||
if html_target.exists():
|
||||
html_target.unlink()
|
||||
print(f"Skipped graph.html: {viz_err}")
|
||||
print(f"Done — {len(communities)} communities. GRAPH_REPORT.md and graph.json updated.")
|
||||
|
||||
elif cmd == "update":
|
||||
force = os.environ.get("GRAPHIFY_FORCE", "").lower() in ("1", "true", "yes")
|
||||
|
||||
+117
-4
@@ -509,6 +509,115 @@ def _is_ignored(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> boo
|
||||
return result
|
||||
|
||||
|
||||
def _load_graphifyinclude(root: Path) -> list[tuple[Path, str]]:
|
||||
"""Read .graphifyinclude allowlist patterns from root and ancestors.
|
||||
|
||||
Include patterns opt matching hidden files/dirs into traversal. Sensitive
|
||||
files and hard-skipped noise directories are still excluded later.
|
||||
Uses the same VCS-root ceiling logic as _load_graphifyignore.
|
||||
"""
|
||||
root = root.resolve()
|
||||
ceiling = _find_vcs_root(root) or root
|
||||
|
||||
dirs: list[Path] = []
|
||||
current = root
|
||||
while True:
|
||||
dirs.append(current)
|
||||
if current == ceiling:
|
||||
break
|
||||
current = current.parent
|
||||
dirs.reverse()
|
||||
|
||||
patterns: list[tuple[Path, str]] = []
|
||||
for d in dirs:
|
||||
include_file = d / ".graphifyinclude"
|
||||
if include_file.exists():
|
||||
for raw in include_file.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
line = _parse_gitignore_line(raw)
|
||||
if line:
|
||||
patterns.append((d, line))
|
||||
return patterns
|
||||
|
||||
|
||||
def _is_included(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> bool:
|
||||
"""Return True if path matches any .graphifyinclude allowlist pattern."""
|
||||
if not patterns:
|
||||
return False
|
||||
|
||||
def _matches(rel: str, p: str) -> bool:
|
||||
parts = rel.split("/")
|
||||
if fnmatch.fnmatch(rel, p):
|
||||
return True
|
||||
if fnmatch.fnmatch(path.name, p):
|
||||
return True
|
||||
for i, part in enumerate(parts):
|
||||
if fnmatch.fnmatch(part, p):
|
||||
return True
|
||||
if fnmatch.fnmatch("/".join(parts[:i + 1]), p):
|
||||
return True
|
||||
return False
|
||||
|
||||
for anchor, pattern in patterns:
|
||||
anchored = pattern.startswith("/")
|
||||
p = pattern.strip("/")
|
||||
if not p:
|
||||
continue
|
||||
if anchored:
|
||||
try:
|
||||
rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/")
|
||||
if _matches(rel_anchor, p):
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
rel = str(path.relative_to(root)).replace(os.sep, "/")
|
||||
if _matches(rel, p):
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
if anchor != root:
|
||||
try:
|
||||
rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/")
|
||||
if _matches(rel_anchor, p):
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _could_contain_included_path(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> bool:
|
||||
"""Return True if a directory may contain files matched by .graphifyinclude."""
|
||||
if not patterns:
|
||||
return False
|
||||
|
||||
rels: list[str] = []
|
||||
try:
|
||||
rels.append(str(path.relative_to(root)).replace(os.sep, "/"))
|
||||
except ValueError:
|
||||
pass
|
||||
for anchor, _ in patterns:
|
||||
if anchor != root:
|
||||
try:
|
||||
rels.append(str(path.relative_to(anchor)).replace(os.sep, "/"))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
for rel in rels:
|
||||
rel = rel.strip("/")
|
||||
if not rel:
|
||||
return True
|
||||
for _, pattern in patterns:
|
||||
p = pattern.strip("/")
|
||||
if not p:
|
||||
continue
|
||||
if p == rel or p.startswith(rel + "/"):
|
||||
return True
|
||||
if fnmatch.fnmatch(rel, p):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def detect(root: Path, *, follow_symlinks: bool = False) -> dict:
|
||||
root = root.resolve()
|
||||
files: dict[FileType, list[str]] = {
|
||||
@@ -522,6 +631,7 @@ def detect(root: Path, *, follow_symlinks: bool = False) -> dict:
|
||||
|
||||
skipped_sensitive: list[str] = []
|
||||
ignore_patterns = _load_graphifyignore(root)
|
||||
include_patterns = _load_graphifyinclude(root)
|
||||
|
||||
# Always include graphify-out/memory/ - query results filed back into the graph
|
||||
memory_dir = root / "graphify-out" / "memory"
|
||||
@@ -543,10 +653,12 @@ def detect(root: Path, *, follow_symlinks: bool = False) -> dict:
|
||||
dirnames.clear()
|
||||
continue
|
||||
if not in_memory_tree:
|
||||
# Prune noise dirs in-place so os.walk never descends into them
|
||||
# Prune noise dirs in-place so os.walk never descends into them.
|
||||
# Hidden dirs are allowed through if they could contain an
|
||||
# explicitly included path (.graphifyinclude allowlist).
|
||||
dirnames[:] = [
|
||||
d for d in dirnames
|
||||
if not d.startswith(".")
|
||||
if (not d.startswith(".") or _could_contain_included_path(dp / d, root, include_patterns))
|
||||
and not _is_noise_dir(d)
|
||||
and not _is_ignored(dp / d, root, ignore_patterns)
|
||||
]
|
||||
@@ -565,8 +677,9 @@ def detect(root: Path, *, follow_symlinks: bool = False) -> dict:
|
||||
in_memory = memory_dir.exists() and str(p).startswith(str(memory_dir))
|
||||
if not in_memory:
|
||||
# Hidden files are already excluded via dir pruning above,
|
||||
# but catch hidden files at the root level
|
||||
if p.name.startswith("."):
|
||||
# but catch hidden files at the root level. A .graphifyinclude
|
||||
# entry can opt a specific hidden file back in.
|
||||
if p.name.startswith(".") and not _is_included(p, root, include_patterns):
|
||||
continue
|
||||
# Skip files inside our own converted/ dir (avoid re-processing sidecars)
|
||||
if str(p).startswith(str(converted_dir)):
|
||||
|
||||
+38
-6
@@ -25,6 +25,22 @@ COMMUNITY_COLORS = [
|
||||
MAX_NODES_FOR_VIZ = 5_000
|
||||
|
||||
|
||||
def _viz_node_limit() -> int:
|
||||
"""Return the effective viz node limit, honoring GRAPHIFY_VIZ_NODE_LIMIT env var.
|
||||
|
||||
Falls back to MAX_NODES_FOR_VIZ when the env var is unset, empty, or non-integer.
|
||||
Set to 0 to disable HTML viz unconditionally (useful for CI runners).
|
||||
"""
|
||||
import os
|
||||
raw = os.environ.get("GRAPHIFY_VIZ_NODE_LIMIT")
|
||||
if raw is None or not raw.strip():
|
||||
return MAX_NODES_FOR_VIZ
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return MAX_NODES_FOR_VIZ
|
||||
|
||||
|
||||
def _html_styles() -> str:
|
||||
return """<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@@ -357,6 +373,15 @@ def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *,
|
||||
if "confidence_score" not in link:
|
||||
conf = link.get("confidence", "EXTRACTED")
|
||||
link["confidence_score"] = _CONFIDENCE_SCORE_DEFAULTS.get(conf, 1.0)
|
||||
# Restore original edge direction. Undirected NetworkX storage may
|
||||
# canonicalize endpoint order, flipping `calls` and other directional
|
||||
# edges in graph.json. The build path stashes the true endpoints in
|
||||
# _src/_tgt for exactly this purpose (#563).
|
||||
true_src = link.pop("_src", None)
|
||||
true_tgt = link.pop("_tgt", None)
|
||||
if true_src is not None and true_tgt is not None:
|
||||
link["source"] = true_src
|
||||
link["target"] = true_tgt
|
||||
data["hyperedges"] = getattr(G, "graph", {}).get("hyperedges", [])
|
||||
with open(output_path, "w", encoding="utf-8") as f: # nosec
|
||||
json.dump(data, f, indent=2)
|
||||
@@ -421,10 +446,12 @@ def to_html(
|
||||
If member_counts is provided (aggregated community view), node sizes are
|
||||
based on community member counts rather than graph degree.
|
||||
"""
|
||||
if G.number_of_nodes() > MAX_NODES_FOR_VIZ:
|
||||
limit = _viz_node_limit()
|
||||
if G.number_of_nodes() > limit:
|
||||
raise ValueError(
|
||||
f"Graph has {G.number_of_nodes()} nodes - too large for HTML viz. "
|
||||
f"Use --no-viz or reduce input size."
|
||||
f"Graph has {G.number_of_nodes()} nodes - too large for HTML viz "
|
||||
f"(limit: {limit}). Use --no-viz, raise GRAPHIFY_VIZ_NODE_LIMIT, "
|
||||
f"or reduce input size."
|
||||
)
|
||||
|
||||
node_community = _node_community_map(communities)
|
||||
@@ -461,14 +488,19 @@ def to_html(
|
||||
"degree": deg,
|
||||
})
|
||||
|
||||
# Build edges list
|
||||
# Build edges list. Restore original edge direction from _src/_tgt
|
||||
# (stashed by build.py for exactly this reason): undirected NetworkX
|
||||
# canonicalizes endpoint order, which would otherwise flip the arrow
|
||||
# for `calls` and `rationale_for` in the rendered graph (#563).
|
||||
vis_edges = []
|
||||
for u, v, data in G.edges(data=True):
|
||||
confidence = data.get("confidence", "EXTRACTED")
|
||||
relation = data.get("relation", "")
|
||||
true_src = data.get("_src", u)
|
||||
true_tgt = data.get("_tgt", v)
|
||||
vis_edges.append({
|
||||
"from": u,
|
||||
"to": v,
|
||||
"from": true_src,
|
||||
"to": true_tgt,
|
||||
"label": relation,
|
||||
"title": _html.escape(f"{relation} [{confidence}]"),
|
||||
"dashes": confidence != "EXTRACTED",
|
||||
|
||||
+20
-3
@@ -2838,8 +2838,17 @@ def _resolve_cross_file_imports(
|
||||
stem = Path(src).stem
|
||||
label = node.get("label", "")
|
||||
nid = node.get("id", "")
|
||||
# Only index real classes/functions (not file nodes, not method stubs)
|
||||
if label and not label.endswith((")", ".py")) and "_" not in label[:1]:
|
||||
# Index class-level entities only. Function/method labels end in "()"
|
||||
# so are excluded by the `endswith(")")` filter; file nodes end in ".py";
|
||||
# private/internal labels start with "_"; rationale nodes carry
|
||||
# file_type=="rationale" and must never participate in cross-file
|
||||
# import resolution (#563).
|
||||
if (
|
||||
label
|
||||
and not label.endswith((")", ".py"))
|
||||
and "_" not in label[:1]
|
||||
and node.get("file_type") != "rationale"
|
||||
):
|
||||
stem_to_entities.setdefault(stem, {})[label] = nid
|
||||
|
||||
# Pass 2: for each file, find `from .X import A, B, C` and resolve
|
||||
@@ -2850,12 +2859,15 @@ def _resolve_cross_file_imports(
|
||||
stem = _file_stem(path)
|
||||
str_path = str(path)
|
||||
|
||||
# Find all classes defined in this file (the importers)
|
||||
# Find all classes defined in this file (the importers).
|
||||
# Excludes rationale nodes whose labels happen not to end in ")" or ".py"
|
||||
# but which must never be treated as importing entities (#563).
|
||||
local_classes = [
|
||||
n["id"] for n in file_result.get("nodes", [])
|
||||
if n.get("source_file") == str_path
|
||||
and not n["label"].endswith((")", ".py"))
|
||||
and n["id"] != _make_id(stem) # exclude file-level node
|
||||
and n.get("file_type") != "rationale"
|
||||
]
|
||||
if not local_classes:
|
||||
continue
|
||||
@@ -3578,8 +3590,13 @@ def extract(paths: list[Path], cache_root: Path | None = None) -> dict:
|
||||
# Build name → ALL matching node IDs so we can skip ambiguous common names
|
||||
# (e.g. "log", "execute", "find") that appear in multiple files — resolving
|
||||
# those inflates god_nodes ranking with spurious cross-file edges.
|
||||
# Build label -> node_id index for cross-file call resolution.
|
||||
# Skip rationale nodes (their labels are docstring text, not callable
|
||||
# identifiers, and they were polluting matches for short names — #563).
|
||||
global_label_to_nids: dict[str, list[str]] = {}
|
||||
for n in all_nodes:
|
||||
if n.get("file_type") == "rationale":
|
||||
continue
|
||||
raw = n.get("label", "")
|
||||
normalised = raw.strip("()").lstrip(".")
|
||||
if normalised:
|
||||
|
||||
@@ -826,7 +826,7 @@ G = json_graph.node_link_graph(data, edges='links')
|
||||
|
||||
detection = {'total_files': 0, 'total_words': 99999, 'needs_graph': True, 'warning': None,
|
||||
'files': {'code': [], 'document': [], 'paper': []}}
|
||||
tokens = {'input': 0, 'output':': 0}
|
||||
tokens = {'input': 0, 'output': 0}
|
||||
|
||||
communities = cluster(G)
|
||||
cohesion = score_all(G, communities)
|
||||
|
||||
@@ -279,10 +279,16 @@ If a file has YAML frontmatter (--- ... ---), copy source_url, captured_at, auth
|
||||
|
||||
confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a default:
|
||||
- EXTRACTED edges: confidence_score = 1.0 always
|
||||
- INFERRED edges: reason about each edge individually.
|
||||
Direct structural evidence (shared data structure, clear dependency): 0.8-0.9.
|
||||
Reasonable inference with some uncertainty: 0.6-0.7.
|
||||
Weak or speculative: 0.4-0.5. Most edges should be 0.6-0.9, not 0.5.
|
||||
- INFERRED edges: pick exactly ONE value from this set — never 0.5:
|
||||
0.95 direct structural evidence (shared data structure, named cross-file reference).
|
||||
0.85 strong inference (clear functional alignment, no direct symbol link).
|
||||
0.75 reasonable inference (shared problem domain + similar shape, requires interpretation).
|
||||
0.65 weak inference (thematically related, no shape evidence).
|
||||
0.55 speculative but plausible (surface-level co-occurrence only).
|
||||
Models follow discrete rubrics better than continuous ranges; the bimodal
|
||||
distribution observed in production (>50% at 0.5, >40% at 0.85+) shows the
|
||||
range guidance is being collapsed to a binary. If no value above fits, mark
|
||||
the edge AMBIGUOUS rather than picking 0.4 or below.
|
||||
- AMBIGUOUS edges: 0.1-0.3
|
||||
|
||||
Output exactly this JSON (no other text):
|
||||
@@ -785,9 +791,28 @@ G_existing = json_graph.node_link_graph(existing_data, edges='links')
|
||||
new_extraction = json.loads(Path('.graphify_extract.json').read_text())
|
||||
G_new = build_from_json(new_extraction)
|
||||
|
||||
# Prune nodes from deleted files
|
||||
incremental = json.loads(Path('.graphify_incremental.json').read_text())
|
||||
deleted = set(incremental.get('deleted_files', []))
|
||||
if deleted:
|
||||
to_remove = [n for n, d in G_existing.nodes(data=True) if d.get('source_file') in deleted]
|
||||
G_existing.remove_nodes_from(to_remove)
|
||||
if to_remove:
|
||||
print(f'Pruned {len(to_remove)} ghost node(s) from {len(deleted)} deleted file(s) — drift detected and corrected.')
|
||||
else:
|
||||
print(f'{len(deleted)} file(s) deleted since last run, but no ghost nodes were present in the graph — no drift.')
|
||||
|
||||
# Merge: new nodes/edges into existing graph
|
||||
G_existing.update(G_new)
|
||||
print(f'Merged: {G_existing.number_of_nodes()} nodes, {G_existing.number_of_edges()} edges')
|
||||
|
||||
# Save manifest with the CURRENT full file list so the next --update
|
||||
# diffs against today's filesystem state, not the prior --update's
|
||||
# baseline. Without this, deleted files get reported as ghosts again
|
||||
# on every subsequent --update until a full rebuild runs.
|
||||
from graphify.detect import save_manifest
|
||||
save_manifest(incremental['files'])
|
||||
print('[graphify update] Manifest saved.')
|
||||
"
|
||||
```
|
||||
|
||||
|
||||
+22
-5
@@ -337,10 +337,16 @@ If a file has YAML frontmatter (--- ... ---), copy source_url, captured_at, auth
|
||||
|
||||
confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a default:
|
||||
- EXTRACTED edges: confidence_score = 1.0 always
|
||||
- INFERRED edges: reason about each edge individually.
|
||||
Direct structural evidence (shared data structure, clear dependency): 0.8-0.9.
|
||||
Reasonable inference with some uncertainty: 0.6-0.7.
|
||||
Weak or speculative: 0.4-0.5. Most edges should be 0.6-0.9, not 0.5.
|
||||
- INFERRED edges: pick exactly ONE value from this set — never 0.5:
|
||||
0.95 direct structural evidence (shared data structure, named cross-file reference).
|
||||
0.85 strong inference (clear functional alignment, no direct symbol link).
|
||||
0.75 reasonable inference (shared problem domain + similar shape, requires interpretation).
|
||||
0.65 weak inference (thematically related, no shape evidence).
|
||||
0.55 speculative but plausible (surface-level co-occurrence only).
|
||||
Models follow discrete rubrics better than continuous ranges; the bimodal
|
||||
distribution observed in production (>50% at 0.5, >40% at 0.85+) shows the
|
||||
range guidance is being collapsed to a binary. If no value above fits, mark
|
||||
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 the filename without extension and entity is the symbol name, both normalized (lowercase, non-alphanumeric chars replaced with `_`). Example: `src/auth/session.py` + `ValidateToken` → `session_validatetoken`. This must match the ID the AST extractor generates so cross-references between code and semantic nodes connect correctly. 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.
|
||||
@@ -923,7 +929,10 @@ deleted = set(incremental.get('deleted_files', []))
|
||||
if deleted:
|
||||
to_remove = [n for n, d in G_existing.nodes(data=True) if d.get('source_file') in deleted]
|
||||
G_existing.remove_nodes_from(to_remove)
|
||||
print(f'Pruned {len(to_remove)} ghost nodes from {len(deleted)} deleted file(s)')
|
||||
if to_remove:
|
||||
print(f'Pruned {len(to_remove)} ghost node(s) from {len(deleted)} deleted file(s) — drift detected and corrected.')
|
||||
else:
|
||||
print(f'{len(deleted)} file(s) deleted since last run, but no ghost nodes were present in the graph — no drift.')
|
||||
|
||||
# Merge: new nodes/edges into existing graph
|
||||
G_existing.update(G_new)
|
||||
@@ -939,6 +948,14 @@ merged_out = {
|
||||
}
|
||||
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged_out))
|
||||
print(f'[graphify update] Merged extraction written ({len(merged_out[\"nodes\"])} nodes, {len(merged_out[\"edges\"])} edges)')
|
||||
|
||||
# Save manifest with the CURRENT full file list so the next --update
|
||||
# diffs against today's filesystem state, not the prior --update's
|
||||
# baseline. Without this, deleted files get reported as ghosts again
|
||||
# on every subsequent --update until a full rebuild runs.
|
||||
from graphify.detect import save_manifest
|
||||
save_manifest(incremental['files'])
|
||||
print('[graphify update] Manifest saved.')
|
||||
"
|
||||
```
|
||||
|
||||
|
||||
+21
-1
@@ -7,7 +7,18 @@ import networkx as nx
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
return name.replace("/", "-").replace(" ", "_").replace(":", "-")
|
||||
"""Make a label safe for use as a filename across platforms.
|
||||
|
||||
Substitutes characters that Windows reserves in filenames
|
||||
(< > : " / \\ | ? *) and strips trailing dots/spaces, also reserved.
|
||||
Falls back to 'unnamed' for empty results and caps length at 200
|
||||
chars to stay well under common filesystem limits.
|
||||
"""
|
||||
import re
|
||||
s = name.replace("/", "-").replace(" ", "_").replace(":", "-")
|
||||
s = re.sub(r'[<>:"/\\|?*]', '_', s)
|
||||
s = s.strip('. ')
|
||||
return s[:200] if s else 'unnamed'
|
||||
|
||||
|
||||
def _cross_community_links(G: nx.Graph, nodes: list[str], own_cid: int, labels: dict[int, str]) -> list[tuple[str, int]]:
|
||||
@@ -185,6 +196,15 @@ def to_wiki(
|
||||
out = Path(output_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Clear stale .md files from previous runs to prevent orphan accumulation.
|
||||
# Community labels are LLM-generated (per skill.md Step 5) and non-deterministic
|
||||
# across runs — the same conceptual community may be named differently each time
|
||||
# (e.g. "AutoAgent Skills" → "AutoAgent Methodology"), leaving the previous file
|
||||
# as an orphan. Since to_wiki() owns wiki/ entirely (always writes the full set),
|
||||
# it can safely clear .md files at the start of each call.
|
||||
for old_article in out.glob("*.md"):
|
||||
old_article.unlink()
|
||||
|
||||
labels = community_labels or {cid: f"Community {cid}" for cid in communities}
|
||||
cohesion = cohesion or {}
|
||||
god_nodes_data = god_nodes_data or []
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "graphifyy"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
|
||||
Reference in New Issue
Block a user