graphmark 0.3.2__tar.gz → 0.3.4__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.2 → graphmark-0.3.4}/CHANGELOG.md +60 -0
  2. {graphmark-0.3.2 → graphmark-0.3.4}/PKG-INFO +1 -1
  3. {graphmark-0.3.2 → graphmark-0.3.4}/pyproject.toml +1 -1
  4. {graphmark-0.3.2 → graphmark-0.3.4}/src/graphmark/graph.py +54 -11
  5. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_graph.py +47 -0
  6. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_unresolved.py +108 -5
  7. {graphmark-0.3.2 → graphmark-0.3.4}/.gitignore +0 -0
  8. {graphmark-0.3.2 → graphmark-0.3.4}/LICENSE +0 -0
  9. {graphmark-0.3.2 → graphmark-0.3.4}/README.md +0 -0
  10. {graphmark-0.3.2 → graphmark-0.3.4}/docs/ROADMAP.md +0 -0
  11. {graphmark-0.3.2 → graphmark-0.3.4}/src/graphmark/__init__.py +0 -0
  12. {graphmark-0.3.2 → graphmark-0.3.4}/src/graphmark/check.py +0 -0
  13. {graphmark-0.3.2 → graphmark-0.3.4}/src/graphmark/cli.py +0 -0
  14. {graphmark-0.3.2 → graphmark-0.3.4}/src/graphmark/config.py +0 -0
  15. {graphmark-0.3.2 → graphmark-0.3.4}/src/graphmark/dismiss.py +0 -0
  16. {graphmark-0.3.2 → graphmark-0.3.4}/src/graphmark/export.py +0 -0
  17. {graphmark-0.3.2 → graphmark-0.3.4}/src/graphmark/interfaces.py +0 -0
  18. {graphmark-0.3.2 → graphmark-0.3.4}/src/graphmark/metrics.py +0 -0
  19. {graphmark-0.3.2 → graphmark-0.3.4}/src/graphmark/model.py +0 -0
  20. {graphmark-0.3.2 → graphmark-0.3.4}/src/graphmark/parse.py +0 -0
  21. {graphmark-0.3.2 → graphmark-0.3.4}/src/graphmark/py.typed +0 -0
  22. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/README.md +0 -0
  23. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/alt/config.toml +0 -0
  24. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/alt/expected.json +0 -0
  25. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
  26. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
  27. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/alt/vault/docs/center.md +0 -0
  28. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/alt/vault/misc/echo.md +0 -0
  29. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
  30. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
  31. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/alt/vault/misc/stub.md +0 -0
  32. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/alt/vault/refs/beta.md +0 -0
  33. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/alt/vault/refs/delta.md +0 -0
  34. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
  35. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/dismiss/expected.json +0 -0
  36. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
  37. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/dismiss/vault/alpha.md +0 -0
  38. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/dismiss/vault/beta.md +0 -0
  39. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/dismiss/vault/gamma.md +0 -0
  40. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/gaps/config.toml +0 -0
  41. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/gaps/expected.json +0 -0
  42. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/gaps/expected_oog.json +0 -0
  43. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/gaps/similar.json +0 -0
  44. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/gaps/similar_oog.json +0 -0
  45. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/gaps/vault/docs/a.md +0 -0
  46. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
  47. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/gaps/vault/misc/c.md +0 -0
  48. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/gaps/vault/misc/d.md +0 -0
  49. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/gaps/vault/refs/b.md +0 -0
  50. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/gaps/vault/refs/e.md +0 -0
  51. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/scoped/config.toml +0 -0
  52. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/scoped/expected.json +0 -0
  53. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/scoped/vault/docs/one.md +0 -0
  54. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
  55. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
  56. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/scoped/vault/refs/two.md +0 -0
  57. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/selflink/config.toml +0 -0
  58. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/selflink/expected.json +0 -0
  59. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/selflink/vault/a.md +0 -0
  60. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/selflink/vault/b.md +0 -0
  61. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/simple/config.toml +0 -0
  62. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/simple/expected.json +0 -0
  63. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
  64. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/simple/vault/brain/hub.md +0 -0
  65. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/simple/vault/personal/beta.md +0 -0
  66. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
  67. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/simple/vault/reference/island.md +0 -0
  68. {graphmark-0.3.2 → graphmark-0.3.4}/tests/fixtures/simple/vault/reference/stub.md +0 -0
  69. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_build_filters.py +0 -0
  70. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_check.py +0 -0
  71. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_check_policy.py +0 -0
  72. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_cli.py +0 -0
  73. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_config.py +0 -0
  74. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_degenerate.py +0 -0
  75. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_dismiss.py +0 -0
  76. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_export.py +0 -0
  77. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_gaps.py +0 -0
  78. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_gaps_banding.py +0 -0
  79. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_gaps_oog.py +0 -0
  80. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_gaps_params.py +0 -0
  81. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_metrics.py +0 -0
  82. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_model.py +0 -0
  83. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_packaging.py +0 -0
  84. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_pagerank.py +0 -0
  85. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_parse.py +0 -0
  86. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_public_api.py +0 -0
  87. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_scoped.py +0 -0
  88. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_selflink.py +0 -0
  89. {graphmark-0.3.2 → graphmark-0.3.4}/tests/test_smoke.py +0 -0
