From 36e894aa628ca569e171f6f76ebc5ef04cb6bf10 Mon Sep 17 00:00:00 2001 From: Safi Date: Sat, 2 May 2026 14:25:26 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 13 ++++ graphify/__main__.py | 26 ++++++-- graphify/detect.py | 121 ++++++++++++++++++++++++++++++++++++-- graphify/export.py | 44 ++++++++++++-- graphify/extract.py | 23 +++++++- graphify/skill-trae.md | 2 +- graphify/skill-windows.md | 33 +++++++++-- graphify/skill.md | 27 +++++++-- graphify/wiki.py | 22 ++++++- pyproject.toml | 2 +- 10 files changed, 283 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fb5f9d5..4939e65d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/graphify/__main__.py b/graphify/__main__.py index 94d1f69c..42d4429a 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -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 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 \"\" 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") diff --git a/graphify/detect.py b/graphify/detect.py index ba1595e6..419fc5c9 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -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)): diff --git a/graphify/export.py b/graphify/export.py index 1d45e53a..14587add 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -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 """