graphmark 0.8.1__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.1 → graphmark-0.9.0}/CHANGELOG.md +55 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/PKG-INFO +8 -2
- {graphmark-0.8.1 → graphmark-0.9.0}/README.md +7 -1
- {graphmark-0.8.1 → graphmark-0.9.0}/docs/ROADMAP.md +1 -1
- {graphmark-0.8.1 → graphmark-0.9.0}/pyproject.toml +1 -1
- {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/config.py +5 -2
- {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/graph.py +75 -36
- graphmark-0.9.0/tests/test_autolinks.py +130 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_path_index.py +14 -7
- graphmark-0.9.0/tests/test_path_normalization.py +141 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/.gitignore +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/LICENSE +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/__init__.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/check.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/cli.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/dismiss.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/export.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/interfaces.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/metrics.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/model.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/parse.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/src/graphmark/py.typed +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/README.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/config.toml +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/expected.json +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/vault/docs/center.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/vault/misc/echo.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/vault/misc/stub.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/vault/refs/beta.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/vault/refs/delta.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/dismiss/expected.json +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/dismiss/vault/alpha.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/dismiss/vault/beta.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/dismiss/vault/gamma.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/config.toml +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/expected.json +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/expected_oog.json +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/similar.json +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/similar_oog.json +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/vault/docs/a.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/vault/misc/c.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/vault/misc/d.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/vault/refs/b.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/gaps/vault/refs/e.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/scoped/config.toml +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/scoped/expected.json +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/scoped/vault/docs/one.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/scoped/vault/refs/two.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/selflink/config.toml +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/selflink/expected.json +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/selflink/vault/a.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/selflink/vault/b.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/simple/config.toml +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/simple/expected.json +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/simple/vault/brain/hub.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/simple/vault/personal/beta.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/simple/vault/reference/island.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/simple/vault/reference/stub.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/suggest/README.md +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_aliases.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_bom.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_build_filters.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_check.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_check_policy.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_cli.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_config.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_degenerate.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_diagnose.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_dismiss.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_export.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_gaps.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_gaps_banding.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_gaps_oog.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_gaps_params.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_graph.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_graph_catalogs.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_link_counts.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_links_report.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_markdown_links.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_metrics.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_model.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_non_note_file.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_packaging.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_pagerank.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_parse.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_path_suffix.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_properties.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_public_api.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_readme_examples.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_scoped.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_selflink.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_smoke.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_suggestions.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_unicode.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_unicode_punctuation.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_unread_syntax.py +0 -0
- {graphmark-0.8.1 → graphmark-0.9.0}/tests/test_unresolved.py +0 -0
|
@@ -1,6 +1,61 @@
|
|
|
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
|
+
|
|
4
59
|
## v0.8.1 (2026-07-26)
|
|
5
60
|
|
|
6
61
|
### Performance Improvements
|
|
@@ -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,8 +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(_suffix_matches(suffix, catalog, _CANDIDATE_INDEX))
|
|
160
|
+
return sorted(_path_matches(target, catalog, _CANDIDATE_INDEX))
|
|
148
161
|
return list(catalog.get(_normalize(target), ()))
|
|
149
162
|
|
|
150
163
|
|
|
@@ -212,14 +225,21 @@ def build_catalog(docs: list[Document]) -> dict[str, list[str]]:
|
|
|
212
225
|
return catalog
|
|
213
226
|
|
|
214
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
|
+
|
|
215
233
|
class _FilenameIndex:
|
|
216
|
-
"""Catalog paths bucketed by
|
|
234
|
+
"""Catalog paths bucketed by normalized final component, so a path link scans a bucket.
|
|
217
235
|
|
|
218
|
-
A
|
|
219
|
-
candidate's **last** component always equals the display's last
|
|
220
|
-
Bucketing by
|
|
221
|
-
|
|
222
|
-
|
|
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.
|
|
223
243
|
|
|
224
244
|
This mattered little while path-qualified links were rare: most wikilink displays are bare names
|
|
225
245
|
hitting the catalog dict directly, which is why the ``O(L·N)`` ceiling was documented and
|
|
@@ -245,17 +265,18 @@ class _FilenameIndex:
|
|
|
245
265
|
|
|
246
266
|
def __init__(self) -> None:
|
|
247
267
|
# Insertion-ordered, so the oldest entry is the one evicted.
|
|
248
|
-
self._slots: dict[int, tuple[dict[str, list[str]],
|
|
268
|
+
self._slots: dict[int, tuple[dict[str, list[str]], _Buckets]] = {}
|
|
249
269
|
|
|
250
|
-
def for_catalog(self, catalog: dict[str, list[str]]) ->
|
|
270
|
+
def for_catalog(self, catalog: dict[str, list[str]]) -> _Buckets:
|
|
251
271
|
cached = self._slots.get(id(catalog))
|
|
252
272
|
if cached is not None and cached[0] is catalog:
|
|
253
273
|
return cached[1]
|
|
254
274
|
|
|
255
|
-
buckets:
|
|
275
|
+
buckets: _Buckets = {}
|
|
256
276
|
for paths in catalog.values():
|
|
257
277
|
for path in paths:
|
|
258
|
-
|
|
278
|
+
components = _path_components(path)
|
|
279
|
+
buckets.setdefault(components[-1], []).append((path, components))
|
|
259
280
|
|
|
260
281
|
if len(self._slots) >= self._MAX_SLOTS:
|
|
261
282
|
del self._slots[next(iter(self._slots))]
|
|
@@ -263,10 +284,13 @@ class _FilenameIndex:
|
|
|
263
284
|
return buckets
|
|
264
285
|
|
|
265
286
|
|
|
266
|
-
def
|
|
267
|
-
"""Every rel_path that
|
|
268
|
-
|
|
269
|
-
|
|
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)]
|
|
270
294
|
|
|
271
295
|
|
|
272
296
|
#: Shared by ``candidates_for``, which takes a catalog rather than a resolver and so cannot reach
|
|
@@ -291,8 +315,7 @@ class NormalizeResolver:
|
|
|
291
315
|
# Path-suffix resolution: find unique rel_path ending with "display.md". Note that an
|
|
292
316
|
# exact whole-path hit cannot short-circuit — "work/Tasks" names both "work/Tasks.md"
|
|
293
317
|
# and "a/work/Tasks.md", and the honest answer there is to decline.
|
|
294
|
-
|
|
295
|
-
matches = _suffix_matches(suffix, catalog, self._index)
|
|
318
|
+
matches = _path_matches(display, catalog, self._index)
|
|
296
319
|
return matches[0] if len(matches) == 1 else None
|
|
297
320
|
|
|
298
321
|
# Bare-link resolution: normalize and look up in catalog
|
|
@@ -510,8 +533,20 @@ def diagnose(graph: VaultGraph, display: str, *, suggest: int = 0) -> LinkDiagno
|
|
|
510
533
|
return diagnosis
|
|
511
534
|
|
|
512
535
|
|
|
513
|
-
def
|
|
514
|
-
|
|
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.
|
|
515
550
|
|
|
516
551
|
A markdown target is a **path**, not a name: ``[x](../other/b.md)`` means "the file at that
|
|
517
552
|
location relative to me". That is the first resolution rule in this package that depends on
|
|
@@ -529,10 +564,13 @@ def resolve_relative_target(target: str, source_rel_path: str) -> str | None:
|
|
|
529
564
|
is a separate decision, not a fallback to slip in here — silently trying a second rule when the
|
|
530
565
|
first fails is how a link resolves to the wrong note.
|
|
531
566
|
|
|
532
|
-
``
|
|
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 —
|
|
533
569
|
a link out of the vault names no note in the graph, so it is reported ``missing`` like any other
|
|
534
570
|
target that is not there.
|
|
535
571
|
"""
|
|
572
|
+
if autolinks and "/" not in target:
|
|
573
|
+
return target
|
|
536
574
|
combined = PurePosixPath(source_rel_path).parent / target
|
|
537
575
|
normalized = posixpath.normpath(str(combined))
|
|
538
576
|
# normpath leaves leading "..", which is the only way to express "above the root".
|
|
@@ -672,14 +710,15 @@ class VaultGraph:
|
|
|
672
710
|
# pre-processing: a wikilink display is a *name*, a markdown target is a *path relative to
|
|
673
711
|
# the linking note*, and only the latter can be normalized once the source note is known.
|
|
674
712
|
read_wikilinks = config.link_syntax in ("wikilink", "both")
|
|
675
|
-
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"
|
|
676
715
|
md_extractor = MarkdownLinkExtractor() if read_markdown else None
|
|
677
716
|
|
|
678
717
|
for doc in docs:
|
|
679
718
|
displays = list(extractor.extract(doc.text)) if read_wikilinks else []
|
|
680
719
|
if md_extractor is not None:
|
|
681
720
|
for target in md_extractor.extract(doc.text):
|
|
682
|
-
resolved =
|
|
721
|
+
resolved = resolve_markdown_target(target, doc.rel_path, autolinks=autolinks)
|
|
683
722
|
# A target above the vault root names no note here. Kept in the stream as the
|
|
684
723
|
# raw target so it is counted and reported `missing`, never silently dropped —
|
|
685
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"
|
|
@@ -31,16 +31,23 @@ def _build(root: Path, **kw) -> VaultGraph:
|
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def _scan_resolve(display: str, catalog: dict[str, list[str]]) -> list[str]:
|
|
34
|
-
"""The
|
|
34
|
+
"""The unindexed behavior, written out: compare against every path, in catalog order.
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
An independent reference rather than a call into the implementation, so the equivalence tests
|
|
37
|
+
below compare two things instead of one thing with itself. Restated for #146, which made the
|
|
38
|
+
comparison component-wise: a display names a path when its normalized components are a suffix of
|
|
39
|
+
the path's.
|
|
38
40
|
"""
|
|
39
|
-
from graphmark.graph import
|
|
41
|
+
from graphmark.graph import _normalize, _strip_display
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
def components(name: str) -> list[str]:
|
|
44
|
+
name = name[:-3] if name.lower().endswith(".md") else name
|
|
45
|
+
return [_normalize(part) for part in name.split("/")]
|
|
46
|
+
|
|
47
|
+
want = components(_strip_display(display))
|
|
48
|
+
if not want or "" in want:
|
|
49
|
+
return []
|
|
50
|
+
return [p for paths in catalog.values() for p in paths if components(p)[-len(want) :] == want]
|
|
44
51
|
|
|
45
52
|
|
|
46
53
|
class TestEquivalence:
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Path-qualified links normalize like bare ones (#146).
|
|
2
|
+
|
|
3
|
+
Bare resolution went through `_normalize` — NFC, lowercase, punctuation and symbols folded — while
|
|
4
|
+
path resolution went through `_fold_case`, which folds only case and Unicode form. So
|
|
5
|
+
`[[Q1 - Review]]` found `Q1 — Review.md` but `[[notes/Q1 - Review]]` did not: the same title was
|
|
6
|
+
reachable or not depending on whether the writer happened to qualify the link with a folder, which
|
|
7
|
+
is arbitrary from a user's point of view.
|
|
8
|
+
|
|
9
|
+
The fix compares **path components**, each normalized by the one function: the display's components
|
|
10
|
+
must be a suffix of the candidate's. That subsumes #136's boundary rule for free — a suffix over
|
|
11
|
+
lists cannot match mid-component — while letting every punctuation equivalence `_normalize` already
|
|
12
|
+
absorbs work in both branches.
|
|
13
|
+
|
|
14
|
+
The trap this must not fall into: `/` is structural. `[[a/b]]` must never reach `a-b.md`, even
|
|
15
|
+
though `a/b` and `a-b` normalize to the same characters once the separator is folded. Components
|
|
16
|
+
are normalized individually and the separator is never one of them, which keeps them apart.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from graphmark.config import VaultConfig
|
|
24
|
+
from graphmark.graph import NormalizeResolver, VaultGraph, candidates_for
|
|
25
|
+
from graphmark.parse import WikilinkExtractor
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _write(root: Path, rel: str, text: str = "") -> None:
|
|
29
|
+
path = root / rel
|
|
30
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
path.write_text(text, encoding="utf-8")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build(root: Path, **kw) -> VaultGraph:
|
|
35
|
+
return VaultGraph.build(VaultConfig(root=root, **kw), WikilinkExtractor(), NormalizeResolver())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestSeparatorStaysStructural:
|
|
39
|
+
"""The trap. A `/` is a path boundary, never a character to fold."""
|
|
40
|
+
|
|
41
|
+
def test_a_slash_display_does_not_reach_a_hyphen_file(self, tmp_path):
|
|
42
|
+
_write(tmp_path, "a-b.md")
|
|
43
|
+
_write(tmp_path, "src.md", "[[a/b]]")
|
|
44
|
+
graph = _build(tmp_path)
|
|
45
|
+
assert graph.unresolved == {"src.md": ["a/b"]}
|
|
46
|
+
assert candidates_for("a/b", graph.catalog) == []
|
|
47
|
+
|
|
48
|
+
def test_a_slash_display_does_not_reach_a_space_file(self, tmp_path):
|
|
49
|
+
_write(tmp_path, "a b.md")
|
|
50
|
+
_write(tmp_path, "src.md", "[[a/b]]")
|
|
51
|
+
assert _build(tmp_path).unresolved == {"src.md": ["a/b"]}
|
|
52
|
+
|
|
53
|
+
def test_a_hyphen_display_does_not_reach_a_nested_file(self, tmp_path):
|
|
54
|
+
# The reverse direction: "a-b" is one name, not two components.
|
|
55
|
+
_write(tmp_path, "a/b.md")
|
|
56
|
+
_write(tmp_path, "src.md", "[[a-b]]")
|
|
57
|
+
assert _build(tmp_path).unresolved == {"src.md": ["a-b"]}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestPunctuationFoldsWithinAComponent:
|
|
61
|
+
def test_a_path_qualified_em_dash_title(self, tmp_path):
|
|
62
|
+
_write(tmp_path, "notes/Q1 — Review.md")
|
|
63
|
+
_write(tmp_path, "src.md", "[[notes/Q1 - Review]]")
|
|
64
|
+
graph = _build(tmp_path)
|
|
65
|
+
assert graph.unresolved == {}
|
|
66
|
+
assert graph.out_links["src.md"] == {"notes/Q1 — Review.md"}
|
|
67
|
+
|
|
68
|
+
def test_a_path_qualified_hyphen_vs_space(self, tmp_path):
|
|
69
|
+
_write(tmp_path, "notes/oura pipeline.md")
|
|
70
|
+
_write(tmp_path, "src.md", "[[notes/oura-pipeline]]")
|
|
71
|
+
assert _build(tmp_path).unresolved == {}
|
|
72
|
+
|
|
73
|
+
def test_the_folder_component_folds_too(self, tmp_path):
|
|
74
|
+
_write(tmp_path, "my-notes/thing.md")
|
|
75
|
+
_write(tmp_path, "src.md", "[[my notes/thing]]")
|
|
76
|
+
assert _build(tmp_path).unresolved == {}
|
|
77
|
+
|
|
78
|
+
def test_both_branches_now_agree(self, tmp_path):
|
|
79
|
+
# The actual defect: the same title, reachable one way and not the other.
|
|
80
|
+
_write(tmp_path, "notes/Q1 — Review.md")
|
|
81
|
+
_write(tmp_path, "src.md", "[[Q1 - Review]]\n[[notes/Q1 - Review]]")
|
|
82
|
+
graph = _build(tmp_path)
|
|
83
|
+
assert graph.unresolved == {}
|
|
84
|
+
assert graph.link_counts["resolved"] == 2
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TestBoundaryRuleSurvives:
|
|
88
|
+
"""#136's guarantee, now a consequence of comparing component lists."""
|
|
89
|
+
|
|
90
|
+
def test_a_longer_folder_name_still_does_not_satisfy_a_shorter_one(self, tmp_path):
|
|
91
|
+
_write(tmp_path, "homework/Tasks.md")
|
|
92
|
+
_write(tmp_path, "src.md", "[[work/Tasks]]")
|
|
93
|
+
assert _build(tmp_path).unresolved == {"src.md": ["work/Tasks"]}
|
|
94
|
+
|
|
95
|
+
def test_any_depth_of_prefix_still_resolves(self, tmp_path):
|
|
96
|
+
_write(tmp_path, "a/b/work/Tasks.md")
|
|
97
|
+
_write(tmp_path, "src.md", "[[work/Tasks]]")
|
|
98
|
+
assert _build(tmp_path).out_links["src.md"] == {"a/b/work/Tasks.md"}
|
|
99
|
+
|
|
100
|
+
def test_a_whole_path_still_resolves(self, tmp_path):
|
|
101
|
+
_write(tmp_path, "work/Tasks.md")
|
|
102
|
+
_write(tmp_path, "src.md", "[[work/Tasks]]")
|
|
103
|
+
assert _build(tmp_path).out_links["src.md"] == {"work/Tasks.md"}
|
|
104
|
+
|
|
105
|
+
def test_a_genuine_collision_is_still_ambiguous(self, tmp_path):
|
|
106
|
+
_write(tmp_path, "a/work/Tasks.md")
|
|
107
|
+
_write(tmp_path, "b/work/Tasks.md")
|
|
108
|
+
_write(tmp_path, "src.md", "[[work/Tasks]]")
|
|
109
|
+
graph = _build(tmp_path)
|
|
110
|
+
assert graph.link_counts["ambiguous"] == 1
|
|
111
|
+
assert candidates_for("work/Tasks", graph.catalog) == [
|
|
112
|
+
"a/work/Tasks.md",
|
|
113
|
+
"b/work/Tasks.md",
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
def test_case_and_unicode_form_still_fold(self, tmp_path):
|
|
117
|
+
_write(tmp_path, "Work/Tasks.md")
|
|
118
|
+
_write(tmp_path, "src.md", "[[work/tasks]]")
|
|
119
|
+
assert _build(tmp_path).unresolved == {}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TestMarkdownModeAgrees:
|
|
123
|
+
def test_a_markdown_link_to_a_punctuation_variant_resolves(self, tmp_path):
|
|
124
|
+
# Markdown targets are normalized to full vault-relative paths, so they take the same
|
|
125
|
+
# component path — the fix must reach them too.
|
|
126
|
+
_write(tmp_path, "notes/Q1 — Review.md")
|
|
127
|
+
# Percent-encoded, because a bare space in a destination is not a link per CommonMark —
|
|
128
|
+
# it needs <> or encoding. That also exercises the decode path.
|
|
129
|
+
_write(tmp_path, "notes/src.md", "[x](Q1%20-%20Review.md)\n")
|
|
130
|
+
graph = _build(tmp_path, link_syntax="markdown")
|
|
131
|
+
assert graph.out_links["notes/src.md"] == {"notes/Q1 — Review.md"}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TestDegenerate:
|
|
135
|
+
def test_a_trailing_slash_names_nothing(self, tmp_path):
|
|
136
|
+
_write(tmp_path, "a/b.md")
|
|
137
|
+
assert candidates_for("a/", _build(tmp_path).catalog) == []
|
|
138
|
+
|
|
139
|
+
def test_a_punctuation_only_component_names_nothing(self, tmp_path):
|
|
140
|
+
_write(tmp_path, "a/b.md")
|
|
141
|
+
assert candidates_for("a/--", _build(tmp_path).catalog) == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{graphmark-0.8.1 → graphmark-0.9.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|