graphmark 0.7.2__tar.gz → 0.8.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 (105) hide show
  1. {graphmark-0.7.2 → graphmark-0.8.0}/CHANGELOG.md +101 -0
  2. {graphmark-0.7.2 → graphmark-0.8.0}/PKG-INFO +27 -1
  3. {graphmark-0.7.2 → graphmark-0.8.0}/README.md +26 -0
  4. {graphmark-0.7.2 → graphmark-0.8.0}/docs/ROADMAP.md +20 -2
  5. {graphmark-0.7.2 → graphmark-0.8.0}/pyproject.toml +1 -1
  6. {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/config.py +17 -0
  7. {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/graph.py +86 -3
  8. {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/parse.py +49 -0
  9. graphmark-0.8.0/tests/test_markdown_links.py +155 -0
  10. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_readme_examples.py +12 -5
  11. graphmark-0.8.0/tests/test_unread_syntax.py +111 -0
  12. {graphmark-0.7.2 → graphmark-0.8.0}/.gitignore +0 -0
  13. {graphmark-0.7.2 → graphmark-0.8.0}/LICENSE +0 -0
  14. {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/__init__.py +0 -0
  15. {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/check.py +0 -0
  16. {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/cli.py +0 -0
  17. {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/dismiss.py +0 -0
  18. {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/export.py +0 -0
  19. {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/interfaces.py +0 -0
  20. {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/metrics.py +0 -0
  21. {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/model.py +0 -0
  22. {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/py.typed +0 -0
  23. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/README.md +0 -0
  24. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/config.toml +0 -0
  25. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/expected.json +0 -0
  26. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
  27. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
  28. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/vault/docs/center.md +0 -0
  29. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/vault/misc/echo.md +0 -0
  30. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
  31. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
  32. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/vault/misc/stub.md +0 -0
  33. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/vault/refs/beta.md +0 -0
  34. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/vault/refs/delta.md +0 -0
  35. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
  36. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/dismiss/expected.json +0 -0
  37. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
  38. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/dismiss/vault/alpha.md +0 -0
  39. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/dismiss/vault/beta.md +0 -0
  40. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/dismiss/vault/gamma.md +0 -0
  41. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/config.toml +0 -0
  42. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/expected.json +0 -0
  43. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/expected_oog.json +0 -0
  44. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/similar.json +0 -0
  45. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/similar_oog.json +0 -0
  46. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/vault/docs/a.md +0 -0
  47. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
  48. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/vault/misc/c.md +0 -0
  49. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/vault/misc/d.md +0 -0
  50. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/vault/refs/b.md +0 -0
  51. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/vault/refs/e.md +0 -0
  52. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/scoped/config.toml +0 -0
  53. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/scoped/expected.json +0 -0
  54. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/scoped/vault/docs/one.md +0 -0
  55. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
  56. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
  57. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/scoped/vault/refs/two.md +0 -0
  58. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/selflink/config.toml +0 -0
  59. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/selflink/expected.json +0 -0
  60. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/selflink/vault/a.md +0 -0
  61. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/selflink/vault/b.md +0 -0
  62. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/simple/config.toml +0 -0
  63. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/simple/expected.json +0 -0
  64. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
  65. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/simple/vault/brain/hub.md +0 -0
  66. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/simple/vault/personal/beta.md +0 -0
  67. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
  68. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/simple/vault/reference/island.md +0 -0
  69. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/simple/vault/reference/stub.md +0 -0
  70. {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/suggest/README.md +0 -0
  71. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_aliases.py +0 -0
  72. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_bom.py +0 -0
  73. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_build_filters.py +0 -0
  74. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_check.py +0 -0
  75. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_check_policy.py +0 -0
  76. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_cli.py +0 -0
  77. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_config.py +0 -0
  78. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_degenerate.py +0 -0
  79. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_diagnose.py +0 -0
  80. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_dismiss.py +0 -0
  81. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_export.py +0 -0
  82. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_gaps.py +0 -0
  83. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_gaps_banding.py +0 -0
  84. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_gaps_oog.py +0 -0
  85. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_gaps_params.py +0 -0
  86. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_graph.py +0 -0
  87. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_graph_catalogs.py +0 -0
  88. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_link_counts.py +0 -0
  89. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_links_report.py +0 -0
  90. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_metrics.py +0 -0
  91. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_model.py +0 -0
  92. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_non_note_file.py +0 -0
  93. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_packaging.py +0 -0
  94. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_pagerank.py +0 -0
  95. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_parse.py +0 -0
  96. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_path_suffix.py +0 -0
  97. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_properties.py +0 -0
  98. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_public_api.py +0 -0
  99. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_scoped.py +0 -0
  100. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_selflink.py +0 -0
  101. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_smoke.py +0 -0
  102. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_suggestions.py +0 -0
  103. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_unicode.py +0 -0
  104. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_unicode_punctuation.py +0 -0
  105. {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_unresolved.py +0 -0
@@ -1,6 +1,107 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.8.0 (2026-07-26)
5
+
6
+ ### Documentation
7
+
8
+ - Document link_syntax and close the markdown non-goal
9
+ ([#158](https://github.com/cdcoonce/graphmark/pull/158),
10
+ [`5523fe3`](https://github.com/cdcoonce/graphmark/commit/5523fe31b548eb5e94001515ab8e8adb7c084849))
11
+
12
+ * docs: document link_syntax and close the markdown non-goal
13
+
14
+ README gains a Link syntax section — the config key, the warning, and the one thing a user must
15
+ understand: a wikilink NAMES a note (ambiguous when two share it) while a markdown link is a PATH
16
+ RELATIVE TO THE LINKING NOTE (never ambiguous for the same reason). Different resolution regimes,
17
+ not two spellings.
18
+
19
+ Roadmap's non-goal narrowed to Logseq, recording that #152 was the "on demand" trigger the clause
20
+ reserved — and recording what it did not do: no basename fallback, because silently trying a
21
+ second rule when the first fails is the shape of #136. The dialect question is #156 with its
22
+ measurement.
23
+
24
+ * fix(test): a console block's output is not a command
25
+
26
+ The README-example extractor treated every line starting with "graphmark " as an invocation,
27
+ including output. Adding the link-syntax section wrapped the #151 warning onto a second line
28
+ beginning "graphmark only reads ..." — collected as a command and run.
29
+
30
+ In a `console` block, `$ ` marks input and everything else is output, so the prefix is now required
31
+ there; `bash` blocks keep bare lines. Caught by the suite it belongs to, on its author's own
32
+ input.
33
+
34
+ - Second corpus run — the null result and the extractor blind spot
35
+ ([#153](https://github.com/cdcoonce/graphmark/pull/153),
36
+ [`475b111`](https://github.com/cdcoonce/graphmark/commit/475b1112767bb9ca92d82cb0d391bf20543759c4))
37
+
38
+ Repeated the corpus study after #123/#136/#137/#138/#139 shipped, to ask whether those fixes move
39
+ any real number and whether there is demand for the link-syntax work the roadmap defers. Six
40
+ vaults; two overlap the first run.
41
+
42
+ The five fixes moved NOTHING — arkalim and kepano reproduce the first run's percentages to the
43
+ decimal. Recorded rather than buried: correctness-by-inspection has outrun what this corpus can
44
+ corroborate.
45
+
46
+ The finding that was not theoretical: lyz-code/blue-book, 1120 notes, carries 11,198 markdown-style
47
+ [text](note.md) links (99% targeting a real note) and extracts to ZERO edges. Every note an
48
+ orphan, and `check` looks nearly healthy because those links were never extracted and so are not
49
+ unresolved.
50
+
51
+ That is a third named limit of the Track F thesis: the conservation law sums over what the EXTRACTOR
52
+ produced, so a syntax it does not know is invisible to it by construction. The accounting is
53
+ auditable only within the universe the extractor defines. Filed as #151 (warn) and #152 (the
54
+ markdown-syntax decision).
55
+
56
+ ### Features
57
+
58
+ - Read markdown-style [text](note.md) links, config-gated (#152)
59
+ ([#155](https://github.com/cdcoonce/graphmark/pull/155),
60
+ [`6716a38`](https://github.com/cdcoonce/graphmark/commit/6716a3845b15665190f03f1bce3605a2927322e2))
61
+
62
+ The roadmap reserved alternate link-syntax adapters for "on demand"; the second corpus run supplied
63
+ the demand — lyz-code/blue-book, 1120 notes, 10,149 markdown-style links, zero extracted edges.
64
+
65
+ Four decisions, all reversible in config:
66
+
67
+ 1. `link_syntax = "wikilink" | "markdown" | "both"`, defaulting to "wikilink", so every existing
68
+ vault, every frozen fixture and the reference vault stay byte-identical. An unknown value fails
69
+ loudly — a typo would otherwise produce an empty graph, the exact failure #151 exists to surface.
70
+ 2. Resolution is RELATIVE TO THE LINKING NOTE, which is genuinely new: a markdown target is a
71
+ path, not a name. Rather than change the `Resolver` protocol — which never receives the source
72
+ note, and which the roadmap forbids redesigning — the target is normalized into a vault-relative
73
+ path before diagnosis, where the existing path branch resolves it exactly. 3. No new frozen
74
+ fixture: the oracle is wikilink-only and regenerating it is a human's call (Track B). Hand-written
75
+ assertions instead. 4. A target escaping the vault root is `missing` — not resolved, not an error.
76
+
77
+ Relative is default markdown semantics (CommonMark, mkdocs, GitHub) and NOT the wikilink rule.
78
+ blue-book resolves 5.8% that way against 92.7% by basename-anywhere, because it runs the
79
+ mkdocs-autolinks plugin. That dialect is a separate decision, not a fallback slipped in here —
80
+ silently trying a second rule when the first fails is how a link resolves to the wrong note.
81
+
82
+ The #151 warning is suppressed once markdown links are actually read.
83
+
84
+ - **graph**: Warn when the vault's links are in a syntax graphmark cannot read (#151)
85
+ ([#154](https://github.com/cdcoonce/graphmark/pull/154),
86
+ [`d94ae63`](https://github.com/cdcoonce/graphmark/commit/d94ae63e7a789fabdb420423eaeaee8e771eb2a7))
87
+
88
+ graphmark reported a confidently EMPTY graph for a densely linked vault, with nothing to say it had
89
+ seen none of the links. Measured on lyz-code/blue-book: 1120 notes, 11,198 markdown-style
90
+ [text](note.md) links (99% targeting a note that exists), 0 extracted edges. Every note an orphan,
91
+ no clusters, no hubs, uniform PageRank — and `check` looks nearly healthy, because links that were
92
+ never extracted are not `unresolved`.
93
+
94
+ The conservation law from #124 cannot catch this: it sums over what the EXTRACTOR produced, so a
95
+ syntax the extractor does not know sits outside the universe being counted. The buckets balance
96
+ perfectly at 39 while 11,198 links go unseen.
97
+
98
+ The test is relational, not calibrated — it fires only when the unread syntax strictly outnumbers
99
+ the read one. No threshold to tune, no false-positive mode, and the reference vault's
100
+ 17-against-6226 can never trip it. Same discipline as #133's assertion.
101
+
102
+ Warning only: stderr, one line, and it touches no count, no metric and no verdict.
103
+
104
+
4
105
  ## v0.7.2 (2026-07-25)
5
106
 
6
107
  ### 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.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
@@ -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.0"
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
 
@@ -463,6 +465,68 @@ def diagnose(graph: VaultGraph, display: str, *, suggest: int = 0) -> LinkDiagno
463
465
  return diagnosis
464
466
 
465
467
 
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``.
470
+
471
+ A markdown target is a **path**, not a name: ``[x](../other/b.md)`` means "the file at that
472
+ location relative to me". That is the first resolution rule in this package that depends on
473
+ *where the link is* — information the ``Resolver`` protocol never receives, and which the
474
+ roadmap forbids adding by redesigning that boundary.
475
+
476
+ So the dependence is resolved here instead, before diagnosis: the target is normalized against
477
+ the linking note's folder into a full vault-relative path, and the existing path branch of the
478
+ resolver then matches it exactly. ``Resolver`` is untouched and still answers "what does this
479
+ name?" with no notion of a source.
480
+
481
+ Relative is the **default markdown semantics** (CommonMark, mkdocs, GitHub) and deliberately
482
+ not the wikilink rule. Measured on `lyz-code/blue-book`: 5.8% of its links resolve this way
483
+ against 92.7% by basename-anywhere, because it runs the `mkdocs-autolinks` plugin. That dialect
484
+ is a separate decision, not a fallback to slip in here — silently trying a second rule when the
485
+ first fails is how a link resolves to the wrong note.
486
+
487
+ ``None`` for a target that escapes the vault root. That is not an error and not a resolution —
488
+ a link out of the vault names no note in the graph, so it is reported ``missing`` like any other
489
+ target that is not there.
490
+ """
491
+ combined = PurePosixPath(source_rel_path).parent / target
492
+ normalized = posixpath.normpath(str(combined))
493
+ # normpath leaves leading "..", which is the only way to express "above the root".
494
+ if normalized == ".." or normalized.startswith("../"):
495
+ return None
496
+ return normalized
497
+
498
+
499
+ def _warn_if_unread_syntax_dominates(docs: list[Document], extracted: int) -> None:
500
+ """Warn once when the vault's links are mostly in a syntax graphmark does not read.
501
+
502
+ graphmark extracts ``[[wikilinks]]`` only. A vault written in markdown link syntax therefore
503
+ produces a confidently *empty* graph — every note an orphan, no clusters, no hubs, uniform
504
+ PageRank — with nothing to tell the user that none of their links were seen. Worse, ``check``
505
+ looks nearly healthy, because links that were never extracted are not ``unresolved``.
506
+
507
+ Measured on a real 1120-note vault: 11,198 markdown-style links, 99% of them targeting a note
508
+ that exists, and 0 extracted edges.
509
+
510
+ The test is **relational, not calibrated** — it fires only when the unread syntax strictly
511
+ outnumbers the read one. There is no threshold to tune and no false-positive mode; a vault with
512
+ a handful of stray markdown links among thousands of wikilinks (the reference vault: 17 against
513
+ 6226) can never trip it. Same discipline as #133's assertion, and the reason this is a warning
514
+ rather than anything that touches a count or a verdict.
515
+
516
+ stderr only, so stdout stays pipeable JSON — as ``links_summary_line`` and ``breach_lines``
517
+ already do.
518
+ """
519
+ unread = sum(count_markdown_links(doc.text) for doc in docs)
520
+ if unread <= extracted:
521
+ return
522
+ print(
523
+ f"graphmark: warning: {extracted} wikilinks extracted but {unread} markdown-style "
524
+ f"[](.md) links found; graphmark only reads [[wikilinks]] — this graph is probably "
525
+ f"not your vault",
526
+ file=sys.stderr,
527
+ )
528
+
529
+
466
530
  class VaultGraph:
467
531
  """Built graph: all nodes plus resolved out/back adjacency.
468
532
 
@@ -559,8 +623,24 @@ class VaultGraph:
559
623
  # is reported rather than absent — "0 alias-resolved" is a finding, not a non-event.
560
624
  link_counts: dict[str, int] = dict.fromkeys(DIAGNOSIS_REASONS, 0)
561
625
  alias_resolved = 0
626
+ # Which syntaxes to read. Composed rather than swapped, because the two need different
627
+ # pre-processing: a wikilink display is a *name*, a markdown target is a *path relative to
628
+ # the linking note*, and only the latter can be normalized once the source note is known.
629
+ read_wikilinks = config.link_syntax in ("wikilink", "both")
630
+ read_markdown = config.link_syntax in ("markdown", "both")
631
+ md_extractor = MarkdownLinkExtractor() if read_markdown else None
632
+
562
633
  for doc in docs:
563
- for display in extractor.extract(doc.text):
634
+ displays = list(extractor.extract(doc.text)) if read_wikilinks else []
635
+ if md_extractor is not None:
636
+ for target in md_extractor.extract(doc.text):
637
+ resolved = resolve_relative_target(target, doc.rel_path)
638
+ # A target above the vault root names no note here. Kept in the stream as the
639
+ # raw target so it is counted and reported `missing`, never silently dropped —
640
+ # the conservation law holds for every syntax that is read.
641
+ displays.append(resolved if resolved is not None else target)
642
+
643
+ for display in displays:
564
644
  d = _diagnose(display, catalog, out_of_scope, resolver, aliases)
565
645
  link_counts[d.reason] += 1
566
646
  if d.via == "alias":
@@ -570,6 +650,9 @@ class VaultGraph:
570
650
  elif d.target is not None and d.target != doc.rel_path:
571
651
  out_links[doc.rel_path].add(d.target)
572
652
 
653
+ if not read_markdown:
654
+ _warn_if_unread_syntax_dominates(docs, sum(link_counts.values()))
655
+
573
656
  for src, targets in out_links.items():
574
657
  for dst in targets:
575
658
  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
 
@@ -0,0 +1,155 @@
1
+ """Markdown-style `[text](note.md)` links, config-gated (#152).
2
+
3
+ The roadmap lists alternate link-syntax adapters under non-goals with a precise escape hatch — "the
4
+ pluggable interfaces exist so these _can_ be added on demand; do not build them speculatively." The
5
+ second corpus run supplied the demand: `lyz-code/blue-book`, 1120 notes, 11,198 markdown-style links
6
+ (99% targeting a real note), zero extracted edges.
7
+
8
+ Four decisions this encodes, all reversible in config:
9
+
10
+ 1. **Selection** is `link_syntax = "wikilink" | "markdown" | "both"`, defaulting to `"wikilink"`.
11
+ Every existing vault, every frozen fixture and the reference vault are byte-identical untouched.
12
+ 2. **Resolution is relative to the linking note**, which is genuinely new: a markdown target is a
13
+ path, not a name. Rather than change the `Resolver` protocol — which never receives the source
14
+ note, and which the roadmap forbids redesigning — the target is normalized into a vault-relative
15
+ path *before* diagnosis, where the existing path branch resolves it exactly.
16
+ 3. **No new frozen fixture.** The oracle is wikilink-only and regenerating it is a human's call
17
+ (Track B); these are hand-written assertions instead.
18
+ 4. **A target that escapes the vault root is `missing`**, not resolved and not an error.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from pathlib import Path
24
+
25
+ from graphmark.config import VaultConfig
26
+ from graphmark.graph import NormalizeResolver, VaultGraph
27
+ from graphmark.parse import MarkdownLinkExtractor, WikilinkExtractor
28
+
29
+
30
+ def _write(root: Path, rel: str, text: str = "") -> None:
31
+ path = root / rel
32
+ path.parent.mkdir(parents=True, exist_ok=True)
33
+ path.write_text(text, encoding="utf-8")
34
+
35
+
36
+ def _build(root: Path, **config_kwargs) -> VaultGraph:
37
+ return VaultGraph.build(
38
+ VaultConfig(root=root, **config_kwargs), WikilinkExtractor(), NormalizeResolver()
39
+ )
40
+
41
+
42
+ class TestExtractor:
43
+ def test_extracts_a_relative_target(self):
44
+ assert MarkdownLinkExtractor().extract("[the note](other.md)") == ["other.md"]
45
+
46
+ def test_keeps_the_target_not_the_display_text(self):
47
+ assert MarkdownLinkExtractor().extract("[Some Title](a/b.md)") == ["a/b.md"]
48
+
49
+ def test_drops_an_anchor(self):
50
+ assert MarkdownLinkExtractor().extract("[x](note.md#Section)") == ["note.md"]
51
+
52
+ def test_ignores_images_urls_and_non_markdown_targets(self):
53
+ text = "![i](c.png) [u](https://x.com/a.md) [p](doc.pdf) [[Wiki]]"
54
+ assert MarkdownLinkExtractor().extract(text) == []
55
+
56
+ def test_ignores_code_spans_and_fences(self):
57
+ assert MarkdownLinkExtractor().extract("`[a](n.md)`\n```\n[b](n.md)\n```\n") == []
58
+
59
+ def test_decodes_percent_encoding(self):
60
+ # Real markdown encodes spaces; the vault stores them literally.
61
+ assert MarkdownLinkExtractor().extract("[x](my%20note.md)") == ["my note.md"]
62
+
63
+
64
+ class TestDefaultIsUnchanged:
65
+ def test_markdown_links_are_ignored_by_default(self, tmp_path):
66
+ _write(tmp_path, "a.md", "[one](b.md)\n")
67
+ _write(tmp_path, "b.md")
68
+ graph = _build(tmp_path)
69
+ assert graph.out_links["a.md"] == set()
70
+ assert sum(graph.link_counts.values()) == 0
71
+
72
+ def test_wikilinks_still_work_in_markdown_mode_only_if_asked(self, tmp_path):
73
+ _write(tmp_path, "a.md", "[[b]]\n")
74
+ _write(tmp_path, "b.md")
75
+ assert _build(tmp_path, link_syntax="markdown").out_links["a.md"] == set()
76
+ assert _build(tmp_path, link_syntax="both").out_links["a.md"] == {"b.md"}
77
+
78
+
79
+ class TestRelativeResolution:
80
+ def test_a_sibling_target_resolves(self, tmp_path):
81
+ _write(tmp_path, "docs/a.md", "[one](b.md)\n")
82
+ _write(tmp_path, "docs/b.md")
83
+ assert _build(tmp_path, link_syntax="markdown").out_links["docs/a.md"] == {"docs/b.md"}
84
+
85
+ def test_a_parent_relative_target_resolves(self, tmp_path):
86
+ _write(tmp_path, "docs/deep/a.md", "[one](../other/b.md)\n")
87
+ _write(tmp_path, "docs/other/b.md")
88
+ graph = _build(tmp_path, link_syntax="markdown")
89
+ assert graph.out_links["docs/deep/a.md"] == {"docs/other/b.md"}
90
+
91
+ def test_an_explicit_dot_slash_resolves(self, tmp_path):
92
+ _write(tmp_path, "docs/a.md", "[one](./b.md)\n")
93
+ _write(tmp_path, "docs/b.md")
94
+ assert _build(tmp_path, link_syntax="markdown").out_links["docs/a.md"] == {"docs/b.md"}
95
+
96
+ def test_the_same_name_in_two_folders_does_not_collide(self, tmp_path):
97
+ # The point of relative resolution: a bare name is NOT ambiguous here, because the
98
+ # linking note's folder decides. Under wikilink rules this same pair is ambiguous.
99
+ _write(tmp_path, "one/a.md", "[x](n.md)\n")
100
+ _write(tmp_path, "one/n.md")
101
+ _write(tmp_path, "two/n.md")
102
+ graph = _build(tmp_path, link_syntax="markdown")
103
+ assert graph.out_links["one/a.md"] == {"one/n.md"}
104
+ assert graph.link_counts["ambiguous"] == 0
105
+
106
+ def test_a_target_escaping_the_vault_is_missing(self, tmp_path):
107
+ _write(tmp_path, "a.md", "[out](../outside.md)\n")
108
+ graph = _build(tmp_path, link_syntax="markdown")
109
+ assert graph.link_counts["missing"] == 1
110
+ assert graph.out_links["a.md"] == set()
111
+
112
+ def test_a_target_that_does_not_exist_is_missing(self, tmp_path):
113
+ _write(tmp_path, "a.md", "[gone](nowhere.md)\n")
114
+ assert _build(tmp_path, link_syntax="markdown").link_counts["missing"] == 1
115
+
116
+ def test_a_self_link_is_not_an_edge(self, tmp_path):
117
+ _write(tmp_path, "a.md", "[self](a.md)\n")
118
+ graph = _build(tmp_path, link_syntax="markdown")
119
+ assert graph.out_links["a.md"] == set()
120
+ assert graph.link_counts["resolved"] == 1
121
+
122
+
123
+ class TestBothModes:
124
+ def test_both_syntaxes_are_counted_and_conserved(self, tmp_path):
125
+ _write(tmp_path, "a.md", "[[b]]\n[one](b.md)\n[gone](nope.md)\n")
126
+ _write(tmp_path, "b.md")
127
+ graph = _build(tmp_path, link_syntax="both")
128
+ assert sum(graph.link_counts.values()) == 3
129
+ assert graph.link_counts["resolved"] == 2
130
+ assert graph.link_counts["missing"] == 1
131
+ assert graph.out_links["a.md"] == {"b.md"}
132
+
133
+ def test_the_unread_syntax_warning_is_silent_once_markdown_is_read(self, tmp_path, capsys):
134
+ _write(tmp_path, "a.md", "[1](b.md)\n[2](b.md)\n[3](b.md)\n")
135
+ _write(tmp_path, "b.md")
136
+ _build(tmp_path, link_syntax="both")
137
+ assert capsys.readouterr().err == ""
138
+
139
+
140
+ class TestConfig:
141
+ def test_an_unknown_syntax_is_rejected(self, tmp_path):
142
+ _write(tmp_path, "a.md")
143
+ try:
144
+ _build(tmp_path, link_syntax="logseq")
145
+ except ValueError as e:
146
+ assert "link_syntax" in str(e)
147
+ else:
148
+ raise AssertionError("an unknown link_syntax must fail loudly")
149
+
150
+ def test_it_loads_from_toml(self, tmp_path):
151
+ from graphmark.config import load_config
152
+
153
+ cfg = tmp_path / "v.toml"
154
+ cfg.write_text(f'root = "{tmp_path}"\nlink_syntax = "both"\n', encoding="utf-8")
155
+ assert load_config(cfg).link_syntax == "both"
@@ -44,14 +44,21 @@ def _readme_commands() -> list[str]:
44
44
  # non-greedy match begin at a *closing* fence and swallow prose between blocks, which silently
45
45
  # yields zero commands — caught by the guard test below rather than shipping a vacuous suite.
46
46
  blocks = re.findall(
47
- r"^```(?:bash|console|sh)\n(.*?)^```", README.read_text(), re.DOTALL | re.MULTILINE
47
+ r"^```(bash|console|sh)\n(.*?)^```", README.read_text(), re.DOTALL | re.MULTILINE
48
48
  )
49
49
  commands = []
50
- for block in blocks:
50
+ for lang, block in blocks:
51
51
  for line in block.splitlines():
52
- line = line.removeprefix("$ ").strip()
53
- if line.startswith("graphmark "):
54
- commands.append(line)
52
+ # In a `console` block, `$ ` marks input and everything else is *output* — which is why
53
+ # the prefix is required there. Without that rule a wrapped warning line beginning
54
+ # "graphmark only reads ..." is collected as a command and run, which is exactly what
55
+ # happened when the link-syntax section was added.
56
+ if lang == "console":
57
+ if not line.startswith("$ "):
58
+ continue
59
+ line = line.removeprefix("$ ")
60
+ if line.strip().startswith("graphmark "):
61
+ commands.append(line.strip())
55
62
  return commands
56
63
 
57
64
 
@@ -0,0 +1,111 @@
1
+ """Warn when the vault's links are in a syntax graphmark does not read (#151).
2
+
3
+ graphmark can report a confidently empty graph for a densely linked vault. Measured on
4
+ `lyz-code/blue-book`: 1120 notes, 11,198 markdown-style `[text](note.md)` links (99% of them
5
+ targeting a note that exists), and **zero** extracted edges. Every note an orphan, and `check` looks
6
+ nearly healthy — `max_unresolved_links` sees 38, because links that were never extracted are not
7
+ *unresolved*.
8
+
9
+ The conservation law from #124 cannot catch this: it sums over what the extractor produced, so a
10
+ syntax the extractor does not know is invisible to it by construction. The buckets balance perfectly
11
+ at 39 while 11,198 links sit outside the universe being counted.
12
+
13
+ The signal is deliberately relational, not calibrated — it fires only when the unread syntax
14
+ *outnumbers* the read one. No threshold to tune and no false-positive mode, in the same spirit as
15
+ #133's assertion.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+
22
+ from graphmark.config import VaultConfig
23
+ from graphmark.graph import NormalizeResolver, VaultGraph
24
+ from graphmark.parse import WikilinkExtractor, count_markdown_links
25
+
26
+
27
+ def _write(root: Path, rel: str, text: str = "") -> None:
28
+ path = root / rel
29
+ path.parent.mkdir(parents=True, exist_ok=True)
30
+ path.write_text(text, encoding="utf-8")
31
+
32
+
33
+ def _build(root: Path, **config_kwargs) -> VaultGraph:
34
+ return VaultGraph.build(
35
+ VaultConfig(root=root, **config_kwargs), WikilinkExtractor(), NormalizeResolver()
36
+ )
37
+
38
+
39
+ class TestCounting:
40
+ def test_counts_a_relative_markdown_link(self):
41
+ assert count_markdown_links("see [the note](other.md) here") == 1
42
+
43
+ def test_counts_a_path_and_an_anchor(self):
44
+ assert count_markdown_links("[a](../deep/note.md) [b](note.md#Section)") == 2
45
+
46
+ def test_ignores_absolute_urls(self):
47
+ text = "[spec](https://example.com/docs/README.md) [m](mailto:x@y.md)"
48
+ assert count_markdown_links(text) == 0
49
+
50
+ def test_ignores_images_and_non_markdown_targets(self):
51
+ assert count_markdown_links("![img](chart.png) [pdf](doc.pdf) [c](board.canvas)") == 0
52
+
53
+ def test_ignores_wikilinks(self):
54
+ assert count_markdown_links("[[Note]] and [[folder/Note.md]]") == 0
55
+
56
+ def test_ignores_a_link_inside_a_code_fence(self):
57
+ # Same exclusion the wikilink extractor applies; a documented example is not a link.
58
+ assert count_markdown_links("```\n[a](note.md)\n```\n") == 0
59
+
60
+ def test_ignores_a_link_inside_an_inline_code_span(self):
61
+ assert count_markdown_links("`[a](note.md)`") == 0
62
+
63
+
64
+ class TestWarning:
65
+ def test_warns_when_the_unread_syntax_outnumbers_the_read_one(self, tmp_path, capsys):
66
+ _write(tmp_path, "a.md", "[one](b.md)\n[two](c.md)\n[three](b.md)\n[[b]]\n")
67
+ _write(tmp_path, "b.md")
68
+ _write(tmp_path, "c.md")
69
+ _build(tmp_path)
70
+ captured = capsys.readouterr()
71
+ assert captured.out == "", "the JSON surface must stay pipeable"
72
+ assert "3" in captured.err and "markdown" in captured.err.lower()
73
+ assert captured.err.count("warning") == 1, "exactly one line, not one per note"
74
+
75
+ def test_is_silent_when_wikilinks_dominate(self, tmp_path, capsys):
76
+ # The reference vault's real ratio: a handful of markdown links against thousands of
77
+ # wikilinks. This must never fire there.
78
+ body = "[[b]]\n" * 20 + "[stray](b.md)\n"
79
+ _write(tmp_path, "a.md", body)
80
+ _write(tmp_path, "b.md")
81
+ _build(tmp_path)
82
+ assert capsys.readouterr().err == ""
83
+
84
+ def test_is_silent_with_no_markdown_links_at_all(self, tmp_path, capsys):
85
+ _write(tmp_path, "a.md", "[[b]]\n")
86
+ _write(tmp_path, "b.md")
87
+ _build(tmp_path)
88
+ assert capsys.readouterr().err == ""
89
+
90
+ def test_is_silent_on_an_empty_vault(self, tmp_path, capsys):
91
+ _write(tmp_path, "a.md", "no links here\n")
92
+ _build(tmp_path)
93
+ assert capsys.readouterr().err == ""
94
+
95
+ def test_an_equal_count_does_not_warn(self, tmp_path, capsys):
96
+ # Strictly greater, so the signal cannot fire on a tie — deliberately conservative.
97
+ _write(tmp_path, "a.md", "[[b]]\n[one](b.md)\n")
98
+ _write(tmp_path, "b.md")
99
+ _build(tmp_path)
100
+ assert capsys.readouterr().err == ""
101
+
102
+
103
+ class TestNoBehaviorChange:
104
+ def test_the_warning_changes_no_count_and_no_edge(self, tmp_path, capsys):
105
+ _write(tmp_path, "a.md", "[one](b.md)\n[two](b.md)\n[[b]]\n")
106
+ _write(tmp_path, "b.md")
107
+ graph = _build(tmp_path)
108
+ capsys.readouterr()
109
+ assert graph.link_counts["resolved"] == 1
110
+ assert sum(graph.link_counts.values()) == 1, "markdown links are counted nowhere"
111
+ assert graph.out_links["a.md"] == {"b.md"}
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