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:
Safi
2026-05-02 14:25:26 +01:00
co-authored by Claude Sonnet 4.6
parent d40e1c0cef
commit 36e894aa62
10 changed files with 283 additions and 30 deletions
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+1 -1
View File
@@ -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)
+29 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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" }