graphmark 0.3.1__tar.gz → 0.3.3__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.1 → graphmark-0.3.3}/CHANGELOG.md +55 -0
  2. {graphmark-0.3.1 → graphmark-0.3.3}/PKG-INFO +1 -1
  3. {graphmark-0.3.1 → graphmark-0.3.3}/pyproject.toml +1 -1
  4. {graphmark-0.3.1 → graphmark-0.3.3}/src/graphmark/graph.py +29 -0
  5. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_graph.py +47 -0
  6. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_unresolved.py +58 -0
  7. {graphmark-0.3.1 → graphmark-0.3.3}/.gitignore +0 -0
  8. {graphmark-0.3.1 → graphmark-0.3.3}/LICENSE +0 -0
  9. {graphmark-0.3.1 → graphmark-0.3.3}/README.md +0 -0
  10. {graphmark-0.3.1 → graphmark-0.3.3}/docs/ROADMAP.md +0 -0
  11. {graphmark-0.3.1 → graphmark-0.3.3}/src/graphmark/__init__.py +0 -0
  12. {graphmark-0.3.1 → graphmark-0.3.3}/src/graphmark/check.py +0 -0
  13. {graphmark-0.3.1 → graphmark-0.3.3}/src/graphmark/cli.py +0 -0
  14. {graphmark-0.3.1 → graphmark-0.3.3}/src/graphmark/config.py +0 -0
  15. {graphmark-0.3.1 → graphmark-0.3.3}/src/graphmark/dismiss.py +0 -0
  16. {graphmark-0.3.1 → graphmark-0.3.3}/src/graphmark/export.py +0 -0
  17. {graphmark-0.3.1 → graphmark-0.3.3}/src/graphmark/interfaces.py +0 -0
  18. {graphmark-0.3.1 → graphmark-0.3.3}/src/graphmark/metrics.py +0 -0
  19. {graphmark-0.3.1 → graphmark-0.3.3}/src/graphmark/model.py +0 -0
  20. {graphmark-0.3.1 → graphmark-0.3.3}/src/graphmark/parse.py +0 -0
  21. {graphmark-0.3.1 → graphmark-0.3.3}/src/graphmark/py.typed +0 -0
  22. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/README.md +0 -0
  23. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/alt/config.toml +0 -0
  24. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/alt/expected.json +0 -0
  25. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
  26. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
  27. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/alt/vault/docs/center.md +0 -0
  28. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/alt/vault/misc/echo.md +0 -0
  29. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
  30. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
  31. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/alt/vault/misc/stub.md +0 -0
  32. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/alt/vault/refs/beta.md +0 -0
  33. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/alt/vault/refs/delta.md +0 -0
  34. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
  35. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/dismiss/expected.json +0 -0
  36. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
  37. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/dismiss/vault/alpha.md +0 -0
  38. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/dismiss/vault/beta.md +0 -0
  39. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/dismiss/vault/gamma.md +0 -0
  40. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/gaps/config.toml +0 -0
  41. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/gaps/expected.json +0 -0
  42. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/gaps/expected_oog.json +0 -0
  43. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/gaps/similar.json +0 -0
  44. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/gaps/similar_oog.json +0 -0
  45. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/gaps/vault/docs/a.md +0 -0
  46. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
  47. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/gaps/vault/misc/c.md +0 -0
  48. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/gaps/vault/misc/d.md +0 -0
  49. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/gaps/vault/refs/b.md +0 -0
  50. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/gaps/vault/refs/e.md +0 -0
  51. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/scoped/config.toml +0 -0
  52. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/scoped/expected.json +0 -0
  53. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/scoped/vault/docs/one.md +0 -0
  54. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
  55. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
  56. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/scoped/vault/refs/two.md +0 -0
  57. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/selflink/config.toml +0 -0
  58. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/selflink/expected.json +0 -0
  59. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/selflink/vault/a.md +0 -0
  60. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/selflink/vault/b.md +0 -0
  61. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/simple/config.toml +0 -0
  62. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/simple/expected.json +0 -0
  63. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
  64. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/simple/vault/brain/hub.md +0 -0
  65. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/simple/vault/personal/beta.md +0 -0
  66. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
  67. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/simple/vault/reference/island.md +0 -0
  68. {graphmark-0.3.1 → graphmark-0.3.3}/tests/fixtures/simple/vault/reference/stub.md +0 -0
  69. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_build_filters.py +0 -0
  70. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_check.py +0 -0
  71. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_check_policy.py +0 -0
  72. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_cli.py +0 -0
  73. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_config.py +0 -0
  74. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_degenerate.py +0 -0
  75. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_dismiss.py +0 -0
  76. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_export.py +0 -0
  77. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_gaps.py +0 -0
  78. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_gaps_banding.py +0 -0
  79. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_gaps_oog.py +0 -0
  80. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_gaps_params.py +0 -0
  81. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_metrics.py +0 -0
  82. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_model.py +0 -0
  83. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_packaging.py +0 -0
  84. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_pagerank.py +0 -0
  85. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_parse.py +0 -0
  86. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_public_api.py +0 -0
  87. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_scoped.py +0 -0
  88. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_selflink.py +0 -0
  89. {graphmark-0.3.1 → graphmark-0.3.3}/tests/test_smoke.py +0 -0
