mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-12 02:17:04 +00:00
This commit is contained in:
@@ -2,6 +2,11 @@
|
||||
|
||||
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
|
||||
|
||||
## 0.3.12 (2026-04-07)
|
||||
|
||||
- Fix: `sanitize_label` was double-encoding HTML entities in the interactive graph (`&lt;` instead of `<`) — removed `html.escape()` from `sanitize_label`; callers that inject directly into HTML now call `html.escape()` themselves (#66)
|
||||
- Fix: `--wiki` flag missing from `skill.md` usage table (#55)
|
||||
|
||||
## 0.3.11 (2026-04-07)
|
||||
|
||||
- Fix: Louvain fallback hangs indefinitely on large sparse graphs — added `max_level=10, threshold=1e-4` to prevent infinite loops while preserving community quality (#48)
|
||||
|
||||
+31
-4
@@ -382,7 +382,9 @@ def main() -> None:
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: graphify query \"<question>\" [--dfs] [--budget N] [--graph path]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
from graphify.serve import _load_graph, _score_nodes, _bfs, _dfs, _subgraph_to_text
|
||||
from graphify.serve import _score_nodes, _bfs, _dfs, _subgraph_to_text
|
||||
from graphify.security import sanitize_label
|
||||
from networkx.readwrite import json_graph
|
||||
question = sys.argv[2]
|
||||
use_dfs = "--dfs" in sys.argv
|
||||
budget = 2000
|
||||
@@ -391,14 +393,39 @@ def main() -> None:
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == "--budget" and i + 1 < len(args):
|
||||
budget = int(args[i + 1]); i += 2
|
||||
try:
|
||||
budget = int(args[i + 1])
|
||||
except ValueError:
|
||||
print(f"error: --budget must be an integer", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
i += 2
|
||||
elif args[i].startswith("--budget="):
|
||||
budget = int(args[i].split("=", 1)[1]); i += 1
|
||||
try:
|
||||
budget = int(args[i].split("=", 1)[1])
|
||||
except ValueError:
|
||||
print(f"error: --budget must be an integer", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
i += 1
|
||||
elif args[i] == "--graph" and i + 1 < len(args):
|
||||
graph_path = args[i + 1]; i += 2
|
||||
else:
|
||||
i += 1
|
||||
G = _load_graph(graph_path)
|
||||
# Load graph directly — validate_graph_path restricts to graphify-out/
|
||||
# so for custom --graph paths we resolve and load directly after existence check
|
||||
gp = Path(graph_path).resolve()
|
||||
if not gp.exists():
|
||||
print(f"error: graph file not found: {gp}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not gp.suffix == ".json":
|
||||
print(f"error: graph file must be a .json file", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
import json as _json
|
||||
import networkx as _nx
|
||||
G = json_graph.node_link_graph(_json.loads(gp.read_text(encoding="utf-8")), edges="links")
|
||||
except Exception as exc:
|
||||
print(f"error: could not load graph: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
terms = [t.lower() for t in question.split() if len(t) > 2]
|
||||
scored = _score_nodes(G, terms)
|
||||
if not scored:
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
# write graph to HTML, JSON, SVG, GraphML, Obsidian vault, and Neo4j Cypher
|
||||
from __future__ import annotations
|
||||
import html as _html
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
@@ -371,7 +372,7 @@ def to_html(
|
||||
edges_json = json.dumps(vis_edges)
|
||||
legend_json = json.dumps(legend_data)
|
||||
hyperedges_json = json.dumps(getattr(G, "graph", {}).get("hyperedges", []))
|
||||
title = sanitize_label(str(output_path))
|
||||
title = _html.escape(sanitize_label(str(output_path)))
|
||||
stats = f"{G.number_of_nodes()} nodes · {G.number_of_edges()} edges · {len(communities)} communities"
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
|
||||
@@ -186,13 +186,12 @@ _MAX_LABEL_LEN = 256
|
||||
|
||||
|
||||
def sanitize_label(text: str) -> str:
|
||||
"""Strip control characters, cap length, then HTML-escape.
|
||||
"""Strip control characters and cap length.
|
||||
|
||||
Applied to all node labels and edge titles before they are embedded
|
||||
in pyvis HTML output or returned via the MCP server, preventing both
|
||||
XSS and broken visualisations from malformed source identifiers.
|
||||
Safe for embedding in JSON data (inside <script> tags) and plain text.
|
||||
For direct HTML injection, wrap the result with html.escape().
|
||||
"""
|
||||
text = _CONTROL_CHAR_RE.sub("", text)
|
||||
if len(text) > _MAX_LABEL_LEN:
|
||||
text = text[:_MAX_LABEL_LEN]
|
||||
return html.escape(text)
|
||||
return text
|
||||
|
||||
@@ -24,6 +24,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
|
||||
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
|
||||
/graphify <path> --mcp # start MCP stdio server for agent access
|
||||
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
|
||||
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
|
||||
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
|
||||
/graphify add <url> # fetch URL, save to ./raw, update graph
|
||||
/graphify add <url> --author "Name" # tag who wrote it
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "graphifyy"
|
||||
version = "0.3.11"
|
||||
version = "0.3.12"
|
||||
description = "AI coding assistant skill (Claude Code, Codex, OpenCode, OpenClaw) - turn any folder of code, docs, papers, or images into a queryable knowledge graph"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
|
||||
@@ -168,9 +168,11 @@ def test_validate_graph_path_raises_if_file_missing(tmp_path):
|
||||
# sanitize_label
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sanitize_label_escapes_html():
|
||||
assert "<script>" in sanitize_label("<script>")
|
||||
assert "&" in sanitize_label("foo & bar")
|
||||
def test_sanitize_label_passthrough_html_chars():
|
||||
# sanitize_label does NOT HTML-escape — callers that inject into HTML must
|
||||
# wrap with html.escape() themselves (e.g. the title in to_html())
|
||||
assert sanitize_label("<script>") == "<script>"
|
||||
assert sanitize_label("foo & bar") == "foo & bar"
|
||||
|
||||
def test_sanitize_label_strips_control_chars():
|
||||
result = sanitize_label("hello\x00\x1fworld")
|
||||
|
||||
Reference in New Issue
Block a user