graphmark 0.8.1__tar.gz → 0.9.0__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 (108) hide show
  1. {graphmark-0.8.1 → graphmark-0.9.0}/CHANGELOG.md +55 -0
  2. {graphmark-0.8.1 → graphmark-0.9.0}/PKG-INFO +8 -2
  3. {graphmark-0.8.1 → graphmark-0.9.0}/README.md +7 -1
  4. {graphmark-0.8.1 → graphmark-0.9.0}/docs/ROADMAP.md +1 -1
  5. {graphmark-0.8.1 → graphmark-0.9.0}/pyproject.toml +1 -1
  6. {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/config.py +5 -2
  7. {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/graph.py +75 -36
  8. graphmark-0.9.0/tests/test_autolinks.py +130 -0
  9. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_path_index.py +14 -7
  10. graphmark-0.9.0/tests/test_path_normalization.py +141 -0
  11. {graphmark-0.8.1 → graphmark-0.9.0}/.gitignore +0 -0
  12. {graphmark-0.8.1 → graphmark-0.9.0}/LICENSE +0 -0
  13. {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/__init__.py +0 -0
  14. {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/check.py +0 -0
  15. {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/cli.py +0 -0
  16. {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/dismiss.py +0 -0
  17. {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/export.py +0 -0
  18. {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/interfaces.py +0 -0
  19. {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/metrics.py +0 -0
  20. {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/model.py +0 -0
  21. {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/parse.py +0 -0
  22. {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/py.typed +0 -0
  23. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/README.md +0 -0
  24. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/config.toml +0 -0
  25. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/expected.json +0 -0
  26. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
  27. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
  28. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/vault/docs/center.md +0 -0
  29. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/vault/misc/echo.md +0 -0
  30. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
  31. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
  32. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/vault/misc/stub.md +0 -0
  33. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/vault/refs/beta.md +0 -0
  34. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/vault/refs/delta.md +0 -0
  35. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
  36. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/dismiss/expected.json +0 -0
  37. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
  38. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/dismiss/vault/alpha.md +0 -0
  39. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/dismiss/vault/beta.md +0 -0
  40. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/dismiss/vault/gamma.md +0 -0
  41. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/config.toml +0 -0
  42. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/expected.json +0 -0
  43. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/expected_oog.json +0 -0
  44. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/similar.json +0 -0
  45. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/similar_oog.json +0 -0
  46. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/vault/docs/a.md +0 -0
  47. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
  48. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/vault/misc/c.md +0 -0
  49. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/vault/misc/d.md +0 -0
  50. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/vault/refs/b.md +0 -0
  51. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/vault/refs/e.md +0 -0
  52. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/scoped/config.toml +0 -0
  53. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/scoped/expected.json +0 -0
  54. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/scoped/vault/docs/one.md +0 -0
  55. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
  56. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
  57. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/scoped/vault/refs/two.md +0 -0
  58. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/selflink/config.toml +0 -0
  59. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/selflink/expected.json +0 -0
  60. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/selflink/vault/a.md +0 -0
  61. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/selflink/vault/b.md +0 -0
  62. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/simple/config.toml +0 -0
  63. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/simple/expected.json +0 -0
  64. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
  65. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/simple/vault/brain/hub.md +0 -0
  66. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/simple/vault/personal/beta.md +0 -0
  67. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
  68. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/simple/vault/reference/island.md +0 -0
  69. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/simple/vault/reference/stub.md +0 -0
  70. {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/suggest/README.md +0 -0
  71. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_aliases.py +0 -0
  72. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_bom.py +0 -0
  73. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_build_filters.py +0 -0
  74. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_check.py +0 -0
  75. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_check_policy.py +0 -0
  76. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_cli.py +0 -0
  77. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_config.py +0 -0
  78. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_degenerate.py +0 -0
  79. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_diagnose.py +0 -0
  80. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_dismiss.py +0 -0
  81. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_export.py +0 -0
  82. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_gaps.py +0 -0
  83. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_gaps_banding.py +0 -0
  84. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_gaps_oog.py +0 -0
  85. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_gaps_params.py +0 -0
  86. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_graph.py +0 -0
  87. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_graph_catalogs.py +0 -0
  88. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_link_counts.py +0 -0
  89. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_links_report.py +0 -0
  90. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_markdown_links.py +0 -0
  91. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_metrics.py +0 -0
  92. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_model.py +0 -0
  93. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_non_note_file.py +0 -0
  94. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_packaging.py +0 -0
  95. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_pagerank.py +0 -0
  96. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_parse.py +0 -0
  97. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_path_suffix.py +0 -0
  98. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_properties.py +0 -0
  99. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_public_api.py +0 -0
  100. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_readme_examples.py +0 -0
  101. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_scoped.py +0 -0
  102. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_selflink.py +0 -0
  103. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_smoke.py +0 -0
  104. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_suggestions.py +0 -0
  105. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_unicode.py +0 -0
  106. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_unicode_punctuation.py +0 -0
  107. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_unread_syntax.py +0 -0
  108. {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_unresolved.py +0 -0
@@ -1,6 +1,61 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.9.0 (2026-07-26)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **graph**: Normalize path-qualified links component-wise (#146)
9
+ ([#162](https://github.com/cdcoonce/graphmark/pull/162),
10
+ [`a6cc158`](https://github.com/cdcoonce/graphmark/commit/a6cc1587cdbcf28a6b5360e2bec627a8df8f20c1))
11
+
12
+ Bare resolution went through `_normalize` — NFC, lowercase, punctuation and symbols folded — while
13
+ path resolution went through `_fold_case`, which folds only case and Unicode form. So `[[Q1 -
14
+ Review]]` found `Q1 — Review.md` but `[[notes/Q1 - Review]]` did not: the same title reachable or
15
+ not depending on whether the writer happened to qualify the link with a folder.
16
+
17
+ Both branches now compare **path components**, each through the one normalizer: the display's
18
+ components must be a suffix of the candidate's. That subsumes #136's boundary rule rather than
19
+ restating it — a suffix over lists cannot match mid-component — while letting every punctuation
20
+ equivalence `_normalize` already absorbs work in both branches.
21
+
22
+ The trap: `/` is structural. `[[a/b]]` must never reach `a-b.md`, even though `a/b` and `a-b`
23
+ normalize alike once a separator is folded. Components are normalized individually and the
24
+ separator is never one of them. Tested in both directions, and those tests passed BEFORE the
25
+ change — they are the guard, not the feature.
26
+
27
+ The index now buckets by normalized final component and caches each path's components, so
28
+ per-component normalization runs once per note rather than once per link.
29
+
30
+ Reference vault A/B'd on one snapshot: byte-identical (6230 resolved, 3725 edges, 0 unresolved).
31
+ blue-book markdown counts identical; build 267 -> 393 ms, still 7x better than the 2718 ms this
32
+ session started from.
33
+
34
+ ### Features
35
+
36
+ - **graph**: Add the markdown-autolinks dialect (#156)
37
+ ([#163](https://github.com/cdcoonce/graphmark/pull/163),
38
+ [`5ccf527`](https://github.com/cdcoonce/graphmark/commit/5ccf527576037915bd12485d205f2c7bf63b1c7f))
39
+
40
+ A markdown link is a relative path, but the widely-used mkdocs-autolinks plugin resolves a BARE
41
+ filename anywhere in the tree. blue-book runs it: 5.8% of its links resolve strictly, 92.7% by
42
+ name, 1.5% by neither.
43
+
44
+ Added as an explicit fourth `link_syntax` value, NOT a fallback. Trying a second rule when the first
45
+ fails is the shape of #136 — a quiet extra rule producing an edge to a note the link does not name
46
+ — and the 1.5% that match neither dialect show a fallback would not even have been complete. The
47
+ vault owner states which renderer they use, exactly as they already state their scope.
48
+
49
+ Only BARE targets take the plugin's rule; a target containing "/" keeps the relative rule in this
50
+ mode too, so a wrong qualified path is never rescued by a filename match elsewhere. Bare names go
51
+ through the existing catalog lookup, so ambiguity is refused exactly as it always was — no dialect
52
+ may invent a resolution.
53
+
54
+ blue-book, markdown -> markdown-autolinks: resolved 591 (5.8%) -> 10,001 (98.5%), edges 430 -> 4878,
55
+ orphans 790 -> 106, ambiguous 0, missing 9558 -> 148. Also faster (356 -> 234 ms): a bare name
56
+ hits the catalog dict instead of the path index.
57
+
58
+
4
59
  ## v0.8.1 (2026-07-26)
5
60
 
6
61
  ### Performance Improvements
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphmark
3
- Version: 0.8.1
3
+ Version: 0.9.0
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
@@ -119,7 +119,7 @@ graph, so set `link_syntax`:
119
119
 
120
120
  ```toml
121
121
  root = "."
122
- link_syntax = "both" # "wikilink" (default) | "markdown" | "both"
122
+ link_syntax = "both" # "wikilink" (default) | "markdown" | "both" | "markdown-autolinks"
123
123
  ```
124
124
 
125
125
  The two resolve differently, and the difference is not cosmetic:
@@ -129,6 +129,12 @@ The two resolve differently, and the difference is not cosmetic:
129
129
  - a **markdown** link is a path **relative to the linking note** — `[x](../other/b.md)` means that
130
130
  file and nothing else, so the same filename in two folders is not ambiguous at all.
131
131
 
132
+ `markdown-autolinks` is `markdown` with the [mkdocs-autolinks](https://github.com/zachhannum/mkdocs-autolinks-plugin)
133
+ plugin's rule for **bare** targets: `[tdd](tdd.md)` names a note anywhere in the tree, not a sibling
134
+ file. Set it only if your site actually runs that plugin — it is never applied as a fallback, because
135
+ a second rule tried after the first fails is how a link ends up pointing at a note it does not name.
136
+ A target that contains `/` keeps the relative rule in this mode too.
137
+
132
138
  If your links are in a syntax graphmark is not reading, it says so on stderr rather than reporting a
133
139
  confidently empty graph:
134
140
 
@@ -91,7 +91,7 @@ graph, so set `link_syntax`:
91
91
 
92
92
  ```toml
93
93
  root = "."
94
- link_syntax = "both" # "wikilink" (default) | "markdown" | "both"
94
+ link_syntax = "both" # "wikilink" (default) | "markdown" | "both" | "markdown-autolinks"
95
95
  ```
96
96
 
97
97
  The two resolve differently, and the difference is not cosmetic:
@@ -101,6 +101,12 @@ The two resolve differently, and the difference is not cosmetic:
101
101
  - a **markdown** link is a path **relative to the linking note** — `[x](../other/b.md)` means that
102
102
  file and nothing else, so the same filename in two folders is not ambiguous at all.
103
103
 
104
+ `markdown-autolinks` is `markdown` with the [mkdocs-autolinks](https://github.com/zachhannum/mkdocs-autolinks-plugin)
105
+ plugin's rule for **bare** targets: `[tdd](tdd.md)` names a note anywhere in the tree, not a sibling
106
+ file. Set it only if your site actually runs that plugin — it is never applied as a fallback, because
107
+ a second rule tried after the first fails is how a link ends up pointing at a note it does not name.
108
+ A target that contains `/` keeps the relative rule in this mode too.
109
+
104
110
  If your links are in a syntax graphmark is not reading, it says so on stderr rather than reporting a
105
111
  confidently empty graph:
106
112
 
@@ -243,7 +243,7 @@ Evaluated against the ecosystem and dropped for zero consumer pull; do not re-li
243
243
  10,149 of them and zero extracted edges. Config-gated (`link_syntax`), default unchanged,
244
244
  `Resolver` untouched. Note what it did *not* do: blue-book resolves 5.8% by strict relative path
245
245
  and 92.7% by basename-anywhere (it runs `mkdocs-autolinks`), and no fallback was slipped in —
246
- silently trying a second rule when the first fails is the shape of #136. The dialect is #156.
246
+ silently trying a second rule when the first fails is the shape of #136. The dialect shipped too (#156), as an explicit fourth value rather than a fallback: blue-book goes from 5.8% to **98.5%** resolved, 430 to 4878 edges, 790 to 106 orphans, with ambiguity still refused at 0.
247
247
  - Re-platforming (no swapping networkx; no async/parallel rewrites).
248
248
  - Performance work without a benchmark showing pain (the live consumer is a ~340-note vault). The
249
249
  known ceilings — O(L·N) path-suffix resolution, O(A·(V+E)) `siloed_notes`, per-metric graph
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphmark"
3
- version = "0.8.1"
3
+ version = "0.9.0"
4
4
  description = "Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -13,7 +13,7 @@ from pathlib import Path
13
13
 
14
14
  #: Every value ``VaultConfig.link_syntax`` accepts. A consumer may switch on these, so the set is
15
15
  #: part of the config contract.
16
- LINK_SYNTAXES = frozenset({"wikilink", "markdown", "both"})
16
+ LINK_SYNTAXES = frozenset({"wikilink", "markdown", "both", "markdown-autolinks"})
17
17
 
18
18
 
19
19
  @dataclass(frozen=True)
@@ -55,7 +55,10 @@ class VaultConfig:
55
55
  #: what every frozen fixture encodes, so the default keeps existing behavior byte-identical.
56
56
  #: ``"markdown"`` reads ``[text](note.md)``, the syntax non-Obsidian markdown vaults use — added
57
57
  #: on demand after a corpus vault turned out to have 11,198 of them and zero extracted edges.
58
- #: ``"both"`` reads each, which is what Obsidian itself accepts.
58
+ #: ``"both"`` reads each, which is what Obsidian itself accepts. ``"markdown-autolinks"`` is
59
+ #: ``"markdown"`` with the widely-used mkdocs plugin's rule for **bare** targets: a filename
60
+ #: with no ``/`` names a note anywhere in the tree rather than a sibling file. Stated by the
61
+ #: vault owner, never guessed — see the note on ``_resolve_markdown_target``.
59
62
  link_syntax: str = "wikilink"
60
63
  check: CheckPolicy = field(default_factory=CheckPolicy)
61
64
 
@@ -110,22 +110,36 @@ def _targets_non_note_file(display: str) -> bool:
110
110
  return bool(match) and match.group(0).lower() != ".md"
111
111
 
112
112
 
113
- def _matches_path_suffix(rel_path: str, suffix: str) -> bool:
114
- """True when ``rel_path`` ends with ``suffix`` **on a path-component boundary**.
113
+ def _path_components(name: str) -> tuple[str, ...]:
114
+ """A path split on ``/``, each component through ``_normalize``, ``.md`` dropped.
115
+
116
+ This is what makes path-qualified links normalize like bare ones. They used to fold only case
117
+ and Unicode form, so ``[[Q1 - Review]]`` found ``Q1 — Review.md`` while
118
+ ``[[notes/Q1 - Review]]`` did not — the same title reachable or not depending on whether the
119
+ writer happened to qualify it with a folder.
120
+
121
+ Components are normalized **individually** and the separator is never one of them, which is what
122
+ keeps ``/`` structural: ``[[a/b]]`` yields ``("a", "b")`` and can never reach ``a-b.md``, whose
123
+ single component is ``"a b"``. That distinction is the whole reason this is a tuple rather than
124
+ a normalized string.
125
+
126
+ A component that normalizes to empty — a trailing slash, or pure punctuation — is kept as
127
+ ``""``. No real path component ever normalizes to empty, so such a display matches nothing,
128
+ which is the conservative answer.
129
+ """
130
+ name = name[: -len(".md")] if name.lower().endswith(".md") else name
131
+ return tuple(_normalize(part) for part in name.split("/"))
132
+
115
133
 
116
- ``suffix`` is a lowercased ``folder/note.md``. A raw ``str.endswith`` is not enough: it accepts
117
- ``homework/Tasks.md`` for ``[[work/Tasks]]``, because nothing requires the character before the
118
- match to be a separator. That produced an edge to the wrong note — silently, since the link then
119
- counts as ``resolved`` — and, when the real folder also existed, a spurious second match that
120
- made the resolver decline a correct link.
134
+ def _names_path(candidate: tuple[str, ...], display: tuple[str, ...]) -> bool:
135
+ """True when ``display``'s components are a suffix of ``candidate``'s.
121
136
 
122
- The match is legal only when it consumes the whole rel_path or is preceded by ``/``.
137
+ Subsumes the boundary rule #136 added: a suffix over *lists* cannot match mid-component, so
138
+ ``[[work/Tasks]]`` cannot reach ``homework/Tasks.md`` — ``("homework", "tasks")`` does not end
139
+ with ``("work", "tasks")``. The string-suffix version accepted it, silently producing an edge
140
+ to the wrong note.
123
141
  """
124
- lowered = _fold_case(rel_path)
125
- if not lowered.endswith(suffix):
126
- return False
127
- rest = len(lowered) - len(suffix)
128
- return rest == 0 or lowered[rest - 1] == "/"
142
+ return bool(display) and "" not in display and candidate[-len(display) :] == display
129
143
 
130
144
 
131
145
  def candidates_for(display: str, catalog: dict[str, list[str]]) -> list[str]:
@@ -143,8 +157,7 @@ def candidates_for(display: str, catalog: dict[str, list[str]]) -> list[str]:
143
157
  if not target:
144
158
  return []
145
159
  if "/" in target:
146
- suffix = _fold_case(target) + ".md"
147
- return sorted(_suffix_matches(suffix, catalog, _CANDIDATE_INDEX))
160
+ return sorted(_path_matches(target, catalog, _CANDIDATE_INDEX))
148
161
  return list(catalog.get(_normalize(target), ()))
149
162
 
150
163
 
@@ -212,14 +225,21 @@ def build_catalog(docs: list[Document]) -> dict[str, list[str]]:
212
225
  return catalog
213
226
 
214
227
 
228
+ #: A normalized final component → the paths ending with it, each paired with its precomputed
229
+ #: components. Precomputing means the per-component normalization runs once per note, not per link.
230
+ _Buckets = dict[str, list[tuple[str, tuple[str, ...]]]]
231
+
232
+
215
233
  class _FilenameIndex:
216
- """Catalog paths bucketed by folded filename, so a path-qualified link scans a bucket.
234
+ """Catalog paths bucketed by normalized final component, so a path link scans a bucket.
217
235
 
218
- A legal suffix match must consume whole path components (see ``_matches_path_suffix``), so a
219
- candidate's **last** component always equals the display's last component plus ``.md``.
220
- Bucketing by that filename is therefore *exactly* equivalent to scanning every path — same
221
- matches, same order — while turning an O(N) sweep into a dict lookup over a bucket that is
222
- almost always one entry deep.
236
+ A match requires the display's components to be a suffix of the candidate's
237
+ (``_names_path``), so a candidate's **last** component always equals the display's last.
238
+ Bucketing by it is therefore *exactly* equivalent to scanning every path — same matches, same
239
+ order — while turning an O(N) sweep into a dict lookup over a bucket almost always one deep.
240
+
241
+ Components are cached alongside each path, so the per-component normalization happens once per
242
+ note rather than once per link.
223
243
 
224
244
  This mattered little while path-qualified links were rare: most wikilink displays are bare names
225
245
  hitting the catalog dict directly, which is why the ``O(L·N)`` ceiling was documented and
@@ -245,17 +265,18 @@ class _FilenameIndex:
245
265
 
246
266
  def __init__(self) -> None:
247
267
  # Insertion-ordered, so the oldest entry is the one evicted.
248
- self._slots: dict[int, tuple[dict[str, list[str]], dict[str, list[str]]]] = {}
268
+ self._slots: dict[int, tuple[dict[str, list[str]], _Buckets]] = {}
249
269
 
250
- def for_catalog(self, catalog: dict[str, list[str]]) -> dict[str, list[str]]:
270
+ def for_catalog(self, catalog: dict[str, list[str]]) -> _Buckets:
251
271
  cached = self._slots.get(id(catalog))
252
272
  if cached is not None and cached[0] is catalog:
253
273
  return cached[1]
254
274
 
255
- buckets: dict[str, list[str]] = {}
275
+ buckets: _Buckets = {}
256
276
  for paths in catalog.values():
257
277
  for path in paths:
258
- buckets.setdefault(_fold_case(path.rsplit("/", 1)[-1]), []).append(path)
278
+ components = _path_components(path)
279
+ buckets.setdefault(components[-1], []).append((path, components))
259
280
 
260
281
  if len(self._slots) >= self._MAX_SLOTS:
261
282
  del self._slots[next(iter(self._slots))]
@@ -263,10 +284,13 @@ class _FilenameIndex:
263
284
  return buckets
264
285
 
265
286
 
266
- def _suffix_matches(suffix: str, catalog: dict[str, list[str]], index: _FilenameIndex) -> list[str]:
267
- """Every rel_path that ``suffix`` (a folded ``folder/note.md``) names, in flatten order."""
268
- bucket = index.for_catalog(catalog).get(suffix.rsplit("/", 1)[-1], ())
269
- return [path for path in bucket if _matches_path_suffix(path, suffix)]
287
+ def _path_matches(target: str, catalog: dict[str, list[str]], index: _FilenameIndex) -> list[str]:
288
+ """Every rel_path that a path-qualified ``target`` names, in catalog order."""
289
+ display = _path_components(target)
290
+ if not display or "" in display:
291
+ return []
292
+ bucket = index.for_catalog(catalog).get(display[-1], ())
293
+ return [path for path, components in bucket if _names_path(components, display)]
270
294
 
271
295
 
272
296
  #: Shared by ``candidates_for``, which takes a catalog rather than a resolver and so cannot reach
@@ -291,8 +315,7 @@ class NormalizeResolver:
291
315
  # Path-suffix resolution: find unique rel_path ending with "display.md". Note that an
292
316
  # exact whole-path hit cannot short-circuit — "work/Tasks" names both "work/Tasks.md"
293
317
  # and "a/work/Tasks.md", and the honest answer there is to decline.
294
- suffix = _fold_case(display) + ".md"
295
- matches = _suffix_matches(suffix, catalog, self._index)
318
+ matches = _path_matches(display, catalog, self._index)
296
319
  return matches[0] if len(matches) == 1 else None
297
320
 
298
321
  # Bare-link resolution: normalize and look up in catalog
@@ -510,8 +533,20 @@ def diagnose(graph: VaultGraph, display: str, *, suggest: int = 0) -> LinkDiagno
510
533
  return diagnosis
511
534
 
512
535
 
513
- def resolve_relative_target(target: str, source_rel_path: str) -> str | None:
514
- """Turn a markdown link's relative target into a vault-relative path, or ``None``.
536
+ def resolve_markdown_target(
537
+ target: str, source_rel_path: str, *, autolinks: bool = False
538
+ ) -> str | None:
539
+ """Turn a markdown link's target into something the resolver can match, or ``None``.
540
+
541
+ With ``autolinks``, a **bare** target — a filename with no ``/`` — is handed through unchanged
542
+ so the ordinary name-based rule finds it anywhere in the tree. That is the `mkdocs-autolinks`
543
+ plugin's behavior, and it is **opt-in rather than a fallback**: trying a second rule when the
544
+ first fails is the shape of #136, where a quiet extra rule produced an edge to a note the link
545
+ did not name. A target that *is* path-qualified keeps the relative rule even in this mode, so
546
+ a wrong qualified path is never rescued by a filename match elsewhere.
547
+
548
+ Measured on `lyz-code/blue-book`, which runs the plugin: 5.8% of its links resolve strictly,
549
+ 92.7% by name, 1.5% by neither — so a fallback would not even have been complete.
515
550
 
516
551
  A markdown target is a **path**, not a name: ``[x](../other/b.md)`` means "the file at that
517
552
  location relative to me". That is the first resolution rule in this package that depends on
@@ -529,10 +564,13 @@ def resolve_relative_target(target: str, source_rel_path: str) -> str | None:
529
564
  is a separate decision, not a fallback to slip in here — silently trying a second rule when the
530
565
  first fails is how a link resolves to the wrong note.
531
566
 
532
- ``None`` for a target that escapes the vault root. That is not an error and not a resolution —
567
+ Without ``autolinks``, or for any target containing ``/``: ``None`` when the target escapes the
568
+ vault root. That is not an error and not a resolution —
533
569
  a link out of the vault names no note in the graph, so it is reported ``missing`` like any other
534
570
  target that is not there.
535
571
  """
572
+ if autolinks and "/" not in target:
573
+ return target
536
574
  combined = PurePosixPath(source_rel_path).parent / target
537
575
  normalized = posixpath.normpath(str(combined))
538
576
  # normpath leaves leading "..", which is the only way to express "above the root".
@@ -672,14 +710,15 @@ class VaultGraph:
672
710
  # pre-processing: a wikilink display is a *name*, a markdown target is a *path relative to
673
711
  # the linking note*, and only the latter can be normalized once the source note is known.
674
712
  read_wikilinks = config.link_syntax in ("wikilink", "both")
675
- read_markdown = config.link_syntax in ("markdown", "both")
713
+ read_markdown = config.link_syntax in ("markdown", "both", "markdown-autolinks")
714
+ autolinks = config.link_syntax == "markdown-autolinks"
676
715
  md_extractor = MarkdownLinkExtractor() if read_markdown else None
677
716
 
678
717
  for doc in docs:
679
718
  displays = list(extractor.extract(doc.text)) if read_wikilinks else []
680
719
  if md_extractor is not None:
681
720
  for target in md_extractor.extract(doc.text):
682
- resolved = resolve_relative_target(target, doc.rel_path)
721
+ resolved = resolve_markdown_target(target, doc.rel_path, autolinks=autolinks)
683
722
  # A target above the vault root names no note here. Kept in the stream as the
684
723
  # raw target so it is counted and reported `missing`, never silently dropped —
685
724
  # the conservation law holds for every syntax that is read.
@@ -0,0 +1,130 @@
1
+ """The mkdocs-autolinks dialect: a bare markdown target resolves by name (#156).
2
+
3
+ A markdown link is a *relative path* — `[tdd](tdd.md)` from `docs/architecture/x.md` means
4
+ `docs/architecture/tdd.md` and nothing else. But the widely-used `mkdocs-autolinks` plugin resolves
5
+ a bare filename **anywhere in the tree**, so the same link reaches `docs/coding/tdd.md`. Measured on
6
+ `lyz-code/blue-book`, which runs that plugin: 5.8% of its links resolve strictly, 92.7% by name.
7
+
8
+ This is an explicit fourth `link_syntax` value, **not a fallback**. Trying a second rule when the
9
+ first fails is the shape of #136 — a rule that quietly produces an edge to a note the link does not
10
+ name — and the 1.5% that match neither dialect show a fallback would not even be complete. The vault
11
+ owner states which renderer they use, exactly as they already state their scope.
12
+
13
+ A bare name goes to the *existing* wikilink rule, so ambiguity is refused the same way it
14
+ always was: no dialect may invent a resolution.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+
21
+ from graphmark.config import VaultConfig
22
+ from graphmark.graph import NormalizeResolver, VaultGraph
23
+ from graphmark.parse import WikilinkExtractor
24
+
25
+
26
+ def _write(root: Path, rel: str, text: str = "") -> None:
27
+ path = root / rel
28
+ path.parent.mkdir(parents=True, exist_ok=True)
29
+ path.write_text(text, encoding="utf-8")
30
+
31
+
32
+ def _build(root: Path, **kw) -> VaultGraph:
33
+ return VaultGraph.build(VaultConfig(root=root, **kw), WikilinkExtractor(), NormalizeResolver())
34
+
35
+
36
+ class TestBareTargetsResolveByName:
37
+ def test_a_bare_target_reaches_a_note_elsewhere_in_the_tree(self, tmp_path):
38
+ # blue-book's shape: [tdd](tdd.md) from docs/architecture/ reaching docs/coding/tdd.md.
39
+ _write(tmp_path, "docs/coding/tdd.md")
40
+ _write(tmp_path, "docs/architecture/x.md", "[tdd](tdd.md)\n")
41
+ graph = _build(tmp_path, link_syntax="markdown-autolinks")
42
+ assert graph.out_links["docs/architecture/x.md"] == {"docs/coding/tdd.md"}
43
+ assert graph.link_counts["missing"] == 0
44
+
45
+ def test_strict_markdown_still_reports_that_link_missing(self, tmp_path):
46
+ _write(tmp_path, "docs/coding/tdd.md")
47
+ _write(tmp_path, "docs/architecture/x.md", "[tdd](tdd.md)\n")
48
+ assert _build(tmp_path, link_syntax="markdown").link_counts["missing"] == 1
49
+
50
+ def test_a_sibling_target_still_resolves(self, tmp_path):
51
+ _write(tmp_path, "docs/b.md")
52
+ _write(tmp_path, "docs/a.md", "[b](b.md)\n")
53
+ assert _build(tmp_path, link_syntax="markdown-autolinks").out_links["docs/a.md"] == {
54
+ "docs/b.md"
55
+ }
56
+
57
+ def test_the_name_normalizes_like_any_other(self, tmp_path):
58
+ _write(tmp_path, "docs/coding/Q1 — Review.md")
59
+ _write(tmp_path, "x.md", "[q](Q1%20-%20Review.md)\n")
60
+ assert _build(tmp_path, link_syntax="markdown-autolinks").unresolved == {}
61
+
62
+
63
+ class TestAmbiguityIsStillRefused:
64
+ def test_a_name_claimed_by_two_notes_is_ambiguous(self, tmp_path):
65
+ # The rule this dialect must not break: no dialect may invent a resolution.
66
+ _write(tmp_path, "one/note.md")
67
+ _write(tmp_path, "two/note.md")
68
+ _write(tmp_path, "x.md", "[n](note.md)\n")
69
+ graph = _build(tmp_path, link_syntax="markdown-autolinks")
70
+ assert graph.link_counts["ambiguous"] == 1
71
+ assert graph.out_links["x.md"] == set()
72
+
73
+ def test_a_name_that_exists_nowhere_is_missing(self, tmp_path):
74
+ _write(tmp_path, "x.md", "[gone](nowhere.md)\n")
75
+ assert _build(tmp_path, link_syntax="markdown-autolinks").link_counts["missing"] == 1
76
+
77
+
78
+ class TestPathQualifiedTargetsStayRelative:
79
+ def test_a_target_with_a_slash_uses_the_relative_rule(self, tmp_path):
80
+ # Only *bare* names get the plugin's behavior; a qualified target still means what it says.
81
+ _write(tmp_path, "docs/deep/other/b.md")
82
+ _write(tmp_path, "docs/deep/a.md", "[b](other/b.md)\n")
83
+ assert _build(tmp_path, link_syntax="markdown-autolinks").out_links["docs/deep/a.md"] == {
84
+ "docs/deep/other/b.md"
85
+ }
86
+
87
+ def test_a_qualified_target_that_is_wrong_is_not_rescued_by_name(self, tmp_path):
88
+ # The anti-fallback assertion: "elsewhere/b.md" names no note here, and the fact that a
89
+ # b.md exists somewhere must NOT rescue it. That rescue is exactly the #136 shape.
90
+ _write(tmp_path, "docs/coding/b.md")
91
+ _write(tmp_path, "docs/a.md", "[b](elsewhere/b.md)\n")
92
+ graph = _build(tmp_path, link_syntax="markdown-autolinks")
93
+ assert graph.link_counts["missing"] == 1
94
+ assert graph.out_links["docs/a.md"] == set()
95
+
96
+ def test_a_parent_relative_target_still_resolves(self, tmp_path):
97
+ _write(tmp_path, "docs/other/b.md")
98
+ _write(tmp_path, "docs/deep/a.md", "[b](../other/b.md)\n")
99
+ assert _build(tmp_path, link_syntax="markdown-autolinks").out_links["docs/deep/a.md"] == {
100
+ "docs/other/b.md"
101
+ }
102
+
103
+
104
+ class TestOtherModesUnchanged:
105
+ def test_wikilinks_are_not_read_in_this_mode(self, tmp_path):
106
+ _write(tmp_path, "b.md")
107
+ _write(tmp_path, "a.md", "[[b]]\n")
108
+ assert _build(tmp_path, link_syntax="markdown-autolinks").out_links["a.md"] == set()
109
+
110
+ def test_the_default_is_still_wikilink(self, tmp_path):
111
+ _write(tmp_path, "b.md")
112
+ _write(tmp_path, "a.md", "[b](b.md)\n[[b]]\n")
113
+ graph = _build(tmp_path)
114
+ assert graph.link_counts["resolved"] == 1
115
+
116
+
117
+ class TestConfig:
118
+ def test_it_is_an_accepted_syntax(self, tmp_path):
119
+ from graphmark.config import LINK_SYNTAXES
120
+
121
+ assert "markdown-autolinks" in LINK_SYNTAXES
122
+
123
+ def test_it_loads_from_toml(self, tmp_path):
124
+ from graphmark.config import load_config
125
+
126
+ cfg = tmp_path / "v.toml"
127
+ cfg.write_text(
128
+ f'root = "{tmp_path}"\nlink_syntax = "markdown-autolinks"\n', encoding="utf-8"
129
+ )
130
+ assert load_config(cfg).link_syntax == "markdown-autolinks"
@@ -31,16 +31,23 @@ def _build(root: Path, **kw) -> VaultGraph:
31
31
 
32
32
 
33
33
  def _scan_resolve(display: str, catalog: dict[str, list[str]]) -> list[str]:
34
- """The pre-#157 behavior, written out: compare against every path, in flatten order.
34
+ """The unindexed behavior, written out: compare against every path, in catalog order.
35
35
 
36
- Kept as an independent reference rather than by calling the implementation, so the equivalence
37
- tests below compare two things instead of one thing with itself.
36
+ An independent reference rather than a call into the implementation, so the equivalence tests
37
+ below compare two things instead of one thing with itself. Restated for #146, which made the
38
+ comparison component-wise: a display names a path when its normalized components are a suffix of
39
+ the path's.
38
40
  """
39
- from graphmark.graph import _fold_case, _matches_path_suffix, _strip_display
41
+ from graphmark.graph import _normalize, _strip_display
40
42
 
41
- target = _strip_display(display)
42
- suffix = _fold_case(target) + ".md"
43
- return [p for paths in catalog.values() for p in paths if _matches_path_suffix(p, suffix)]
43
+ def components(name: str) -> list[str]:
44
+ name = name[:-3] if name.lower().endswith(".md") else name
45
+ return [_normalize(part) for part in name.split("/")]
46
+
47
+ want = components(_strip_display(display))
48
+ if not want or "" in want:
49
+ return []
50
+ return [p for paths in catalog.values() for p in paths if components(p)[-len(want) :] == want]
44
51
 
45
52
 
46
53
  class TestEquivalence:
@@ -0,0 +1,141 @@
1
+ """Path-qualified links normalize like bare ones (#146).
2
+
3
+ Bare resolution went through `_normalize` — NFC, lowercase, punctuation and symbols folded — while
4
+ path resolution went through `_fold_case`, which folds only case and Unicode form. So
5
+ `[[Q1 - Review]]` found `Q1 — Review.md` but `[[notes/Q1 - Review]]` did not: the same title was
6
+ reachable or not depending on whether the writer happened to qualify the link with a folder, which
7
+ is arbitrary from a user's point of view.
8
+
9
+ The fix compares **path components**, each normalized by the one function: the display's components
10
+ must be a suffix of the candidate's. That subsumes #136's boundary rule for free — a suffix over
11
+ lists cannot match mid-component — while letting every punctuation equivalence `_normalize` already
12
+ absorbs work in both branches.
13
+
14
+ The trap this must not fall into: `/` is structural. `[[a/b]]` must never reach `a-b.md`, even
15
+ though `a/b` and `a-b` normalize to the same characters once the separator is folded. Components
16
+ are normalized individually and the separator is never one of them, which keeps them apart.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+
23
+ from graphmark.config import VaultConfig
24
+ from graphmark.graph import NormalizeResolver, VaultGraph, candidates_for
25
+ from graphmark.parse import WikilinkExtractor
26
+
27
+
28
+ def _write(root: Path, rel: str, text: str = "") -> None:
29
+ path = root / rel
30
+ path.parent.mkdir(parents=True, exist_ok=True)
31
+ path.write_text(text, encoding="utf-8")
32
+
33
+
34
+ def _build(root: Path, **kw) -> VaultGraph:
35
+ return VaultGraph.build(VaultConfig(root=root, **kw), WikilinkExtractor(), NormalizeResolver())
36
+
37
+
38
+ class TestSeparatorStaysStructural:
39
+ """The trap. A `/` is a path boundary, never a character to fold."""
40
+
41
+ def test_a_slash_display_does_not_reach_a_hyphen_file(self, tmp_path):
42
+ _write(tmp_path, "a-b.md")
43
+ _write(tmp_path, "src.md", "[[a/b]]")
44
+ graph = _build(tmp_path)
45
+ assert graph.unresolved == {"src.md": ["a/b"]}
46
+ assert candidates_for("a/b", graph.catalog) == []
47
+
48
+ def test_a_slash_display_does_not_reach_a_space_file(self, tmp_path):
49
+ _write(tmp_path, "a b.md")
50
+ _write(tmp_path, "src.md", "[[a/b]]")
51
+ assert _build(tmp_path).unresolved == {"src.md": ["a/b"]}
52
+
53
+ def test_a_hyphen_display_does_not_reach_a_nested_file(self, tmp_path):
54
+ # The reverse direction: "a-b" is one name, not two components.
55
+ _write(tmp_path, "a/b.md")
56
+ _write(tmp_path, "src.md", "[[a-b]]")
57
+ assert _build(tmp_path).unresolved == {"src.md": ["a-b"]}
58
+
59
+
60
+ class TestPunctuationFoldsWithinAComponent:
61
+ def test_a_path_qualified_em_dash_title(self, tmp_path):
62
+ _write(tmp_path, "notes/Q1 — Review.md")
63
+ _write(tmp_path, "src.md", "[[notes/Q1 - Review]]")
64
+ graph = _build(tmp_path)
65
+ assert graph.unresolved == {}
66
+ assert graph.out_links["src.md"] == {"notes/Q1 — Review.md"}
67
+
68
+ def test_a_path_qualified_hyphen_vs_space(self, tmp_path):
69
+ _write(tmp_path, "notes/oura pipeline.md")
70
+ _write(tmp_path, "src.md", "[[notes/oura-pipeline]]")
71
+ assert _build(tmp_path).unresolved == {}
72
+
73
+ def test_the_folder_component_folds_too(self, tmp_path):
74
+ _write(tmp_path, "my-notes/thing.md")
75
+ _write(tmp_path, "src.md", "[[my notes/thing]]")
76
+ assert _build(tmp_path).unresolved == {}
77
+
78
+ def test_both_branches_now_agree(self, tmp_path):
79
+ # The actual defect: the same title, reachable one way and not the other.
80
+ _write(tmp_path, "notes/Q1 — Review.md")
81
+ _write(tmp_path, "src.md", "[[Q1 - Review]]\n[[notes/Q1 - Review]]")
82
+ graph = _build(tmp_path)
83
+ assert graph.unresolved == {}
84
+ assert graph.link_counts["resolved"] == 2
85
+
86
+
87
+ class TestBoundaryRuleSurvives:
88
+ """#136's guarantee, now a consequence of comparing component lists."""
89
+
90
+ def test_a_longer_folder_name_still_does_not_satisfy_a_shorter_one(self, tmp_path):
91
+ _write(tmp_path, "homework/Tasks.md")
92
+ _write(tmp_path, "src.md", "[[work/Tasks]]")
93
+ assert _build(tmp_path).unresolved == {"src.md": ["work/Tasks"]}
94
+
95
+ def test_any_depth_of_prefix_still_resolves(self, tmp_path):
96
+ _write(tmp_path, "a/b/work/Tasks.md")
97
+ _write(tmp_path, "src.md", "[[work/Tasks]]")
98
+ assert _build(tmp_path).out_links["src.md"] == {"a/b/work/Tasks.md"}
99
+
100
+ def test_a_whole_path_still_resolves(self, tmp_path):
101
+ _write(tmp_path, "work/Tasks.md")
102
+ _write(tmp_path, "src.md", "[[work/Tasks]]")
103
+ assert _build(tmp_path).out_links["src.md"] == {"work/Tasks.md"}
104
+
105
+ def test_a_genuine_collision_is_still_ambiguous(self, tmp_path):
106
+ _write(tmp_path, "a/work/Tasks.md")
107
+ _write(tmp_path, "b/work/Tasks.md")
108
+ _write(tmp_path, "src.md", "[[work/Tasks]]")
109
+ graph = _build(tmp_path)
110
+ assert graph.link_counts["ambiguous"] == 1
111
+ assert candidates_for("work/Tasks", graph.catalog) == [
112
+ "a/work/Tasks.md",
113
+ "b/work/Tasks.md",
114
+ ]
115
+
116
+ def test_case_and_unicode_form_still_fold(self, tmp_path):
117
+ _write(tmp_path, "Work/Tasks.md")
118
+ _write(tmp_path, "src.md", "[[work/tasks]]")
119
+ assert _build(tmp_path).unresolved == {}
120
+
121
+
122
+ class TestMarkdownModeAgrees:
123
+ def test_a_markdown_link_to_a_punctuation_variant_resolves(self, tmp_path):
124
+ # Markdown targets are normalized to full vault-relative paths, so they take the same
125
+ # component path — the fix must reach them too.
126
+ _write(tmp_path, "notes/Q1 — Review.md")
127
+ # Percent-encoded, because a bare space in a destination is not a link per CommonMark —
128
+ # it needs <> or encoding. That also exercises the decode path.
129
+ _write(tmp_path, "notes/src.md", "[x](Q1%20-%20Review.md)\n")
130
+ graph = _build(tmp_path, link_syntax="markdown")
131
+ assert graph.out_links["notes/src.md"] == {"notes/Q1 — Review.md"}
132
+
133
+
134
+ class TestDegenerate:
135
+ def test_a_trailing_slash_names_nothing(self, tmp_path):
136
+ _write(tmp_path, "a/b.md")
137
+ assert candidates_for("a/", _build(tmp_path).catalog) == []
138
+
139
+ def test_a_punctuation_only_component_names_nothing(self, tmp_path):
140
+ _write(tmp_path, "a/b.md")
141
+ assert candidates_for("a/--", _build(tmp_path).catalog) == []
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