graphmark 0.5.0__tar.gz → 0.6.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.5.0 → graphmark-0.6.0}/CHANGELOG.md +84 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/PKG-INFO +32 -1
- {graphmark-0.5.0 → graphmark-0.6.0}/README.md +31 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/pyproject.toml +1 -1
- {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/__init__.py +2 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/config.py +4 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/graph.py +62 -3
- {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/parse.py +32 -1
- graphmark-0.6.0/tests/test_aliases.py +256 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_parse.py +91 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_public_api.py +1 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/.gitignore +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/LICENSE +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/docs/ROADMAP.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/check.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/cli.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/dismiss.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/export.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/interfaces.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/metrics.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/model.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/py.typed +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/README.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/config.toml +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/expected.json +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/docs/center.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/misc/echo.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/misc/stub.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/refs/beta.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/refs/delta.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/dismiss/expected.json +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/dismiss/vault/alpha.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/dismiss/vault/beta.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/dismiss/vault/gamma.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/config.toml +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/expected.json +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/expected_oog.json +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/similar.json +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/similar_oog.json +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/docs/a.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/misc/c.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/misc/d.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/refs/b.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/refs/e.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/scoped/config.toml +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/scoped/expected.json +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/scoped/vault/docs/one.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/scoped/vault/refs/two.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/selflink/config.toml +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/selflink/expected.json +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/selflink/vault/a.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/selflink/vault/b.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/simple/config.toml +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/simple/expected.json +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/brain/hub.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/personal/beta.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/reference/island.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/reference/stub.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/suggest/README.md +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_build_filters.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_check.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_check_policy.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_cli.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_config.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_degenerate.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_diagnose.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_dismiss.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_export.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_gaps.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_gaps_banding.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_gaps_oog.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_gaps_params.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_graph.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_graph_catalogs.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_metrics.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_model.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_packaging.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_pagerank.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_scoped.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_selflink.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_smoke.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_suggestions.py +0 -0
- {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_unresolved.py +0 -0
|
@@ -1,6 +1,90 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.6.0 (2026-07-25)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- **parse**: Parse block-style frontmatter lists (#118)
|
|
9
|
+
([#120](https://github.com/cdcoonce/graphmark/pull/120),
|
|
10
|
+
[`723c517`](https://github.com/cdcoonce/graphmark/commit/723c517d30732286fd0e6005996fb36d577042f5))
|
|
11
|
+
|
|
12
|
+
* chore(release): 0.3.4 [skip ci]
|
|
13
|
+
|
|
14
|
+
* chore(release): 0.4.0 [skip ci]
|
|
15
|
+
|
|
16
|
+
* chore(release): 0.5.0 [skip ci]
|
|
17
|
+
|
|
18
|
+
* fix(parse): parse block-style frontmatter lists (#118)
|
|
19
|
+
|
|
20
|
+
_parse_frontmatter handled scalars, quoted strings and inline lists, but a block list's item lines
|
|
21
|
+
contain no colon, so the loop skipped them and the bare `key:` line stored "". Every block-style
|
|
22
|
+
property in a real vault — aliases:, tags: — silently became an empty string instead of a list.
|
|
23
|
+
|
|
24
|
+
Block form is what Obsidian's own Properties UI writes, so this is the common case, not the edge
|
|
25
|
+
case, and Document.frontmatter is public: a consumer reading doc.frontmatter["tags"] got "" rather
|
|
26
|
+
than a list. Not an error, just quietly wrong data.
|
|
27
|
+
|
|
28
|
+
Item lines are matched on the leading dash BEFORE the key/value split, so an item containing a colon
|
|
29
|
+
("- Note: A Subtitle") stays an item. A block closes at the next key rather than merely pausing —
|
|
30
|
+
without that, a stray indented line later in a malformed note would silently grow the earlier
|
|
31
|
+
list, which is misattribution rather than the promised fail-soft drop. That distinction survived
|
|
32
|
+
the first four mutations and now has its own test.
|
|
33
|
+
|
|
34
|
+
A `key:` with no items still reads as "" — an empty value, not an empty list — so nothing that
|
|
35
|
+
parsed before changes shape.
|
|
36
|
+
|
|
37
|
+
Parity: fixture notes use inline or scalar frontmatter only, so no expected.json can move. A test
|
|
38
|
+
asserts that property directly rather than leaving it as a claim in the commit message.
|
|
39
|
+
|
|
40
|
+
Still a targeted scan, not a YAML dependency: this runs over every note in a vault and a note
|
|
41
|
+
someone is mid-edit must not take the graph down.
|
|
42
|
+
|
|
43
|
+
---------
|
|
44
|
+
|
|
45
|
+
Co-authored-by: semantic-release <semantic-release>
|
|
46
|
+
|
|
47
|
+
### Features
|
|
48
|
+
|
|
49
|
+
- **graph**: Resolve frontmatter aliases in-package (#119)
|
|
50
|
+
([#121](https://github.com/cdcoonce/graphmark/pull/121),
|
|
51
|
+
[`05bb6ce`](https://github.com/cdcoonce/graphmark/commit/05bb6ced17656a3fb3d1d1798068763b26b68588))
|
|
52
|
+
|
|
53
|
+
graphmark never read frontmatter during resolution, so every link written against an Obsidian
|
|
54
|
+
`aliases:` entry was reported broken. Measured on a real 521-note vault: stock graphmark reported
|
|
55
|
+
23 broken links where the actual count was 0, and dropped 17 edges with them. A 100%
|
|
56
|
+
false-positive rate in max_unresolved_links — the flagship threshold of graphmark check, the one
|
|
57
|
+
product this repo offers outward. Larger than the four false-positive classes already fixed
|
|
58
|
+
(#98/#101/#104/#107) combined.
|
|
59
|
+
|
|
60
|
+
This reverses a documented decision. the-vault's AliasResolver says "naming policy belongs to the
|
|
61
|
+
seam, so the fallback lives here rather than in the graph algorithm". That holds for idiosyncratic
|
|
62
|
+
conventions; aliases: is core Obsidian, and this package claims to work on any Obsidian-family
|
|
63
|
+
vault. Human-triaged: move it in-package, on by default, since a default that silently produces
|
|
64
|
+
wrong answers is not fixed by a knob nobody knows to flip. resolve_aliases = false restores the
|
|
65
|
+
old behavior.
|
|
66
|
+
|
|
67
|
+
That AliasResolver is the oracle, and the live-vault differential is exact: stock graphmark now
|
|
68
|
+
reports the same broken-link set as the reference implementation, note for note — 0 and 0, from a
|
|
69
|
+
51-entry alias map.
|
|
70
|
+
|
|
71
|
+
Its conservative rules are preserved. An alias colliding with any real note name is dropped at index
|
|
72
|
+
time, not merely outranked, so a note's own title can never be hijacked and an already-ambiguous
|
|
73
|
+
basename can't be rescued into resolving. An alias claimed by two notes resolves to nothing. A
|
|
74
|
+
slash-bearing alias is refused when the map is built, because normalization turns "/" into a space
|
|
75
|
+
and would otherwise make it reachable from a slash-free display — the lookup-side guard alone does
|
|
76
|
+
not cover that, which mutation testing caught.
|
|
77
|
+
|
|
78
|
+
One honest note: rule 1 (resolver before alias) is structurally unreachable given the other two
|
|
79
|
+
rules — no display can match both a catalog key and an alias key — so mutating the order leaves
|
|
80
|
+
the suite green. It is kept as insurance against a future relaxation and is documented as untested
|
|
81
|
+
rather than dressed up as covered.
|
|
82
|
+
|
|
83
|
+
Parity: this changes what resolves, so it is the most parity-sensitive change since extraction. It
|
|
84
|
+
is safe only because no frozen fixture declares aliases; a test asserts that property directly so
|
|
85
|
+
it cannot go stale.
|
|
86
|
+
|
|
87
|
+
|
|
4
88
|
## v0.5.0 (2026-07-25)
|
|
5
89
|
|
|
6
90
|
### Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graphmark
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -185,6 +185,7 @@ graphmark.to_dot(graph) # Graphviz DOT
|
|
|
185
185
|
graph.unresolved # {rel_path: [broken link displays]}
|
|
186
186
|
graph.catalog # {normalized stem: [rel_paths]} — 2+ means ambiguous
|
|
187
187
|
graph.out_of_scope # same, for markdown outside the configured scope
|
|
188
|
+
graph.aliases # {normalized alias: rel_path} — frontmatter aliases
|
|
188
189
|
```
|
|
189
190
|
|
|
190
191
|
`catalog` and `out_of_scope` are the resolution state the build consulted. They are what you need to
|
|
@@ -253,10 +254,40 @@ Both constants were calibrated against a human-annotated baseline of a real vaul
|
|
|
253
254
|
rather than chosen by taste — see [`tests/fixtures/suggest/README.md`](tests/fixtures/suggest/README.md)
|
|
254
255
|
for the method and the measured result.
|
|
255
256
|
|
|
257
|
+
## Frontmatter aliases
|
|
258
|
+
|
|
259
|
+
Obsidian's `aliases:` property declares additional real names for a note, so links written against
|
|
260
|
+
one resolve like any other link. Block, inline and scalar forms all work:
|
|
261
|
+
|
|
262
|
+
```yaml
|
|
263
|
+
---
|
|
264
|
+
aliases:
|
|
265
|
+
- Mood Tracker
|
|
266
|
+
- mood-tracker
|
|
267
|
+
---
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`[[Mood Tracker]]` now resolves to `2026-04-11-mood-tracker.md` and produces a real edge. On a
|
|
271
|
+
521-note vault this was the difference between **23 reported broken links and 0** — every one of the
|
|
272
|
+
23 a false positive, plus 17 edges that were silently missing.
|
|
273
|
+
|
|
274
|
+
Three rules keep it conservative:
|
|
275
|
+
|
|
276
|
+
- **A real note name always wins.** An alias can never hijack a link to a note that actually bears
|
|
277
|
+
that name.
|
|
278
|
+
- **An alias that collides with any real note name is dropped entirely** — not merely outranked, so
|
|
279
|
+
an already-ambiguous basename can't be rescued into resolving by someone's alias.
|
|
280
|
+
- **An alias claimed by two notes resolves to nothing**, the same refusal applied to colliding
|
|
281
|
+
basenames.
|
|
282
|
+
|
|
283
|
+
Set `resolve_aliases = false` for strict basename-only resolution. `graph.aliases` exposes the
|
|
284
|
+
resolved map.
|
|
285
|
+
|
|
256
286
|
## Configuration
|
|
257
287
|
|
|
258
288
|
```toml
|
|
259
289
|
root = "." # required unless --root is passed
|
|
290
|
+
resolve_aliases = true # resolve frontmatter aliases: (default true)
|
|
260
291
|
scoped_folders = ["brain", "work"] # limit the vault to these top-level folders
|
|
261
292
|
excluded_dirs = [".git", "templates"] # skip notes under these directories
|
|
262
293
|
rules_files = ["CLAUDE.md", "CLAUDE.local.md"] # agent-rules files, not vault content
|
|
@@ -158,6 +158,7 @@ graphmark.to_dot(graph) # Graphviz DOT
|
|
|
158
158
|
graph.unresolved # {rel_path: [broken link displays]}
|
|
159
159
|
graph.catalog # {normalized stem: [rel_paths]} — 2+ means ambiguous
|
|
160
160
|
graph.out_of_scope # same, for markdown outside the configured scope
|
|
161
|
+
graph.aliases # {normalized alias: rel_path} — frontmatter aliases
|
|
161
162
|
```
|
|
162
163
|
|
|
163
164
|
`catalog` and `out_of_scope` are the resolution state the build consulted. They are what you need to
|
|
@@ -226,10 +227,40 @@ Both constants were calibrated against a human-annotated baseline of a real vaul
|
|
|
226
227
|
rather than chosen by taste — see [`tests/fixtures/suggest/README.md`](tests/fixtures/suggest/README.md)
|
|
227
228
|
for the method and the measured result.
|
|
228
229
|
|
|
230
|
+
## Frontmatter aliases
|
|
231
|
+
|
|
232
|
+
Obsidian's `aliases:` property declares additional real names for a note, so links written against
|
|
233
|
+
one resolve like any other link. Block, inline and scalar forms all work:
|
|
234
|
+
|
|
235
|
+
```yaml
|
|
236
|
+
---
|
|
237
|
+
aliases:
|
|
238
|
+
- Mood Tracker
|
|
239
|
+
- mood-tracker
|
|
240
|
+
---
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
`[[Mood Tracker]]` now resolves to `2026-04-11-mood-tracker.md` and produces a real edge. On a
|
|
244
|
+
521-note vault this was the difference between **23 reported broken links and 0** — every one of the
|
|
245
|
+
23 a false positive, plus 17 edges that were silently missing.
|
|
246
|
+
|
|
247
|
+
Three rules keep it conservative:
|
|
248
|
+
|
|
249
|
+
- **A real note name always wins.** An alias can never hijack a link to a note that actually bears
|
|
250
|
+
that name.
|
|
251
|
+
- **An alias that collides with any real note name is dropped entirely** — not merely outranked, so
|
|
252
|
+
an already-ambiguous basename can't be rescued into resolving by someone's alias.
|
|
253
|
+
- **An alias claimed by two notes resolves to nothing**, the same refusal applied to colliding
|
|
254
|
+
basenames.
|
|
255
|
+
|
|
256
|
+
Set `resolve_aliases = false` for strict basename-only resolution. `graph.aliases` exposes the
|
|
257
|
+
resolved map.
|
|
258
|
+
|
|
229
259
|
## Configuration
|
|
230
260
|
|
|
231
261
|
```toml
|
|
232
262
|
root = "." # required unless --root is passed
|
|
263
|
+
resolve_aliases = true # resolve frontmatter aliases: (default true)
|
|
233
264
|
scoped_folders = ["brain", "work"] # limit the vault to these top-level folders
|
|
234
265
|
excluded_dirs = [".git", "templates"] # skip notes under these directories
|
|
235
266
|
rules_files = ["CLAUDE.md", "CLAUDE.local.md"] # agent-rules files, not vault content
|
|
@@ -33,6 +33,7 @@ from graphmark.graph import (
|
|
|
33
33
|
LinkDiagnosis,
|
|
34
34
|
NormalizeResolver,
|
|
35
35
|
VaultGraph,
|
|
36
|
+
build_aliases,
|
|
36
37
|
build_catalog,
|
|
37
38
|
candidates_for,
|
|
38
39
|
diagnose,
|
|
@@ -112,6 +113,7 @@ __all__ = [
|
|
|
112
113
|
"active_dismissed_sigs",
|
|
113
114
|
"bridges",
|
|
114
115
|
"build",
|
|
116
|
+
"build_aliases",
|
|
115
117
|
"build_catalog",
|
|
116
118
|
"candidates_for",
|
|
117
119
|
"diagnose",
|
|
@@ -44,6 +44,9 @@ class VaultConfig:
|
|
|
44
44
|
excluded_dirs: list[str] = field(default_factory=list)
|
|
45
45
|
rules_files: list[str] = field(default_factory=lambda: ["CLAUDE.md", "CLAUDE.local.md"])
|
|
46
46
|
transient_prefixes: tuple[str, ...] = ()
|
|
47
|
+
# Obsidian's `aliases:` property names a note, so links written against one resolve by
|
|
48
|
+
# default. Set False for strict basename-only resolution.
|
|
49
|
+
resolve_aliases: bool = True
|
|
47
50
|
check: CheckPolicy = field(default_factory=CheckPolicy)
|
|
48
51
|
|
|
49
52
|
def __post_init__(self) -> None:
|
|
@@ -118,5 +121,6 @@ def load_config(path: str | Path, *, root_override: str | Path | None = None) ->
|
|
|
118
121
|
excluded_dirs=data.get("excluded_dirs", []),
|
|
119
122
|
rules_files=data.get("rules_files", ["CLAUDE.md", "CLAUDE.local.md"]),
|
|
120
123
|
transient_prefixes=tuple(data.get("transient_prefixes", [])),
|
|
124
|
+
resolve_aliases=bool(data.get("resolve_aliases", True)),
|
|
121
125
|
check=_parse_check(data, path),
|
|
122
126
|
)
|
|
@@ -76,6 +76,43 @@ def candidates_for(display: str, catalog: dict[str, list[str]]) -> list[str]:
|
|
|
76
76
|
return list(catalog.get(_normalize(target), ()))
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
def build_aliases(docs: list[Document], catalog: dict[str, list[str]]) -> dict[str, str]:
|
|
80
|
+
"""Map normalized alias → rel_path, for aliases that unambiguously name one note.
|
|
81
|
+
|
|
82
|
+
Obsidian's ``aliases:`` property declares additional real names for a note, so a link written
|
|
83
|
+
against one is not a broken link. Two rules keep this conservative, and both matter:
|
|
84
|
+
|
|
85
|
+
* **An alias that collides with any real note name is dropped entirely** — not merely
|
|
86
|
+
outranked. A note's own title must never be hijackable by someone else's alias, and an
|
|
87
|
+
already-ambiguous basename must not be rescued into resolving by a third note's alias.
|
|
88
|
+
* **An alias claimed by two or more notes resolves to nothing** — the same refusal graphmark
|
|
89
|
+
already applies to colliding basenames. Ambiguity stays ambiguous.
|
|
90
|
+
|
|
91
|
+
Alias keys go through ``_normalize``, the function that builds catalog keys, so an alias and a
|
|
92
|
+
note name can never drift apart on case or punctuation.
|
|
93
|
+
"""
|
|
94
|
+
claims: dict[str, set[str]] = {}
|
|
95
|
+
for doc in docs:
|
|
96
|
+
raw = doc.frontmatter.get("aliases")
|
|
97
|
+
# A scalar is one alias; a list is many; anything else (a number, a mapping, a missing
|
|
98
|
+
# key) yields none rather than raising — a note someone is mid-edit must not break a build.
|
|
99
|
+
if isinstance(raw, str):
|
|
100
|
+
values = [raw]
|
|
101
|
+
elif isinstance(raw, list):
|
|
102
|
+
values = [v for v in raw if isinstance(v, str)]
|
|
103
|
+
else:
|
|
104
|
+
continue
|
|
105
|
+
for alias in values:
|
|
106
|
+
# A path is not a name: "[[folder/note]]" is resolved by path suffix, never by alias.
|
|
107
|
+
if "/" in alias:
|
|
108
|
+
continue
|
|
109
|
+
key = _normalize(alias)
|
|
110
|
+
if not key or key in catalog:
|
|
111
|
+
continue
|
|
112
|
+
claims.setdefault(key, set()).add(doc.rel_path)
|
|
113
|
+
return {key: paths.pop() for key, paths in claims.items() if len(paths) == 1}
|
|
114
|
+
|
|
115
|
+
|
|
79
116
|
def _is_intra_note_reference(display: str) -> bool:
|
|
80
117
|
"""True for a link that targets no note, e.g. ``[[#Heading]]`` or ``[[#^block]]``.
|
|
81
118
|
|
|
@@ -269,6 +306,7 @@ def _diagnose(
|
|
|
269
306
|
catalog: dict[str, list[str]],
|
|
270
307
|
out_of_scope: dict[str, list[str]],
|
|
271
308
|
resolver: Resolver,
|
|
309
|
+
aliases: dict[str, str] | None = None,
|
|
272
310
|
) -> LinkDiagnosis:
|
|
273
311
|
"""Classify one display against already-built resolution state.
|
|
274
312
|
|
|
@@ -284,6 +322,22 @@ def _diagnose(
|
|
|
284
322
|
if target is not None:
|
|
285
323
|
return LinkDiagnosis(display=display, target=target, reason="resolved")
|
|
286
324
|
|
|
325
|
+
# The resolver runs first, so a real note named X always beats an alias X — otherwise renaming
|
|
326
|
+
# a note could silently hijack live links. An alias hit is a genuine resolution, not a
|
|
327
|
+
# consolation: it names the note as surely as the filename does.
|
|
328
|
+
#
|
|
329
|
+
# Given the rules build_aliases already enforces, this ordering is defense in depth rather
|
|
330
|
+
# than load-bearing: an alias key can never collide with a catalog key (dropped at index
|
|
331
|
+
# time), and a path-qualified display is excluded from alias lookup below, so no display can
|
|
332
|
+
# match both paths. Mutating the order leaves the suite green, which is expected — the
|
|
333
|
+
# ordering is insurance against a future relaxation of those rules, not the mechanism.
|
|
334
|
+
if aliases:
|
|
335
|
+
stripped = _strip_display(display)
|
|
336
|
+
if stripped and "/" not in stripped:
|
|
337
|
+
aliased = aliases.get(_normalize(stripped))
|
|
338
|
+
if aliased is not None:
|
|
339
|
+
return LinkDiagnosis(display=display, target=aliased, reason="resolved")
|
|
340
|
+
|
|
287
341
|
# The resolver declined. Whether it declined because nothing matched or because too much did
|
|
288
342
|
# is the distinction consumers need, and only the catalog can answer it.
|
|
289
343
|
collisions = candidates_for(display, catalog)
|
|
@@ -318,7 +372,7 @@ def diagnose(graph: VaultGraph, display: str, *, suggest: int = 0) -> LinkDiagno
|
|
|
318
372
|
"""
|
|
319
373
|
if suggest < 0:
|
|
320
374
|
raise ValueError(f"suggest must be >= 0, got {suggest}")
|
|
321
|
-
diagnosis = _diagnose(display, graph.catalog, graph.out_of_scope, graph.resolver)
|
|
375
|
+
diagnosis = _diagnose(display, graph.catalog, graph.out_of_scope, graph.resolver, graph.aliases)
|
|
322
376
|
if suggest and diagnosis.reason == "missing":
|
|
323
377
|
return replace(diagnosis, candidates=suggest_notes(display, graph.catalog, suggest))
|
|
324
378
|
return diagnosis
|
|
@@ -348,6 +402,7 @@ class VaultGraph:
|
|
|
348
402
|
catalog: dict[str, list[str]] | None = None,
|
|
349
403
|
out_of_scope: dict[str, list[str]] | None = None,
|
|
350
404
|
resolver: Resolver | None = None,
|
|
405
|
+
aliases: dict[str, str] | None = None,
|
|
351
406
|
) -> None:
|
|
352
407
|
self.nodes = nodes
|
|
353
408
|
self.out_links = out_links
|
|
@@ -355,6 +410,7 @@ class VaultGraph:
|
|
|
355
410
|
self.unresolved = unresolved if unresolved is not None else {}
|
|
356
411
|
self.catalog = catalog if catalog is not None else {}
|
|
357
412
|
self.out_of_scope = out_of_scope if out_of_scope is not None else {}
|
|
413
|
+
self.aliases = aliases if aliases is not None else {}
|
|
358
414
|
# Retained so diagnose() answers with the same resolver that built the graph; a
|
|
359
415
|
# pluggable Resolver that disagreed with the graph it describes would be worse than none.
|
|
360
416
|
self.resolver: Resolver = resolver if resolver is not None else NormalizeResolver()
|
|
@@ -398,6 +454,7 @@ class VaultGraph:
|
|
|
398
454
|
docs = [parse_document(p, root) for p in md_files]
|
|
399
455
|
nodes = {doc.rel_path: doc for doc in docs}
|
|
400
456
|
catalog = build_catalog(docs)
|
|
457
|
+
aliases = build_aliases(docs, catalog) if config.resolve_aliases else {}
|
|
401
458
|
|
|
402
459
|
out_links: dict[str, set[str]] = {rel: set() for rel in nodes}
|
|
403
460
|
back_links: dict[str, set[str]] = {rel: set() for rel in nodes}
|
|
@@ -411,7 +468,7 @@ class VaultGraph:
|
|
|
411
468
|
broken = {"ambiguous", "missing"}
|
|
412
469
|
for doc in docs:
|
|
413
470
|
for display in extractor.extract(doc.text):
|
|
414
|
-
d = _diagnose(display, catalog, out_of_scope, resolver)
|
|
471
|
+
d = _diagnose(display, catalog, out_of_scope, resolver, aliases)
|
|
415
472
|
if d.reason in broken:
|
|
416
473
|
unresolved.setdefault(doc.rel_path, []).append(display)
|
|
417
474
|
elif d.target is not None and d.target != doc.rel_path:
|
|
@@ -421,4 +478,6 @@ class VaultGraph:
|
|
|
421
478
|
for dst in targets:
|
|
422
479
|
back_links[dst].add(src)
|
|
423
480
|
|
|
424
|
-
return cls(
|
|
481
|
+
return cls(
|
|
482
|
+
nodes, out_links, back_links, unresolved, catalog, out_of_scope, resolver, aliases
|
|
483
|
+
)
|
|
@@ -45,10 +45,38 @@ def _strip_fenced_blocks(text: str) -> str:
|
|
|
45
45
|
return "".join(out)
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
#: A block-list item line: leading whitespace, a dash, then the value. Checked before the
|
|
49
|
+
#: key/value split because an item may itself contain a colon ("- Note: A Subtitle") — the dash
|
|
50
|
+
#: decides, not the colon.
|
|
51
|
+
_BLOCK_ITEM_RE = re.compile(r"^\s+-\s*(.*)$")
|
|
52
|
+
|
|
53
|
+
|
|
48
54
|
def _parse_frontmatter(raw: str) -> dict:
|
|
49
|
-
"""Minimal YAML-like frontmatter parser
|
|
55
|
+
"""Minimal YAML-like frontmatter parser: scalar, quoted-string, inline-list, block-list.
|
|
56
|
+
|
|
57
|
+
Deliberately a targeted scan rather than a YAML dependency — this runs over every note in a
|
|
58
|
+
vault, and a note someone is mid-edit must not take the graph down. Anything unparseable
|
|
59
|
+
yields nothing rather than raising.
|
|
60
|
+
|
|
61
|
+
Block lists (``key:`` followed by indented ``- item`` lines) are what Obsidian's own
|
|
62
|
+
Properties UI writes, so they are the common form in real vaults, not an edge case. They
|
|
63
|
+
produce the same ``list[str]`` an inline list does. A ``key:`` with no items that follow stays
|
|
64
|
+
``""`` — an empty value, not an empty list.
|
|
65
|
+
"""
|
|
50
66
|
result: dict = {}
|
|
67
|
+
current_list_key: str | None = None
|
|
51
68
|
for line in raw.splitlines():
|
|
69
|
+
item = _BLOCK_ITEM_RE.match(line)
|
|
70
|
+
if item is not None and current_list_key is not None:
|
|
71
|
+
value = item.group(1).strip().strip("\"'")
|
|
72
|
+
if value:
|
|
73
|
+
# The key held "" until its first item arrived; replace it with the list.
|
|
74
|
+
if not isinstance(result.get(current_list_key), list):
|
|
75
|
+
result[current_list_key] = []
|
|
76
|
+
result[current_list_key].append(value)
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
current_list_key = None
|
|
52
80
|
if ":" not in line:
|
|
53
81
|
continue
|
|
54
82
|
key, _, value = line.partition(":")
|
|
@@ -59,6 +87,9 @@ def _parse_frontmatter(raw: str) -> dict:
|
|
|
59
87
|
result[key] = [i for i in items if i]
|
|
60
88
|
else:
|
|
61
89
|
result[key] = value.strip("\"'")
|
|
90
|
+
# A bare "key:" may open a block list; the next line decides.
|
|
91
|
+
if not value:
|
|
92
|
+
current_list_key = key
|
|
62
93
|
return result
|
|
63
94
|
|
|
64
95
|
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Frontmatter `aliases:` resolution — a declared alias is a real name for a note.
|
|
2
|
+
|
|
3
|
+
Obsidian's `aliases:` property is a core feature, not a vault convention, and graphmark claims to
|
|
4
|
+
work on any Obsidian-family vault. Until now it never read frontmatter during resolution, so every
|
|
5
|
+
link written against an alias was reported broken. Measured on a real 521-note vault: stock
|
|
6
|
+
graphmark reported **23 broken links against an actual 0**, and dropped the corresponding edges —
|
|
7
|
+
a 100% false-positive rate in `max_unresolved_links`, the flagship `graphmark check` threshold.
|
|
8
|
+
|
|
9
|
+
The behavioral spec is the reference implementation this replaces (the consumer's `AliasResolver`).
|
|
10
|
+
Its six rules, each of which has a test below:
|
|
11
|
+
|
|
12
|
+
1. the base resolver runs FIRST — a real note named X always beats an alias X, or renaming a note
|
|
13
|
+
could silently hijack live links. NOTE: rules 3 and 4 make this ordering structurally
|
|
14
|
+
unreachable (no display can match both a catalog key and an alias key), so mutating the order
|
|
15
|
+
leaves this suite green. It is kept as insurance against a future relaxation of those rules,
|
|
16
|
+
and is deliberately not claimed as test-covered;
|
|
17
|
+
2. an alias claimed by two or more notes resolves to nothing — the same refusal graphmark already
|
|
18
|
+
applies to colliding basenames;
|
|
19
|
+
3. an alias colliding with any real note name is dropped ENTIRELY, not merely deprioritized;
|
|
20
|
+
4. a path-qualified display never matches an alias — `[[folder/note]]` is a path, not a name;
|
|
21
|
+
5. alias keys normalize through the same function as note names, so the two cannot drift;
|
|
22
|
+
6. fail-soft — an unreadable or unparseable note yields no aliases, never an exception.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
import pytest
|
|
30
|
+
|
|
31
|
+
from graphmark.config import VaultConfig
|
|
32
|
+
from graphmark.graph import NormalizeResolver, VaultGraph, diagnose
|
|
33
|
+
from graphmark.parse import WikilinkExtractor
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _write(root: Path, rel: str, aliases: list[str] | None = None, body: str = "") -> None:
|
|
37
|
+
path = root / rel
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
front = ""
|
|
40
|
+
if aliases is not None:
|
|
41
|
+
items = "".join(f" - {a}\n" for a in aliases)
|
|
42
|
+
front = f"---\naliases:\n{items}---\n\n"
|
|
43
|
+
path.write_text(front + body, encoding="utf-8")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build(root: Path, **config_kwargs) -> VaultGraph:
|
|
47
|
+
return VaultGraph.build(
|
|
48
|
+
VaultConfig(root=root, **config_kwargs), WikilinkExtractor(), NormalizeResolver()
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestAliasResolution:
|
|
53
|
+
def test_a_link_to_an_alias_resolves_to_the_note(self, tmp_path):
|
|
54
|
+
_write(tmp_path, "notes/2026-04-11-mood-tracker.md", aliases=["Mood Tracker"])
|
|
55
|
+
_write(tmp_path, "src.md", body="See [[Mood Tracker]].\n")
|
|
56
|
+
graph = _build(tmp_path)
|
|
57
|
+
assert graph.unresolved == {}
|
|
58
|
+
assert graph.out_links["src.md"] == {"notes/2026-04-11-mood-tracker.md"}
|
|
59
|
+
|
|
60
|
+
def test_an_alias_link_diagnoses_as_resolved(self, tmp_path):
|
|
61
|
+
_write(tmp_path, "notes/target.md", aliases=["Other Name"])
|
|
62
|
+
_write(tmp_path, "src.md", body="See [[Other Name]].\n")
|
|
63
|
+
d = diagnose(_build(tmp_path), "Other Name")
|
|
64
|
+
assert (d.reason, d.target) == ("resolved", "notes/target.md")
|
|
65
|
+
|
|
66
|
+
def test_several_aliases_on_one_note_all_resolve(self, tmp_path):
|
|
67
|
+
_write(tmp_path, "n.md", aliases=["First", "Second", "Third"])
|
|
68
|
+
_write(tmp_path, "src.md", body="[[First]] [[Second]] [[Third]]\n")
|
|
69
|
+
graph = _build(tmp_path)
|
|
70
|
+
assert graph.unresolved == {}
|
|
71
|
+
assert graph.out_links["src.md"] == {"n.md"}
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize(
|
|
74
|
+
"display", ["Mood Tracker", "mood tracker", "MOOD TRACKER", "Mood-Tracker"]
|
|
75
|
+
)
|
|
76
|
+
def test_alias_keys_normalize_like_note_names(self, tmp_path, display):
|
|
77
|
+
# Rule 5: same normalization as note names — case, punctuation and whitespace all fold.
|
|
78
|
+
_write(tmp_path, "n.md", aliases=["Mood Tracker"])
|
|
79
|
+
_write(tmp_path, "src.md", body=f"See [[{display}]].\n")
|
|
80
|
+
assert _build(tmp_path).unresolved == {}
|
|
81
|
+
|
|
82
|
+
@pytest.mark.parametrize("form", ["Alias Name|shown", "Alias Name#Section", "Alias Name.md"])
|
|
83
|
+
def test_alias_display_forms_resolve(self, tmp_path, form):
|
|
84
|
+
_write(tmp_path, "n.md", aliases=["Alias Name"])
|
|
85
|
+
_write(tmp_path, "src.md", body=f"See [[{form}]].\n")
|
|
86
|
+
assert _build(tmp_path).unresolved == {}
|
|
87
|
+
|
|
88
|
+
def test_inline_alias_lists_work_too(self, tmp_path):
|
|
89
|
+
(tmp_path / "n.md").write_text("---\naliases: [One, Two]\n---\n", encoding="utf-8")
|
|
90
|
+
(tmp_path / "src.md").write_text("See [[Two]].\n", encoding="utf-8")
|
|
91
|
+
assert _build(tmp_path).unresolved == {}
|
|
92
|
+
|
|
93
|
+
def test_a_scalar_alias_works(self, tmp_path):
|
|
94
|
+
(tmp_path / "n.md").write_text("---\naliases: Solo Name\n---\n", encoding="utf-8")
|
|
95
|
+
(tmp_path / "src.md").write_text("See [[Solo Name]].\n", encoding="utf-8")
|
|
96
|
+
assert _build(tmp_path).unresolved == {}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TestRealNamesWin:
|
|
100
|
+
"""Rule 1 + rule 3 — the anti-hijacking guarantees."""
|
|
101
|
+
|
|
102
|
+
def test_a_real_note_beats_an_alias_of_the_same_name(self, tmp_path):
|
|
103
|
+
_write(tmp_path, "real/Target.md")
|
|
104
|
+
_write(tmp_path, "other/impostor.md", aliases=["Target"])
|
|
105
|
+
_write(tmp_path, "src.md", body="See [[Target]].\n")
|
|
106
|
+
graph = _build(tmp_path)
|
|
107
|
+
assert graph.out_links["src.md"] == {"real/Target.md"}
|
|
108
|
+
|
|
109
|
+
def test_an_alias_colliding_with_a_real_name_is_dropped_entirely(self, tmp_path):
|
|
110
|
+
# Rule 3: not merely deprioritized. Two notes named "note" make the bare link ambiguous;
|
|
111
|
+
# a third note claiming "note" as an alias must not rescue it into resolving.
|
|
112
|
+
_write(tmp_path, "one/note.md")
|
|
113
|
+
_write(tmp_path, "two/note.md")
|
|
114
|
+
_write(tmp_path, "three/other.md", aliases=["note"])
|
|
115
|
+
_write(tmp_path, "src.md", body="See [[note]].\n")
|
|
116
|
+
graph = _build(tmp_path)
|
|
117
|
+
assert graph.unresolved == {"src.md": ["note"]}
|
|
118
|
+
assert graph.out_links["src.md"] == set()
|
|
119
|
+
|
|
120
|
+
def test_a_notes_own_alias_does_not_shadow_another_notes_title(self, tmp_path):
|
|
121
|
+
_write(tmp_path, "a/Real Title.md")
|
|
122
|
+
_write(tmp_path, "b/other.md", aliases=["Real Title"])
|
|
123
|
+
_write(tmp_path, "src.md", body="See [[Real Title]].\n")
|
|
124
|
+
assert _build(tmp_path).out_links["src.md"] == {"a/Real Title.md"}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TestAmbiguousAliases:
|
|
128
|
+
"""Rule 2 — ambiguity stays ambiguous."""
|
|
129
|
+
|
|
130
|
+
def test_an_alias_claimed_by_two_notes_resolves_to_nothing(self, tmp_path):
|
|
131
|
+
_write(tmp_path, "a.md", aliases=["Shared"])
|
|
132
|
+
_write(tmp_path, "b.md", aliases=["Shared"])
|
|
133
|
+
_write(tmp_path, "src.md", body="See [[Shared]].\n")
|
|
134
|
+
graph = _build(tmp_path)
|
|
135
|
+
assert graph.unresolved == {"src.md": ["Shared"]}
|
|
136
|
+
assert graph.out_links["src.md"] == set()
|
|
137
|
+
|
|
138
|
+
def test_an_alias_claimed_twice_by_the_same_note_still_resolves(self, tmp_path):
|
|
139
|
+
# Duplicate within one note is not a conflict — one claimant, one target.
|
|
140
|
+
_write(tmp_path, "a.md", aliases=["Shared", "Shared"])
|
|
141
|
+
_write(tmp_path, "src.md", body="See [[Shared]].\n")
|
|
142
|
+
assert _build(tmp_path).out_links["src.md"] == {"a.md"}
|
|
143
|
+
|
|
144
|
+
def test_a_contested_alias_does_not_poison_an_uncontested_one(self, tmp_path):
|
|
145
|
+
_write(tmp_path, "a.md", aliases=["Shared", "Unique A"])
|
|
146
|
+
_write(tmp_path, "b.md", aliases=["Shared"])
|
|
147
|
+
_write(tmp_path, "src.md", body="[[Shared]] and [[Unique A]]\n")
|
|
148
|
+
graph = _build(tmp_path)
|
|
149
|
+
assert graph.unresolved == {"src.md": ["Shared"]}
|
|
150
|
+
assert graph.out_links["src.md"] == {"a.md"}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TestPathQualifiedLinks:
|
|
154
|
+
"""Rule 4 — a path is not a name."""
|
|
155
|
+
|
|
156
|
+
def test_a_path_qualified_display_never_matches_an_alias(self, tmp_path):
|
|
157
|
+
_write(tmp_path, "n.md", aliases=["folder/thing"])
|
|
158
|
+
_write(tmp_path, "src.md", body="See [[folder/thing]].\n")
|
|
159
|
+
assert _build(tmp_path).unresolved == {"src.md": ["folder/thing"]}
|
|
160
|
+
|
|
161
|
+
def test_a_slash_bearing_alias_is_rejected_at_index_time(self, tmp_path):
|
|
162
|
+
# The lookup-side guard (skip alias lookup for path-qualified displays) is not enough on
|
|
163
|
+
# its own: normalization turns "/" into a space, so an alias "folder/thing" would key as
|
|
164
|
+
# "folder thing" and be reachable from a slash-free display. The reference implementation
|
|
165
|
+
# refuses such an alias when building the map, and so must this.
|
|
166
|
+
_write(tmp_path, "n.md", aliases=["folder/thing"])
|
|
167
|
+
_write(tmp_path, "src.md", body="See [[folder thing]].\n")
|
|
168
|
+
graph = _build(tmp_path)
|
|
169
|
+
assert graph.aliases == {}
|
|
170
|
+
assert graph.unresolved == {"src.md": ["folder thing"]}
|
|
171
|
+
|
|
172
|
+
def test_path_resolution_still_works_alongside_aliases(self, tmp_path):
|
|
173
|
+
_write(tmp_path, "docs/deep/note.md", aliases=["Nickname"])
|
|
174
|
+
_write(tmp_path, "src.md", body="[[deep/note]] and [[Nickname]]\n")
|
|
175
|
+
graph = _build(tmp_path)
|
|
176
|
+
assert graph.unresolved == {}
|
|
177
|
+
assert graph.out_links["src.md"] == {"docs/deep/note.md"}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TestScopeAndFailSoft:
|
|
181
|
+
def test_an_out_of_scope_note_contributes_no_aliases(self, tmp_path):
|
|
182
|
+
# Out-of-scope notes are not part of the graph, so their names — including alias names —
|
|
183
|
+
# cannot become link targets.
|
|
184
|
+
_write(tmp_path, "docs/a.md")
|
|
185
|
+
_write(tmp_path, "templates/t.md", aliases=["Template Nickname"])
|
|
186
|
+
_write(tmp_path, "docs/src.md", body="See [[Template Nickname]].\n")
|
|
187
|
+
graph = _build(tmp_path, scoped_folders=["docs"])
|
|
188
|
+
assert graph.unresolved == {"docs/src.md": ["Template Nickname"]}
|
|
189
|
+
|
|
190
|
+
def test_a_note_without_frontmatter_is_fine(self, tmp_path):
|
|
191
|
+
_write(tmp_path, "n.md")
|
|
192
|
+
_write(tmp_path, "src.md", body="See [[Nowhere]].\n")
|
|
193
|
+
assert _build(tmp_path).unresolved == {"src.md": ["Nowhere"]}
|
|
194
|
+
|
|
195
|
+
def test_an_empty_alias_list_is_fine(self, tmp_path):
|
|
196
|
+
(tmp_path / "n.md").write_text("---\naliases:\n---\n", encoding="utf-8")
|
|
197
|
+
(tmp_path / "src.md").write_text("See [[Nowhere]].\n", encoding="utf-8")
|
|
198
|
+
assert _build(tmp_path).unresolved == {"src.md": ["Nowhere"]}
|
|
199
|
+
|
|
200
|
+
def test_an_empty_alias_entry_is_ignored(self, tmp_path):
|
|
201
|
+
(tmp_path / "n.md").write_text('---\naliases:\n - ""\n - Real\n---\n', encoding="utf-8")
|
|
202
|
+
(tmp_path / "src.md").write_text("See [[Real]].\n", encoding="utf-8")
|
|
203
|
+
assert _build(tmp_path).unresolved == {}
|
|
204
|
+
|
|
205
|
+
def test_a_non_list_aliases_value_does_not_raise(self, tmp_path):
|
|
206
|
+
# Rule 6: anything unparseable yields no aliases rather than taking the graph down.
|
|
207
|
+
(tmp_path / "n.md").write_text("---\naliases: 42\n---\n", encoding="utf-8")
|
|
208
|
+
(tmp_path / "src.md").write_text("See [[Nowhere]].\n", encoding="utf-8")
|
|
209
|
+
assert _build(tmp_path).unresolved == {"src.md": ["Nowhere"]}
|
|
210
|
+
|
|
211
|
+
def test_a_self_alias_link_is_not_an_edge(self, tmp_path):
|
|
212
|
+
_write(tmp_path, "solo.md", aliases=["Me"], body="I link to [[Me]].\n")
|
|
213
|
+
graph = _build(tmp_path)
|
|
214
|
+
assert graph.unresolved == {}
|
|
215
|
+
assert graph.out_links["solo.md"] == set()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class TestAliasesAreInspectable:
|
|
219
|
+
def test_the_graph_exposes_the_resolved_alias_map(self, tmp_path):
|
|
220
|
+
_write(tmp_path, "n.md", aliases=["Nickname"])
|
|
221
|
+
graph = _build(tmp_path)
|
|
222
|
+
assert graph.aliases == {"nickname": "n.md"}
|
|
223
|
+
|
|
224
|
+
def test_contested_and_colliding_aliases_are_absent_from_the_map(self, tmp_path):
|
|
225
|
+
_write(tmp_path, "real.md")
|
|
226
|
+
_write(tmp_path, "a.md", aliases=["Shared", "real"])
|
|
227
|
+
_write(tmp_path, "b.md", aliases=["Shared"])
|
|
228
|
+
assert _build(tmp_path).aliases == {}
|
|
229
|
+
|
|
230
|
+
def test_directly_constructed_graph_defaults_to_no_aliases(self):
|
|
231
|
+
assert VaultGraph(nodes={}, out_links={}, back_links={}).aliases == {}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class TestConfigKnob:
|
|
235
|
+
def test_aliases_can_be_disabled(self, tmp_path):
|
|
236
|
+
_write(tmp_path, "n.md", aliases=["Nickname"])
|
|
237
|
+
_write(tmp_path, "src.md", body="See [[Nickname]].\n")
|
|
238
|
+
graph = _build(tmp_path, resolve_aliases=False)
|
|
239
|
+
assert graph.unresolved == {"src.md": ["Nickname"]}
|
|
240
|
+
assert graph.aliases == {}
|
|
241
|
+
|
|
242
|
+
def test_the_default_is_on(self, tmp_path):
|
|
243
|
+
cfg = VaultConfig(root=tmp_path)
|
|
244
|
+
assert cfg.resolve_aliases is True
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class TestFixtureParity:
|
|
248
|
+
def test_no_fixture_note_declares_aliases(self):
|
|
249
|
+
# The parity argument: this change alters what resolves, so it is the most parity-sensitive
|
|
250
|
+
# change since extraction. It is safe only because no frozen fixture uses aliases. Asserted
|
|
251
|
+
# rather than claimed, so it cannot go stale.
|
|
252
|
+
fixtures = Path(__file__).parent / "fixtures"
|
|
253
|
+
offenders = [
|
|
254
|
+
str(n) for n in fixtures.rglob("*.md") if "aliases:" in n.read_text(encoding="utf-8")
|
|
255
|
+
]
|
|
256
|
+
assert offenders == []
|
|
@@ -164,3 +164,94 @@ class TestFrontmatterLineEndings:
|
|
|
164
164
|
doc = self._parse(tmp_path, "fm_only_crlf.md", b"---\r\ntitle: Note\r\n---")
|
|
165
165
|
assert doc.frontmatter == {"title": "Note"}
|
|
166
166
|
assert doc.text == ""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TestBlockStyleLists:
|
|
170
|
+
"""`key:` followed by ` - item` lines — what Obsidian's Properties UI actually writes.
|
|
171
|
+
|
|
172
|
+
The parser handled scalars, quoted strings and inline lists, but a block list's item lines
|
|
173
|
+
contain no `:` so the loop skipped them, and the bare `key:` line stored `''`. Every
|
|
174
|
+
block-style property in a real vault — `aliases:`, `tags:` — silently became an empty string
|
|
175
|
+
rather than a list. Not an error, just quietly wrong data behind a public attribute.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def _fm(self, raw: str) -> dict:
|
|
179
|
+
from graphmark.parse import _parse_frontmatter
|
|
180
|
+
|
|
181
|
+
return _parse_frontmatter(raw)
|
|
182
|
+
|
|
183
|
+
def test_block_list_parses_to_a_list(self):
|
|
184
|
+
assert self._fm("aliases:\n - One\n - Two") == {"aliases": ["One", "Two"]}
|
|
185
|
+
|
|
186
|
+
def test_a_single_item_block_is_still_a_list(self):
|
|
187
|
+
assert self._fm("tags:\n - project") == {"tags": ["project"]}
|
|
188
|
+
|
|
189
|
+
def test_inline_lists_are_unchanged(self):
|
|
190
|
+
assert self._fm("aliases: [One, Two]") == {"aliases": ["One", "Two"]}
|
|
191
|
+
|
|
192
|
+
def test_scalars_are_unchanged(self):
|
|
193
|
+
assert self._fm("title: Solo") == {"title": "Solo"}
|
|
194
|
+
|
|
195
|
+
def test_quoted_scalars_are_unchanged(self):
|
|
196
|
+
assert self._fm('description: "a thing"') == {"description": "a thing"}
|
|
197
|
+
|
|
198
|
+
def test_an_empty_key_stays_an_empty_string(self):
|
|
199
|
+
# A key with no items is an empty VALUE, not an empty list — unchanged behavior.
|
|
200
|
+
assert self._fm("aliases:\ndate: 2026-07-25") == {"aliases": "", "date": "2026-07-25"}
|
|
201
|
+
|
|
202
|
+
def test_the_block_ends_at_the_next_key(self):
|
|
203
|
+
parsed = self._fm("aliases:\n - One\n - Two\ndate: 2026-07-25\ntitle: T")
|
|
204
|
+
assert parsed == {"aliases": ["One", "Two"], "date": "2026-07-25", "title": "T"}
|
|
205
|
+
|
|
206
|
+
def test_a_stray_item_after_another_key_is_not_absorbed(self):
|
|
207
|
+
# The block must CLOSE at the next key, not merely pause. Without that, a malformed or
|
|
208
|
+
# mid-edit note silently grows the earlier list — the fail-soft promise says drop the
|
|
209
|
+
# junk, not misattribute it.
|
|
210
|
+
parsed = self._fm("aliases:\n - One\ndate: 2026-07-25\n - Stray")
|
|
211
|
+
assert parsed == {"aliases": ["One"], "date": "2026-07-25"}
|
|
212
|
+
|
|
213
|
+
def test_two_block_lists_in_one_document(self):
|
|
214
|
+
parsed = self._fm("aliases:\n - A\ntags:\n - x\n - y")
|
|
215
|
+
assert parsed == {"aliases": ["A"], "tags": ["x", "y"]}
|
|
216
|
+
|
|
217
|
+
def test_quoted_block_items_are_unquoted(self):
|
|
218
|
+
assert self._fm("aliases:\n - \"Mood Tracker\"\n - 'mood-tracker'") == {
|
|
219
|
+
"aliases": ["Mood Tracker", "mood-tracker"]
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
def test_empty_items_are_dropped(self):
|
|
223
|
+
assert self._fm("aliases:\n - One\n -\n - Two") == {"aliases": ["One", "Two"]}
|
|
224
|
+
|
|
225
|
+
def test_items_containing_a_colon_are_not_read_as_keys(self):
|
|
226
|
+
# "- Note: A Subtitle" is an item, not a nested key — the dash decides.
|
|
227
|
+
assert self._fm("aliases:\n - Note: A Subtitle") == {"aliases": ["Note: A Subtitle"]}
|
|
228
|
+
|
|
229
|
+
def test_a_block_list_survives_a_real_note(self, tmp_path):
|
|
230
|
+
note = tmp_path / "n.md"
|
|
231
|
+
note.write_text(
|
|
232
|
+
"---\naliases:\n - Mood Tracker\ntags:\n - health\n---\n\nBody [[x]].\n",
|
|
233
|
+
encoding="utf-8",
|
|
234
|
+
)
|
|
235
|
+
doc = parse_document(note, tmp_path)
|
|
236
|
+
assert doc.frontmatter["aliases"] == ["Mood Tracker"]
|
|
237
|
+
assert doc.frontmatter["tags"] == ["health"]
|
|
238
|
+
assert "Body" in doc.text
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class TestFixtureFrontmatterUnchanged:
|
|
242
|
+
def test_no_fixture_note_uses_a_block_list(self):
|
|
243
|
+
# The parity argument for this change: fixture notes use inline/scalar frontmatter only,
|
|
244
|
+
# so no expected.json can move. If this ever fails, re-check the oracles before shipping.
|
|
245
|
+
import re
|
|
246
|
+
|
|
247
|
+
block_key = re.compile(r"^[A-Za-z_][\w-]*:\s*$", re.MULTILINE)
|
|
248
|
+
fixtures = Path(__file__).parent / "fixtures"
|
|
249
|
+
offenders = []
|
|
250
|
+
for note in fixtures.rglob("*.md"):
|
|
251
|
+
raw = note.read_text(encoding="utf-8")
|
|
252
|
+
if not raw.startswith("---"):
|
|
253
|
+
continue
|
|
254
|
+
end = raw.find("\n---", 3)
|
|
255
|
+
if block_key.search(raw[3:end] if end != -1 else raw[3:]):
|
|
256
|
+
offenders.append(str(note))
|
|
257
|
+
assert offenders == []
|
|
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.5.0 → graphmark-0.6.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
|