graphmark 0.3.0__tar.gz → 0.3.2__tar.gz

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.
Files changed (89) hide show
  1. {graphmark-0.3.0 → graphmark-0.3.2}/CHANGELOG.md +55 -0
  2. {graphmark-0.3.0 → graphmark-0.3.2}/PKG-INFO +1 -1
  3. {graphmark-0.3.0 → graphmark-0.3.2}/pyproject.toml +1 -1
  4. {graphmark-0.3.0 → graphmark-0.3.2}/src/graphmark/graph.py +36 -0
  5. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_unresolved.py +103 -0
  6. {graphmark-0.3.0 → graphmark-0.3.2}/.gitignore +0 -0
  7. {graphmark-0.3.0 → graphmark-0.3.2}/LICENSE +0 -0
  8. {graphmark-0.3.0 → graphmark-0.3.2}/README.md +0 -0
  9. {graphmark-0.3.0 → graphmark-0.3.2}/docs/ROADMAP.md +0 -0
  10. {graphmark-0.3.0 → graphmark-0.3.2}/src/graphmark/__init__.py +0 -0
  11. {graphmark-0.3.0 → graphmark-0.3.2}/src/graphmark/check.py +0 -0
  12. {graphmark-0.3.0 → graphmark-0.3.2}/src/graphmark/cli.py +0 -0
  13. {graphmark-0.3.0 → graphmark-0.3.2}/src/graphmark/config.py +0 -0
  14. {graphmark-0.3.0 → graphmark-0.3.2}/src/graphmark/dismiss.py +0 -0
  15. {graphmark-0.3.0 → graphmark-0.3.2}/src/graphmark/export.py +0 -0
  16. {graphmark-0.3.0 → graphmark-0.3.2}/src/graphmark/interfaces.py +0 -0
  17. {graphmark-0.3.0 → graphmark-0.3.2}/src/graphmark/metrics.py +0 -0
  18. {graphmark-0.3.0 → graphmark-0.3.2}/src/graphmark/model.py +0 -0
  19. {graphmark-0.3.0 → graphmark-0.3.2}/src/graphmark/parse.py +0 -0
  20. {graphmark-0.3.0 → graphmark-0.3.2}/src/graphmark/py.typed +0 -0
  21. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/README.md +0 -0
  22. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/alt/config.toml +0 -0
  23. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/alt/expected.json +0 -0
  24. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
  25. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
  26. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/alt/vault/docs/center.md +0 -0
  27. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/alt/vault/misc/echo.md +0 -0
  28. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
  29. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
  30. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/alt/vault/misc/stub.md +0 -0
  31. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/alt/vault/refs/beta.md +0 -0
  32. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/alt/vault/refs/delta.md +0 -0
  33. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
  34. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/dismiss/expected.json +0 -0
  35. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
  36. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/dismiss/vault/alpha.md +0 -0
  37. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/dismiss/vault/beta.md +0 -0
  38. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/dismiss/vault/gamma.md +0 -0
  39. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/gaps/config.toml +0 -0
  40. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/gaps/expected.json +0 -0
  41. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/gaps/expected_oog.json +0 -0
  42. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/gaps/similar.json +0 -0
  43. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/gaps/similar_oog.json +0 -0
  44. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/gaps/vault/docs/a.md +0 -0
  45. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
  46. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/gaps/vault/misc/c.md +0 -0
  47. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/gaps/vault/misc/d.md +0 -0
  48. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/gaps/vault/refs/b.md +0 -0
  49. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/gaps/vault/refs/e.md +0 -0
  50. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/scoped/config.toml +0 -0
  51. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/scoped/expected.json +0 -0
  52. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/scoped/vault/docs/one.md +0 -0
  53. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
  54. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
  55. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/scoped/vault/refs/two.md +0 -0
  56. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/selflink/config.toml +0 -0
  57. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/selflink/expected.json +0 -0
  58. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/selflink/vault/a.md +0 -0
  59. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/selflink/vault/b.md +0 -0
  60. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/simple/config.toml +0 -0
  61. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/simple/expected.json +0 -0
  62. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
  63. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/simple/vault/brain/hub.md +0 -0
  64. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/simple/vault/personal/beta.md +0 -0
  65. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
  66. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/simple/vault/reference/island.md +0 -0
  67. {graphmark-0.3.0 → graphmark-0.3.2}/tests/fixtures/simple/vault/reference/stub.md +0 -0
  68. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_build_filters.py +0 -0
  69. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_check.py +0 -0
  70. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_check_policy.py +0 -0
  71. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_cli.py +0 -0
  72. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_config.py +0 -0
  73. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_degenerate.py +0 -0
  74. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_dismiss.py +0 -0
  75. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_export.py +0 -0
  76. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_gaps.py +0 -0
  77. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_gaps_banding.py +0 -0
  78. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_gaps_oog.py +0 -0
  79. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_gaps_params.py +0 -0
  80. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_graph.py +0 -0
  81. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_metrics.py +0 -0
  82. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_model.py +0 -0
  83. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_packaging.py +0 -0
  84. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_pagerank.py +0 -0
  85. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_parse.py +0 -0
  86. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_public_api.py +0 -0
  87. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_scoped.py +0 -0
  88. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_selflink.py +0 -0
  89. {graphmark-0.3.0 → graphmark-0.3.2}/tests/test_smoke.py +0 -0
