graphmark 0.8.0__tar.gz → 0.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {graphmark-0.8.0 → graphmark-0.9.0}/CHANGELOG.md +83 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/PKG-INFO +8 -2
- {graphmark-0.8.0 → graphmark-0.9.0}/README.md +7 -1
- {graphmark-0.8.0 → graphmark-0.9.0}/docs/ROADMAP.md +1 -1
- {graphmark-0.8.0 → graphmark-0.9.0}/pyproject.toml +1 -1
- {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/config.py +5 -2
- {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/graph.py +128 -44
- graphmark-0.9.0/tests/test_autolinks.py +130 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_graph.py +6 -10
- graphmark-0.9.0/tests/test_path_index.py +160 -0
- graphmark-0.9.0/tests/test_path_normalization.py +141 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/.gitignore +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/LICENSE +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/__init__.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/check.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/cli.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/dismiss.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/export.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/interfaces.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/metrics.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/model.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/parse.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/src/graphmark/py.typed +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/README.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/config.toml +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/expected.json +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/vault/docs/center.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/vault/misc/echo.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/vault/misc/stub.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/vault/refs/beta.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/vault/refs/delta.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/dismiss/expected.json +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/dismiss/vault/alpha.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/dismiss/vault/beta.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/dismiss/vault/gamma.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/config.toml +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/expected.json +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/expected_oog.json +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/similar.json +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/similar_oog.json +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/vault/docs/a.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/vault/misc/c.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/vault/misc/d.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/vault/refs/b.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/gaps/vault/refs/e.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/scoped/config.toml +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/scoped/expected.json +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/scoped/vault/docs/one.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/scoped/vault/refs/two.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/selflink/config.toml +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/selflink/expected.json +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/selflink/vault/a.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/selflink/vault/b.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/simple/config.toml +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/simple/expected.json +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/simple/vault/brain/hub.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/simple/vault/personal/beta.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/simple/vault/reference/island.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/simple/vault/reference/stub.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/fixtures/suggest/README.md +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_aliases.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_bom.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_build_filters.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_check.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_check_policy.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_cli.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_config.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_degenerate.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_diagnose.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_dismiss.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_export.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_gaps.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_gaps_banding.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_gaps_oog.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_gaps_params.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_graph_catalogs.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_link_counts.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_links_report.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_markdown_links.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_metrics.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_model.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_non_note_file.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_packaging.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_pagerank.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_parse.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_path_suffix.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_properties.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_public_api.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_readme_examples.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_scoped.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_selflink.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_smoke.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_suggestions.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_unicode.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_unicode_punctuation.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_unread_syntax.py +0 -0
- {graphmark-0.8.0 → graphmark-0.9.0}/tests/test_unresolved.py +0 -0
|
@@ -1,6 +1,89 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.9.0 (2026-07-26)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- **graph**: Normalize path-qualified links component-wise (#146)
|
|
9
|
+
([#162](https://github.com/cdcoonce/graphmark/pull/162),
|
|
10
|
+
[`a6cc158`](https://github.com/cdcoonce/graphmark/commit/a6cc1587cdbcf28a6b5360e2bec627a8df8f20c1))
|
|
11
|
+
|
|
12
|
+
Bare resolution went through `_normalize` — NFC, lowercase, punctuation and symbols folded — while
|
|
13
|
+
path resolution went through `_fold_case`, which folds only case and Unicode form. So `[[Q1 -
|
|
14
|
+
Review]]` found `Q1 — Review.md` but `[[notes/Q1 - Review]]` did not: the same title reachable or
|
|
15
|
+
not depending on whether the writer happened to qualify the link with a folder.
|
|
16
|
+
|
|
17
|
+
Both branches now compare **path components**, each through the one normalizer: the display's
|
|
18
|
+
components must be a suffix of the candidate's. That subsumes #136's boundary rule rather than
|
|
19
|
+
restating it — a suffix over lists cannot match mid-component — while letting every punctuation
|
|
20
|
+
equivalence `_normalize` already absorbs work in both branches.
|
|
21
|
+
|
|
22
|
+
The trap: `/` is structural. `[[a/b]]` must never reach `a-b.md`, even though `a/b` and `a-b`
|
|
23
|
+
normalize alike once a separator is folded. Components are normalized individually and the
|
|
24
|
+
separator is never one of them. Tested in both directions, and those tests passed BEFORE the
|
|
25
|
+
change — they are the guard, not the feature.
|
|
26
|
+
|
|
27
|
+
The index now buckets by normalized final component and caches each path's components, so
|
|
28
|
+
per-component normalization runs once per note rather than once per link.
|
|
29
|
+
|
|
30
|
+
Reference vault A/B'd on one snapshot: byte-identical (6230 resolved, 3725 edges, 0 unresolved).
|
|
31
|
+
blue-book markdown counts identical; build 267 -> 393 ms, still 7x better than the 2718 ms this
|
|
32
|
+
session started from.
|
|
33
|
+
|
|
34
|
+
### Features
|
|
35
|
+
|
|
36
|
+
- **graph**: Add the markdown-autolinks dialect (#156)
|
|
37
|
+
([#163](https://github.com/cdcoonce/graphmark/pull/163),
|
|
38
|
+
[`5ccf527`](https://github.com/cdcoonce/graphmark/commit/5ccf527576037915bd12485d205f2c7bf63b1c7f))
|
|
39
|
+
|
|
40
|
+
A markdown link is a relative path, but the widely-used mkdocs-autolinks plugin resolves a BARE
|
|
41
|
+
filename anywhere in the tree. blue-book runs it: 5.8% of its links resolve strictly, 92.7% by
|
|
42
|
+
name, 1.5% by neither.
|
|
43
|
+
|
|
44
|
+
Added as an explicit fourth `link_syntax` value, NOT a fallback. Trying a second rule when the first
|
|
45
|
+
fails is the shape of #136 — a quiet extra rule producing an edge to a note the link does not name
|
|
46
|
+
— and the 1.5% that match neither dialect show a fallback would not even have been complete. The
|
|
47
|
+
vault owner states which renderer they use, exactly as they already state their scope.
|
|
48
|
+
|
|
49
|
+
Only BARE targets take the plugin's rule; a target containing "/" keeps the relative rule in this
|
|
50
|
+
mode too, so a wrong qualified path is never rescued by a filename match elsewhere. Bare names go
|
|
51
|
+
through the existing catalog lookup, so ambiguity is refused exactly as it always was — no dialect
|
|
52
|
+
may invent a resolution.
|
|
53
|
+
|
|
54
|
+
blue-book, markdown -> markdown-autolinks: resolved 591 (5.8%) -> 10,001 (98.5%), edges 430 -> 4878,
|
|
55
|
+
orphans 790 -> 106, ambiguous 0, missing 9558 -> 148. Also faster (356 -> 234 ms): a bare name
|
|
56
|
+
hits the catalog dict instead of the path index.
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
## v0.8.1 (2026-07-26)
|
|
60
|
+
|
|
61
|
+
### Performance Improvements
|
|
62
|
+
|
|
63
|
+
- **graph**: Index path resolution by filename instead of scanning (#157)
|
|
64
|
+
([#160](https://github.com/cdcoonce/graphmark/pull/160),
|
|
65
|
+
[`84cdd29`](https://github.com/cdcoonce/graphmark/commit/84cdd297a665d93ffa3cbb988c18550ea57f5c94))
|
|
66
|
+
|
|
67
|
+
Every path-qualified display flattened the catalog and compared against every rel_path. That ceiling
|
|
68
|
+
was documented and accepted while the branch was rare — most wikilink displays are bare names
|
|
69
|
+
hitting a dict — but #152 made it the only branch, since every markdown link is path-qualified by
|
|
70
|
+
construction. blue-book (1120 notes, 10,149 markdown links) took 2.7 s against 0.25 s for the same
|
|
71
|
+
vault read as wikilinks.
|
|
72
|
+
|
|
73
|
+
A legal suffix match must consume whole path components, so a candidate's last component always
|
|
74
|
+
equals the display's last plus ".md". Bucketing by that filename is exactly equivalent to scanning
|
|
75
|
+
— same matches, same order, same verdicts.
|
|
76
|
+
|
|
77
|
+
The first attempt was no faster, and profiling rather than guessing found why: `_diagnose` consults
|
|
78
|
+
the in-scope catalog AND the out-of-scope one for the same display, so a single-slot cache
|
|
79
|
+
alternated and missed every time — 29,265 index rebuilds for 10,149 links. The cache is now
|
|
80
|
+
bounded-multi-slot, and it holds each catalog rather than just its id(), so a freed dict cannot
|
|
81
|
+
have its address reused under a stale index.
|
|
82
|
+
|
|
83
|
+
blue-book markdown: 2718 ms -> 267 ms (10.2x), now level with wikilink mode. Reference vault 195 ->
|
|
84
|
+
181 ms. All link counts byte-identical in every mode.
|
|
85
|
+
|
|
86
|
+
|
|
4
87
|
## v0.8.0 (2026-07-26)
|
|
5
88
|
|
|
6
89
|
### Documentation
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graphmark
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults.
|
|
5
5
|
Project-URL: Homepage, https://github.com/cdcoonce/graphmark
|
|
6
6
|
Project-URL: Repository, https://github.com/cdcoonce/graphmark
|
|
@@ -119,7 +119,7 @@ graph, so set `link_syntax`:
|
|
|
119
119
|
|
|
120
120
|
```toml
|
|
121
121
|
root = "."
|
|
122
|
-
link_syntax = "both" # "wikilink" (default) | "markdown" | "both"
|
|
122
|
+
link_syntax = "both" # "wikilink" (default) | "markdown" | "both" | "markdown-autolinks"
|
|
123
123
|
```
|
|
124
124
|
|
|
125
125
|
The two resolve differently, and the difference is not cosmetic:
|
|
@@ -129,6 +129,12 @@ The two resolve differently, and the difference is not cosmetic:
|
|
|
129
129
|
- a **markdown** link is a path **relative to the linking note** — `[x](../other/b.md)` means that
|
|
130
130
|
file and nothing else, so the same filename in two folders is not ambiguous at all.
|
|
131
131
|
|
|
132
|
+
`markdown-autolinks` is `markdown` with the [mkdocs-autolinks](https://github.com/zachhannum/mkdocs-autolinks-plugin)
|
|
133
|
+
plugin's rule for **bare** targets: `[tdd](tdd.md)` names a note anywhere in the tree, not a sibling
|
|
134
|
+
file. Set it only if your site actually runs that plugin — it is never applied as a fallback, because
|
|
135
|
+
a second rule tried after the first fails is how a link ends up pointing at a note it does not name.
|
|
136
|
+
A target that contains `/` keeps the relative rule in this mode too.
|
|
137
|
+
|
|
132
138
|
If your links are in a syntax graphmark is not reading, it says so on stderr rather than reporting a
|
|
133
139
|
confidently empty graph:
|
|
134
140
|
|
|
@@ -91,7 +91,7 @@ graph, so set `link_syntax`:
|
|
|
91
91
|
|
|
92
92
|
```toml
|
|
93
93
|
root = "."
|
|
94
|
-
link_syntax = "both" # "wikilink" (default) | "markdown" | "both"
|
|
94
|
+
link_syntax = "both" # "wikilink" (default) | "markdown" | "both" | "markdown-autolinks"
|
|
95
95
|
```
|
|
96
96
|
|
|
97
97
|
The two resolve differently, and the difference is not cosmetic:
|
|
@@ -101,6 +101,12 @@ The two resolve differently, and the difference is not cosmetic:
|
|
|
101
101
|
- a **markdown** link is a path **relative to the linking note** — `[x](../other/b.md)` means that
|
|
102
102
|
file and nothing else, so the same filename in two folders is not ambiguous at all.
|
|
103
103
|
|
|
104
|
+
`markdown-autolinks` is `markdown` with the [mkdocs-autolinks](https://github.com/zachhannum/mkdocs-autolinks-plugin)
|
|
105
|
+
plugin's rule for **bare** targets: `[tdd](tdd.md)` names a note anywhere in the tree, not a sibling
|
|
106
|
+
file. Set it only if your site actually runs that plugin — it is never applied as a fallback, because
|
|
107
|
+
a second rule tried after the first fails is how a link ends up pointing at a note it does not name.
|
|
108
|
+
A target that contains `/` keeps the relative rule in this mode too.
|
|
109
|
+
|
|
104
110
|
If your links are in a syntax graphmark is not reading, it says so on stderr rather than reporting a
|
|
105
111
|
confidently empty graph:
|
|
106
112
|
|
|
@@ -243,7 +243,7 @@ Evaluated against the ecosystem and dropped for zero consumer pull; do not re-li
|
|
|
243
243
|
10,149 of them and zero extracted edges. Config-gated (`link_syntax`), default unchanged,
|
|
244
244
|
`Resolver` untouched. Note what it did *not* do: blue-book resolves 5.8% by strict relative path
|
|
245
245
|
and 92.7% by basename-anywhere (it runs `mkdocs-autolinks`), and no fallback was slipped in —
|
|
246
|
-
silently trying a second rule when the first fails is the shape of #136. The dialect
|
|
246
|
+
silently trying a second rule when the first fails is the shape of #136. The dialect shipped too (#156), as an explicit fourth value rather than a fallback: blue-book goes from 5.8% to **98.5%** resolved, 430 to 4878 edges, 790 to 106 orphans, with ambiguity still refused at 0.
|
|
247
247
|
- Re-platforming (no swapping networkx; no async/parallel rewrites).
|
|
248
248
|
- Performance work without a benchmark showing pain (the live consumer is a ~340-note vault). The
|
|
249
249
|
known ceilings — O(L·N) path-suffix resolution, O(A·(V+E)) `siloed_notes`, per-metric graph
|
|
@@ -13,7 +13,7 @@ from pathlib import Path
|
|
|
13
13
|
|
|
14
14
|
#: Every value ``VaultConfig.link_syntax`` accepts. A consumer may switch on these, so the set is
|
|
15
15
|
#: part of the config contract.
|
|
16
|
-
LINK_SYNTAXES = frozenset({"wikilink", "markdown", "both"})
|
|
16
|
+
LINK_SYNTAXES = frozenset({"wikilink", "markdown", "both", "markdown-autolinks"})
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
@dataclass(frozen=True)
|
|
@@ -55,7 +55,10 @@ class VaultConfig:
|
|
|
55
55
|
#: what every frozen fixture encodes, so the default keeps existing behavior byte-identical.
|
|
56
56
|
#: ``"markdown"`` reads ``[text](note.md)``, the syntax non-Obsidian markdown vaults use — added
|
|
57
57
|
#: on demand after a corpus vault turned out to have 11,198 of them and zero extracted edges.
|
|
58
|
-
#: ``"both"`` reads each, which is what Obsidian itself accepts.
|
|
58
|
+
#: ``"both"`` reads each, which is what Obsidian itself accepts. ``"markdown-autolinks"`` is
|
|
59
|
+
#: ``"markdown"`` with the widely-used mkdocs plugin's rule for **bare** targets: a filename
|
|
60
|
+
#: with no ``/`` names a note anywhere in the tree rather than a sibling file. Stated by the
|
|
61
|
+
#: vault owner, never guessed — see the note on ``_resolve_markdown_target``.
|
|
59
62
|
link_syntax: str = "wikilink"
|
|
60
63
|
check: CheckPolicy = field(default_factory=CheckPolicy)
|
|
61
64
|
|
|
@@ -110,22 +110,36 @@ def _targets_non_note_file(display: str) -> bool:
|
|
|
110
110
|
return bool(match) and match.group(0).lower() != ".md"
|
|
111
111
|
|
|
112
112
|
|
|
113
|
-
def
|
|
114
|
-
"""
|
|
113
|
+
def _path_components(name: str) -> tuple[str, ...]:
|
|
114
|
+
"""A path split on ``/``, each component through ``_normalize``, ``.md`` dropped.
|
|
115
|
+
|
|
116
|
+
This is what makes path-qualified links normalize like bare ones. They used to fold only case
|
|
117
|
+
and Unicode form, so ``[[Q1 - Review]]`` found ``Q1 — Review.md`` while
|
|
118
|
+
``[[notes/Q1 - Review]]`` did not — the same title reachable or not depending on whether the
|
|
119
|
+
writer happened to qualify it with a folder.
|
|
120
|
+
|
|
121
|
+
Components are normalized **individually** and the separator is never one of them, which is what
|
|
122
|
+
keeps ``/`` structural: ``[[a/b]]`` yields ``("a", "b")`` and can never reach ``a-b.md``, whose
|
|
123
|
+
single component is ``"a b"``. That distinction is the whole reason this is a tuple rather than
|
|
124
|
+
a normalized string.
|
|
125
|
+
|
|
126
|
+
A component that normalizes to empty — a trailing slash, or pure punctuation — is kept as
|
|
127
|
+
``""``. No real path component ever normalizes to empty, so such a display matches nothing,
|
|
128
|
+
which is the conservative answer.
|
|
129
|
+
"""
|
|
130
|
+
name = name[: -len(".md")] if name.lower().endswith(".md") else name
|
|
131
|
+
return tuple(_normalize(part) for part in name.split("/"))
|
|
132
|
+
|
|
115
133
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
match to be a separator. That produced an edge to the wrong note — silently, since the link then
|
|
119
|
-
counts as ``resolved`` — and, when the real folder also existed, a spurious second match that
|
|
120
|
-
made the resolver decline a correct link.
|
|
134
|
+
def _names_path(candidate: tuple[str, ...], display: tuple[str, ...]) -> bool:
|
|
135
|
+
"""True when ``display``'s components are a suffix of ``candidate``'s.
|
|
121
136
|
|
|
122
|
-
|
|
137
|
+
Subsumes the boundary rule #136 added: a suffix over *lists* cannot match mid-component, so
|
|
138
|
+
``[[work/Tasks]]`` cannot reach ``homework/Tasks.md`` — ``("homework", "tasks")`` does not end
|
|
139
|
+
with ``("work", "tasks")``. The string-suffix version accepted it, silently producing an edge
|
|
140
|
+
to the wrong note.
|
|
123
141
|
"""
|
|
124
|
-
|
|
125
|
-
if not lowered.endswith(suffix):
|
|
126
|
-
return False
|
|
127
|
-
rest = len(lowered) - len(suffix)
|
|
128
|
-
return rest == 0 or lowered[rest - 1] == "/"
|
|
142
|
+
return bool(display) and "" not in display and candidate[-len(display) :] == display
|
|
129
143
|
|
|
130
144
|
|
|
131
145
|
def candidates_for(display: str, catalog: dict[str, list[str]]) -> list[str]:
|
|
@@ -143,13 +157,7 @@ def candidates_for(display: str, catalog: dict[str, list[str]]) -> list[str]:
|
|
|
143
157
|
if not target:
|
|
144
158
|
return []
|
|
145
159
|
if "/" in target:
|
|
146
|
-
|
|
147
|
-
return sorted(
|
|
148
|
-
path
|
|
149
|
-
for paths in catalog.values()
|
|
150
|
-
for path in paths
|
|
151
|
-
if _matches_path_suffix(path, suffix)
|
|
152
|
-
)
|
|
160
|
+
return sorted(_path_matches(target, catalog, _CANDIDATE_INDEX))
|
|
153
161
|
return list(catalog.get(_normalize(target), ()))
|
|
154
162
|
|
|
155
163
|
|
|
@@ -217,25 +225,85 @@ def build_catalog(docs: list[Document]) -> dict[str, list[str]]:
|
|
|
217
225
|
return catalog
|
|
218
226
|
|
|
219
227
|
|
|
228
|
+
#: A normalized final component → the paths ending with it, each paired with its precomputed
|
|
229
|
+
#: components. Precomputing means the per-component normalization runs once per note, not per link.
|
|
230
|
+
_Buckets = dict[str, list[tuple[str, tuple[str, ...]]]]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class _FilenameIndex:
|
|
234
|
+
"""Catalog paths bucketed by normalized final component, so a path link scans a bucket.
|
|
235
|
+
|
|
236
|
+
A match requires the display's components to be a suffix of the candidate's
|
|
237
|
+
(``_names_path``), so a candidate's **last** component always equals the display's last.
|
|
238
|
+
Bucketing by it is therefore *exactly* equivalent to scanning every path — same matches, same
|
|
239
|
+
order — while turning an O(N) sweep into a dict lookup over a bucket almost always one deep.
|
|
240
|
+
|
|
241
|
+
Components are cached alongside each path, so the per-component normalization happens once per
|
|
242
|
+
note rather than once per link.
|
|
243
|
+
|
|
244
|
+
This mattered little while path-qualified links were rare: most wikilink displays are bare names
|
|
245
|
+
hitting the catalog dict directly, which is why the ``O(L·N)`` ceiling was documented and
|
|
246
|
+
accepted. #152 changed which branch is hot — every markdown link is path-qualified by
|
|
247
|
+
construction — and a 1120-note vault with 10,149 of them took 2.9 s against 0.3 s for the same
|
|
248
|
+
vault read as wikilinks.
|
|
249
|
+
|
|
250
|
+
Keyed by catalog identity, which is safe because a catalog is invariant for a whole
|
|
251
|
+
``VaultGraph.build()``. Each catalog is **held**, not just its ``id()``: without a reference the
|
|
252
|
+
dict could be freed and a new one allocated at the same address, and the stale index would then
|
|
253
|
+
answer for it.
|
|
254
|
+
|
|
255
|
+
Several slots, not one, and that is load-bearing rather than defensive. ``_diagnose`` consults
|
|
256
|
+
two different mappings for the same display — the in-scope ``catalog`` and the ``out_of_scope``
|
|
257
|
+
one — so a single slot alternates between them and misses *every* time. Measured: a single-slot
|
|
258
|
+
version rebuilt the index 29,265 times for 10,149 links and was no faster than the scan it
|
|
259
|
+
replaced. The cap keeps this from growing without bound if a caller cycles through catalogs.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
#: Enough for the two mappings a build alternates between, with room to spare. Small on purpose:
|
|
263
|
+
#: this holds catalogs alive, so it must not become an unbounded retainer.
|
|
264
|
+
_MAX_SLOTS = 4
|
|
265
|
+
|
|
266
|
+
def __init__(self) -> None:
|
|
267
|
+
# Insertion-ordered, so the oldest entry is the one evicted.
|
|
268
|
+
self._slots: dict[int, tuple[dict[str, list[str]], _Buckets]] = {}
|
|
269
|
+
|
|
270
|
+
def for_catalog(self, catalog: dict[str, list[str]]) -> _Buckets:
|
|
271
|
+
cached = self._slots.get(id(catalog))
|
|
272
|
+
if cached is not None and cached[0] is catalog:
|
|
273
|
+
return cached[1]
|
|
274
|
+
|
|
275
|
+
buckets: _Buckets = {}
|
|
276
|
+
for paths in catalog.values():
|
|
277
|
+
for path in paths:
|
|
278
|
+
components = _path_components(path)
|
|
279
|
+
buckets.setdefault(components[-1], []).append((path, components))
|
|
280
|
+
|
|
281
|
+
if len(self._slots) >= self._MAX_SLOTS:
|
|
282
|
+
del self._slots[next(iter(self._slots))]
|
|
283
|
+
self._slots[id(catalog)] = (catalog, buckets)
|
|
284
|
+
return buckets
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _path_matches(target: str, catalog: dict[str, list[str]], index: _FilenameIndex) -> list[str]:
|
|
288
|
+
"""Every rel_path that a path-qualified ``target`` names, in catalog order."""
|
|
289
|
+
display = _path_components(target)
|
|
290
|
+
if not display or "" in display:
|
|
291
|
+
return []
|
|
292
|
+
bucket = index.for_catalog(catalog).get(display[-1], ())
|
|
293
|
+
return [path for path, components in bucket if _names_path(components, display)]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
#: Shared by ``candidates_for``, which takes a catalog rather than a resolver and so cannot reach
|
|
297
|
+
#: the resolver's own index. One index per process is enough — both callers see the same catalog
|
|
298
|
+
#: object for the length of a build, and a different catalog simply re-keys it.
|
|
299
|
+
_CANDIDATE_INDEX = _FilenameIndex()
|
|
300
|
+
|
|
301
|
+
|
|
220
302
|
class NormalizeResolver:
|
|
221
303
|
"""Resolves wikilink displays via normalized basename, with path-suffix fallback."""
|
|
222
304
|
|
|
223
305
|
def __init__(self) -> None:
|
|
224
|
-
|
|
225
|
-
# VaultGraph.build(), so folder-style links reuse one flatten instead of rebuilding it
|
|
226
|
-
# on every call. Keyed by id() and single-slot: a new catalog evicts the previous.
|
|
227
|
-
self._flat_cache_id: int | None = None
|
|
228
|
-
self._flat_cache: list[str] | None = None
|
|
229
|
-
|
|
230
|
-
@staticmethod
|
|
231
|
-
def _compute_flat(catalog: dict[str, list[str]]) -> list[str]:
|
|
232
|
-
return [p for paths in catalog.values() for p in paths]
|
|
233
|
-
|
|
234
|
-
def _flatten_paths(self, catalog: dict[str, list[str]]) -> list[str]:
|
|
235
|
-
if self._flat_cache_id != id(catalog) or self._flat_cache is None:
|
|
236
|
-
self._flat_cache = self._compute_flat(catalog)
|
|
237
|
-
self._flat_cache_id = id(catalog)
|
|
238
|
-
return self._flat_cache
|
|
306
|
+
self._index = _FilenameIndex()
|
|
239
307
|
|
|
240
308
|
def resolve(self, display: str, catalog: dict[str, list[str]]) -> str | None:
|
|
241
309
|
# Alias ("Note|alias"), anchor ("Note#Section") and an explicit ".md" extension all name
|
|
@@ -244,10 +312,10 @@ class NormalizeResolver:
|
|
|
244
312
|
display = _strip_display(display)
|
|
245
313
|
|
|
246
314
|
if "/" in display:
|
|
247
|
-
# Path-suffix resolution: find unique rel_path ending with "display.md"
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
matches =
|
|
315
|
+
# Path-suffix resolution: find unique rel_path ending with "display.md". Note that an
|
|
316
|
+
# exact whole-path hit cannot short-circuit — "work/Tasks" names both "work/Tasks.md"
|
|
317
|
+
# and "a/work/Tasks.md", and the honest answer there is to decline.
|
|
318
|
+
matches = _path_matches(display, catalog, self._index)
|
|
251
319
|
return matches[0] if len(matches) == 1 else None
|
|
252
320
|
|
|
253
321
|
# Bare-link resolution: normalize and look up in catalog
|
|
@@ -465,8 +533,20 @@ def diagnose(graph: VaultGraph, display: str, *, suggest: int = 0) -> LinkDiagno
|
|
|
465
533
|
return diagnosis
|
|
466
534
|
|
|
467
535
|
|
|
468
|
-
def
|
|
469
|
-
|
|
536
|
+
def resolve_markdown_target(
|
|
537
|
+
target: str, source_rel_path: str, *, autolinks: bool = False
|
|
538
|
+
) -> str | None:
|
|
539
|
+
"""Turn a markdown link's target into something the resolver can match, or ``None``.
|
|
540
|
+
|
|
541
|
+
With ``autolinks``, a **bare** target — a filename with no ``/`` — is handed through unchanged
|
|
542
|
+
so the ordinary name-based rule finds it anywhere in the tree. That is the `mkdocs-autolinks`
|
|
543
|
+
plugin's behavior, and it is **opt-in rather than a fallback**: trying a second rule when the
|
|
544
|
+
first fails is the shape of #136, where a quiet extra rule produced an edge to a note the link
|
|
545
|
+
did not name. A target that *is* path-qualified keeps the relative rule even in this mode, so
|
|
546
|
+
a wrong qualified path is never rescued by a filename match elsewhere.
|
|
547
|
+
|
|
548
|
+
Measured on `lyz-code/blue-book`, which runs the plugin: 5.8% of its links resolve strictly,
|
|
549
|
+
92.7% by name, 1.5% by neither — so a fallback would not even have been complete.
|
|
470
550
|
|
|
471
551
|
A markdown target is a **path**, not a name: ``[x](../other/b.md)`` means "the file at that
|
|
472
552
|
location relative to me". That is the first resolution rule in this package that depends on
|
|
@@ -484,10 +564,13 @@ def resolve_relative_target(target: str, source_rel_path: str) -> str | None:
|
|
|
484
564
|
is a separate decision, not a fallback to slip in here — silently trying a second rule when the
|
|
485
565
|
first fails is how a link resolves to the wrong note.
|
|
486
566
|
|
|
487
|
-
``
|
|
567
|
+
Without ``autolinks``, or for any target containing ``/``: ``None`` when the target escapes the
|
|
568
|
+
vault root. That is not an error and not a resolution —
|
|
488
569
|
a link out of the vault names no note in the graph, so it is reported ``missing`` like any other
|
|
489
570
|
target that is not there.
|
|
490
571
|
"""
|
|
572
|
+
if autolinks and "/" not in target:
|
|
573
|
+
return target
|
|
491
574
|
combined = PurePosixPath(source_rel_path).parent / target
|
|
492
575
|
normalized = posixpath.normpath(str(combined))
|
|
493
576
|
# normpath leaves leading "..", which is the only way to express "above the root".
|
|
@@ -627,14 +710,15 @@ class VaultGraph:
|
|
|
627
710
|
# pre-processing: a wikilink display is a *name*, a markdown target is a *path relative to
|
|
628
711
|
# the linking note*, and only the latter can be normalized once the source note is known.
|
|
629
712
|
read_wikilinks = config.link_syntax in ("wikilink", "both")
|
|
630
|
-
read_markdown = config.link_syntax in ("markdown", "both")
|
|
713
|
+
read_markdown = config.link_syntax in ("markdown", "both", "markdown-autolinks")
|
|
714
|
+
autolinks = config.link_syntax == "markdown-autolinks"
|
|
631
715
|
md_extractor = MarkdownLinkExtractor() if read_markdown else None
|
|
632
716
|
|
|
633
717
|
for doc in docs:
|
|
634
718
|
displays = list(extractor.extract(doc.text)) if read_wikilinks else []
|
|
635
719
|
if md_extractor is not None:
|
|
636
720
|
for target in md_extractor.extract(doc.text):
|
|
637
|
-
resolved =
|
|
721
|
+
resolved = resolve_markdown_target(target, doc.rel_path, autolinks=autolinks)
|
|
638
722
|
# A target above the vault root names no note here. Kept in the stream as the
|
|
639
723
|
# raw target so it is counted and reported `missing`, never silently dropped —
|
|
640
724
|
# the conservation law holds for every syntax that is read.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""The mkdocs-autolinks dialect: a bare markdown target resolves by name (#156).
|
|
2
|
+
|
|
3
|
+
A markdown link is a *relative path* — `[tdd](tdd.md)` from `docs/architecture/x.md` means
|
|
4
|
+
`docs/architecture/tdd.md` and nothing else. But the widely-used `mkdocs-autolinks` plugin resolves
|
|
5
|
+
a bare filename **anywhere in the tree**, so the same link reaches `docs/coding/tdd.md`. Measured on
|
|
6
|
+
`lyz-code/blue-book`, which runs that plugin: 5.8% of its links resolve strictly, 92.7% by name.
|
|
7
|
+
|
|
8
|
+
This is an explicit fourth `link_syntax` value, **not a fallback**. Trying a second rule when the
|
|
9
|
+
first fails is the shape of #136 — a rule that quietly produces an edge to a note the link does not
|
|
10
|
+
name — and the 1.5% that match neither dialect show a fallback would not even be complete. The vault
|
|
11
|
+
owner states which renderer they use, exactly as they already state their scope.
|
|
12
|
+
|
|
13
|
+
A bare name goes to the *existing* wikilink rule, so ambiguity is refused the same way it
|
|
14
|
+
always was: no dialect may invent a resolution.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from graphmark.config import VaultConfig
|
|
22
|
+
from graphmark.graph import NormalizeResolver, VaultGraph
|
|
23
|
+
from graphmark.parse import WikilinkExtractor
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _write(root: Path, rel: str, text: str = "") -> None:
|
|
27
|
+
path = root / rel
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
path.write_text(text, encoding="utf-8")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _build(root: Path, **kw) -> VaultGraph:
|
|
33
|
+
return VaultGraph.build(VaultConfig(root=root, **kw), WikilinkExtractor(), NormalizeResolver())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestBareTargetsResolveByName:
|
|
37
|
+
def test_a_bare_target_reaches_a_note_elsewhere_in_the_tree(self, tmp_path):
|
|
38
|
+
# blue-book's shape: [tdd](tdd.md) from docs/architecture/ reaching docs/coding/tdd.md.
|
|
39
|
+
_write(tmp_path, "docs/coding/tdd.md")
|
|
40
|
+
_write(tmp_path, "docs/architecture/x.md", "[tdd](tdd.md)\n")
|
|
41
|
+
graph = _build(tmp_path, link_syntax="markdown-autolinks")
|
|
42
|
+
assert graph.out_links["docs/architecture/x.md"] == {"docs/coding/tdd.md"}
|
|
43
|
+
assert graph.link_counts["missing"] == 0
|
|
44
|
+
|
|
45
|
+
def test_strict_markdown_still_reports_that_link_missing(self, tmp_path):
|
|
46
|
+
_write(tmp_path, "docs/coding/tdd.md")
|
|
47
|
+
_write(tmp_path, "docs/architecture/x.md", "[tdd](tdd.md)\n")
|
|
48
|
+
assert _build(tmp_path, link_syntax="markdown").link_counts["missing"] == 1
|
|
49
|
+
|
|
50
|
+
def test_a_sibling_target_still_resolves(self, tmp_path):
|
|
51
|
+
_write(tmp_path, "docs/b.md")
|
|
52
|
+
_write(tmp_path, "docs/a.md", "[b](b.md)\n")
|
|
53
|
+
assert _build(tmp_path, link_syntax="markdown-autolinks").out_links["docs/a.md"] == {
|
|
54
|
+
"docs/b.md"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
def test_the_name_normalizes_like_any_other(self, tmp_path):
|
|
58
|
+
_write(tmp_path, "docs/coding/Q1 — Review.md")
|
|
59
|
+
_write(tmp_path, "x.md", "[q](Q1%20-%20Review.md)\n")
|
|
60
|
+
assert _build(tmp_path, link_syntax="markdown-autolinks").unresolved == {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TestAmbiguityIsStillRefused:
|
|
64
|
+
def test_a_name_claimed_by_two_notes_is_ambiguous(self, tmp_path):
|
|
65
|
+
# The rule this dialect must not break: no dialect may invent a resolution.
|
|
66
|
+
_write(tmp_path, "one/note.md")
|
|
67
|
+
_write(tmp_path, "two/note.md")
|
|
68
|
+
_write(tmp_path, "x.md", "[n](note.md)\n")
|
|
69
|
+
graph = _build(tmp_path, link_syntax="markdown-autolinks")
|
|
70
|
+
assert graph.link_counts["ambiguous"] == 1
|
|
71
|
+
assert graph.out_links["x.md"] == set()
|
|
72
|
+
|
|
73
|
+
def test_a_name_that_exists_nowhere_is_missing(self, tmp_path):
|
|
74
|
+
_write(tmp_path, "x.md", "[gone](nowhere.md)\n")
|
|
75
|
+
assert _build(tmp_path, link_syntax="markdown-autolinks").link_counts["missing"] == 1
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestPathQualifiedTargetsStayRelative:
|
|
79
|
+
def test_a_target_with_a_slash_uses_the_relative_rule(self, tmp_path):
|
|
80
|
+
# Only *bare* names get the plugin's behavior; a qualified target still means what it says.
|
|
81
|
+
_write(tmp_path, "docs/deep/other/b.md")
|
|
82
|
+
_write(tmp_path, "docs/deep/a.md", "[b](other/b.md)\n")
|
|
83
|
+
assert _build(tmp_path, link_syntax="markdown-autolinks").out_links["docs/deep/a.md"] == {
|
|
84
|
+
"docs/deep/other/b.md"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def test_a_qualified_target_that_is_wrong_is_not_rescued_by_name(self, tmp_path):
|
|
88
|
+
# The anti-fallback assertion: "elsewhere/b.md" names no note here, and the fact that a
|
|
89
|
+
# b.md exists somewhere must NOT rescue it. That rescue is exactly the #136 shape.
|
|
90
|
+
_write(tmp_path, "docs/coding/b.md")
|
|
91
|
+
_write(tmp_path, "docs/a.md", "[b](elsewhere/b.md)\n")
|
|
92
|
+
graph = _build(tmp_path, link_syntax="markdown-autolinks")
|
|
93
|
+
assert graph.link_counts["missing"] == 1
|
|
94
|
+
assert graph.out_links["docs/a.md"] == set()
|
|
95
|
+
|
|
96
|
+
def test_a_parent_relative_target_still_resolves(self, tmp_path):
|
|
97
|
+
_write(tmp_path, "docs/other/b.md")
|
|
98
|
+
_write(tmp_path, "docs/deep/a.md", "[b](../other/b.md)\n")
|
|
99
|
+
assert _build(tmp_path, link_syntax="markdown-autolinks").out_links["docs/deep/a.md"] == {
|
|
100
|
+
"docs/other/b.md"
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestOtherModesUnchanged:
|
|
105
|
+
def test_wikilinks_are_not_read_in_this_mode(self, tmp_path):
|
|
106
|
+
_write(tmp_path, "b.md")
|
|
107
|
+
_write(tmp_path, "a.md", "[[b]]\n")
|
|
108
|
+
assert _build(tmp_path, link_syntax="markdown-autolinks").out_links["a.md"] == set()
|
|
109
|
+
|
|
110
|
+
def test_the_default_is_still_wikilink(self, tmp_path):
|
|
111
|
+
_write(tmp_path, "b.md")
|
|
112
|
+
_write(tmp_path, "a.md", "[b](b.md)\n[[b]]\n")
|
|
113
|
+
graph = _build(tmp_path)
|
|
114
|
+
assert graph.link_counts["resolved"] == 1
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TestConfig:
|
|
118
|
+
def test_it_is_an_accepted_syntax(self, tmp_path):
|
|
119
|
+
from graphmark.config import LINK_SYNTAXES
|
|
120
|
+
|
|
121
|
+
assert "markdown-autolinks" in LINK_SYNTAXES
|
|
122
|
+
|
|
123
|
+
def test_it_loads_from_toml(self, tmp_path):
|
|
124
|
+
from graphmark.config import load_config
|
|
125
|
+
|
|
126
|
+
cfg = tmp_path / "v.toml"
|
|
127
|
+
cfg.write_text(
|
|
128
|
+
f'root = "{tmp_path}"\nlink_syntax = "markdown-autolinks"\n', encoding="utf-8"
|
|
129
|
+
)
|
|
130
|
+
assert load_config(cfg).link_syntax == "markdown-autolinks"
|
|
@@ -91,19 +91,15 @@ class TestNormalizeResolver:
|
|
|
91
91
|
assert self.resolver.resolve("brain/alpha", catalog) == "brain/alpha.md"
|
|
92
92
|
assert self.resolver.resolve("work/gamma", catalog) == "work/gamma.md"
|
|
93
93
|
|
|
94
|
-
def
|
|
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")
|