@@ -1,6 +1,66 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.3.4 (2026-07-25)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **graph**: Links to existing out-of-scope notes are not broken (#107)
9
+ ([#108](https://github.com/cdcoonce/graphmark/pull/108),
10
+ [`9e386f6`](https://github.com/cdcoonce/graphmark/commit/9e386f6df700b1d866b7d50854b0cc8734a08bf5))
11
+
12
+ build() dropped unscoped folders, excluded dirs and rules files from the catalog and then forgot
13
+ they existed, so a link to one failed the resolver and landed in unresolved. The link is correct —
14
+ Obsidian follows it — it just points somewhere graphmark deliberately does not index, so there was
15
+ nothing for anyone to fix. On the live vault that was 11 of 155 reported breaks: 8 [[CLAUDE]], 1
16
+ [[AGENTS]], 2 into templates/.
17
+
18
+ The same rglob that builds the catalog now records what it skipped, so no extra I/O buys the ability
19
+ to tell "exists but out of scope" apart from "exists nowhere". Consulted only AFTER the resolver
20
+ fails — mirroring _targets_non_note_file — so an in-graph note always wins over an out-of-scope
21
+ namesake and keeps its edge. Any candidate suppresses: out-of-scope notes are never link targets,
22
+ so ambiguity among them says nothing about whether the in-graph link is broken.
23
+
24
+ Alias/anchor/.md stripping moved into a shared _strip_display so the resolver and the new check
25
+ cannot drift on what a display names. That refactor also strips surrounding whitespace, which the
26
+ resolver did not: 13 column-aligned links of the form [[folder/note | alias]] were reported broken
27
+ while pointing at real notes, and now resolve into edges (3570 → 3583 on the live vault). Covered
28
+ by its own test class.
29
+
30
+ Fixtures are untouched: only the unresolved path changes shape, no expected.json carries an
31
+ unresolved key, and edges only grow via the whitespace fix. uv.lock catches up to the 0.3.3
32
+ release bump.
33
+
34
+ Live vault: 155 → 130 unresolved (12 suppressed, 13 resolved), no genuine break lost — verified by
35
+ diffing the full before/after sets.
36
+
37
+
38
+ ## v0.3.3 (2026-07-25)
39
+
40
+ ### Bug Fixes
41
+
42
+ - **resolve**: [[note.md]] resolves like [[Note]]
43
+ ([#105](https://github.com/cdcoonce/graphmark/pull/105),
44
+ [`916da52`](https://github.com/cdcoonce/graphmark/commit/916da52465cc3c5295a9440ae86af1ee6065e1d5))
45
+
46
+ Obsidian accepts an explicit .md extension, so [[Note.md]] and [[Note]] are the same link. graphmark
47
+ resolved only the second: the normalizer turned "Note.md" into the key "note md" while the file's
48
+ catalog key is "note", so the link never matched and was reported as broken. The path-suffix
49
+ branch was worse — it appends ".md" itself, so [[folder/note.md]] searched for folder/note.md.md.
50
+
51
+ Strip a case-insensitive trailing ".md" after alias/anchor stripping and before both resolution
52
+ branches. A missing [[Nowhere.md]] stays unresolved and an ambiguous stem stays ambiguous; a title
53
+ merely ending in the word "MD" is untouched.
54
+
55
+ Parity-safe: no frozen fixture uses a .md-style link, so every expected.json is unchanged (81 oracle
56
+ tests green). The change only adds resolutions that previously failed — it never removes one.
57
+
58
+ Also updates the test added in #101 that documented this gap as current behavior; it now asserts the
59
+ fix.
60
+
61
+ Closes #104
62
+
63
+
4
64
  ## v0.3.2 (2026-07-25)
5
65
 
6
66
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphmark
3
- Version: 0.3.2
3
+ Version: 0.3.4
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.2"
3
+ version = "0.3.4"
4
4
  description = "Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -25,6 +25,19 @@ def _normalize(text: str) -> str:
25
25
  _FILE_SUFFIX_RE = re.compile(r"\.[A-Za-z0-9]{1,10}$")
26
26
 
27
27
 
28
+ def _strip_display(display: str) -> str:
29
+ """Reduce a raw wikilink display to its note part: no alias, no anchor, no ``.md``.
30
+
31
+ Obsidian treats ``[[Note]]``, ``[[Note|alias]]``, ``[[Note#Section]]`` and ``[[Note.md]]`` as
32
+ the same link, so every place that asks "which note does this display name?" must strip the
33
+ same three things. Shared by the resolver and the out-of-scope check so they cannot drift.
34
+ """
35
+ target = display.split("|")[0].split("#")[0].strip()
36
+ if target.lower().endswith(".md"):
37
+ target = target[: -len(".md")]
38
+ return target
39
+
40
+
28
41
  def _targets_non_note_file(display: str) -> bool:
29
42
  """True for a link to a file graphmark does not index, e.g. ``[[Board.canvas]]``.
30
43
 
@@ -40,6 +53,28 @@ def _targets_non_note_file(display: str) -> bool:
40
53
  return bool(match) and match.group(0).lower() != ".md"
41
54
 
42
55
 
56
+ def _targets_out_of_scope_note(display: str, out_of_scope: dict[str, list[str]]) -> bool:
57
+ """True for a link to a markdown note that exists but is outside the configured scope.
58
+
59
+ ``build`` drops unscoped folders, excluded dirs and rules files from the catalog, so links to
60
+ them fail the resolver — yet the link is correct, Obsidian follows it, and there is nothing for
61
+ anyone to fix. Reporting it as broken just fills the vault-health count with noise.
62
+
63
+ Only ever consulted after the resolver has already failed, so an in-graph note always wins over
64
+ an out-of-scope namesake. **Any** candidate suppresses: out-of-scope notes are never link
65
+ targets, so ambiguity among them says nothing about whether the in-graph link is broken.
66
+ """
67
+ target = _strip_display(display)
68
+ if not target:
69
+ return False
70
+ if "/" in target:
71
+ suffix = target.lower() + ".md"
72
+ return any(
73
+ path.lower().endswith(suffix) for paths in out_of_scope.values() for path in paths
74
+ )
75
+ return _normalize(target) in out_of_scope
76
+
77
+
43
78
  def _is_intra_note_reference(display: str) -> bool:
44
79
  """True for a link that targets no note, e.g. ``[[#Heading]]`` or ``[[#^block]]``.
45
80
 
@@ -80,10 +115,10 @@ class NormalizeResolver:
80
115
  return self._flat_cache
81
116
 
82
117
  def resolve(self, display: str, catalog: dict[str, list[str]]) -> str | None:
83
- # Strip alias: "Note|alias" "Note"
84
- display = display.split("|")[0]
85
- # Strip anchor: "Note#Section" "Note"
86
- display = display.split("#")[0]
118
+ # Alias ("Note|alias"), anchor ("Note#Section") and an explicit ".md" extension all name
119
+ # the same note. Stripped before both branches below: the path-suffix branch appends
120
+ # ".md" itself, and the bare branch would otherwise normalize to the key "note md".
121
+ display = _strip_display(display)
87
122
 
88
123
  if "/" in display:
89
124
  # Path-suffix resolution: find unique rel_path ending with "display.md"
@@ -137,13 +172,19 @@ class VaultGraph:
137
172
 
138
173
  scoped = set(config.scoped_folders)
139
174
  md_files: list[Path] = []
175
+ # Markdown that exists but is out of scope, normalized stem → rel_paths. Collected in
176
+ # this same walk (no extra I/O) so a link to one can be told apart from a link to a note
177
+ # that exists nowhere at all.
178
+ out_of_scope: dict[str, list[str]] = {}
140
179
  for path in sorted(root.rglob("*.md")):
141
- rel_parts = path.relative_to(root).parts
142
- if scoped and rel_parts[0] not in scoped:
143
- continue
144
- if any(p in excluded for p in rel_parts[:-1]):
145
- continue
146
- if path.name in rules:
180
+ rel = path.relative_to(root)
181
+ rel_parts = rel.parts
182
+ if (
183
+ (scoped and rel_parts[0] not in scoped)
184
+ or any(p in excluded for p in rel_parts[:-1])
185
+ or path.name in rules
186
+ ):
187
+ out_of_scope.setdefault(_normalize(path.stem), []).append(rel.as_posix())
147
188
  continue
148
189
  md_files.append(path)
149
190
 
@@ -162,7 +203,9 @@ class VaultGraph:
162
203
  continue
163
204
  target = resolver.resolve(display, catalog)
164
205
  if target is None:
165
- if _targets_non_note_file(display):
206
+ if _targets_non_note_file(display) or _targets_out_of_scope_note(
207
+ display, out_of_scope
208
+ ):
166
209
  continue # out of scope, not a broken note link
167
210
  # Unresolvable OR ambiguous — the Resolver protocol conflates the two, and
168
211
  # both are equally broken from a vault-health view. Record the raw display
@@ -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"
@@ -204,13 +204,13 @@ class TestNonMarkdownTargets:
204
204
  assert graph.out_links["a.md"] == set()
205
205
 
206
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.
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).
211
209
  _write(tmp_path, "a.md", "See [[b.md]].\n")
212
210
  _write(tmp_path, "b.md", "Target.\n")
213
- assert _build(tmp_path).unresolved == {"a.md": ["b.md"]}
211
+ graph = _build(tmp_path)
212
+ assert graph.unresolved == {}
213
+ assert graph.out_links["a.md"] == {"b.md"}
214
214
 
215
215
  def test_missing_md_target_is_still_unresolved(self, tmp_path):
216
216
  _write(tmp_path, "a.md", "See [[Nowhere.md]].\n")
@@ -233,3 +233,106 @@ class TestNonMarkdownTargets:
233
233
  def test_mixed_note_keeps_only_the_missing_note(self, tmp_path):
234
234
  _write(tmp_path, "a.md", "[[Chart.base]] and [[Missing]] and [[#local]].\n")
235
235
  assert _build(tmp_path).unresolved == {"a.md": ["Missing"]}
236
+
237
+
238
+ class TestPaddedDisplays:
239
+ """Whitespace around the note part must not stop a link from resolving.
240
+
241
+ Real vaults contain column-aligned tables — ``[[folder/note | alias]]`` — where the
242
+ note part carries trailing spaces. Both resolution branches now see the stripped display
243
+ (13 such links on a live vault were reported broken while pointing at real notes).
244
+ """
245
+
246
+ def test_padded_bare_display_resolves(self, tmp_path):
247
+ _write(tmp_path, "a.md", "See [[ b | the alias]].\n")
248
+ _write(tmp_path, "b.md", "Target.\n")
249
+ graph = _build(tmp_path)
250
+ assert graph.unresolved == {}
251
+ assert graph.out_links["a.md"] == {"b.md"}
252
+
253
+ def test_padded_path_qualified_display_resolves(self, tmp_path):
254
+ _write(tmp_path, "a.md", "See [[docs/b | the alias]].\n")
255
+ _write(tmp_path, "docs/b.md", "Target.\n")
256
+ graph = _build(tmp_path)
257
+ assert graph.unresolved == {}
258
+ assert graph.out_links["a.md"] == {"docs/b.md"}
259
+
260
+ def test_padded_md_extension_still_strips(self, tmp_path):
261
+ _write(tmp_path, "a.md", "See [[b.md | alias]].\n")
262
+ _write(tmp_path, "b.md", "Target.\n")
263
+ assert _build(tmp_path).out_links["a.md"] == {"b.md"}
264
+
265
+ def test_a_padded_missing_note_is_still_unresolved(self, tmp_path):
266
+ _write(tmp_path, "a.md", "See [[ Nowhere | alias]].\n")
267
+ assert _build(tmp_path).unresolved == {"a.md": [" Nowhere | alias"]}
268
+
269
+
270
+ class TestOutOfScopeNoteTargets:
271
+ """A link to a note that exists but is out of graph scope is out of scope, not broken.
272
+
273
+ build() drops unscoped folders, excluded dirs and rules files from the catalog and then
274
+ forgets they exist, so links to them fail the resolver. The link is correct — Obsidian
275
+ follows it — it just points somewhere graphmark deliberately does not index. On a real
276
+ vault these were 7% of the reported total ([[CLAUDE]], [[AGENTS]], a templates/ note).
277
+
278
+ Consulted only AFTER the resolver fails, so nothing that resolves in-graph is affected.
279
+ """
280
+
281
+ def test_link_to_a_rules_file_is_not_unresolved(self, tmp_path):
282
+ _write(tmp_path, "notes/a.md", "See [[CLAUDE]].\n")
283
+ _write(tmp_path, "CLAUDE.md", "Rules.\n")
284
+ graph = _build(tmp_path, rules_files=["CLAUDE.md"])
285
+ assert graph.unresolved == {}
286
+ assert graph.out_links["notes/a.md"] == set()
287
+
288
+ def test_link_into_an_unscoped_folder_is_not_unresolved(self, tmp_path):
289
+ _write(tmp_path, "docs/a.md", "See [[Intake Guide]].\n")
290
+ _write(tmp_path, "templates/Intake Guide.md", "Template.\n")
291
+ assert _build(tmp_path, scoped_folders=["docs"]).unresolved == {}
292
+
293
+ def test_link_into_an_excluded_dir_is_not_unresolved(self, tmp_path):
294
+ _write(tmp_path, "a.md", "See [[old thing]].\n")
295
+ _write(tmp_path, "archive/old thing.md", "Archived.\n")
296
+ assert _build(tmp_path, excluded_dirs=["archive"]).unresolved == {}
297
+
298
+ @pytest.mark.parametrize(
299
+ "display",
300
+ ["CLAUDE", "claude", "CLAUDE|the rules", "CLAUDE#Non-negotiables", "CLAUDE.md"],
301
+ )
302
+ def test_alias_anchor_case_and_md_forms_all_suppress(self, tmp_path, display):
303
+ _write(tmp_path, "notes/a.md", f"See [[{display}]].\n")
304
+ _write(tmp_path, "CLAUDE.md", "Rules.\n")
305
+ assert _build(tmp_path, rules_files=["CLAUDE.md"]).unresolved == {}
306
+
307
+ def test_path_qualified_link_to_an_out_of_scope_note_is_not_unresolved(self, tmp_path):
308
+ _write(tmp_path, "docs/a.md", "See [[templates/data/Intake Guide]].\n")
309
+ _write(tmp_path, "templates/data/Intake Guide.md", "Template.\n")
310
+ assert _build(tmp_path, scoped_folders=["docs"]).unresolved == {}
311
+
312
+ def test_an_ambiguous_out_of_scope_stem_still_suppresses(self, tmp_path):
313
+ # Out-of-scope notes are never link targets, so ambiguity among them says nothing
314
+ # about whether the in-graph link is broken. Any candidate suppresses.
315
+ _write(tmp_path, "docs/a.md", "See [[shared]].\n")
316
+ _write(tmp_path, "templates/one/shared.md", "")
317
+ _write(tmp_path, "templates/two/shared.md", "")
318
+ assert _build(tmp_path, scoped_folders=["docs"]).unresolved == {}
319
+
320
+ def test_a_note_that_exists_nowhere_is_still_unresolved(self, tmp_path):
321
+ _write(tmp_path, "docs/a.md", "See [[Nowhere]].\n")
322
+ _write(tmp_path, "templates/Something Else.md", "")
323
+ assert _build(tmp_path, scoped_folders=["docs"]).unresolved == {"docs/a.md": ["Nowhere"]}
324
+
325
+ def test_an_in_scope_note_still_wins_over_an_out_of_scope_namesake(self, tmp_path):
326
+ # The rule only applies after the resolver fails, so the real in-graph note wins and
327
+ # keeps its edge.
328
+ _write(tmp_path, "docs/a.md", "See [[guide]].\n")
329
+ _write(tmp_path, "docs/guide.md", "In scope.\n")
330
+ _write(tmp_path, "templates/guide.md", "Out of scope.\n")
331
+ graph = _build(tmp_path, scoped_folders=["docs"])
332
+ assert graph.unresolved == {}
333
+ assert graph.out_links["docs/a.md"] == {"docs/guide.md"}
334
+
335
+ def test_mixed_note_keeps_only_the_real_break(self, tmp_path):
336
+ _write(tmp_path, "docs/a.md", "[[Intake Guide]] and [[Missing]].\n")
337
+ _write(tmp_path, "templates/Intake Guide.md", "Template.\n")
338
+ assert _build(tmp_path, scoped_folders=["docs"]).unresolved == {"docs/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