graphmark 0.3.3__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.3 → graphmark-0.3.4}/CHANGELOG.md +34 -0
  2. {graphmark-0.3.3 → graphmark-0.3.4}/PKG-INFO +1 -1
  3. {graphmark-0.3.3 → graphmark-0.3.4}/pyproject.toml +1 -1
  4. {graphmark-0.3.3 → graphmark-0.3.4}/src/graphmark/graph.py +54 -16
  5. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_unresolved.py +103 -0
  6. {graphmark-0.3.3 → graphmark-0.3.4}/.gitignore +0 -0
  7. {graphmark-0.3.3 → graphmark-0.3.4}/LICENSE +0 -0
  8. {graphmark-0.3.3 → graphmark-0.3.4}/README.md +0 -0
  9. {graphmark-0.3.3 → graphmark-0.3.4}/docs/ROADMAP.md +0 -0
  10. {graphmark-0.3.3 → graphmark-0.3.4}/src/graphmark/__init__.py +0 -0
  11. {graphmark-0.3.3 → graphmark-0.3.4}/src/graphmark/check.py +0 -0
  12. {graphmark-0.3.3 → graphmark-0.3.4}/src/graphmark/cli.py +0 -0
  13. {graphmark-0.3.3 → graphmark-0.3.4}/src/graphmark/config.py +0 -0
  14. {graphmark-0.3.3 → graphmark-0.3.4}/src/graphmark/dismiss.py +0 -0
  15. {graphmark-0.3.3 → graphmark-0.3.4}/src/graphmark/export.py +0 -0
  16. {graphmark-0.3.3 → graphmark-0.3.4}/src/graphmark/interfaces.py +0 -0
  17. {graphmark-0.3.3 → graphmark-0.3.4}/src/graphmark/metrics.py +0 -0
  18. {graphmark-0.3.3 → graphmark-0.3.4}/src/graphmark/model.py +0 -0
  19. {graphmark-0.3.3 → graphmark-0.3.4}/src/graphmark/parse.py +0 -0
  20. {graphmark-0.3.3 → graphmark-0.3.4}/src/graphmark/py.typed +0 -0
  21. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/README.md +0 -0
  22. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/alt/config.toml +0 -0
  23. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/alt/expected.json +0 -0
  24. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
  25. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
  26. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/alt/vault/docs/center.md +0 -0
  27. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/alt/vault/misc/echo.md +0 -0
  28. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
  29. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
  30. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/alt/vault/misc/stub.md +0 -0
  31. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/alt/vault/refs/beta.md +0 -0
  32. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/alt/vault/refs/delta.md +0 -0
  33. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
  34. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/dismiss/expected.json +0 -0
  35. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
  36. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/dismiss/vault/alpha.md +0 -0
  37. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/dismiss/vault/beta.md +0 -0
  38. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/dismiss/vault/gamma.md +0 -0
  39. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/gaps/config.toml +0 -0
  40. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/gaps/expected.json +0 -0
  41. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/gaps/expected_oog.json +0 -0
  42. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/gaps/similar.json +0 -0
  43. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/gaps/similar_oog.json +0 -0
  44. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/gaps/vault/docs/a.md +0 -0
  45. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
  46. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/gaps/vault/misc/c.md +0 -0
  47. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/gaps/vault/misc/d.md +0 -0
  48. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/gaps/vault/refs/b.md +0 -0
  49. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/gaps/vault/refs/e.md +0 -0
  50. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/scoped/config.toml +0 -0
  51. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/scoped/expected.json +0 -0
  52. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/scoped/vault/docs/one.md +0 -0
  53. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
  54. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
  55. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/scoped/vault/refs/two.md +0 -0
  56. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/selflink/config.toml +0 -0
  57. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/selflink/expected.json +0 -0
  58. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/selflink/vault/a.md +0 -0
  59. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/selflink/vault/b.md +0 -0
  60. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/simple/config.toml +0 -0
  61. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/simple/expected.json +0 -0
  62. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
  63. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/simple/vault/brain/hub.md +0 -0
  64. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/simple/vault/personal/beta.md +0 -0
  65. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
  66. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/simple/vault/reference/island.md +0 -0
  67. {graphmark-0.3.3 → graphmark-0.3.4}/tests/fixtures/simple/vault/reference/stub.md +0 -0
  68. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_build_filters.py +0 -0
  69. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_check.py +0 -0
  70. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_check_policy.py +0 -0
  71. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_cli.py +0 -0
  72. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_config.py +0 -0
  73. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_degenerate.py +0 -0
  74. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_dismiss.py +0 -0
  75. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_export.py +0 -0
  76. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_gaps.py +0 -0
  77. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_gaps_banding.py +0 -0
  78. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_gaps_oog.py +0 -0
  79. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_gaps_params.py +0 -0
  80. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_graph.py +0 -0
  81. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_metrics.py +0 -0
  82. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_model.py +0 -0
  83. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_packaging.py +0 -0
  84. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_pagerank.py +0 -0
  85. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_parse.py +0 -0
  86. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_public_api.py +0 -0
  87. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_scoped.py +0 -0
  88. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_selflink.py +0 -0
  89. {graphmark-0.3.3 → graphmark-0.3.4}/tests/test_smoke.py +0 -0
@@ -1,6 +1,40 @@
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
+
4
38
  ## v0.3.3 (2026-07-25)
5
39
 
6
40
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphmark
3
- Version: 0.3.3
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.3"
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,15 +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]
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")]
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)
92
122
 
93
123
  if "/" in display:
94
124
  # Path-suffix resolution: find unique rel_path ending with "display.md"
@@ -142,13 +172,19 @@ class VaultGraph:
142
172
 
143
173
  scoped = set(config.scoped_folders)
144
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]] = {}
145
179
  for path in sorted(root.rglob("*.md")):
146
- rel_parts = path.relative_to(root).parts
147
- if scoped and rel_parts[0] not in scoped:
148
- continue
149
- if any(p in excluded for p in rel_parts[:-1]):
150
- continue
151
- 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())
152
188
  continue
153
189
  md_files.append(path)
154
190
 
@@ -167,7 +203,9 @@ class VaultGraph:
167
203
  continue
168
204
  target = resolver.resolve(display, catalog)
169
205
  if target is None:
170
- 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
+ ):
171
209
  continue # out of scope, not a broken note link
172
210
  # Unresolvable OR ambiguous — the Resolver protocol conflates the two, and
173
211
  # both are equally broken from a vault-health view. Record the raw display
@@ -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
File without changes