graphmark 0.8.0__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.0 → graphmark-0.9.0}/CHANGELOG.md +83 -0
  2. {graphmark-0.8.0 → graphmark-0.9.0}/PKG-INFO +8 -2
  3. {graphmark-0.8.0 → graphmark-0.9.0}/README.md +7 -1
  4. {graphmark-0.8.0 → graphmark-0.9.0}/docs/ROADMAP.md +1 -1
  5. {graphmark-0.8.0 → graphmark-0.9.0}/pyproject.toml +1 -1
  6. {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/config.py +5 -2
  7. {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/graph.py +128 -44
  8. graphmark-0.9.0/tests/test_autolinks.py +130 -0
  9. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_graph.py +6 -10
  10. graphmark-0.9.0/tests/test_path_index.py +160 -0
  11. graphmark-0.9.0/tests/test_path_normalization.py +141 -0
  12. {graphmark-0.8.0 → graphmark-0.9.0}/.gitignore +0 -0
  13. {graphmark-0.8.0 → graphmark-0.9.0}/LICENSE +0 -0
  14. {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/__init__.py +0 -0
  15. {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/check.py +0 -0
  16. {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/cli.py +0 -0
  17. {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/dismiss.py +0 -0
  18. {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/export.py +0 -0
  19. {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/interfaces.py +0 -0
  20. {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/metrics.py +0 -0
  21. {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/model.py +0 -0
  22. {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/parse.py +0 -0
  23. {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/py.typed +0 -0
  24. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/README.md +0 -0
  25. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/config.toml +0 -0
  26. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/expected.json +0 -0
  27. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
  28. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
  29. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/vault/docs/center.md +0 -0
  30. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/vault/misc/echo.md +0 -0
  31. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
  32. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
  33. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/vault/misc/stub.md +0 -0
  34. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/vault/refs/beta.md +0 -0
  35. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/vault/refs/delta.md +0 -0
  36. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
  37. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/dismiss/expected.json +0 -0
  38. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
  39. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/dismiss/vault/alpha.md +0 -0
  40. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/dismiss/vault/beta.md +0 -0
  41. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/dismiss/vault/gamma.md +0 -0
  42. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/config.toml +0 -0
  43. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/expected.json +0 -0
  44. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/expected_oog.json +0 -0
  45. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/similar.json +0 -0
  46. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/similar_oog.json +0 -0
  47. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/vault/docs/a.md +0 -0
  48. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
  49. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/vault/misc/c.md +0 -0
  50. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/vault/misc/d.md +0 -0
  51. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/vault/refs/b.md +0 -0
  52. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/vault/refs/e.md +0 -0
  53. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/scoped/config.toml +0 -0
  54. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/scoped/expected.json +0 -0
  55. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/scoped/vault/docs/one.md +0 -0
  56. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
  57. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
  58. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/scoped/vault/refs/two.md +0 -0
  59. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/selflink/config.toml +0 -0
  60. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/selflink/expected.json +0 -0
  61. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/selflink/vault/a.md +0 -0
  62. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/selflink/vault/b.md +0 -0
  63. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/simple/config.toml +0 -0
  64. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/simple/expected.json +0 -0
  65. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
  66. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/simple/vault/brain/hub.md +0 -0
  67. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/simple/vault/personal/beta.md +0 -0
  68. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
  69. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/simple/vault/reference/island.md +0 -0
  70. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/simple/vault/reference/stub.md +0 -0
  71. {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/suggest/README.md +0 -0
  72. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_aliases.py +0 -0
  73. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_bom.py +0 -0
  74. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_build_filters.py +0 -0
  75. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_check.py +0 -0
  76. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_check_policy.py +0 -0
  77. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_cli.py +0 -0
  78. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_config.py +0 -0
  79. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_degenerate.py +0 -0
  80. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_diagnose.py +0 -0
  81. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_dismiss.py +0 -0
  82. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_export.py +0 -0
  83. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_gaps.py +0 -0
  84. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_gaps_banding.py +0 -0
  85. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_gaps_oog.py +0 -0
  86. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_gaps_params.py +0 -0
  87. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_graph_catalogs.py +0 -0
  88. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_link_counts.py +0 -0
  89. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_links_report.py +0 -0
  90. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_markdown_links.py +0 -0
  91. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_metrics.py +0 -0
  92. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_model.py +0 -0
  93. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_non_note_file.py +0 -0
  94. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_packaging.py +0 -0
  95. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_pagerank.py +0 -0
  96. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_parse.py +0 -0
  97. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_path_suffix.py +0 -0
  98. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_properties.py +0 -0
  99. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_public_api.py +0 -0
  100. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_readme_examples.py +0 -0
  101. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_scoped.py +0 -0
  102. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_selflink.py +0 -0
  103. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_smoke.py +0 -0
  104. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_suggestions.py +0 -0
  105. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_unicode.py +0 -0
  106. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_unicode_punctuation.py +0 -0
  107. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_unread_syntax.py +0 -0
  108. {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_unresolved.py +0 -0
@@ -1,6 +1,89 @@
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
+
59
+ ## v0.8.1 (2026-07-26)
60
+
61
+ ### Performance Improvements
62
+
63
+ - **graph**: Index path resolution by filename instead of scanning (#157)
64
+ ([#160](https://github.com/cdcoonce/graphmark/pull/160),
65
+ [`84cdd29`](https://github.com/cdcoonce/graphmark/commit/84cdd297a665d93ffa3cbb988c18550ea57f5c94))
66
+
67
+ Every path-qualified display flattened the catalog and compared against every rel_path. That ceiling
68
+ was documented and accepted while the branch was rare — most wikilink displays are bare names
69
+ hitting a dict — but #152 made it the only branch, since every markdown link is path-qualified by
70
+ construction. blue-book (1120 notes, 10,149 markdown links) took 2.7 s against 0.25 s for the same
71
+ vault read as wikilinks.
72
+
73
+ A legal suffix match must consume whole path components, so a candidate's last component always
74
+ equals the display's last plus ".md". Bucketing by that filename is exactly equivalent to scanning
75
+ — same matches, same order, same verdicts.
76
+
77
+ The first attempt was no faster, and profiling rather than guessing found why: `_diagnose` consults
78
+ the in-scope catalog AND the out-of-scope one for the same display, so a single-slot cache
79
+ alternated and missed every time — 29,265 index rebuilds for 10,149 links. The cache is now
80
+ bounded-multi-slot, and it holds each catalog rather than just its id(), so a freed dict cannot
81
+ have its address reused under a stale index.
82
+
83
+ blue-book markdown: 2718 ms -> 267 ms (10.2x), now level with wikilink mode. Reference vault 195 ->
84
+ 181 ms. All link counts byte-identical in every mode.
85
+
86
+
4
87
  ## v0.8.0 (2026-07-26)
5
88
 
6
89
  ### Documentation
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphmark
3
- Version: 0.8.0
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.0"
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,13 +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(
148
- path
149
- for paths in catalog.values()
150
- for path in paths
151
- if _matches_path_suffix(path, suffix)
152
- )
160
+ return sorted(_path_matches(target, catalog, _CANDIDATE_INDEX))
153
161
  return list(catalog.get(_normalize(target), ()))
154
162
 
155
163
 
@@ -217,25 +225,85 @@ def build_catalog(docs: list[Document]) -> dict[str, list[str]]:
217
225
  return catalog
218
226
 
219
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
+
233
+ class _FilenameIndex:
234
+ """Catalog paths bucketed by normalized final component, so a path link scans a bucket.
235
+
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.
243
+
244
+ This mattered little while path-qualified links were rare: most wikilink displays are bare names
245
+ hitting the catalog dict directly, which is why the ``O(L·N)`` ceiling was documented and
246
+ accepted. #152 changed which branch is hot — every markdown link is path-qualified by
247
+ construction — and a 1120-note vault with 10,149 of them took 2.9 s against 0.3 s for the same
248
+ vault read as wikilinks.
249
+
250
+ Keyed by catalog identity, which is safe because a catalog is invariant for a whole
251
+ ``VaultGraph.build()``. Each catalog is **held**, not just its ``id()``: without a reference the
252
+ dict could be freed and a new one allocated at the same address, and the stale index would then
253
+ answer for it.
254
+
255
+ Several slots, not one, and that is load-bearing rather than defensive. ``_diagnose`` consults
256
+ two different mappings for the same display — the in-scope ``catalog`` and the ``out_of_scope``
257
+ one — so a single slot alternates between them and misses *every* time. Measured: a single-slot
258
+ version rebuilt the index 29,265 times for 10,149 links and was no faster than the scan it
259
+ replaced. The cap keeps this from growing without bound if a caller cycles through catalogs.
260
+ """
261
+
262
+ #: Enough for the two mappings a build alternates between, with room to spare. Small on purpose:
263
+ #: this holds catalogs alive, so it must not become an unbounded retainer.
264
+ _MAX_SLOTS = 4
265
+
266
+ def __init__(self) -> None:
267
+ # Insertion-ordered, so the oldest entry is the one evicted.
268
+ self._slots: dict[int, tuple[dict[str, list[str]], _Buckets]] = {}
269
+
270
+ def for_catalog(self, catalog: dict[str, list[str]]) -> _Buckets:
271
+ cached = self._slots.get(id(catalog))
272
+ if cached is not None and cached[0] is catalog:
273
+ return cached[1]
274
+
275
+ buckets: _Buckets = {}
276
+ for paths in catalog.values():
277
+ for path in paths:
278
+ components = _path_components(path)
279
+ buckets.setdefault(components[-1], []).append((path, components))
280
+
281
+ if len(self._slots) >= self._MAX_SLOTS:
282
+ del self._slots[next(iter(self._slots))]
283
+ self._slots[id(catalog)] = (catalog, buckets)
284
+ return buckets
285
+
286
+
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)]
294
+
295
+
296
+ #: Shared by ``candidates_for``, which takes a catalog rather than a resolver and so cannot reach
297
+ #: the resolver's own index. One index per process is enough — both callers see the same catalog
298
+ #: object for the length of a build, and a different catalog simply re-keys it.
299
+ _CANDIDATE_INDEX = _FilenameIndex()
300
+
301
+
220
302
  class NormalizeResolver:
221
303
  """Resolves wikilink displays via normalized basename, with path-suffix fallback."""
222
304
 
223
305
  def __init__(self) -> None:
224
- # Cache the flattened path list per catalog identity. catalog is invariant for a whole
225
- # VaultGraph.build(), so folder-style links reuse one flatten instead of rebuilding it
226
- # on every call. Keyed by id() and single-slot: a new catalog evicts the previous.
227
- self._flat_cache_id: int | None = None
228
- self._flat_cache: list[str] | None = None
229
-
230
- @staticmethod
231
- def _compute_flat(catalog: dict[str, list[str]]) -> list[str]:
232
- return [p for paths in catalog.values() for p in paths]
233
-
234
- def _flatten_paths(self, catalog: dict[str, list[str]]) -> list[str]:
235
- if self._flat_cache_id != id(catalog) or self._flat_cache is None:
236
- self._flat_cache = self._compute_flat(catalog)
237
- self._flat_cache_id = id(catalog)
238
- return self._flat_cache
306
+ self._index = _FilenameIndex()
239
307
 
240
308
  def resolve(self, display: str, catalog: dict[str, list[str]]) -> str | None:
241
309
  # Alias ("Note|alias"), anchor ("Note#Section") and an explicit ".md" extension all name
@@ -244,10 +312,10 @@ class NormalizeResolver:
244
312
  display = _strip_display(display)
245
313
 
246
314
  if "/" in display:
247
- # Path-suffix resolution: find unique rel_path ending with "display.md"
248
- suffix = _fold_case(display) + ".md"
249
- all_paths = self._flatten_paths(catalog)
250
- matches = [p for p in all_paths if _matches_path_suffix(p, suffix)]
315
+ # Path-suffix resolution: find unique rel_path ending with "display.md". Note that an
316
+ # exact whole-path hit cannot short-circuit — "work/Tasks" names both "work/Tasks.md"
317
+ # and "a/work/Tasks.md", and the honest answer there is to decline.
318
+ matches = _path_matches(display, catalog, self._index)
251
319
  return matches[0] if len(matches) == 1 else None
252
320
 
253
321
  # Bare-link resolution: normalize and look up in catalog
@@ -465,8 +533,20 @@ def diagnose(graph: VaultGraph, display: str, *, suggest: int = 0) -> LinkDiagno
465
533
  return diagnosis
466
534
 
467
535
 
468
- def resolve_relative_target(target: str, source_rel_path: str) -> str | None:
469
- """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.
470
550
 
471
551
  A markdown target is a **path**, not a name: ``[x](../other/b.md)`` means "the file at that
472
552
  location relative to me". That is the first resolution rule in this package that depends on
@@ -484,10 +564,13 @@ def resolve_relative_target(target: str, source_rel_path: str) -> str | None:
484
564
  is a separate decision, not a fallback to slip in here — silently trying a second rule when the
485
565
  first fails is how a link resolves to the wrong note.
486
566
 
487
- ``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 —
488
569
  a link out of the vault names no note in the graph, so it is reported ``missing`` like any other
489
570
  target that is not there.
490
571
  """
572
+ if autolinks and "/" not in target:
573
+ return target
491
574
  combined = PurePosixPath(source_rel_path).parent / target
492
575
  normalized = posixpath.normpath(str(combined))
493
576
  # normpath leaves leading "..", which is the only way to express "above the root".
@@ -627,14 +710,15 @@ class VaultGraph:
627
710
  # pre-processing: a wikilink display is a *name*, a markdown target is a *path relative to
628
711
  # the linking note*, and only the latter can be normalized once the source note is known.
629
712
  read_wikilinks = config.link_syntax in ("wikilink", "both")
630
- 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"
631
715
  md_extractor = MarkdownLinkExtractor() if read_markdown else None
632
716
 
633
717
  for doc in docs:
634
718
  displays = list(extractor.extract(doc.text)) if read_wikilinks else []
635
719
  if md_extractor is not None:
636
720
  for target in md_extractor.extract(doc.text):
637
- resolved = resolve_relative_target(target, doc.rel_path)
721
+ resolved = resolve_markdown_target(target, doc.rel_path, autolinks=autolinks)
638
722
  # A target above the vault root names no note here. Kept in the stream as the
639
723
  # raw target so it is counted and reported `missing`, never silently dropped —
640
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"
@@ -91,19 +91,15 @@ class TestNormalizeResolver:
91
91
  assert self.resolver.resolve("brain/alpha", catalog) == "brain/alpha.md"
92
92
  assert self.resolver.resolve("work/gamma", catalog) == "work/gamma.md"
93
93
 
94
- def test_flatten_runs_once_per_catalog(self, monkeypatch):
94
+ def test_the_path_index_is_built_once_per_catalog(self):
95
+ # Was written against the flatten this replaced (#157); the property is the same one and
96
+ # still worth holding — the O(n) precompute must not run per link.
95
97
  catalog = self._catalog("brain/alpha.md", "personal/beta.md")
96
- calls = {"n": 0}
97
- real_compute = NormalizeResolver._compute_flat
98
-
99
- def counting_compute(cat):
100
- calls["n"] += 1
101
- return real_compute(cat)
102
-
103
- monkeypatch.setattr(self.resolver, "_compute_flat", counting_compute)
98
+ self.resolver.resolve("brain/alpha", catalog)
99
+ buckets = self.resolver._index.for_catalog(catalog)
104
100
  for _ in range(5):
105
101
  self.resolver.resolve("brain/alpha", catalog)
106
- assert calls["n"] == 1 # the O(n) flatten runs once, reused for the rest
102
+ assert self.resolver._index.for_catalog(catalog) is buckets
107
103
 
108
104
  def test_new_catalog_triggers_reflatten(self):
109
105
  cat1 = self._catalog("brain/alpha.md")