graphmark 0.7.2__tar.gz → 0.8.1__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 (106) hide show
  1. {graphmark-0.7.2 → graphmark-0.8.1}/CHANGELOG.md +129 -0
  2. {graphmark-0.7.2 → graphmark-0.8.1}/PKG-INFO +27 -1
  3. {graphmark-0.7.2 → graphmark-0.8.1}/README.md +26 -0
  4. {graphmark-0.7.2 → graphmark-0.8.1}/docs/ROADMAP.md +20 -2
  5. {graphmark-0.7.2 → graphmark-0.8.1}/pyproject.toml +1 -1
  6. {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/config.py +17 -0
  7. {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/graph.py +155 -27
  8. {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/parse.py +49 -0
  9. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_graph.py +6 -10
  10. graphmark-0.8.1/tests/test_markdown_links.py +155 -0
  11. graphmark-0.8.1/tests/test_path_index.py +153 -0
  12. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_readme_examples.py +12 -5
  13. graphmark-0.8.1/tests/test_unread_syntax.py +111 -0
  14. {graphmark-0.7.2 → graphmark-0.8.1}/.gitignore +0 -0
  15. {graphmark-0.7.2 → graphmark-0.8.1}/LICENSE +0 -0
  16. {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/__init__.py +0 -0
  17. {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/check.py +0 -0
  18. {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/cli.py +0 -0
  19. {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/dismiss.py +0 -0
  20. {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/export.py +0 -0
  21. {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/interfaces.py +0 -0
  22. {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/metrics.py +0 -0
  23. {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/model.py +0 -0
  24. {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/py.typed +0 -0
  25. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/README.md +0 -0
  26. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/config.toml +0 -0
  27. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/expected.json +0 -0
  28. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
  29. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
  30. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/vault/docs/center.md +0 -0
  31. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/vault/misc/echo.md +0 -0
  32. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
  33. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
  34. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/vault/misc/stub.md +0 -0
  35. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/vault/refs/beta.md +0 -0
  36. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/vault/refs/delta.md +0 -0
  37. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
  38. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/dismiss/expected.json +0 -0
  39. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
  40. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/dismiss/vault/alpha.md +0 -0
  41. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/dismiss/vault/beta.md +0 -0
  42. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/dismiss/vault/gamma.md +0 -0
  43. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/config.toml +0 -0
  44. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/expected.json +0 -0
  45. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/expected_oog.json +0 -0
  46. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/similar.json +0 -0
  47. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/similar_oog.json +0 -0
  48. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/vault/docs/a.md +0 -0
  49. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
  50. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/vault/misc/c.md +0 -0
  51. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/vault/misc/d.md +0 -0
  52. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/vault/refs/b.md +0 -0
  53. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/vault/refs/e.md +0 -0
  54. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/scoped/config.toml +0 -0
  55. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/scoped/expected.json +0 -0
  56. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/scoped/vault/docs/one.md +0 -0
  57. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
  58. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
  59. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/scoped/vault/refs/two.md +0 -0
  60. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/selflink/config.toml +0 -0
  61. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/selflink/expected.json +0 -0
  62. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/selflink/vault/a.md +0 -0
  63. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/selflink/vault/b.md +0 -0
  64. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/simple/config.toml +0 -0
  65. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/simple/expected.json +0 -0
  66. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
  67. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/simple/vault/brain/hub.md +0 -0
  68. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/simple/vault/personal/beta.md +0 -0
  69. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
  70. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/simple/vault/reference/island.md +0 -0
  71. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/simple/vault/reference/stub.md +0 -0
  72. {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/suggest/README.md +0 -0
  73. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_aliases.py +0 -0
  74. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_bom.py +0 -0
  75. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_build_filters.py +0 -0
  76. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_check.py +0 -0
  77. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_check_policy.py +0 -0
  78. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_cli.py +0 -0
  79. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_config.py +0 -0
  80. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_degenerate.py +0 -0
  81. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_diagnose.py +0 -0
  82. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_dismiss.py +0 -0
  83. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_export.py +0 -0
  84. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_gaps.py +0 -0
  85. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_gaps_banding.py +0 -0
  86. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_gaps_oog.py +0 -0
  87. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_gaps_params.py +0 -0
  88. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_graph_catalogs.py +0 -0
  89. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_link_counts.py +0 -0
  90. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_links_report.py +0 -0
  91. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_metrics.py +0 -0
  92. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_model.py +0 -0
  93. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_non_note_file.py +0 -0
  94. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_packaging.py +0 -0
  95. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_pagerank.py +0 -0
  96. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_parse.py +0 -0
  97. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_path_suffix.py +0 -0
  98. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_properties.py +0 -0
  99. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_public_api.py +0 -0
  100. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_scoped.py +0 -0
  101. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_selflink.py +0 -0
  102. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_smoke.py +0 -0
  103. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_suggestions.py +0 -0
  104. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_unicode.py +0 -0
  105. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_unicode_punctuation.py +0 -0
  106. {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_unresolved.py +0 -0
@@ -1,6 +1,135 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.8.1 (2026-07-26)
5
+
6
+ ### Performance Improvements
7
+
8
+ - **graph**: Index path resolution by filename instead of scanning (#157)
9
+ ([#160](https://github.com/cdcoonce/graphmark/pull/160),
10
+ [`84cdd29`](https://github.com/cdcoonce/graphmark/commit/84cdd297a665d93ffa3cbb988c18550ea57f5c94))
11
+
12
+ Every path-qualified display flattened the catalog and compared against every rel_path. That ceiling
13
+ was documented and accepted while the branch was rare — most wikilink displays are bare names
14
+ hitting a dict — but #152 made it the only branch, since every markdown link is path-qualified by
15
+ construction. blue-book (1120 notes, 10,149 markdown links) took 2.7 s against 0.25 s for the same
16
+ vault read as wikilinks.
17
+
18
+ A legal suffix match must consume whole path components, so a candidate's last component always
19
+ equals the display's last plus ".md". Bucketing by that filename is exactly equivalent to scanning
20
+ — same matches, same order, same verdicts.
21
+
22
+ The first attempt was no faster, and profiling rather than guessing found why: `_diagnose` consults
23
+ the in-scope catalog AND the out-of-scope one for the same display, so a single-slot cache
24
+ alternated and missed every time — 29,265 index rebuilds for 10,149 links. The cache is now
25
+ bounded-multi-slot, and it holds each catalog rather than just its id(), so a freed dict cannot
26
+ have its address reused under a stale index.
27
+
28
+ blue-book markdown: 2718 ms -> 267 ms (10.2x), now level with wikilink mode. Reference vault 195 ->
29
+ 181 ms. All link counts byte-identical in every mode.
30
+
31
+
32
+ ## v0.8.0 (2026-07-26)
33
+
34
+ ### Documentation
35
+
36
+ - Document link_syntax and close the markdown non-goal
37
+ ([#158](https://github.com/cdcoonce/graphmark/pull/158),
38
+ [`5523fe3`](https://github.com/cdcoonce/graphmark/commit/5523fe31b548eb5e94001515ab8e8adb7c084849))
39
+
40
+ * docs: document link_syntax and close the markdown non-goal
41
+
42
+ README gains a Link syntax section — the config key, the warning, and the one thing a user must
43
+ understand: a wikilink NAMES a note (ambiguous when two share it) while a markdown link is a PATH
44
+ RELATIVE TO THE LINKING NOTE (never ambiguous for the same reason). Different resolution regimes,
45
+ not two spellings.
46
+
47
+ Roadmap's non-goal narrowed to Logseq, recording that #152 was the "on demand" trigger the clause
48
+ reserved — and recording what it did not do: no basename fallback, because silently trying a
49
+ second rule when the first fails is the shape of #136. The dialect question is #156 with its
50
+ measurement.
51
+
52
+ * fix(test): a console block's output is not a command
53
+
54
+ The README-example extractor treated every line starting with "graphmark " as an invocation,
55
+ including output. Adding the link-syntax section wrapped the #151 warning onto a second line
56
+ beginning "graphmark only reads ..." — collected as a command and run.
57
+
58
+ In a `console` block, `$ ` marks input and everything else is output, so the prefix is now required
59
+ there; `bash` blocks keep bare lines. Caught by the suite it belongs to, on its author's own
60
+ input.
61
+
62
+ - Second corpus run — the null result and the extractor blind spot
63
+ ([#153](https://github.com/cdcoonce/graphmark/pull/153),
64
+ [`475b111`](https://github.com/cdcoonce/graphmark/commit/475b1112767bb9ca92d82cb0d391bf20543759c4))
65
+
66
+ Repeated the corpus study after #123/#136/#137/#138/#139 shipped, to ask whether those fixes move
67
+ any real number and whether there is demand for the link-syntax work the roadmap defers. Six
68
+ vaults; two overlap the first run.
69
+
70
+ The five fixes moved NOTHING — arkalim and kepano reproduce the first run's percentages to the
71
+ decimal. Recorded rather than buried: correctness-by-inspection has outrun what this corpus can
72
+ corroborate.
73
+
74
+ The finding that was not theoretical: lyz-code/blue-book, 1120 notes, carries 11,198 markdown-style
75
+ [text](note.md) links (99% targeting a real note) and extracts to ZERO edges. Every note an
76
+ orphan, and `check` looks nearly healthy because those links were never extracted and so are not
77
+ unresolved.
78
+
79
+ That is a third named limit of the Track F thesis: the conservation law sums over what the EXTRACTOR
80
+ produced, so a syntax it does not know is invisible to it by construction. The accounting is
81
+ auditable only within the universe the extractor defines. Filed as #151 (warn) and #152 (the
82
+ markdown-syntax decision).
83
+
84
+ ### Features
85
+
86
+ - Read markdown-style [text](note.md) links, config-gated (#152)
87
+ ([#155](https://github.com/cdcoonce/graphmark/pull/155),
88
+ [`6716a38`](https://github.com/cdcoonce/graphmark/commit/6716a3845b15665190f03f1bce3605a2927322e2))
89
+
90
+ The roadmap reserved alternate link-syntax adapters for "on demand"; the second corpus run supplied
91
+ the demand — lyz-code/blue-book, 1120 notes, 10,149 markdown-style links, zero extracted edges.
92
+
93
+ Four decisions, all reversible in config:
94
+
95
+ 1. `link_syntax = "wikilink" | "markdown" | "both"`, defaulting to "wikilink", so every existing
96
+ vault, every frozen fixture and the reference vault stay byte-identical. An unknown value fails
97
+ loudly — a typo would otherwise produce an empty graph, the exact failure #151 exists to surface.
98
+ 2. Resolution is RELATIVE TO THE LINKING NOTE, which is genuinely new: a markdown target is a
99
+ path, not a name. Rather than change the `Resolver` protocol — which never receives the source
100
+ note, and which the roadmap forbids redesigning — the target is normalized into a vault-relative
101
+ path before diagnosis, where the existing path branch resolves it exactly. 3. No new frozen
102
+ fixture: the oracle is wikilink-only and regenerating it is a human's call (Track B). Hand-written
103
+ assertions instead. 4. A target escaping the vault root is `missing` — not resolved, not an error.
104
+
105
+ Relative is default markdown semantics (CommonMark, mkdocs, GitHub) and NOT the wikilink rule.
106
+ blue-book resolves 5.8% that way against 92.7% by basename-anywhere, because it runs the
107
+ mkdocs-autolinks plugin. That dialect is a separate decision, not a fallback slipped in here —
108
+ silently trying a second rule when the first fails is how a link resolves to the wrong note.
109
+
110
+ The #151 warning is suppressed once markdown links are actually read.
111
+
112
+ - **graph**: Warn when the vault's links are in a syntax graphmark cannot read (#151)
113
+ ([#154](https://github.com/cdcoonce/graphmark/pull/154),
114
+ [`d94ae63`](https://github.com/cdcoonce/graphmark/commit/d94ae63e7a789fabdb420423eaeaee8e771eb2a7))
115
+
116
+ graphmark reported a confidently EMPTY graph for a densely linked vault, with nothing to say it had
117
+ seen none of the links. Measured on lyz-code/blue-book: 1120 notes, 11,198 markdown-style
118
+ [text](note.md) links (99% targeting a note that exists), 0 extracted edges. Every note an orphan,
119
+ no clusters, no hubs, uniform PageRank — and `check` looks nearly healthy, because links that were
120
+ never extracted are not `unresolved`.
121
+
122
+ The conservation law from #124 cannot catch this: it sums over what the EXTRACTOR produced, so a
123
+ syntax the extractor does not know sits outside the universe being counted. The buckets balance
124
+ perfectly at 39 while 11,198 links go unseen.
125
+
126
+ The test is relational, not calibrated — it fires only when the unread syntax strictly outnumbers
127
+ the read one. No threshold to tune, no false-positive mode, and the reference vault's
128
+ 17-against-6226 can never trip it. Same discipline as #133's assertion.
129
+
130
+ Warning only: stderr, one line, and it touches no count, no metric and no verdict.
131
+
132
+
4
133
  ## v0.7.2 (2026-07-25)
5
134
 
6
135
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphmark
3
- Version: 0.7.2
3
+ Version: 0.8.1
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
@@ -111,6 +111,32 @@ notice the tool is wrong about your vault.
111
111
  The same block appears in `check`'s report under `links`, as context for the verdict. It never
112
112
  changes the verdict.
113
113
 
114
+ ## Link syntax
115
+
116
+ graphmark reads `[[wikilinks]]` by default. A vault written in markdown link syntax —
117
+ `[text](note.md)`, what most non-Obsidian markdown gardens use — would otherwise produce an empty
118
+ graph, so set `link_syntax`:
119
+
120
+ ```toml
121
+ root = "."
122
+ link_syntax = "both" # "wikilink" (default) | "markdown" | "both"
123
+ ```
124
+
125
+ The two resolve differently, and the difference is not cosmetic:
126
+
127
+ - a **wikilink** names a note — `[[Note]]` finds `Note.md` anywhere in the vault, and two notes with
128
+ that name make it ambiguous;
129
+ - a **markdown** link is a path **relative to the linking note** — `[x](../other/b.md)` means that
130
+ file and nothing else, so the same filename in two folders is not ambiguous at all.
131
+
132
+ If your links are in a syntax graphmark is not reading, it says so on stderr rather than reporting a
133
+ confidently empty graph:
134
+
135
+ ```console
136
+ graphmark: warning: 39 wikilinks extracted but 10149 markdown-style [](.md) links found;
137
+ graphmark only reads [[wikilinks]] — this graph is probably not your vault
138
+ ```
139
+
114
140
  ## `graphmark check` — vault health as a CI gate
115
141
 
116
142
  Declare thresholds in the config's `[check]` table, then run `graphmark check` as a build step. It
@@ -83,6 +83,32 @@ notice the tool is wrong about your vault.
83
83
  The same block appears in `check`'s report under `links`, as context for the verdict. It never
84
84
  changes the verdict.
85
85
 
86
+ ## Link syntax
87
+
88
+ graphmark reads `[[wikilinks]]` by default. A vault written in markdown link syntax —
89
+ `[text](note.md)`, what most non-Obsidian markdown gardens use — would otherwise produce an empty
90
+ graph, so set `link_syntax`:
91
+
92
+ ```toml
93
+ root = "."
94
+ link_syntax = "both" # "wikilink" (default) | "markdown" | "both"
95
+ ```
96
+
97
+ The two resolve differently, and the difference is not cosmetic:
98
+
99
+ - a **wikilink** names a note — `[[Note]]` finds `Note.md` anywhere in the vault, and two notes with
100
+ that name make it ambiguous;
101
+ - a **markdown** link is a path **relative to the linking note** — `[x](../other/b.md)` means that
102
+ file and nothing else, so the same filename in two folders is not ambiguous at all.
103
+
104
+ If your links are in a syntax graphmark is not reading, it says so on stderr rather than reporting a
105
+ confidently empty graph:
106
+
107
+ ```console
108
+ graphmark: warning: 39 wikilinks extracted but 10149 markdown-style [](.md) links found;
109
+ graphmark only reads [[wikilinks]] — this graph is probably not your vault
110
+ ```
111
+
86
112
  ## `graphmark check` — vault health as a CI gate
87
113
 
88
114
  Declare thresholds in the config's `[check]` table, then run `graphmark check` as a build step. It
@@ -185,6 +185,19 @@ The generalizable lesson, and the one to carry into future test design here: **a
185
185
  generated input still needs a correct answer to compare against.** Invariants prove the package is
186
186
  consistent with itself, which is exactly the thing a systematically wrong resolver also is.
187
187
 
188
+ _Second corpus run, same day (see `docs/corpus-study.md`):_ a **third** named limit, and a null
189
+ result. The null result first — the five fixes above moved **no** third-party number; the two vaults
190
+ overlapping the first run reproduce it to the decimal. They are correct and pinned, but against real
191
+ data they are theoretical, joining #123 there.
192
+
193
+ The limit: `lyz-code/blue-book` (1120 notes) carries **11,198 markdown-style `[text](note.md)` links,
194
+ 99% of them targeting a real note**, and graphmark extracts **zero**. Every note an orphan, and
195
+ `check` looks nearly healthy because those links were never extracted and so are not *unresolved*.
196
+ The conservation law sums over what the **extractor** produced — so a link syntax the extractor does
197
+ not know is invisible to it by construction. **The accounting is auditable only within the universe
198
+ the extractor defines.** Filed as #151 (warn) and #152 (the markdown-syntax decision — the "on
199
+ demand" trigger the non-goals list reserved).
200
+
188
201
  Audited alongside: the reference vault's two suppressed buckets, by hand — 17 `non-note-file` (all
189
202
  genuine `.base`/`.png`) and 40 `intra-note` (all genuine heading refs), 0 false suppressions. A
190
203
  suppressed bucket is only trustworthy once someone has read it.
@@ -224,8 +237,13 @@ Evaluated against the ecosystem and dropped for zero consumer pull; do not re-li
224
237
  - Betweenness centrality, Louvain/Leiden community detection.
225
238
  - CSV/GML/Cypher export; incremental/cached builds; any LLM "propose" pass.
226
239
  - MCP server or retrieval/search surface.
227
- - Alternate link-syntax adapters (Logseq, `[](.)` markdown) — the pluggable interfaces exist so
228
- these _can_ be added on demand; do not build them speculatively.
240
+ - Alternate link-syntax adapters (Logseq) — the pluggable interfaces exist so these _can_ be added
241
+ on demand; do not build them speculatively. **Markdown `[text](note.md)` links SHIPPED 2026-07-25**
242
+ (#152) — the second corpus run supplied the demand this clause reserved: a 1120-note vault with
243
+ 10,149 of them and zero extracted edges. Config-gated (`link_syntax`), default unchanged,
244
+ `Resolver` untouched. Note what it did *not* do: blue-book resolves 5.8% by strict relative path
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.
229
247
  - Re-platforming (no swapping networkx; no async/parallel rewrites).
230
248
  - Performance work without a benchmark showing pain (the live consumer is a ~340-note vault). The
231
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.7.2"
3
+ version = "0.8.1"
4
4
  description = "Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -11,6 +11,10 @@ import tomllib
11
11
  from dataclasses import dataclass, field, fields
12
12
  from pathlib import Path
13
13
 
14
+ #: Every value ``VaultConfig.link_syntax`` accepts. A consumer may switch on these, so the set is
15
+ #: part of the config contract.
16
+ LINK_SYNTAXES = frozenset({"wikilink", "markdown", "both"})
17
+
14
18
 
15
19
  @dataclass(frozen=True)
16
20
  class CheckPolicy:
@@ -47,6 +51,12 @@ class VaultConfig:
47
51
  # Obsidian's `aliases:` property names a note, so links written against one resolve by
48
52
  # default. Set False for strict basename-only resolution.
49
53
  resolve_aliases: bool = True
54
+ #: Which link syntax to read. ``"wikilink"`` (the default) is Obsidian's ``[[Note]]`` and is
55
+ #: what every frozen fixture encodes, so the default keeps existing behavior byte-identical.
56
+ #: ``"markdown"`` reads ``[text](note.md)``, the syntax non-Obsidian markdown vaults use — added
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.
59
+ link_syntax: str = "wikilink"
50
60
  check: CheckPolicy = field(default_factory=CheckPolicy)
51
61
 
52
62
  def __post_init__(self) -> None:
@@ -54,6 +64,12 @@ class VaultConfig:
54
64
  # obscure AttributeError on the first Path operation.
55
65
  if not isinstance(self.root, Path):
56
66
  self.root = Path(self.root)
67
+ # Fail loudly rather than silently reading nothing: a typo here would produce an empty
68
+ # graph, which is exactly the failure #151 exists to make visible.
69
+ if self.link_syntax not in LINK_SYNTAXES:
70
+ raise ValueError(
71
+ f"link_syntax must be one of {sorted(LINK_SYNTAXES)}, got {self.link_syntax!r}"
72
+ )
57
73
 
58
74
 
59
75
  def _parse_check(data: dict, path: Path) -> CheckPolicy:
@@ -122,5 +138,6 @@ def load_config(path: str | Path, *, root_override: str | Path | None = None) ->
122
138
  rules_files=data.get("rules_files", ["CLAUDE.md", "CLAUDE.local.md"]),
123
139
  transient_prefixes=tuple(data.get("transient_prefixes", [])),
124
140
  resolve_aliases=bool(data.get("resolve_aliases", True)),
141
+ link_syntax=data.get("link_syntax", "wikilink"),
125
142
  check=_parse_check(data, path),
126
143
  )
@@ -2,17 +2,19 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import posixpath
5
6
  import re
6
7
  import string
8
+ import sys
7
9
  import unicodedata
8
10
  from dataclasses import dataclass, field, replace
9
11
  from functools import lru_cache
10
- from pathlib import Path
12
+ from pathlib import Path, PurePosixPath
11
13
 
12
14
  from graphmark.config import VaultConfig
13
15
  from graphmark.interfaces import LinkExtractor, Resolver
14
16
  from graphmark.model import Document
15
- from graphmark.parse import parse_document
17
+ from graphmark.parse import MarkdownLinkExtractor, count_markdown_links, parse_document
16
18
 
17
19
  _PUNCT_TABLE = str.maketrans(string.punctuation, " " * len(string.punctuation))
18
20
 
@@ -142,12 +144,7 @@ def candidates_for(display: str, catalog: dict[str, list[str]]) -> list[str]:
142
144
  return []
143
145
  if "/" in target:
144
146
  suffix = _fold_case(target) + ".md"
145
- return sorted(
146
- path
147
- for paths in catalog.values()
148
- for path in paths
149
- if _matches_path_suffix(path, suffix)
150
- )
147
+ return sorted(_suffix_matches(suffix, catalog, _CANDIDATE_INDEX))
151
148
  return list(catalog.get(_normalize(target), ()))
152
149
 
153
150
 
@@ -215,25 +212,74 @@ def build_catalog(docs: list[Document]) -> dict[str, list[str]]:
215
212
  return catalog
216
213
 
217
214
 
215
+ class _FilenameIndex:
216
+ """Catalog paths bucketed by folded filename, so a path-qualified link scans a bucket.
217
+
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.
223
+
224
+ This mattered little while path-qualified links were rare: most wikilink displays are bare names
225
+ hitting the catalog dict directly, which is why the ``O(L·N)`` ceiling was documented and
226
+ accepted. #152 changed which branch is hot — every markdown link is path-qualified by
227
+ construction — and a 1120-note vault with 10,149 of them took 2.9 s against 0.3 s for the same
228
+ vault read as wikilinks.
229
+
230
+ Keyed by catalog identity, which is safe because a catalog is invariant for a whole
231
+ ``VaultGraph.build()``. Each catalog is **held**, not just its ``id()``: without a reference the
232
+ dict could be freed and a new one allocated at the same address, and the stale index would then
233
+ answer for it.
234
+
235
+ Several slots, not one, and that is load-bearing rather than defensive. ``_diagnose`` consults
236
+ two different mappings for the same display — the in-scope ``catalog`` and the ``out_of_scope``
237
+ one — so a single slot alternates between them and misses *every* time. Measured: a single-slot
238
+ version rebuilt the index 29,265 times for 10,149 links and was no faster than the scan it
239
+ replaced. The cap keeps this from growing without bound if a caller cycles through catalogs.
240
+ """
241
+
242
+ #: Enough for the two mappings a build alternates between, with room to spare. Small on purpose:
243
+ #: this holds catalogs alive, so it must not become an unbounded retainer.
244
+ _MAX_SLOTS = 4
245
+
246
+ def __init__(self) -> None:
247
+ # Insertion-ordered, so the oldest entry is the one evicted.
248
+ self._slots: dict[int, tuple[dict[str, list[str]], dict[str, list[str]]]] = {}
249
+
250
+ def for_catalog(self, catalog: dict[str, list[str]]) -> dict[str, list[str]]:
251
+ cached = self._slots.get(id(catalog))
252
+ if cached is not None and cached[0] is catalog:
253
+ return cached[1]
254
+
255
+ buckets: dict[str, list[str]] = {}
256
+ for paths in catalog.values():
257
+ for path in paths:
258
+ buckets.setdefault(_fold_case(path.rsplit("/", 1)[-1]), []).append(path)
259
+
260
+ if len(self._slots) >= self._MAX_SLOTS:
261
+ del self._slots[next(iter(self._slots))]
262
+ self._slots[id(catalog)] = (catalog, buckets)
263
+ return buckets
264
+
265
+
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)]
270
+
271
+
272
+ #: Shared by ``candidates_for``, which takes a catalog rather than a resolver and so cannot reach
273
+ #: the resolver's own index. One index per process is enough — both callers see the same catalog
274
+ #: object for the length of a build, and a different catalog simply re-keys it.
275
+ _CANDIDATE_INDEX = _FilenameIndex()
276
+
277
+
218
278
  class NormalizeResolver:
219
279
  """Resolves wikilink displays via normalized basename, with path-suffix fallback."""
220
280
 
221
281
  def __init__(self) -> None:
222
- # Cache the flattened path list per catalog identity. catalog is invariant for a whole
223
- # VaultGraph.build(), so folder-style links reuse one flatten instead of rebuilding it
224
- # on every call. Keyed by id() and single-slot: a new catalog evicts the previous.
225
- self._flat_cache_id: int | None = None
226
- self._flat_cache: list[str] | None = None
227
-
228
- @staticmethod
229
- def _compute_flat(catalog: dict[str, list[str]]) -> list[str]:
230
- return [p for paths in catalog.values() for p in paths]
231
-
232
- def _flatten_paths(self, catalog: dict[str, list[str]]) -> list[str]:
233
- if self._flat_cache_id != id(catalog) or self._flat_cache is None:
234
- self._flat_cache = self._compute_flat(catalog)
235
- self._flat_cache_id = id(catalog)
236
- return self._flat_cache
282
+ self._index = _FilenameIndex()
237
283
 
238
284
  def resolve(self, display: str, catalog: dict[str, list[str]]) -> str | None:
239
285
  # Alias ("Note|alias"), anchor ("Note#Section") and an explicit ".md" extension all name
@@ -242,10 +288,11 @@ class NormalizeResolver:
242
288
  display = _strip_display(display)
243
289
 
244
290
  if "/" in display:
245
- # Path-suffix resolution: find unique rel_path ending with "display.md"
291
+ # Path-suffix resolution: find unique rel_path ending with "display.md". Note that an
292
+ # exact whole-path hit cannot short-circuit — "work/Tasks" names both "work/Tasks.md"
293
+ # and "a/work/Tasks.md", and the honest answer there is to decline.
246
294
  suffix = _fold_case(display) + ".md"
247
- all_paths = self._flatten_paths(catalog)
248
- matches = [p for p in all_paths if _matches_path_suffix(p, suffix)]
295
+ matches = _suffix_matches(suffix, catalog, self._index)
249
296
  return matches[0] if len(matches) == 1 else None
250
297
 
251
298
  # Bare-link resolution: normalize and look up in catalog
@@ -463,6 +510,68 @@ def diagnose(graph: VaultGraph, display: str, *, suggest: int = 0) -> LinkDiagno
463
510
  return diagnosis
464
511
 
465
512
 
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``.
515
+
516
+ A markdown target is a **path**, not a name: ``[x](../other/b.md)`` means "the file at that
517
+ location relative to me". That is the first resolution rule in this package that depends on
518
+ *where the link is* — information the ``Resolver`` protocol never receives, and which the
519
+ roadmap forbids adding by redesigning that boundary.
520
+
521
+ So the dependence is resolved here instead, before diagnosis: the target is normalized against
522
+ the linking note's folder into a full vault-relative path, and the existing path branch of the
523
+ resolver then matches it exactly. ``Resolver`` is untouched and still answers "what does this
524
+ name?" with no notion of a source.
525
+
526
+ Relative is the **default markdown semantics** (CommonMark, mkdocs, GitHub) and deliberately
527
+ not the wikilink rule. Measured on `lyz-code/blue-book`: 5.8% of its links resolve this way
528
+ against 92.7% by basename-anywhere, because it runs the `mkdocs-autolinks` plugin. That dialect
529
+ is a separate decision, not a fallback to slip in here — silently trying a second rule when the
530
+ first fails is how a link resolves to the wrong note.
531
+
532
+ ``None`` for a target that escapes the vault root. That is not an error and not a resolution —
533
+ a link out of the vault names no note in the graph, so it is reported ``missing`` like any other
534
+ target that is not there.
535
+ """
536
+ combined = PurePosixPath(source_rel_path).parent / target
537
+ normalized = posixpath.normpath(str(combined))
538
+ # normpath leaves leading "..", which is the only way to express "above the root".
539
+ if normalized == ".." or normalized.startswith("../"):
540
+ return None
541
+ return normalized
542
+
543
+
544
+ def _warn_if_unread_syntax_dominates(docs: list[Document], extracted: int) -> None:
545
+ """Warn once when the vault's links are mostly in a syntax graphmark does not read.
546
+
547
+ graphmark extracts ``[[wikilinks]]`` only. A vault written in markdown link syntax therefore
548
+ produces a confidently *empty* graph — every note an orphan, no clusters, no hubs, uniform
549
+ PageRank — with nothing to tell the user that none of their links were seen. Worse, ``check``
550
+ looks nearly healthy, because links that were never extracted are not ``unresolved``.
551
+
552
+ Measured on a real 1120-note vault: 11,198 markdown-style links, 99% of them targeting a note
553
+ that exists, and 0 extracted edges.
554
+
555
+ The test is **relational, not calibrated** — it fires only when the unread syntax strictly
556
+ outnumbers the read one. There is no threshold to tune and no false-positive mode; a vault with
557
+ a handful of stray markdown links among thousands of wikilinks (the reference vault: 17 against
558
+ 6226) can never trip it. Same discipline as #133's assertion, and the reason this is a warning
559
+ rather than anything that touches a count or a verdict.
560
+
561
+ stderr only, so stdout stays pipeable JSON — as ``links_summary_line`` and ``breach_lines``
562
+ already do.
563
+ """
564
+ unread = sum(count_markdown_links(doc.text) for doc in docs)
565
+ if unread <= extracted:
566
+ return
567
+ print(
568
+ f"graphmark: warning: {extracted} wikilinks extracted but {unread} markdown-style "
569
+ f"[](.md) links found; graphmark only reads [[wikilinks]] — this graph is probably "
570
+ f"not your vault",
571
+ file=sys.stderr,
572
+ )
573
+
574
+
466
575
  class VaultGraph:
467
576
  """Built graph: all nodes plus resolved out/back adjacency.
468
577
 
@@ -559,8 +668,24 @@ class VaultGraph:
559
668
  # is reported rather than absent — "0 alias-resolved" is a finding, not a non-event.
560
669
  link_counts: dict[str, int] = dict.fromkeys(DIAGNOSIS_REASONS, 0)
561
670
  alias_resolved = 0
671
+ # Which syntaxes to read. Composed rather than swapped, because the two need different
672
+ # pre-processing: a wikilink display is a *name*, a markdown target is a *path relative to
673
+ # the linking note*, and only the latter can be normalized once the source note is known.
674
+ read_wikilinks = config.link_syntax in ("wikilink", "both")
675
+ read_markdown = config.link_syntax in ("markdown", "both")
676
+ md_extractor = MarkdownLinkExtractor() if read_markdown else None
677
+
562
678
  for doc in docs:
563
- for display in extractor.extract(doc.text):
679
+ displays = list(extractor.extract(doc.text)) if read_wikilinks else []
680
+ if md_extractor is not None:
681
+ for target in md_extractor.extract(doc.text):
682
+ resolved = resolve_relative_target(target, doc.rel_path)
683
+ # A target above the vault root names no note here. Kept in the stream as the
684
+ # raw target so it is counted and reported `missing`, never silently dropped —
685
+ # the conservation law holds for every syntax that is read.
686
+ displays.append(resolved if resolved is not None else target)
687
+
688
+ for display in displays:
564
689
  d = _diagnose(display, catalog, out_of_scope, resolver, aliases)
565
690
  link_counts[d.reason] += 1
566
691
  if d.via == "alias":
@@ -570,6 +695,9 @@ class VaultGraph:
570
695
  elif d.target is not None and d.target != doc.rel_path:
571
696
  out_links[doc.rel_path].add(d.target)
572
697
 
698
+ if not read_markdown:
699
+ _warn_if_unread_syntax_dominates(docs, sum(link_counts.values()))
700
+
573
701
  for src, targets in out_links.items():
574
702
  for dst in targets:
575
703
  back_links[dst].add(src)
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import re
6
6
  import sys
7
7
  from pathlib import Path
8
+ from urllib.parse import unquote
8
9
 
9
10
  from graphmark.model import Document
10
11
 
@@ -93,6 +94,33 @@ def _parse_frontmatter(raw: str) -> dict:
93
94
  return result
94
95
 
95
96
 
97
+ #: A markdown-style link to a local markdown file: ``[text](note.md)`` or
98
+ #: ``[text](../a/b.md#Anchor)``.
99
+ #: Not an image (``![...]``) and not an absolute URL — a link to somebody's README on the web is not
100
+ #: a link into this vault. Deliberately narrow: this counts a *signal*, so a false positive here
101
+ #: would produce a warning about nothing.
102
+ _MD_LINK_RE = re.compile(r"(?<!!)\[[^\]]*\]\((?!\w+:)([^)\s]+?\.md)(?:#[^)]*)?\)")
103
+
104
+
105
+ def count_markdown_links(text: str) -> int:
106
+ """How many ``[text](note.md)`` links the text holds — a syntax graphmark does not read.
107
+
108
+ graphmark extracts ``[[wikilinks]]`` only, so a vault written in markdown link syntax produces
109
+ an empty graph with no indication that anything was missed: every note an orphan, no clusters,
110
+ and a `check` that looks healthy because links which were never extracted are not *unresolved*.
111
+
112
+ The conservation law in ``VaultGraph.build`` cannot see this — it sums over what the extractor
113
+ produced, so a syntax the extractor does not know sits outside the universe being counted. This
114
+ function exists to make that universe's edge visible; it feeds a warning and nothing else.
115
+
116
+ Code spans and fenced blocks are skipped, exactly as wikilink extraction skips them: a
117
+ documented example is not a link.
118
+ """
119
+ text = _strip_fenced_blocks(text)
120
+ text = _INLINE_CODE_RE.sub("", text)
121
+ return len(_MD_LINK_RE.findall(text))
122
+
123
+
96
124
  class WikilinkExtractor:
97
125
  """Extracts raw wikilink displays from note text, excluding code spans."""
98
126
 
@@ -102,6 +130,27 @@ class WikilinkExtractor:
102
130
  return _WIKILINK_RE.findall(text)
103
131
 
104
132
 
133
+ class MarkdownLinkExtractor:
134
+ """Extracts ``[text](note.md)`` targets — the syntax non-Obsidian markdown vaults use.
135
+
136
+ Returns the **target**, not the display text, and without its anchor: unlike a wikilink, where
137
+ the visible text names the note, here the parenthesized path is the only thing that identifies
138
+ it. The display text is prose and never resolves to anything.
139
+
140
+ Percent-encoding is decoded, because markdown encodes spaces (``my%20note.md``) while the vault
141
+ stores them literally — a link no editor would consider broken.
142
+
143
+ The returned target is **relative to the linking note**, which callers must resolve; this class
144
+ cannot, since ``extract`` sees text and never learns which note it came from. See
145
+ ``VaultGraph.build``.
146
+ """
147
+
148
+ def extract(self, text: str) -> list[str]:
149
+ text = _strip_fenced_blocks(text)
150
+ text = _INLINE_CODE_RE.sub("", text)
151
+ return [unquote(target) for target in _MD_LINK_RE.findall(text)]
152
+
153
+
105
154
  def parse_document(path: Path, root: Path) -> Document:
106
155
  """Parse a markdown note into a Document, splitting YAML frontmatter from body.
107
156
 
@@ -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")