graphmark 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
graphmark/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """graphmark — deterministic knowledge-graph analysis for markdown/wikilink vaults."""
2
+
3
+ __version__ = "0.1.0"
graphmark/cli.py ADDED
@@ -0,0 +1,101 @@
1
+ """Command-line interface for graphmark."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from graphmark.config import VaultConfig, load_config
10
+ from graphmark.export import to_dot, to_json
11
+ from graphmark.graph import NormalizeResolver, VaultGraph
12
+ from graphmark.metrics import (
13
+ bridges,
14
+ clusters,
15
+ gaps,
16
+ hubs,
17
+ neighborhood,
18
+ orphans,
19
+ pagerank,
20
+ siloed_notes,
21
+ stats,
22
+ )
23
+ from graphmark.parse import WikilinkExtractor
24
+
25
+
26
+ def _load(args: argparse.Namespace) -> tuple[VaultGraph, VaultConfig]:
27
+ if args.config is not None:
28
+ config = load_config(Path(args.config))
29
+ if args.root is not None:
30
+ config.root = Path(args.root)
31
+ elif args.root is not None:
32
+ config = VaultConfig(root=Path(args.root))
33
+ else:
34
+ print("error: --config or --root required", file=sys.stderr)
35
+ sys.exit(1)
36
+ graph = VaultGraph.build(config, WikilinkExtractor(), NormalizeResolver())
37
+ return graph, config
38
+
39
+
40
+ def main() -> None:
41
+ parser = argparse.ArgumentParser(prog="graphmark")
42
+ parser.add_argument("--config", metavar="PATH", help="TOML config file")
43
+ parser.add_argument("--root", metavar="PATH", help="Vault root (overrides --config root)")
44
+
45
+ sub = parser.add_subparsers(dest="command")
46
+
47
+ sub.add_parser("stats")
48
+ sub.add_parser("orphans")
49
+
50
+ hubs_p = sub.add_parser("hubs")
51
+ hubs_p.add_argument("--n", type=int, default=10)
52
+
53
+ sub.add_parser("clusters")
54
+ sub.add_parser("bridges")
55
+ sub.add_parser("siloed")
56
+
57
+ nb_p = sub.add_parser("neighborhood")
58
+ nb_p.add_argument("--note", required=True)
59
+ nb_p.add_argument("--depth", type=int, default=1)
60
+
61
+ pr_p = sub.add_parser("pagerank")
62
+ pr_p.add_argument("--n", type=int, default=10)
63
+ pr_p.add_argument("--alpha", type=float, default=0.85)
64
+
65
+ exp_p = sub.add_parser("export")
66
+ exp_p.add_argument("format", choices=["dot"])
67
+
68
+ sub.add_parser("gaps")
69
+
70
+ args = parser.parse_args()
71
+
72
+ if args.command is None:
73
+ parser.print_help()
74
+ sys.exit(1)
75
+
76
+ graph, config = _load(args)
77
+
78
+ if args.command == "stats":
79
+ print(to_json(stats(graph)))
80
+ elif args.command == "orphans":
81
+ print(to_json(orphans(graph, config)))
82
+ elif args.command == "hubs":
83
+ print(to_json(hubs(graph, n=args.n)))
84
+ elif args.command == "clusters":
85
+ print(to_json(clusters(graph)))
86
+ elif args.command == "bridges":
87
+ print(to_json(bridges(graph)))
88
+ elif args.command == "siloed":
89
+ print(to_json(siloed_notes(graph)))
90
+ elif args.command == "neighborhood":
91
+ print(to_json(neighborhood(graph, args.note, depth=args.depth)))
92
+ elif args.command == "pagerank":
93
+ print(to_json(pagerank(graph, n=args.n, alpha=args.alpha)))
94
+ elif args.command == "export" and args.format == "dot":
95
+ print(to_dot(graph))
96
+ elif args.command == "gaps":
97
+ print(to_json(gaps(graph, lambda _rel, _k: [])))
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
graphmark/config.py ADDED
@@ -0,0 +1,44 @@
1
+ """Vault configuration — the domain seam that makes the engine general.
2
+
3
+ ``VaultConfig`` holds every vault-specific policy the engine consults. ``load_config`` (TOML →
4
+ VaultConfig) is intentionally left unimplemented; wiring config through the engine is afk Issue #3.
5
+ Fixture tests may construct ``VaultConfig`` directly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import tomllib
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+
15
+ @dataclass
16
+ class VaultConfig:
17
+ """All vault-specific behavior, parametrized."""
18
+
19
+ root: Path
20
+ scoped_folders: list[str] = field(default_factory=list)
21
+ excluded_dirs: list[str] = field(default_factory=list)
22
+ rules_files: list[str] = field(default_factory=lambda: ["CLAUDE.md", "CLAUDE.local.md"])
23
+ wikilink_pattern: str = r"\[\[(.+?)\]\]"
24
+ orphan_min_chars: int = 300
25
+ transient_prefixes: tuple[str, ...] = ()
26
+
27
+
28
+ def load_config(path: Path) -> VaultConfig:
29
+ """Load a VaultConfig from a TOML file."""
30
+ with open(path, "rb") as f:
31
+ data = tomllib.load(f)
32
+
33
+ toml_dir = path.parent
34
+ root = toml_dir / data["root"]
35
+
36
+ return VaultConfig(
37
+ root=root,
38
+ scoped_folders=data.get("scoped_folders", []),
39
+ excluded_dirs=data.get("excluded_dirs", []),
40
+ rules_files=data.get("rules_files", ["CLAUDE.md", "CLAUDE.local.md"]),
41
+ wikilink_pattern=data.get("wikilink_pattern", r"\[\[(.+?)\]\]"),
42
+ orphan_min_chars=data.get("orphan_min_chars", 300),
43
+ transient_prefixes=tuple(data.get("transient_prefixes", [])),
44
+ )
graphmark/dismiss.py ADDED
@@ -0,0 +1,59 @@
1
+ """Dismissal store with content-hash staleness for weaklink suggestions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from pathlib import Path
8
+
9
+ _DEFAULT_PATH = ".claude/data/connect-dismissed.json"
10
+
11
+
12
+ def weaklink_sig(a: str, b: str) -> str:
13
+ return "weaklink|" + "|".join(sorted([a, b]))
14
+
15
+
16
+ def content_hash(path: Path) -> str:
17
+ return hashlib.sha1(path.read_bytes()).hexdigest()
18
+
19
+
20
+ def record_dismissal(root: Path, a: str, b: str, *, path: str = _DEFAULT_PATH) -> None:
21
+ dismissed_file = root / path
22
+ dismissed_file.parent.mkdir(parents=True, exist_ok=True)
23
+ existing = {}
24
+ if dismissed_file.exists():
25
+ try:
26
+ existing = json.loads(dismissed_file.read_text())
27
+ except (json.JSONDecodeError, OSError):
28
+ existing = {}
29
+ sig = weaklink_sig(a, b)
30
+ existing[sig] = {
31
+ "a": a,
32
+ "a_hash": content_hash(root / a),
33
+ "b": b,
34
+ "b_hash": content_hash(root / b),
35
+ }
36
+ dismissed_file.write_text(json.dumps(existing, indent=2))
37
+
38
+
39
+ def load_dismissed(root: Path, *, path: str = _DEFAULT_PATH) -> dict:
40
+ dismissed_file = root / path
41
+ if not dismissed_file.exists():
42
+ return {}
43
+ return json.loads(dismissed_file.read_text())
44
+
45
+
46
+ def active_dismissed_sigs(root: Path, *, path: str = _DEFAULT_PATH) -> set[str]:
47
+ dismissed = load_dismissed(root, path=path)
48
+ active: set[str] = set()
49
+ for sig, record in dismissed.items():
50
+ a_path = root / record["a"]
51
+ b_path = root / record["b"]
52
+ if (
53
+ a_path.exists()
54
+ and b_path.exists()
55
+ and content_hash(a_path) == record["a_hash"]
56
+ and content_hash(b_path) == record["b_hash"]
57
+ ):
58
+ active.add(sig)
59
+ return active
graphmark/export.py ADDED
@@ -0,0 +1,24 @@
1
+ """Export helpers: JSON serialisation and Graphviz DOT output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from graphmark.graph import VaultGraph
8
+
9
+
10
+ def to_json(obj) -> str:
11
+ """Serialise any JSON-serialisable object to a string."""
12
+ return json.dumps(obj)
13
+
14
+
15
+ def to_dot(graph: VaultGraph) -> str:
16
+ """Emit a Graphviz digraph containing every node and directed edge."""
17
+ lines = ["digraph G {"]
18
+ for node in sorted(graph.nodes):
19
+ lines.append(f' "{node}";')
20
+ for src in sorted(graph.out_links):
21
+ for dst in sorted(graph.out_links[src]):
22
+ lines.append(f' "{src}" -> "{dst}";')
23
+ lines.append("}")
24
+ return "\n".join(lines)
graphmark/graph.py ADDED
@@ -0,0 +1,107 @@
1
+ """Graph construction: catalog building, link resolution, and VaultGraph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import string
6
+ from pathlib import Path
7
+
8
+ from graphmark.config import VaultConfig
9
+ from graphmark.interfaces import LinkExtractor, Resolver
10
+ from graphmark.model import Document
11
+ from graphmark.parse import parse_document
12
+
13
+ _PUNCT_TABLE = str.maketrans(string.punctuation, " " * len(string.punctuation))
14
+
15
+
16
+ def _normalize(text: str) -> str:
17
+ """Lowercase, replace punctuation with spaces, collapse whitespace."""
18
+ return " ".join(text.lower().translate(_PUNCT_TABLE).split())
19
+
20
+
21
+ def build_catalog(docs: list[Document]) -> dict[str, list[str]]:
22
+ """Map normalized stem → list of rel_paths (len > 1 means ambiguous)."""
23
+ catalog: dict[str, list[str]] = {}
24
+ for doc in docs:
25
+ key = _normalize(Path(doc.rel_path).stem)
26
+ catalog.setdefault(key, []).append(doc.rel_path)
27
+ return catalog
28
+
29
+
30
+ class NormalizeResolver:
31
+ """Resolves wikilink displays via normalized basename, with path-suffix fallback."""
32
+
33
+ def resolve(self, display: str, catalog: dict[str, list[str]]) -> str | None:
34
+ # Strip alias: "Note|alias" → "Note"
35
+ display = display.split("|")[0]
36
+ # Strip anchor: "Note#Section" → "Note"
37
+ display = display.split("#")[0]
38
+
39
+ if "/" in display:
40
+ # Path-suffix resolution: find unique rel_path ending with "display.md"
41
+ suffix = display.lower() + ".md"
42
+ all_paths = [p for paths in catalog.values() for p in paths]
43
+ matches = [p for p in all_paths if p.lower().endswith(suffix)]
44
+ return matches[0] if len(matches) == 1 else None
45
+
46
+ # Bare-link resolution: normalize and look up in catalog
47
+ key = _normalize(display)
48
+ paths = catalog.get(key)
49
+ if paths is None or len(paths) != 1:
50
+ return None
51
+ return paths[0]
52
+
53
+
54
+ class VaultGraph:
55
+ """Built graph: all nodes plus resolved out/back adjacency."""
56
+
57
+ def __init__(
58
+ self,
59
+ nodes: dict[str, Document],
60
+ out_links: dict[str, set[str]],
61
+ back_links: dict[str, set[str]],
62
+ ) -> None:
63
+ self.nodes = nodes
64
+ self.out_links = out_links
65
+ self.back_links = back_links
66
+
67
+ @classmethod
68
+ def build(
69
+ cls,
70
+ config: VaultConfig,
71
+ extractor: LinkExtractor,
72
+ resolver: Resolver,
73
+ ) -> VaultGraph:
74
+ root = config.root
75
+ excluded = set(config.excluded_dirs)
76
+ rules = set(config.rules_files)
77
+
78
+ scoped = set(config.scoped_folders)
79
+ md_files: list[Path] = []
80
+ for path in sorted(root.rglob("*.md")):
81
+ rel_parts = path.relative_to(root).parts
82
+ if scoped and rel_parts[0] not in scoped:
83
+ continue
84
+ if any(p in excluded for p in rel_parts[:-1]):
85
+ continue
86
+ if path.name in rules:
87
+ continue
88
+ md_files.append(path)
89
+
90
+ docs = [parse_document(p, root) for p in md_files]
91
+ nodes = {doc.rel_path: doc for doc in docs}
92
+ catalog = build_catalog(docs)
93
+
94
+ out_links: dict[str, set[str]] = {rel: set() for rel in nodes}
95
+ back_links: dict[str, set[str]] = {rel: set() for rel in nodes}
96
+
97
+ for doc in docs:
98
+ for display in extractor.extract(doc.text):
99
+ target = resolver.resolve(display, catalog)
100
+ if target is not None and target != doc.rel_path:
101
+ out_links[doc.rel_path].add(target)
102
+
103
+ for src, targets in out_links.items():
104
+ for dst in targets:
105
+ back_links[dst].add(src)
106
+
107
+ return cls(nodes, out_links, back_links)
@@ -0,0 +1,33 @@
1
+ """Pluggable interfaces — the generality seams.
2
+
3
+ Only the default wikilink/normalize implementations are required now; these Protocols exist so
4
+ alternate link syntaxes and resolution strategies can be added later without touching the engine.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Protocol
10
+
11
+
12
+ class LinkExtractor(Protocol):
13
+ """Extracts raw link displays from note text."""
14
+
15
+ def extract(self, text: str) -> list[str]:
16
+ """Return raw link displays found in ``text``, excluding links inside code spans.
17
+
18
+ A "display" is the inner text of a link before resolution, e.g. ``[[Note|alias]]`` yields
19
+ ``"Note|alias"`` (alias/anchor stripping happens in the Resolver).
20
+ """
21
+ ...
22
+
23
+
24
+ class Resolver(Protocol):
25
+ """Resolves a link display to a target note."""
26
+
27
+ def resolve(self, display: str, catalog: dict[str, list[str]]) -> str | None:
28
+ """Resolve ``display`` to a target rel_path, or ``None`` if unresolved/ambiguous.
29
+
30
+ ``catalog`` maps a normalized note key to the list of rel_paths sharing that key (length > 1
31
+ means a collision → a bare link to it is ambiguous and must not resolve).
32
+ """
33
+ ...
graphmark/metrics.py ADDED
@@ -0,0 +1,211 @@
1
+ """Structural metrics computed over a VaultGraph.
2
+
3
+ All outputs reproduce the reference engine (brain_map.py) exactly — shape,
4
+ ordering, and tie-breaking are pinned by tests/fixtures/simple/expected.json.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import networkx as nx
10
+
11
+ from graphmark.config import VaultConfig
12
+ from graphmark.graph import VaultGraph
13
+
14
+
15
+ def _undirected(graph: VaultGraph) -> nx.Graph:
16
+ G: nx.Graph = nx.Graph()
17
+ G.add_nodes_from(graph.nodes.keys())
18
+ for src, targets in graph.out_links.items():
19
+ for dst in targets:
20
+ G.add_edge(src, dst)
21
+ return G
22
+
23
+
24
+ def stats(graph: VaultGraph) -> dict:
25
+ """Return aggregate vault stats matching the oracle schema."""
26
+ G = _undirected(graph)
27
+ notes = len(graph.nodes)
28
+ edges = sum(len(v) for v in graph.out_links.values())
29
+ n_orphans = sum(1 for n in G.nodes if G.degree(n) == 0)
30
+ n_clusters = sum(1 for c in nx.connected_components(G) if len(c) > 1)
31
+ density = round(edges / notes, 2) if notes > 0 else 0.0
32
+ return {
33
+ "notes": notes,
34
+ "edges": edges,
35
+ "orphans": n_orphans,
36
+ "clusters": n_clusters,
37
+ "density": density,
38
+ }
39
+
40
+
41
+ def orphans(graph: VaultGraph, config: VaultConfig) -> list[str]:
42
+ """Return degree-0 nodes, excluding those matching transient_prefixes, sorted."""
43
+ G = _undirected(graph)
44
+ result = []
45
+ for node in G.nodes:
46
+ if G.degree(node) != 0:
47
+ continue
48
+ if any(node.startswith(prefix) for prefix in config.transient_prefixes):
49
+ continue
50
+ result.append(node)
51
+ return sorted(result)
52
+
53
+
54
+ def hubs(graph: VaultGraph, n: int = 10) -> list[list]:
55
+ """Return top-n nodes by undirected degree (degree > 0), ties broken by path order."""
56
+ G = _undirected(graph)
57
+ degree_pairs = [(node, G.degree(node)) for node in G.nodes if G.degree(node) > 0]
58
+ degree_pairs.sort(key=lambda x: (-x[1], x[0]))
59
+ return [[path, degree] for path, degree in degree_pairs[:n]]
60
+
61
+
62
+ def clusters(graph: VaultGraph) -> list[list[str]]:
63
+ """Return connected components with >1 node, size-desc, members sorted."""
64
+ G = _undirected(graph)
65
+ components = [sorted(c) for c in nx.connected_components(G) if len(c) > 1]
66
+ components.sort(key=lambda c: -len(c))
67
+ return components
68
+
69
+
70
+ def bridges(graph: VaultGraph) -> list[str]:
71
+ """Return articulation points of the undirected graph, sorted."""
72
+ G = _undirected(graph)
73
+ return sorted(nx.articulation_points(G))
74
+
75
+
76
+ def siloed_notes(graph: VaultGraph) -> list[str]:
77
+ """Return notes reachable from the mainland only through a single articulation point.
78
+
79
+ For each bridge b: remove b, find connected components sorted size-desc; every
80
+ component except the largest (mainland) is an island. Collect all island nodes
81
+ (including degree-0 orphans that become singleton islands). Return sorted unique list.
82
+ """
83
+ G = _undirected(graph)
84
+ island_nodes: set[str] = set()
85
+ for bridge in nx.articulation_points(G):
86
+ H = G.copy()
87
+ H.remove_node(bridge)
88
+ components = sorted(nx.connected_components(H), key=lambda c: -len(c))
89
+ for component in components[1:]:
90
+ island_nodes.update(component)
91
+ return sorted(island_nodes)
92
+
93
+
94
+ def pagerank(graph: VaultGraph, n: int = 10, alpha: float = 0.85) -> list[list]:
95
+ """Pure-python power iteration PageRank (matches networkx _pagerank_python).
96
+
97
+ Dangling nodes (no out-edges) redistribute mass uniformly across all nodes.
98
+ Returns top-n [path, score] pairs sorted score-desc then path-asc.
99
+ """
100
+ nodes = list(graph.nodes.keys())
101
+ N = len(nodes)
102
+ if N == 0:
103
+ return []
104
+
105
+ out_links = {node: graph.out_links.get(node, set()) for node in nodes}
106
+ dangling = [node for node in nodes if not out_links[node]]
107
+ dangling_weight = 1.0 / N
108
+
109
+ x: dict[str, float] = {node: 1.0 / N for node in nodes}
110
+
111
+ for _ in range(100):
112
+ xlast = x
113
+ x = dict.fromkeys(xlast, 0.0)
114
+ danglesum = alpha * sum(xlast[node] for node in dangling)
115
+
116
+ for src in nodes:
117
+ out = out_links[src]
118
+ if out:
119
+ share = alpha * xlast[src] / len(out)
120
+ for dst in out:
121
+ x[dst] += share
122
+
123
+ for node in nodes:
124
+ x[node] += danglesum * dangling_weight + (1.0 - alpha) * dangling_weight
125
+
126
+ err = sum(abs(x[node] - xlast[node]) for node in nodes)
127
+ if err < N * 1e-6:
128
+ break
129
+
130
+ pairs = sorted(x.items(), key=lambda kv: (-kv[1], kv[0]))
131
+ return [[path, score] for path, score in pairs[:n]]
132
+
133
+
134
+ def gaps(
135
+ graph: VaultGraph,
136
+ similar_fn,
137
+ threshold: float = 0.0,
138
+ k: int = 5,
139
+ note: str | None = None,
140
+ dismissed: set | frozenset = frozenset(),
141
+ exclude_prefixes: tuple = (),
142
+ max_score: float | None = None,
143
+ hub_degree: int | None = None,
144
+ targets: list | None = None,
145
+ ) -> list[dict]:
146
+ """Return link-gap suggestions ranked by novelty (non-hub, cross-folder, score-desc)."""
147
+ if note is not None:
148
+ target_list = [note]
149
+ elif targets is not None:
150
+ target_list = targets
151
+ else:
152
+ target_list = list(graph.nodes.keys())
153
+
154
+ G = _undirected(graph)
155
+
156
+ def _hub(r: str) -> bool:
157
+ return hub_degree is not None and G.degree(r) >= hub_degree
158
+
159
+ dedup_map: dict = {}
160
+
161
+ for rel in target_list:
162
+ if any(rel.startswith(p) for p in exclude_prefixes):
163
+ continue
164
+
165
+ linked = graph.out_links.get(rel, set()) | graph.back_links.get(rel, set())
166
+
167
+ for other, score in similar_fn(rel, k):
168
+ if other == rel:
169
+ continue
170
+ if other in linked:
171
+ continue
172
+ if score < threshold:
173
+ continue
174
+ if any(other.startswith(p) for p in exclude_prefixes):
175
+ continue
176
+ if max_score is not None and score > max_score:
177
+ continue
178
+
179
+ sig = "weaklink|" + "|".join(sorted([rel, other]))
180
+ if sig in dismissed:
181
+ continue
182
+
183
+ key = frozenset({rel, other})
184
+ if key not in dedup_map or score > dedup_map[key][2]:
185
+ dedup_map[key] = (rel, other, score, sig)
186
+
187
+ def _rank_key(item):
188
+ a, b, score, _sig = item
189
+ hubby = _hub(a) or _hub(b)
190
+ cross = a.split("/", 1)[0] != b.split("/", 1)[0]
191
+ return (hubby, not cross, -score, a, b)
192
+
193
+ candidates = sorted(dedup_map.values(), key=_rank_key)
194
+ return [{"a": a, "b": b, "score": round(s, 4), "sig": sig} for a, b, s, sig in candidates]
195
+
196
+
197
+ def neighborhood(graph: VaultGraph, note: str, depth: int = 1) -> dict:
198
+ """Return out/back neighbors (and two_hop when depth>=2) for a note."""
199
+ out = sorted(graph.out_links.get(note, set()))
200
+ back = sorted(graph.back_links.get(note, set()))
201
+ result: dict = {"note": note, "out": out, "back": back}
202
+ if depth >= 2:
203
+ one_hop = set(out) | set(back)
204
+ two_hop: set[str] = set()
205
+ for neighbor in one_hop:
206
+ two_hop |= set(graph.out_links.get(neighbor, set()))
207
+ two_hop |= set(graph.back_links.get(neighbor, set()))
208
+ two_hop -= one_hop
209
+ two_hop.discard(note)
210
+ result["two_hop"] = sorted(two_hop)
211
+ return result
graphmark/model.py ADDED
@@ -0,0 +1,44 @@
1
+ """Core data model for graphmark.
2
+
3
+ These dataclasses are the seeded boundaries the engine is built against. Do not rename
4
+ their types or fields; implement the engine modules to produce and consume them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Document:
14
+ """A single note in the vault."""
15
+
16
+ rel_path: str # posix rel-path from vault root, e.g. "brain/North Star.md"
17
+ text: str
18
+ frontmatter: dict
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Edge:
23
+ """A resolved directed link from one note to another."""
24
+
25
+ src: str # rel_path
26
+ dst: str # rel_path
27
+
28
+
29
+ @dataclass
30
+ class Graph:
31
+ """The built graph: nodes plus resolved out/back adjacency."""
32
+
33
+ nodes: dict[str, Document] # rel_path -> Document
34
+ out_links: dict[str, set[str]] # rel_path -> resolved target rel_paths
35
+ back_links: dict[str, set[str]] # inverted out_links
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Finding:
40
+ """A health finding (used by health checks)."""
41
+
42
+ kind: str # "orphan" | "broken_link" | "missing_frontmatter"
43
+ note: str # rel_path
44
+ detail: str = ""
graphmark/parse.py ADDED
@@ -0,0 +1,72 @@
1
+ """Note parsing: document splitting and wikilink extraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from graphmark.model import Document
9
+
10
+ _FM_RE = re.compile(r"^---\n(.+?\n)---\n", re.DOTALL)
11
+ _WIKILINK_RE = re.compile(r"\[\[(.+?)\]\]")
12
+ _INLINE_CODE_RE = re.compile(r"`[^`\n]+`")
13
+ _FENCE_OPEN_RE = re.compile(r"^(`{3,}|~{3,})")
14
+
15
+
16
+ def _strip_fenced_blocks(text: str) -> str:
17
+ """Remove fenced code block contents so wikilinks inside them are ignored."""
18
+ lines = text.splitlines(keepends=True)
19
+ out: list[str] = []
20
+ fence_char: str | None = None
21
+ for line in lines:
22
+ ls = line.lstrip()
23
+ if fence_char is None:
24
+ m = _FENCE_OPEN_RE.match(ls)
25
+ if m:
26
+ fence_char = ls[0]
27
+ else:
28
+ out.append(line)
29
+ else:
30
+ if _FENCE_OPEN_RE.match(ls) and ls[0] == fence_char:
31
+ fence_char = None
32
+ return "".join(out)
33
+
34
+
35
+ def _parse_frontmatter(raw: str) -> dict:
36
+ """Minimal YAML-like frontmatter parser covering scalar, quoted-string, inline-list."""
37
+ result: dict = {}
38
+ for line in raw.splitlines():
39
+ if ":" not in line:
40
+ continue
41
+ key, _, value = line.partition(":")
42
+ key = key.strip()
43
+ value = value.strip()
44
+ if value.startswith("[") and value.endswith("]"):
45
+ items = [v.strip().strip("\"'") for v in value[1:-1].split(",")]
46
+ result[key] = [i for i in items if i]
47
+ else:
48
+ result[key] = value.strip("\"'")
49
+ return result
50
+
51
+
52
+ class WikilinkExtractor:
53
+ """Extracts raw wikilink displays from note text, excluding code spans."""
54
+
55
+ def extract(self, text: str) -> list[str]:
56
+ text = _strip_fenced_blocks(text)
57
+ text = _INLINE_CODE_RE.sub("", text)
58
+ return _WIKILINK_RE.findall(text)
59
+
60
+
61
+ def parse_document(path: Path, root: Path) -> Document:
62
+ """Parse a markdown note into a Document, splitting YAML frontmatter from body."""
63
+ raw = path.read_text(encoding="utf-8")
64
+ rel_path = path.relative_to(root).as_posix()
65
+ m = _FM_RE.match(raw)
66
+ if m:
67
+ frontmatter = _parse_frontmatter(m.group(1))
68
+ body = raw[m.end() :]
69
+ else:
70
+ frontmatter = {}
71
+ body = raw
72
+ return Document(rel_path=rel_path, text=body, frontmatter=frontmatter)
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: graphmark
3
+ Version: 0.1.0
4
+ Summary: Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults.
5
+ Project-URL: Homepage, https://github.com/cdcoonce/graphmark
6
+ Project-URL: Repository, https://github.com/cdcoonce/graphmark
7
+ Project-URL: Issues, https://github.com/cdcoonce/graphmark/issues
8
+ Author-email: Charles Coonce <charlescoonce@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: graph,knowledge-graph,markdown,obsidian,pagerank,wikilink
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
19
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: networkx>=3.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8.0; extra == 'dev'
24
+ Requires-Dist: ruff>=0.6; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # graphmark
28
+
29
+ Deterministic knowledge-graph analysis for markdown / `[[wikilink]]` vaults — orphans, hubs,
30
+ clusters, bridges, neighborhoods, and PageRank over your notes, driven by a small config so it works
31
+ on any Obsidian-family vault.
32
+
33
+ > Status: **early**. Engine modules are being built against a differential oracle (a proven
34
+ > reference implementation's frozen outputs). See `CLAUDE.md` for the architecture and the
35
+ > reference-parity contract.
36
+
37
+ ## Install (dev)
38
+
39
+ ```bash
40
+ uv pip install -e ".[dev]"
41
+ ```
42
+
43
+ ## Test
44
+
45
+ ```bash
46
+ uv run --extra dev ruff check . && uv run --extra dev ruff format --check . && uv run --extra dev pytest -q
47
+ ```
48
+
49
+ ## Usage (once built)
50
+
51
+ ```bash
52
+ graphmark stats --config configs/my-brain.toml --root /path/to/vault
53
+ graphmark orphans --config configs/my-brain.toml --root /path/to/vault
54
+ graphmark hubs 10 --config configs/my-brain.toml --root /path/to/vault
55
+ graphmark pagerank --config configs/my-brain.toml --root /path/to/vault
56
+ graphmark export dot --config configs/my-brain.toml --root /path/to/vault > graph.dot
57
+ ```
@@ -0,0 +1,15 @@
1
+ graphmark/__init__.py,sha256=sXSYDxvThezeRGUxh83J9jcfEKSE_FHxX7zKq3cKylE,112
2
+ graphmark/cli.py,sha256=_hCi9T0DYIo8UNsW_8thDxMl9ZQgUtbq9r2Ka95OrHY,2998
3
+ graphmark/config.py,sha256=btPGhf2kp_8-J_DPnn2QysxIeem0mL5xa9LAgs9Qu_s,1542
4
+ graphmark/dismiss.py,sha256=wJUf5BaN5q72ySyxDNC33nq-eTn3gbr8a6nzwu92NV0,1752
5
+ graphmark/export.py,sha256=z3rluLRcRzQeKqM7ALABf0O3JOOpHgs412X7ypVpUZw,682
6
+ graphmark/graph.py,sha256=hrw4sbjD2gJlo6f3erdSEl0qDtSBDAqt-igLez3bY2o,3677
7
+ graphmark/interfaces.py,sha256=a4R_z1Jo15lSH-jSaoBH-kou_ovLS_BUuYOJLqVZZ7k,1198
8
+ graphmark/metrics.py,sha256=TYe9_YnIvC6T-1NEfZ9cU7sC3toI73hvr2VfSa0v3Fw,7229
9
+ graphmark/model.py,sha256=ylxXvnCn0gAu86aW4V3at-3FBhNWBrHdbmFzb8tlRNc,1115
10
+ graphmark/parse.py,sha256=W2EstjIUVD0itb-4CRj2aQICujEad6X16gVuj3a9mAA,2326
11
+ graphmark-0.1.0.dist-info/METADATA,sha256=U28g8NLrWkEAv9kHWbOt7cSmDSbPuHLfaLFA7-D0mro,2163
12
+ graphmark-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
13
+ graphmark-0.1.0.dist-info/entry_points.txt,sha256=sqDtlC7NtKyCUXw6Vn-147PHjhAK2V1hZQSbzP2qyEw,49
14
+ graphmark-0.1.0.dist-info/licenses/LICENSE,sha256=MZbypT-lLCt1Re77foXUp-nevCglz-FNlObDyoL_DXU,1071
15
+ graphmark-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ graphmark = graphmark.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Charles Coonce
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.