@@ -1,6 +1,61 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.3.2 (2026-07-25)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **graph**: Links to non-markdown files are out of scope, not broken
9
+ ([#102](https://github.com/cdcoonce/graphmark/pull/102),
10
+ [`0796aa0`](https://github.com/cdcoonce/graphmark/commit/0796aa02d1d582b85a8905fb4b43b72b076bd072))
11
+
12
+ unresolved reported wikilinks targeting Obsidian Bases, Canvas, images and PDFs as broken. graphmark
13
+ only indexes *.md, so it has no basis to judge those targets — calling them broken just fills the
14
+ vault-health count with entries nobody can act on.
15
+
16
+ Measured on the owner's live vault: 17 of 173 reported breaks (10%) targeted .base/.canvas, and all
17
+ 9 distinct targets exist on disk, in a bases/ directory outside scoped_folders. Every one of those
18
+ links works in Obsidian.
19
+
20
+ When the resolver returns None and the target ends in a plausible file extension other than .md,
21
+ treat it as out of scope: no edge, not counted. The extension test is deliberately strict
22
+ (trailing dot plus 1-10 alphanumerics) so a title like "v1.2 release notes" is still read as a
23
+ note. Applying the rule only after the resolver has already failed is what makes it safe — a note
24
+ that genuinely resolves, such as a real report.v2.md linked as [[report.v2]], never reaches it.
25
+
26
+ Deliberately no filesystem existence check: it would couple build() to the disk and force
27
+ enumerating excluded trees like .git. "Out of scope" is the honest report for a file type
28
+ graphmark does not index.
29
+
30
+ Closes #101
31
+
32
+
33
+ ## v0.3.1 (2026-07-25)
34
+
35
+ ### Bug Fixes
36
+
37
+ - **graph**: Stop counting same-note anchor links as unresolved
38
+ ([#99](https://github.com/cdcoonce/graphmark/pull/99),
39
+ [`a1d03f3`](https://github.com/cdcoonce/graphmark/commit/a1d03f3eb0e7b36b6d44d2a4eecfe8bb70da8d11))
40
+
41
+ unresolved counted anchor-only wikilinks — [[#Heading]], [[#Heading|alias]], [[#^blockref]] — as
42
+ broken. Those are Obsidian same-note references: they target no note at all, so they are neither
43
+ an edge nor a broken link. Counting them corrupted the very metric Track D was built around,
44
+ max_unresolved_links.
45
+
46
+ Measured on the owner's live 521-note vault: 40 of 213 reported unresolved links (19%) were
47
+ anchor-only, and the note reported as the worst offender earned that spot purely by navigating
48
+ itself heavily.
49
+
50
+ build() now skips a display whose note part (before | and #) is empty. [[Note#Heading]] is
51
+ unaffected: it still resolves via Note, and still counts as unresolved when Note is missing.
52
+
53
+ This refines the #75 semantics, which defined ambiguous-counts and self-link-doesn't but never
54
+ considered anchor-only links.
55
+
56
+ Closes #98
57
+
58
+
4
59
  ## v0.3.0 (2026-07-25)
5
60
 
6
61
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphmark
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults.
5
5
  Project-URL: Homepage, https://github.com/cdcoonce/graphmark
6
6
  Project-URL: Repository, https://github.com/cdcoonce/graphmark
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphmark"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  description = "Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import re
5
6
  import string
6
7
  from pathlib import Path
7
8
 
@@ -18,6 +19,37 @@ def _normalize(text: str) -> str:
18
19
  return " ".join(text.lower().translate(_PUNCT_TABLE).split())
19
20
 
20
21
 
22
+ # A trailing dot plus a short alphanumeric run — a plausible file extension. Deliberately
23
+ # strict so a note title like "v1.2 release notes" (spaces after the dot) is not mistaken
24
+ # for one.
25
+ _FILE_SUFFIX_RE = re.compile(r"\.[A-Za-z0-9]{1,10}$")
26
+
27
+
28
+ def _targets_non_note_file(display: str) -> bool:
29
+ """True for a link to a file graphmark does not index, e.g. ``[[Board.canvas]]``.
30
+
31
+ Obsidian wikilinks legitimately target Bases, Canvas, images and PDFs. graphmark only
32
+ indexes markdown, so it has no basis to call such a link broken — reporting it as
33
+ unresolved just fills the vault-health count with entries nobody can act on.
34
+
35
+ Only ever consulted after the resolver has already failed, so a note that genuinely
36
+ resolves (say a real ``report.v2.md`` linked as ``[[report.v2]]``) is never suppressed.
37
+ """
38
+ target = display.split("|")[0].split("#")[0].strip()
39
+ match = _FILE_SUFFIX_RE.search(target)
40
+ return bool(match) and match.group(0).lower() != ".md"
41
+
42
+
43
+ def _is_intra_note_reference(display: str) -> bool:
44
+ """True for a link that targets no note, e.g. ``[[#Heading]]`` or ``[[#^block]]``.
45
+
46
+ Obsidian uses an empty note part to mean "somewhere in this same note". Such a link is
47
+ neither an edge nor a broken link, so it must not be recorded as unresolved — otherwise
48
+ a note that navigates itself heavily looks like the vault's worst offender.
49
+ """
50
+ return not display.split("|")[0].split("#")[0].strip()
51
+
52
+
21
53
  def build_catalog(docs: list[Document]) -> dict[str, list[str]]:
22
54
  """Map normalized stem → list of rel_paths (len > 1 means ambiguous)."""
23
55
  catalog: dict[str, list[str]] = {}
@@ -126,8 +158,12 @@ class VaultGraph:
126
158
 
127
159
  for doc in docs:
128
160
  for display in extractor.extract(doc.text):
161
+ if _is_intra_note_reference(display):
162
+ continue
129
163
  target = resolver.resolve(display, catalog)
130
164
  if target is None:
165
+ if _targets_non_note_file(display):
166
+ continue # out of scope, not a broken note link
131
167
  # Unresolvable OR ambiguous — the Resolver protocol conflates the two, and
132
168
  # both are equally broken from a vault-health view. Record the raw display
133
169
  # (what a human has to go fix) once per occurrence.
@@ -130,3 +130,106 @@ class TestUnresolvedIntegration:
130
130
  assert isinstance(graph.unresolved, dict)
131
131
  # stats is untouched by this addition — the oracle still governs it.
132
132
  assert set(stats(graph)) == {"notes", "edges", "orphans", "clusters", "density"}
133
+
134
+
135
+ class TestIntraNoteAnchorLinks:
136
+ """[[#Heading]] is a same-note reference, not a link to another note.
137
+
138
+ It is neither an edge nor a broken link, so counting it as unresolved inflates the
139
+ check gate's max_unresolved_links threshold (measured: 19% of a real vault's total).
140
+ """
141
+
142
+ def test_anchor_only_link_is_not_unresolved(self, tmp_path):
143
+ _write(tmp_path, "a.md", "# Top\n\nJump to [[#Section]].\n\n## Section\n")
144
+ graph = _build(tmp_path)
145
+ assert graph.unresolved == {}
146
+
147
+ def test_anchor_only_link_with_alias_is_not_unresolved(self, tmp_path):
148
+ _write(tmp_path, "a.md", "See [[#Design cruxes|crux 2]].\n")
149
+ assert _build(tmp_path).unresolved == {}
150
+
151
+ def test_block_reference_is_not_unresolved(self, tmp_path):
152
+ _write(tmp_path, "a.md", "See [[#^abc123]].\n")
153
+ assert _build(tmp_path).unresolved == {}
154
+
155
+ def test_anchor_only_link_creates_no_edge(self, tmp_path):
156
+ _write(tmp_path, "a.md", "Jump to [[#Section]].\n")
157
+ graph = _build(tmp_path)
158
+ assert graph.out_links["a.md"] == set()
159
+
160
+ def test_whitespace_only_display_is_ignored(self, tmp_path):
161
+ _write(tmp_path, "a.md", "Odd [[ ]] link.\n")
162
+ assert _build(tmp_path).unresolved == {}
163
+
164
+ def test_cross_note_anchor_still_resolves(self, tmp_path):
165
+ _write(tmp_path, "a.md", "See [[b#Section]].\n")
166
+ _write(tmp_path, "b.md", "# B\n\n## Section\n")
167
+ graph = _build(tmp_path)
168
+ assert graph.unresolved == {}
169
+ assert graph.out_links["a.md"] == {"b.md"}
170
+
171
+ def test_cross_note_anchor_to_a_missing_note_is_still_unresolved(self, tmp_path):
172
+ _write(tmp_path, "a.md", "See [[Nowhere#Section]].\n")
173
+ assert _build(tmp_path).unresolved == {"a.md": ["Nowhere#Section"]}
174
+
175
+ def test_mixed_note_keeps_only_the_real_break(self, tmp_path):
176
+ _write(tmp_path, "a.md", "[[#Local]] and [[Missing]] and [[#Other|x]].\n")
177
+ assert _build(tmp_path).unresolved == {"a.md": ["Missing"]}
178
+
179
+
180
+ class TestNonMarkdownTargets:
181
+ """A link to a file graphmark does not index is out of scope, not broken.
182
+
183
+ Obsidian wikilinks legitimately target Bases (.base), Canvas (.canvas), images and PDFs.
184
+ graphmark only indexes *.md, so it has no basis to call those links broken — on a real
185
+ vault they were 10% of the reported total, every one of them a working link.
186
+ """
187
+
188
+ @pytest.mark.parametrize(
189
+ "display",
190
+ [
191
+ "Stale Side-Projects.base",
192
+ "Board.canvas",
193
+ "diagram.png",
194
+ "spec.pdf",
195
+ "sketch.excalidraw",
196
+ "People Missing Context.base|People Missing Context",
197
+ "Board.canvas#Section",
198
+ ],
199
+ )
200
+ def test_non_markdown_target_is_not_unresolved(self, tmp_path, display):
201
+ _write(tmp_path, "a.md", f"See [[{display}]].\n")
202
+ graph = _build(tmp_path)
203
+ assert graph.unresolved == {}
204
+ assert graph.out_links["a.md"] == set()
205
+
206
+ def test_md_targets_are_exempt_from_the_rule(self, tmp_path):
207
+ # .md is explicitly excluded from the non-note rule, so a link ending in .md keeps
208
+ # whatever the resolver decides. graphmark does not currently resolve a trailing
209
+ # ".md" (an Obsidian-compatibility gap tracked separately) — the point here is only
210
+ # that this rule does not silently swallow it.
211
+ _write(tmp_path, "a.md", "See [[b.md]].\n")
212
+ _write(tmp_path, "b.md", "Target.\n")
213
+ assert _build(tmp_path).unresolved == {"a.md": ["b.md"]}
214
+
215
+ def test_missing_md_target_is_still_unresolved(self, tmp_path):
216
+ _write(tmp_path, "a.md", "See [[Nowhere.md]].\n")
217
+ assert _build(tmp_path).unresolved == {"a.md": ["Nowhere.md"]}
218
+
219
+ def test_dotted_title_is_not_mistaken_for_a_file_extension(self, tmp_path):
220
+ # "v1.2 release notes" has spaces after the dot — it is a note title, so a missing
221
+ # one must still be reported.
222
+ _write(tmp_path, "a.md", "See [[v1.2 release notes]].\n")
223
+ assert _build(tmp_path).unresolved == {"a.md": ["v1.2 release notes"]}
224
+
225
+ def test_a_resolvable_dotted_note_is_never_suppressed(self, tmp_path):
226
+ # The rule only applies after the resolver fails, so a real note wins.
227
+ _write(tmp_path, "a.md", "See [[report.v2]].\n")
228
+ _write(tmp_path, "report.v2.md", "Target.\n")
229
+ graph = _build(tmp_path)
230
+ assert graph.unresolved == {}
231
+ assert graph.out_links["a.md"] == {"report.v2.md"}
232
+
233
+ def test_mixed_note_keeps_only_the_missing_note(self, tmp_path):
234
+ _write(tmp_path, "a.md", "[[Chart.base]] and [[Missing]] and [[#local]].\n")
235
+ assert _build(tmp_path).unresolved == {"a.md": ["Missing"]}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes