graphmark 0.7.1__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 (106) hide show
  1. {graphmark-0.7.1 → graphmark-0.8.0}/CHANGELOG.md +210 -0
  2. {graphmark-0.7.1 → graphmark-0.8.0}/PKG-INFO +27 -1
  3. {graphmark-0.7.1 → graphmark-0.8.0}/README.md +26 -0
  4. {graphmark-0.7.1 → graphmark-0.8.0}/docs/ROADMAP.md +42 -2
  5. {graphmark-0.7.1 → graphmark-0.8.0}/pyproject.toml +1 -1
  6. {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/cli.py +81 -12
  7. {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/config.py +17 -0
  8. {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/graph.py +149 -12
  9. {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/parse.py +49 -0
  10. graphmark-0.8.0/tests/test_markdown_links.py +155 -0
  11. graphmark-0.8.0/tests/test_non_note_file.py +90 -0
  12. graphmark-0.8.0/tests/test_properties.py +692 -0
  13. graphmark-0.8.0/tests/test_readme_examples.py +158 -0
  14. graphmark-0.8.0/tests/test_unicode.py +102 -0
  15. graphmark-0.8.0/tests/test_unicode_punctuation.py +113 -0
  16. graphmark-0.8.0/tests/test_unread_syntax.py +111 -0
  17. graphmark-0.7.1/tests/test_properties.py +0 -322
  18. {graphmark-0.7.1 → graphmark-0.8.0}/.gitignore +0 -0
  19. {graphmark-0.7.1 → graphmark-0.8.0}/LICENSE +0 -0
  20. {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/__init__.py +0 -0
  21. {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/check.py +0 -0
  22. {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/dismiss.py +0 -0
  23. {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/export.py +0 -0
  24. {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/interfaces.py +0 -0
  25. {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/metrics.py +0 -0
  26. {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/model.py +0 -0
  27. {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/py.typed +0 -0
  28. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/README.md +0 -0
  29. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/config.toml +0 -0
  30. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/expected.json +0 -0
  31. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
  32. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
  33. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/vault/docs/center.md +0 -0
  34. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/vault/misc/echo.md +0 -0
  35. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
  36. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
  37. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/vault/misc/stub.md +0 -0
  38. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/vault/refs/beta.md +0 -0
  39. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/vault/refs/delta.md +0 -0
  40. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
  41. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/dismiss/expected.json +0 -0
  42. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
  43. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/dismiss/vault/alpha.md +0 -0
  44. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/dismiss/vault/beta.md +0 -0
  45. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/dismiss/vault/gamma.md +0 -0
  46. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/config.toml +0 -0
  47. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/expected.json +0 -0
  48. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/expected_oog.json +0 -0
  49. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/similar.json +0 -0
  50. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/similar_oog.json +0 -0
  51. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/vault/docs/a.md +0 -0
  52. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
  53. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/vault/misc/c.md +0 -0
  54. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/vault/misc/d.md +0 -0
  55. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/vault/refs/b.md +0 -0
  56. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/vault/refs/e.md +0 -0
  57. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/scoped/config.toml +0 -0
  58. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/scoped/expected.json +0 -0
  59. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/scoped/vault/docs/one.md +0 -0
  60. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
  61. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
  62. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/scoped/vault/refs/two.md +0 -0
  63. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/selflink/config.toml +0 -0
  64. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/selflink/expected.json +0 -0
  65. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/selflink/vault/a.md +0 -0
  66. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/selflink/vault/b.md +0 -0
  67. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/simple/config.toml +0 -0
  68. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/simple/expected.json +0 -0
  69. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
  70. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/simple/vault/brain/hub.md +0 -0
  71. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/simple/vault/personal/beta.md +0 -0
  72. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
  73. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/simple/vault/reference/island.md +0 -0
  74. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/simple/vault/reference/stub.md +0 -0
  75. {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/suggest/README.md +0 -0
  76. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_aliases.py +0 -0
  77. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_bom.py +0 -0
  78. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_build_filters.py +0 -0
  79. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_check.py +0 -0
  80. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_check_policy.py +0 -0
  81. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_cli.py +0 -0
  82. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_config.py +0 -0
  83. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_degenerate.py +0 -0
  84. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_diagnose.py +0 -0
  85. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_dismiss.py +0 -0
  86. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_export.py +0 -0
  87. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_gaps.py +0 -0
  88. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_gaps_banding.py +0 -0
  89. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_gaps_oog.py +0 -0
  90. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_gaps_params.py +0 -0
  91. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_graph.py +0 -0
  92. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_graph_catalogs.py +0 -0
  93. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_link_counts.py +0 -0
  94. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_links_report.py +0 -0
  95. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_metrics.py +0 -0
  96. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_model.py +0 -0
  97. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_packaging.py +0 -0
  98. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_pagerank.py +0 -0
  99. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_parse.py +0 -0
  100. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_path_suffix.py +0 -0
  101. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_public_api.py +0 -0
  102. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_scoped.py +0 -0
  103. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_selflink.py +0 -0
  104. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_smoke.py +0 -0
  105. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_suggestions.py +0 -0
  106. {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_unresolved.py +0 -0
@@ -1,6 +1,216 @@
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
+
105
+ ## v0.7.2 (2026-07-25)
106
+
107
+ ### Bug Fixes
108
+
109
+ - **cli**: Accept --config/--root after the subcommand (#130)
110
+ ([#148](https://github.com/cdcoonce/graphmark/pull/148),
111
+ [`1a89e2e`](https://github.com/cdcoonce/graphmark/commit/1a89e2ef542581f076200a29ddd04e0773cbc2dd))
112
+
113
+ Every CLI example in the README failed. `--config` and `--root` were defined on the parent parser
114
+ before `add_subparsers`, so argparse accepted them only BEFORE the subcommand, while all ten
115
+ examples in the CLI block — plus the `check` ones — are written the other way. The README is the
116
+ PyPI landing page, so the first thing a new user copies errored.
117
+
118
+ Both options are now attached to every subparser too, under a separate dest, and reconciled after
119
+ parsing (argparse would otherwise let the subparser's default overwrite a value the top-level
120
+ parser had already read). Both collect with action="append", so a repeat within one position is
121
+ caught as well as one across the two: conflicting values are a usage error, not a silent
122
+ last-wins.
123
+
124
+ The actual defect was that the docs and the parser had no shared test — the suite used the working
125
+ form exclusively. `tests/test_readme_examples.py` now extracts every `graphmark ...` invocation
126
+ from README.md and runs it, so an example that stops working fails the gate instead of shipping.
127
+
128
+ - **graph**: Fold non-ASCII punctuation in _normalize (#139)
129
+ ([#145](https://github.com/cdcoonce/graphmark/pull/145),
130
+ [`cef4484`](https://github.com/cdcoonce/graphmark/commit/cef448405c1d59577c85ff4f8723299f3ee74dbc))
131
+
132
+ `string.punctuation` is ASCII-only, so every non-ASCII mark survived normalization and then had to
133
+ be typed byte-for-byte: an em-dash title was unreachable from a typed hyphen, a curly apostrophe
134
+ from a straight one, `…` from `...`. The catalog key literally retained the mark while an
135
+ ASCII-typed display normalized it away, so the two could never meet — while Obsidian's own
136
+ switcher resolves it.
137
+
138
+ `_fold_punctuation` folds by Unicode category (P* and S*) instead, a superset of the old ASCII
139
+ table, so pure-ASCII results are unchanged. The ASCII translate table stays the fast path; the
140
+ cached per-character category lookup runs only on strings that still hold non-ASCII after it.
141
+
142
+ Builds on #123: NFC composition runs first, so a decomposed accent is a single letter rather than a
143
+ letter plus a combining mark this fold must not touch.
144
+
145
+ Path-suffix matching deliberately still folds only case and form — a "/" is structural there and
146
+ punctuation folding needs the extension handled first. Follow-up filed.
147
+
148
+ Reference vault: 531 notes, link counts identical, build 207 ms.
149
+
150
+ - **graph**: Normalize Unicode form before comparing names (#123)
151
+ ([#144](https://github.com/cdcoonce/graphmark/pull/144),
152
+ [`9251554`](https://github.com/cdcoonce/graphmark/commit/9251554691723e7b6ca1d11bf0b7ddb1dffed002))
153
+
154
+ macOS's HFS+/APFS store filenames decomposed (NFD: `é` is `e` + U+0301) while text typed in an
155
+ editor is composed (NFC: a single U+00E9). `_normalize` lowercased, folded punctuation and
156
+ collapsed whitespace but never normalized Unicode form, so the two strings never compared equal
157
+ and an accented note title was a phantom broken link. Obsidian resolves it fine.
158
+
159
+ `_fold_case` does NFC-then-lowercase and is shared by `_normalize` and by path-suffix matching — the
160
+ one comparison that does not go through `_normalize` — so no comparison in the module can be left
161
+ in the wrong form. Note names, aliases and path-qualified links all fold identically as a result.
162
+
163
+ Eighth false-positive class in `unresolved`. The reference vault is unaffected (its only non-ASCII
164
+ filenames are em-dashes, which have no decomposition — which is exactly why this stayed invisible
165
+ there); link counts byte-identical.
166
+
167
+ - **graph**: Require a letter in a file extension (#138)
168
+ ([#147](https://github.com/cdcoonce/graphmark/pull/147),
169
+ [`4206cc5`](https://github.com/cdcoonce/graphmark/commit/4206cc5f941e5f0967202ac0b99390a55ab0393e))
170
+
171
+ `_targets_non_note_file` treated any trailing `.` + 1-10 alphanumerics as an extension, so
172
+ `[[Meeting 3.5]]` with no such note was suppressed as a file instead of reported as broken. `.5`
173
+ satisfied the pattern as readily as `.md`.
174
+
175
+ The first class found that DEFLATES `check`'s flagship number rather than inflating it, which is
176
+ strictly worse for a gate: an undercount is invisible by construction and reads as health. It hits
177
+ any vault that numbers its notes — phases, specs, chapters, versioned documents.
178
+
179
+ The separating rule is that a real extension contains a letter; ".5" and ".61850" differ only in
180
+ length, so no length bound could tell them apart. The existing 10-character bound and the
181
+ space-after-the-dot rule are both preserved.
182
+
183
+ Reference vault: 17 non-note-file suppressions before and after, all genuine .base/.png
184
+ (hand-audited).
185
+
186
+ ### Testing
187
+
188
+ - **properties**: Add the known-name invariant and an oracle-backed generator (#133)
189
+ ([#149](https://github.com/cdcoonce/graphmark/pull/149),
190
+ [`4cd304f`](https://github.com/cdcoonce/graphmark/commit/4cd304f80da52f2941205986d3b4bb3de783fdab))
191
+
192
+ Two things, one purpose: make Track F's detector actually detect.
193
+
194
+ #133's invariant — no link reported `missing` may name a key the resolver already knows (catalog or
195
+ aliases). Under correct behavior that is impossible, so it is an internal-consistency assertion,
196
+ not a heuristic: no thresholds, no calibration, no false-positive mode. Asserted over generated
197
+ vaults, with resolve_aliases=False (where the alias half is vacuous rather than wrong), and with
198
+ teeth — breaking the lookup while the map is still built must fail it.
199
+
200
+ The generator, measured. With the alphabet widened to today's four defect classes, reverting
201
+ #123/#136/#137/#138/#139 one at a time still left the suite GREEN: 0 of 4. Every existing property
202
+ is a conservation or self-consistency check, and each of those bugs is perfectly self-consistent —
203
+ #136's fabricated edge really is in the graph. Self-consistency cannot see a wrong answer.
204
+
205
+ So the generator now carries an oracle: it builds a note, then writes the link FROM it through a
206
+ transformation Obsidian treats as identity-preserving, so the expected target is known by
207
+ construction. Plus a negative oracle over names known to be absent (the false-negative half, which
208
+ no conservation law can see), narrow per-vault name pools so collisions co-occur, and an assertion
209
+ that an `ambiguous` candidate set is minimal rather than merely non-empty.
210
+
211
+ Catch rate on the same experiment: 6 of 6, adding #119.
212
+
213
+
4
214
  ## v0.7.1 (2026-07-25)
5
215
 
6
216
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphmark
3
- Version: 0.7.1
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
@@ -163,6 +163,41 @@ Two things this says about the track:
163
163
  names that are suffixes of other folder names, BOMs, numeric title suffixes, non-ASCII
164
164
  punctuation. Widening the generator's _alphabet_ — not its vault count — is the follow-on.
165
165
 
166
+ _Follow-on, same day (#133's slice):_ widening the alphabet was **not enough, and the measurement
167
+ says why.** With the alphabet widened, reverting #123/#136/#137/#138/#139 one at a time left the
168
+ whole property suite green — a catch rate of **0 of 4** on the classes it was widened for. Every
169
+ invariant in that file is a _conservation or self-consistency_ property, and each of those bugs is
170
+ perfectly self-consistent: #136's fabricated edge really is in the graph, so nothing vanishes; a
171
+ BOM'd note simply has no frontmatter; a link the normalizer cannot match is genuinely missing by the
172
+ package's own rules. **Self-consistency cannot see a wrong answer.**
173
+
174
+ What was missing is an **oracle** — a link whose intended target is known independently of what the
175
+ resolver says. The generator now builds a note first and writes the link _from_ it, through a
176
+ transformation Obsidian treats as identity-preserving (case, `.md`, anchor, alias display, padding,
177
+ NFD/NFC, punctuation swaps, path qualification, ASCII twins of typographic marks), plus a negative
178
+ oracle over names known to be absent. Two further changes were needed to make it bite: narrow
179
+ per-vault name pools, so the collisions that matter actually co-occur, and an assertion that an
180
+ `ambiguous` candidate set is _minimal_, not merely non-empty.
181
+
182
+ Catch rate on the same experiment: **6 of 6**, adding #119 — the class that started this track.
183
+
184
+ The generalizable lesson, and the one to carry into future test design here: **a property suite over
185
+ generated input still needs a correct answer to compare against.** Invariants prove the package is
186
+ consistent with itself, which is exactly the thing a systematically wrong resolver also is.
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
+
166
201
  Audited alongside: the reference vault's two suppressed buckets, by hand — 17 `non-note-file` (all
167
202
  genuine `.base`/`.png`) and 40 `intra-note` (all genuine heading refs), 0 false suppressions. A
168
203
  suppressed bucket is only trustworthy once someone has read it.
@@ -202,8 +237,13 @@ Evaluated against the ecosystem and dropped for zero consumer pull; do not re-li
202
237
  - Betweenness centrality, Louvain/Leiden community detection.
203
238
  - CSV/GML/Cypher export; incremental/cached builds; any LLM "propose" pass.
204
239
  - MCP server or retrieval/search surface.
205
- - Alternate link-syntax adapters (Logseq, `[](.)` markdown) — the pluggable interfaces exist so
206
- 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.
207
247
  - Re-platforming (no swapping networkx; no async/parallel rewrites).
208
248
  - Performance work without a benchmark showing pain (the live consumer is a ~340-note vault). The
209
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.1"
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"
@@ -42,7 +42,39 @@ def _load(args: argparse.Namespace) -> tuple[VaultGraph, VaultConfig]:
42
42
  return graph, config
43
43
 
44
44
 
45
+ def _reconcile_globals(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
46
+ """Merge each global's leading and trailing occurrence into one value.
47
+
48
+ ``--config``/``--root`` are attached to the top-level parser *and*, under a separate dest, to
49
+ every subparser, so both ``graphmark --root X stats`` and ``graphmark stats --root X`` work.
50
+ Trailing-option order is what every comparable CLI accepts and what a user types by reflex — and
51
+ it is what the README documents, so before this the first command a new user copied errored.
52
+
53
+ Repeats with the **same** value are harmless. Two different values are a usage error rather than
54
+ a silent last-wins: guessing which one the user meant is exactly the kind of quiet wrong answer
55
+ this tool exists not to give. Both options collect with ``action="append"``, so this covers a
56
+ repeat *within* one position (``stats --root A --root B``) as well as one across the two —
57
+ argparse's own last-wins would otherwise swallow the first silently.
58
+ """
59
+ for name in ("config", "root"):
60
+ after = f"{name}_after"
61
+ # `after` is absent when no subcommand ran at all — argparse never reached a subparser.
62
+ given = (getattr(args, name) or []) + (getattr(args, after, None) or [])
63
+ distinct = sorted(set(given))
64
+ if len(distinct) > 1:
65
+ parser.error(f"--{name} given more than once with conflicting values: {distinct}")
66
+ setattr(args, name, distinct[0] if distinct else None)
67
+ if hasattr(args, after):
68
+ delattr(args, after)
69
+
70
+
45
71
  def main() -> None:
72
+ # Attached to every subparser under a distinct dest, then reconciled: argparse would otherwise
73
+ # let the subparser's default (None) overwrite a value the top-level parser had already read.
74
+ trailing_globals = argparse.ArgumentParser(add_help=False)
75
+ trailing_globals.add_argument("--config", metavar="PATH", dest="config_after", action="append")
76
+ trailing_globals.add_argument("--root", metavar="PATH", dest="root_after", action="append")
77
+
46
78
  parser = argparse.ArgumentParser(
47
79
  prog="graphmark",
48
80
  description=(
@@ -51,49 +83,86 @@ def main() -> None:
51
83
  ),
52
84
  )
53
85
  parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
54
- parser.add_argument("--config", metavar="PATH", help="TOML config file")
55
- parser.add_argument("--root", metavar="PATH", help="Vault root (overrides --config root)")
86
+ parser.add_argument("--config", metavar="PATH", action="append", help="TOML config file")
87
+ parser.add_argument(
88
+ "--root", metavar="PATH", action="append", help="Vault root (overrides --config root)"
89
+ )
56
90
 
57
91
  sub = parser.add_subparsers(dest="command", metavar="COMMAND")
58
92
 
59
- sub.add_parser("stats", help="Aggregate vault stats: notes, edges, orphans, clusters, density")
60
- sub.add_parser("orphans", help="Notes with no links in or out (degree 0)")
93
+ sub.add_parser(
94
+ "stats",
95
+ parents=[trailing_globals],
96
+ help="Aggregate vault stats: notes, edges, orphans, clusters, density",
97
+ )
98
+ sub.add_parser(
99
+ "orphans", parents=[trailing_globals], help="Notes with no links in or out (degree 0)"
100
+ )
61
101
 
62
- hubs_p = sub.add_parser("hubs", help="Most-connected notes, by undirected degree")
102
+ hubs_p = sub.add_parser(
103
+ "hubs", parents=[trailing_globals], help="Most-connected notes, by undirected degree"
104
+ )
63
105
  hubs_p.add_argument("--n", type=int, default=10, help="How many hubs to return (default: 10)")
64
106
 
65
- sub.add_parser("clusters", help="Connected components of the link graph, largest first")
66
- sub.add_parser("bridges", help="Articulation points: notes whose removal splits the graph")
67
- sub.add_parser("siloed", help="Notes reachable from the mainland only through one bridge")
107
+ sub.add_parser(
108
+ "clusters",
109
+ parents=[trailing_globals],
110
+ help="Connected components of the link graph, largest first",
111
+ )
112
+ sub.add_parser(
113
+ "bridges",
114
+ parents=[trailing_globals],
115
+ help="Articulation points: notes whose removal splits the graph",
116
+ )
117
+ sub.add_parser(
118
+ "siloed",
119
+ parents=[trailing_globals],
120
+ help="Notes reachable from the mainland only through one bridge",
121
+ )
68
122
 
69
- nb_p = sub.add_parser("neighborhood", help="Links in and out of one note")
123
+ nb_p = sub.add_parser(
124
+ "neighborhood", parents=[trailing_globals], help="Links in and out of one note"
125
+ )
70
126
  nb_p.add_argument("--note", required=True, help="Vault-relative path, e.g. brain/hub.md")
71
127
  nb_p.add_argument(
72
128
  "--depth", type=int, default=1, help="1 for direct links, 2 to add two-hop (default: 1)"
73
129
  )
74
130
 
75
- pr_p = sub.add_parser("pagerank", help="PageRank importance ranking over the link graph")
131
+ pr_p = sub.add_parser(
132
+ "pagerank",
133
+ parents=[trailing_globals],
134
+ help="PageRank importance ranking over the link graph",
135
+ )
76
136
  pr_p.add_argument("--n", type=int, default=10, help="How many notes to return (default: 10)")
77
137
  pr_p.add_argument(
78
138
  "--alpha", type=float, default=0.85, help="Damping factor in (0, 1) (default: 0.85)"
79
139
  )
80
140
 
81
- exp_p = sub.add_parser("export", help="Export the graph in another format")
141
+ exp_p = sub.add_parser(
142
+ "export", parents=[trailing_globals], help="Export the graph in another format"
143
+ )
82
144
  exp_p.add_argument("format", choices=["dot"], help="Output format")
83
145
 
84
- sub.add_parser("gaps", help="Link-gap suggestions (library-only; see the README)")
146
+ sub.add_parser(
147
+ "gaps",
148
+ parents=[trailing_globals],
149
+ help="Link-gap suggestions (library-only; see the README)",
150
+ )
85
151
 
86
152
  sub.add_parser(
87
153
  "links",
154
+ parents=[trailing_globals],
88
155
  help="How every wikilink was classified: counts per reason, plus alias resolutions",
89
156
  )
90
157
 
91
158
  sub.add_parser(
92
159
  "check",
160
+ parents=[trailing_globals],
93
161
  help="Gate vault health against the config's [check] thresholds (exit 1 on breach)",
94
162
  )
95
163
 
96
164
  args = parser.parse_args()
165
+ _reconcile_globals(parser, args)
97
166
 
98
167
  # Usage errors all exit 2, matching argparse's own convention (and leaving exit 1 free for
99
168
  # future domain-level outcomes such as a `check` threshold breach). Help for a missing
@@ -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
  )