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.
Files changed (94) hide show
  1. {graphmark-0.5.0 → graphmark-0.6.0}/CHANGELOG.md +84 -0
  2. {graphmark-0.5.0 → graphmark-0.6.0}/PKG-INFO +32 -1
  3. {graphmark-0.5.0 → graphmark-0.6.0}/README.md +31 -0
  4. {graphmark-0.5.0 → graphmark-0.6.0}/pyproject.toml +1 -1
  5. {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/__init__.py +2 -0
  6. {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/config.py +4 -0
  7. {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/graph.py +62 -3
  8. {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/parse.py +32 -1
  9. graphmark-0.6.0/tests/test_aliases.py +256 -0
  10. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_parse.py +91 -0
  11. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_public_api.py +1 -0
  12. {graphmark-0.5.0 → graphmark-0.6.0}/.gitignore +0 -0
  13. {graphmark-0.5.0 → graphmark-0.6.0}/LICENSE +0 -0
  14. {graphmark-0.5.0 → graphmark-0.6.0}/docs/ROADMAP.md +0 -0
  15. {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/check.py +0 -0
  16. {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/cli.py +0 -0
  17. {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/dismiss.py +0 -0
  18. {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/export.py +0 -0
  19. {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/interfaces.py +0 -0
  20. {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/metrics.py +0 -0
  21. {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/model.py +0 -0
  22. {graphmark-0.5.0 → graphmark-0.6.0}/src/graphmark/py.typed +0 -0
  23. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/README.md +0 -0
  24. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/config.toml +0 -0
  25. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/expected.json +0 -0
  26. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
  27. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
  28. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/docs/center.md +0 -0
  29. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/misc/echo.md +0 -0
  30. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
  31. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
  32. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/misc/stub.md +0 -0
  33. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/refs/beta.md +0 -0
  34. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/refs/delta.md +0 -0
  35. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
  36. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/dismiss/expected.json +0 -0
  37. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
  38. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/dismiss/vault/alpha.md +0 -0
  39. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/dismiss/vault/beta.md +0 -0
  40. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/dismiss/vault/gamma.md +0 -0
  41. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/config.toml +0 -0
  42. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/expected.json +0 -0
  43. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/expected_oog.json +0 -0
  44. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/similar.json +0 -0
  45. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/similar_oog.json +0 -0
  46. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/docs/a.md +0 -0
  47. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
  48. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/misc/c.md +0 -0
  49. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/misc/d.md +0 -0
  50. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/refs/b.md +0 -0
  51. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/refs/e.md +0 -0
  52. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/scoped/config.toml +0 -0
  53. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/scoped/expected.json +0 -0
  54. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/scoped/vault/docs/one.md +0 -0
  55. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
  56. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
  57. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/scoped/vault/refs/two.md +0 -0
  58. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/selflink/config.toml +0 -0
  59. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/selflink/expected.json +0 -0
  60. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/selflink/vault/a.md +0 -0
  61. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/selflink/vault/b.md +0 -0
  62. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/simple/config.toml +0 -0
  63. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/simple/expected.json +0 -0
  64. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
  65. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/brain/hub.md +0 -0
  66. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/personal/beta.md +0 -0
  67. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
  68. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/reference/island.md +0 -0
  69. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/reference/stub.md +0 -0
  70. {graphmark-0.5.0 → graphmark-0.6.0}/tests/fixtures/suggest/README.md +0 -0
  71. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_build_filters.py +0 -0
  72. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_check.py +0 -0
  73. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_check_policy.py +0 -0
  74. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_cli.py +0 -0
  75. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_config.py +0 -0
  76. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_degenerate.py +0 -0
  77. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_diagnose.py +0 -0
  78. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_dismiss.py +0 -0
  79. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_export.py +0 -0
  80. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_gaps.py +0 -0
  81. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_gaps_banding.py +0 -0
  82. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_gaps_oog.py +0 -0
  83. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_gaps_params.py +0 -0
  84. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_graph.py +0 -0
  85. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_graph_catalogs.py +0 -0
  86. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_metrics.py +0 -0
  87. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_model.py +0 -0
  88. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_packaging.py +0 -0
  89. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_pagerank.py +0 -0
  90. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_scoped.py +0 -0
  91. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_selflink.py +0 -0
  92. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_smoke.py +0 -0
  93. {graphmark-0.5.0 → graphmark-0.6.0}/tests/test_suggestions.py +0 -0
  94. {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.5.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphmark"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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(nodes, out_links, back_links, unresolved, catalog, out_of_scope, resolver)
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 covering scalar, quoted-string, inline-list."""
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 == []
@@ -117,6 +117,7 @@ class TestPublicSurface:
117
117
  "diagnose",
118
118
  "LinkDiagnosis",
119
119
  "DIAGNOSIS_REASONS",
120
+ "build_aliases",
120
121
  "candidates_for",
121
122
  "suggest_notes",
122
123
  "SUGGEST_MAX_MATCHES",
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