@@ -1,6 +1,61 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.3.3 (2026-07-25)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **resolve**: [[note.md]] resolves like [[Note]]
9
+ ([#105](https://github.com/cdcoonce/graphmark/pull/105),
10
+ [`916da52`](https://github.com/cdcoonce/graphmark/commit/916da52465cc3c5295a9440ae86af1ee6065e1d5))
11
+
12
+ Obsidian accepts an explicit .md extension, so [[Note.md]] and [[Note]] are the same link. graphmark
13
+ resolved only the second: the normalizer turned "Note.md" into the key "note md" while the file's
14
+ catalog key is "note", so the link never matched and was reported as broken. The path-suffix
15
+ branch was worse — it appends ".md" itself, so [[folder/note.md]] searched for folder/note.md.md.
16
+
17
+ Strip a case-insensitive trailing ".md" after alias/anchor stripping and before both resolution
18
+ branches. A missing [[Nowhere.md]] stays unresolved and an ambiguous stem stays ambiguous; a title
19
+ merely ending in the word "MD" is untouched.
20
+
21
+ Parity-safe: no frozen fixture uses a .md-style link, so every expected.json is unchanged (81 oracle
22
+ tests green). The change only adds resolutions that previously failed — it never removes one.
23
+
24
+ Also updates the test added in #101 that documented this gap as current behavior; it now asserts the
25
+ fix.
26
+
27
+ Closes #104
28
+
29
+
30
+ ## v0.3.2 (2026-07-25)
31
+
32
+ ### Bug Fixes
33
+
34
+ - **graph**: Links to non-markdown files are out of scope, not broken
35
+ ([#102](https://github.com/cdcoonce/graphmark/pull/102),
36
+ [`0796aa0`](https://github.com/cdcoonce/graphmark/commit/0796aa02d1d582b85a8905fb4b43b72b076bd072))
37
+
38
+ unresolved reported wikilinks targeting Obsidian Bases, Canvas, images and PDFs as broken. graphmark
39
+ only indexes *.md, so it has no basis to judge those targets — calling them broken just fills the
40
+ vault-health count with entries nobody can act on.
41
+
42
+ Measured on the owner's live vault: 17 of 173 reported breaks (10%) targeted .base/.canvas, and all
43
+ 9 distinct targets exist on disk, in a bases/ directory outside scoped_folders. Every one of those
44
+ links works in Obsidian.
45
+
46
+ When the resolver returns None and the target ends in a plausible file extension other than .md,
47
+ treat it as out of scope: no edge, not counted. The extension test is deliberately strict
48
+ (trailing dot plus 1-10 alphanumerics) so a title like "v1.2 release notes" is still read as a
49
+ note. Applying the rule only after the resolver has already failed is what makes it safe — a note
50
+ that genuinely resolves, such as a real report.v2.md linked as [[report.v2]], never reaches it.
51
+
52
+ Deliberately no filesystem existence check: it would couple build() to the disk and force
53
+ enumerating excluded trees like .git. "Out of scope" is the honest report for a file type
54
+ graphmark does not index.
55
+
56
+ Closes #101
57
+
58
+
4
59
  ## v0.3.1 (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.1
3
+ Version: 0.3.3
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.1"
3
+ version = "0.3.3"
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,27 @@ 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
+
21
43
  def _is_intra_note_reference(display: str) -> bool:
22
44
  """True for a link that targets no note, e.g. ``[[#Heading]]`` or ``[[#^block]]``.
23
45
 
@@ -62,6 +84,11 @@ class NormalizeResolver:
62
84
  display = display.split("|")[0]
63
85
  # Strip anchor: "Note#Section" → "Note"
64
86
  display = display.split("#")[0]
87
+ # Obsidian accepts an explicit extension, so "Note.md" is the same link as "Note".
88
+ # Stripped before both branches below: the path-suffix branch appends ".md" itself,
89
+ # and the bare branch would otherwise normalize to the key "note md".
90
+ if display.lower().endswith(".md"):
91
+ display = display[: -len(".md")]
65
92
 
66
93
  if "/" in display:
67
94
  # Path-suffix resolution: find unique rel_path ending with "display.md"
@@ -140,6 +167,8 @@ class VaultGraph:
140
167
  continue
141
168
  target = resolver.resolve(display, catalog)
142
169
  if target is None:
170
+ if _targets_non_note_file(display):
171
+ continue # out of scope, not a broken note link
143
172
  # Unresolvable OR ambiguous — the Resolver protocol conflates the two, and
144
173
  # both are equally broken from a vault-health view. Record the raw display
145
174
  # (what a human has to go fix) once per occurrence.
@@ -190,3 +190,50 @@ class TestVaultGraphBuild:
190
190
  assert degree("personal/gamma.md") == 2
191
191
  assert degree("reference/island.md") == 0
192
192
  assert degree("reference/stub.md") == 0
193
+
194
+
195
+ class TestMarkdownExtensionInLinks:
196
+ """Obsidian treats [[Note.md]] and [[Note]] as the same link; so must the resolver."""
197
+
198
+ def setup_method(self):
199
+ self.resolver = NormalizeResolver()
200
+
201
+ def _catalog(self, *rel_paths: str) -> dict[str, list[str]]:
202
+ return build_catalog([_doc(p) for p in rel_paths])
203
+
204
+ def test_bare_link_with_md_extension_resolves(self):
205
+ catalog = self._catalog("brain/alpha.md")
206
+ assert self.resolver.resolve("alpha.md", catalog) == "brain/alpha.md"
207
+
208
+ def test_extension_match_is_case_insensitive(self):
209
+ catalog = self._catalog("brain/alpha.md")
210
+ assert self.resolver.resolve("Alpha.MD", catalog) == "brain/alpha.md"
211
+
212
+ def test_md_extension_with_alias_resolves(self):
213
+ catalog = self._catalog("brain/alpha.md")
214
+ assert self.resolver.resolve("alpha.md|the first note", catalog) == "brain/alpha.md"
215
+
216
+ def test_md_extension_with_anchor_resolves(self):
217
+ catalog = self._catalog("brain/alpha.md")
218
+ assert self.resolver.resolve("alpha.md#Section", catalog) == "brain/alpha.md"
219
+
220
+ def test_path_style_link_with_md_extension_resolves(self):
221
+ catalog = self._catalog("brain/alpha.md", "personal/beta.md")
222
+ assert self.resolver.resolve("brain/alpha.md", catalog) == "brain/alpha.md"
223
+
224
+ def test_missing_target_with_md_extension_stays_unresolved(self):
225
+ catalog = self._catalog("brain/alpha.md")
226
+ assert self.resolver.resolve("nowhere.md", catalog) is None
227
+
228
+ def test_ambiguous_stem_stays_ambiguous_with_the_extension(self):
229
+ catalog = {"note": ["dir1/note.md", "dir2/note.md"]}
230
+ assert self.resolver.resolve("note.md", catalog) is None
231
+
232
+ def test_extensionless_links_are_unaffected(self):
233
+ catalog = self._catalog("brain/alpha.md")
234
+ assert self.resolver.resolve("alpha", catalog) == "brain/alpha.md"
235
+
236
+ def test_a_title_ending_in_md_as_a_word_is_not_stripped(self):
237
+ # "Doctor MD" ends with "md" but not ".md" — it must not be truncated.
238
+ catalog = self._catalog("org/Doctor MD.md")
239
+ assert self.resolver.resolve("Doctor MD", catalog) == "org/Doctor MD.md"
@@ -175,3 +175,61 @@ class TestIntraNoteAnchorLinks:
175
175
  def test_mixed_note_keeps_only_the_real_break(self, tmp_path):
176
176
  _write(tmp_path, "a.md", "[[#Local]] and [[Missing]] and [[#Other|x]].\n")
177
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 excluded from the non-note rule, so a link ending in .md is left to the
208
+ # resolver — which now resolves it (a trailing ".md" is stripped, matching Obsidian).
209
+ _write(tmp_path, "a.md", "See [[b.md]].\n")
210
+ _write(tmp_path, "b.md", "Target.\n")
211
+ graph = _build(tmp_path)
212
+ assert graph.unresolved == {}
213
+ assert graph.out_links["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