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.
- {graphmark-0.7.2 → graphmark-0.8.1}/CHANGELOG.md +129 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/PKG-INFO +27 -1
- {graphmark-0.7.2 → graphmark-0.8.1}/README.md +26 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/docs/ROADMAP.md +20 -2
- {graphmark-0.7.2 → graphmark-0.8.1}/pyproject.toml +1 -1
- {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/config.py +17 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/graph.py +155 -27
- {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/parse.py +49 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_graph.py +6 -10
- graphmark-0.8.1/tests/test_markdown_links.py +155 -0
- graphmark-0.8.1/tests/test_path_index.py +153 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_readme_examples.py +12 -5
- graphmark-0.8.1/tests/test_unread_syntax.py +111 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/.gitignore +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/LICENSE +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/__init__.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/check.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/cli.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/dismiss.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/export.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/interfaces.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/metrics.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/model.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/src/graphmark/py.typed +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/README.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/config.toml +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/expected.json +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/vault/docs/center.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/vault/misc/echo.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/vault/misc/stub.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/vault/refs/beta.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/vault/refs/delta.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/dismiss/expected.json +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/dismiss/vault/alpha.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/dismiss/vault/beta.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/dismiss/vault/gamma.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/config.toml +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/expected.json +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/expected_oog.json +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/similar.json +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/similar_oog.json +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/vault/docs/a.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/vault/misc/c.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/vault/misc/d.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/vault/refs/b.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/gaps/vault/refs/e.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/scoped/config.toml +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/scoped/expected.json +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/scoped/vault/docs/one.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/scoped/vault/refs/two.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/selflink/config.toml +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/selflink/expected.json +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/selflink/vault/a.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/selflink/vault/b.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/simple/config.toml +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/simple/expected.json +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/simple/vault/brain/hub.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/simple/vault/personal/beta.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/simple/vault/reference/island.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/simple/vault/reference/stub.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/fixtures/suggest/README.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_aliases.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_bom.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_build_filters.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_check.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_check_policy.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_cli.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_config.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_degenerate.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_diagnose.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_dismiss.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_export.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_gaps.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_gaps_banding.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_gaps_oog.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_gaps_params.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_graph_catalogs.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_link_counts.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_links_report.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_metrics.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_model.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_non_note_file.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_packaging.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_pagerank.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_parse.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_path_suffix.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_properties.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_public_api.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_scoped.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_selflink.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_smoke.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_suggestions.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_unicode.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.1}/tests/test_unicode_punctuation.py +0 -0
- {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.
|
|
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
|
|
228
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
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")
|