graphmark 0.4.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.4.0 → graphmark-0.6.0}/CHANGELOG.md +129 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/PKG-INFO +62 -1
- {graphmark-0.4.0 → graphmark-0.6.0}/README.md +61 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/docs/ROADMAP.md +1 -1
- {graphmark-0.4.0 → graphmark-0.6.0}/pyproject.toml +1 -1
- {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/__init__.py +8 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/config.py +4 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/graph.py +161 -7
- {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/parse.py +32 -1
- graphmark-0.6.0/tests/fixtures/suggest/README.md +62 -0
- graphmark-0.6.0/tests/test_aliases.py +256 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_diagnose.py +4 -4
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_graph_catalogs.py +2 -2
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_parse.py +91 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_public_api.py +4 -0
- graphmark-0.6.0/tests/test_suggestions.py +279 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/.gitignore +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/LICENSE +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/check.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/cli.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/dismiss.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/export.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/interfaces.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/metrics.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/model.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/py.typed +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/README.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/config.toml +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/expected.json +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/docs/center.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/misc/echo.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/misc/stub.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/refs/beta.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/refs/delta.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/dismiss/expected.json +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/dismiss/vault/alpha.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/dismiss/vault/beta.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/dismiss/vault/gamma.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/config.toml +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/expected.json +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/expected_oog.json +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/similar.json +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/similar_oog.json +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/docs/a.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/misc/c.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/misc/d.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/refs/b.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/refs/e.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/scoped/config.toml +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/scoped/expected.json +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/scoped/vault/docs/one.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/scoped/vault/refs/two.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/selflink/config.toml +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/selflink/expected.json +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/selflink/vault/a.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/selflink/vault/b.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/simple/config.toml +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/simple/expected.json +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/brain/hub.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/personal/beta.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/reference/island.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/reference/stub.md +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_build_filters.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_check.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_check_policy.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_cli.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_config.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_degenerate.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_dismiss.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_export.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_gaps.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_gaps_banding.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_gaps_oog.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_gaps_params.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_graph.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_metrics.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_model.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_packaging.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_pagerank.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_scoped.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_selflink.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_smoke.py +0 -0
- {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_unresolved.py +0 -0
|
@@ -1,6 +1,135 @@
|
|
|
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
|
+
|
|
88
|
+
## v0.5.0 (2026-07-25)
|
|
89
|
+
|
|
90
|
+
### Features
|
|
91
|
+
|
|
92
|
+
- **graph**: Add calibrated near-miss suggestions (#112)
|
|
93
|
+
([#116](https://github.com/cdcoonce/graphmark/pull/116),
|
|
94
|
+
[`db873b1`](https://github.com/cdcoonce/graphmark/commit/db873b1009bbef599e9eef035720306b62a548b8))
|
|
95
|
+
|
|
96
|
+
Track E slice 3, and the last thing keeping a consumer's parallel resolver alive. diagnose(graph,
|
|
97
|
+
display, suggest=k) fills candidates with near-miss notes for a `missing` verdict only — every
|
|
98
|
+
other reason already carries the rel_paths in play — so the default of 0 leaves the check gate's
|
|
99
|
+
hot path untouched.
|
|
100
|
+
|
|
101
|
+
The rule was calibrated, not invented. The prior art (bidirectional substring containment over
|
|
102
|
+
stems) was frozen over a real 521-note vault's broken links, giving 53 distinct displays, each
|
|
103
|
+
annotated by a human as useful / useless / missing / correct-none. Only then was an algorithm
|
|
104
|
+
chosen, and it is justified against that set: 27/27 useful kept — the non-negotiable — 7 of 8
|
|
105
|
+
useless dropped, 4 of 5 missing found, zero new false suggestions. Method and measured result in
|
|
106
|
+
tests/fixtures/suggest/README.md; the vault is private, so every shape is reproduced as a named
|
|
107
|
+
test with invented names rather than committing the rows.
|
|
108
|
+
|
|
109
|
+
Matching is directional, which is what separates a suggestion from a wrong answer. A display inside
|
|
110
|
+
a candidate is an abbreviation and always offered ([[Jordan]] -> Jordan Ellis). A candidate inside
|
|
111
|
+
a display is offered only above SUGGEST_MIN_COVERAGE: dropping a "-reference" suffix is the
|
|
112
|
+
answer, matching one word out of five is a real note that is NOT the target, and that shape is
|
|
113
|
+
what made the old hints untrustworthy. Partial overlap in neither direction is rejected — it
|
|
114
|
+
carried no useful hint anywhere in the baseline, and rejecting it is what holds the
|
|
115
|
+
false-suggestion rate at zero.
|
|
116
|
+
|
|
117
|
+
Two annotations were corrected mid-calibration and both changed the design: [[Work Tasks]] and
|
|
118
|
+
[[Personal Index]] looked useless only because the old rule printed bare stems, rendering four
|
|
119
|
+
distinct Index.md files as "Index, Index, Index, Index". Both are correct answers. Suggestions
|
|
120
|
+
therefore return rel_paths, and `index` is deliberately NOT a generic stem.
|
|
121
|
+
|
|
122
|
+
Also scrubs real colleague names out of the package. They had been used as doc/test examples and one
|
|
123
|
+
shipped in a docstring in 0.4.0; all replaced with invented placeholders. Published history still
|
|
124
|
+
carries them.
|
|
125
|
+
|
|
126
|
+
Both constants are pinned by the baseline: the cap is the lowest value that keeps every useful
|
|
127
|
+
suggestion, the floor the highest. Teeth-checked by mutation — disabling suppression fails 1,
|
|
128
|
+
dropping the coverage floor fails 3, dropping folder-keying fails 2, suggesting on every reason
|
|
129
|
+
fails 3, and dropping the digit filter initially survived until a ranking test was added to cover
|
|
130
|
+
it.
|
|
131
|
+
|
|
132
|
+
|
|
4
133
|
## v0.4.0 (2026-07-25)
|
|
5
134
|
|
|
6
135
|
### 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
|
|
@@ -223,10 +224,70 @@ Alias, anchor and `.md` forms all diagnose identically to the bare form.
|
|
|
223
224
|
contradict the graph it describes: `unresolved` is exactly the displays diagnosing as `ambiguous` or
|
|
224
225
|
`missing`, and the edges are exactly the `resolved` non-self targets.
|
|
225
226
|
|
|
227
|
+
### Near-miss suggestions
|
|
228
|
+
|
|
229
|
+
Pass `suggest=k` to turn a `missing` verdict into an actionable one. Suggestions are computed only
|
|
230
|
+
for `missing` — every other reason already carries the rel_paths in play — so the default of `0`
|
|
231
|
+
costs nothing on the `check` hot path.
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
graphmark.diagnose(graph, "Jordan", suggest=5).candidates
|
|
235
|
+
# ('people/Jordan Ellis.md',)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Matching is **directional**, which is what separates a suggestion from a wrong answer:
|
|
239
|
+
|
|
240
|
+
- a display inside a candidate name is an abbreviation — `[[Jordan]]` → `Jordan Ellis`, or
|
|
241
|
+
`[[Mood Tracker]]` → `2026-04-11-mood-tracker` (pure-digit tokens are filing metadata, not
|
|
242
|
+
content);
|
|
243
|
+
- a candidate inside a display is offered only when it covers at least `SUGGEST_MIN_COVERAGE` of it
|
|
244
|
+
— `[[fable-prompt-technique-reference]]` → `fable-prompt-technique` is the answer, while
|
|
245
|
+
`[[Dagster PJM InSchedules]]` → `Dagster` is a real note that is _not_ the target;
|
|
246
|
+
- partial overlap in neither direction is rejected;
|
|
247
|
+
- a display matching more than `SUGGEST_MAX_MATCHES` notes names a topic, not a typo, and gets
|
|
248
|
+
nothing at all.
|
|
249
|
+
|
|
250
|
+
Notes whose stem is generic (`SKILL.md`, `README.md`) are matched on their **parent folder**, where
|
|
251
|
+
their name actually lives.
|
|
252
|
+
|
|
253
|
+
Both constants were calibrated against a human-annotated baseline of a real vault's broken links
|
|
254
|
+
rather than chosen by taste — see [`tests/fixtures/suggest/README.md`](tests/fixtures/suggest/README.md)
|
|
255
|
+
for the method and the measured result.
|
|
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
|
+
|
|
226
286
|
## Configuration
|
|
227
287
|
|
|
228
288
|
```toml
|
|
229
289
|
root = "." # required unless --root is passed
|
|
290
|
+
resolve_aliases = true # resolve frontmatter aliases: (default true)
|
|
230
291
|
scoped_folders = ["brain", "work"] # limit the vault to these top-level folders
|
|
231
292
|
excluded_dirs = [".git", "templates"] # skip notes under these directories
|
|
232
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
|
|
@@ -196,10 +197,70 @@ Alias, anchor and `.md` forms all diagnose identically to the bare form.
|
|
|
196
197
|
contradict the graph it describes: `unresolved` is exactly the displays diagnosing as `ambiguous` or
|
|
197
198
|
`missing`, and the edges are exactly the `resolved` non-self targets.
|
|
198
199
|
|
|
200
|
+
### Near-miss suggestions
|
|
201
|
+
|
|
202
|
+
Pass `suggest=k` to turn a `missing` verdict into an actionable one. Suggestions are computed only
|
|
203
|
+
for `missing` — every other reason already carries the rel_paths in play — so the default of `0`
|
|
204
|
+
costs nothing on the `check` hot path.
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
graphmark.diagnose(graph, "Jordan", suggest=5).candidates
|
|
208
|
+
# ('people/Jordan Ellis.md',)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Matching is **directional**, which is what separates a suggestion from a wrong answer:
|
|
212
|
+
|
|
213
|
+
- a display inside a candidate name is an abbreviation — `[[Jordan]]` → `Jordan Ellis`, or
|
|
214
|
+
`[[Mood Tracker]]` → `2026-04-11-mood-tracker` (pure-digit tokens are filing metadata, not
|
|
215
|
+
content);
|
|
216
|
+
- a candidate inside a display is offered only when it covers at least `SUGGEST_MIN_COVERAGE` of it
|
|
217
|
+
— `[[fable-prompt-technique-reference]]` → `fable-prompt-technique` is the answer, while
|
|
218
|
+
`[[Dagster PJM InSchedules]]` → `Dagster` is a real note that is _not_ the target;
|
|
219
|
+
- partial overlap in neither direction is rejected;
|
|
220
|
+
- a display matching more than `SUGGEST_MAX_MATCHES` notes names a topic, not a typo, and gets
|
|
221
|
+
nothing at all.
|
|
222
|
+
|
|
223
|
+
Notes whose stem is generic (`SKILL.md`, `README.md`) are matched on their **parent folder**, where
|
|
224
|
+
their name actually lives.
|
|
225
|
+
|
|
226
|
+
Both constants were calibrated against a human-annotated baseline of a real vault's broken links
|
|
227
|
+
rather than chosen by taste — see [`tests/fixtures/suggest/README.md`](tests/fixtures/suggest/README.md)
|
|
228
|
+
for the method and the measured result.
|
|
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
|
+
|
|
199
259
|
## Configuration
|
|
200
260
|
|
|
201
261
|
```toml
|
|
202
262
|
root = "." # required unless --root is passed
|
|
263
|
+
resolve_aliases = true # resolve frontmatter aliases: (default true)
|
|
203
264
|
scoped_folders = ["brain", "work"] # limit the vault to these top-level folders
|
|
204
265
|
excluded_dirs = [".git", "templates"] # skip notes under these directories
|
|
205
266
|
rules_files = ["CLAUDE.md", "CLAUDE.local.md"] # agent-rules files, not vault content
|
|
@@ -82,7 +82,7 @@ already learned were fine. #107 is the fourth fix that had to be applied in two
|
|
|
82
82
|
The gardener's stack survives because it answers questions graphmark cannot yet answer. graphmark
|
|
83
83
|
says _whether_ a link resolves; Lane A needs to know **why it failed and what to do about it** —
|
|
84
84
|
the ambiguity set, the near-miss candidates behind a "did you mean", and the canonical title of
|
|
85
|
-
the note a display resolves to (so `[[
|
|
85
|
+
the note a display resolves to (so `[[jordan ellis]]` can be repaired to `[[Jordan Ellis]]`).
|
|
86
86
|
|
|
87
87
|
_Where we're going:_ a documented, typed **link-diagnosis** surface that makes the consumer a
|
|
88
88
|
formatter over graphmark's answers instead of a second resolver. The classification is already
|
|
@@ -28,12 +28,16 @@ from graphmark.dismiss import (
|
|
|
28
28
|
from graphmark.export import to_dot, to_json
|
|
29
29
|
from graphmark.graph import (
|
|
30
30
|
DIAGNOSIS_REASONS,
|
|
31
|
+
SUGGEST_MAX_MATCHES,
|
|
32
|
+
SUGGEST_MIN_COVERAGE,
|
|
31
33
|
LinkDiagnosis,
|
|
32
34
|
NormalizeResolver,
|
|
33
35
|
VaultGraph,
|
|
36
|
+
build_aliases,
|
|
34
37
|
build_catalog,
|
|
35
38
|
candidates_for,
|
|
36
39
|
diagnose,
|
|
40
|
+
suggest_notes,
|
|
37
41
|
)
|
|
38
42
|
from graphmark.interfaces import LinkExtractor, Resolver, Similarity
|
|
39
43
|
from graphmark.metrics import (
|
|
@@ -96,6 +100,8 @@ __all__ = [
|
|
|
96
100
|
"Document",
|
|
97
101
|
"LinkExtractor",
|
|
98
102
|
"DIAGNOSIS_REASONS",
|
|
103
|
+
"SUGGEST_MAX_MATCHES",
|
|
104
|
+
"SUGGEST_MIN_COVERAGE",
|
|
99
105
|
"LinkDiagnosis",
|
|
100
106
|
"NormalizeResolver",
|
|
101
107
|
"Resolver",
|
|
@@ -107,9 +113,11 @@ __all__ = [
|
|
|
107
113
|
"active_dismissed_sigs",
|
|
108
114
|
"bridges",
|
|
109
115
|
"build",
|
|
116
|
+
"build_aliases",
|
|
110
117
|
"build_catalog",
|
|
111
118
|
"candidates_for",
|
|
112
119
|
"diagnose",
|
|
120
|
+
"suggest_notes",
|
|
113
121
|
"clusters",
|
|
114
122
|
"gaps",
|
|
115
123
|
"hubs",
|
|
@@ -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
|
)
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
6
|
import string
|
|
7
|
-
from dataclasses import dataclass, field
|
|
7
|
+
from dataclasses import dataclass, field, replace
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
10
|
from graphmark.config import VaultConfig
|
|
@@ -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
|
|
|
@@ -144,6 +181,90 @@ class NormalizeResolver:
|
|
|
144
181
|
return paths[0]
|
|
145
182
|
|
|
146
183
|
|
|
184
|
+
#: A display matching more than this many notes names a *topic*, not a mistyped note, and gets no
|
|
185
|
+
#: suggestions at all. Calibrated, not chosen: on the reference vault ``[[AMRT]]`` matched 47 notes
|
|
186
|
+
#: while ``[[Priya Raghavan]]`` needs a cap of at least 12 to survive (that token appears in 9 note
|
|
187
|
+
#: stems), so 12 is the lowest value that keeps every human-confirmed useful suggestion.
|
|
188
|
+
SUGGEST_MAX_MATCHES = 12
|
|
189
|
+
|
|
190
|
+
#: When a candidate's name sits *inside* a longer display, the fraction of the display it must
|
|
191
|
+
#: account for. ``[[fable-prompt-technique-reference]]`` → ``fable-prompt-technique`` covers 3 of 4
|
|
192
|
+
#: tokens and is the answer; ``[[Dagster PJM InSchedules]]`` → ``Dagster`` covers 1 of 3 and is a
|
|
193
|
+
#: real note that is not the target — the shape that made the old suggestions untrustworthy. 0.4 is
|
|
194
|
+
#: the highest floor that keeps every useful suggestion in the annotated baseline.
|
|
195
|
+
SUGGEST_MIN_COVERAGE = 0.4
|
|
196
|
+
|
|
197
|
+
#: Note stems that name nothing — the note's identity lives in its parent folder instead. Kept
|
|
198
|
+
#: deliberately short: ``index`` is NOT here, because a vault's ``personal/Index.md`` is a real,
|
|
199
|
+
#: linkable note and re-keying it onto its folder would lose the actual answer.
|
|
200
|
+
GENERIC_STEMS = frozenset({"skill", "readme"})
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _content_tokens(text: str) -> frozenset[str]:
|
|
204
|
+
"""Normalized tokens with pure-digit runs dropped.
|
|
205
|
+
|
|
206
|
+
Date prefixes are filing metadata, not content: ``2026-04-11-mood-tracker`` and
|
|
207
|
+
``Mood Tracker`` are the same note to a human, and the dated twin is the single most common
|
|
208
|
+
near-miss shape in a journal-style vault.
|
|
209
|
+
"""
|
|
210
|
+
return frozenset(t for t in _normalize(text).split() if not t.isdigit())
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _suggestion_keys(catalog: dict[str, list[str]]) -> list[tuple[str, frozenset[str]]]:
|
|
214
|
+
"""(rel_path, name tokens) for every note, keyed by folder where the stem is generic."""
|
|
215
|
+
keys: list[tuple[str, frozenset[str]]] = []
|
|
216
|
+
for paths in catalog.values():
|
|
217
|
+
for rel in paths:
|
|
218
|
+
path = Path(rel)
|
|
219
|
+
name = path.parent.name if path.stem.lower() in GENERIC_STEMS else path.stem
|
|
220
|
+
tokens = _content_tokens(name)
|
|
221
|
+
if tokens:
|
|
222
|
+
keys.append((rel, tokens))
|
|
223
|
+
return keys
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def suggest_notes(display: str, catalog: dict[str, list[str]], k: int) -> tuple[str, ...]:
|
|
227
|
+
"""Up to ``k`` notes whose names are near-misses for ``display``, best first.
|
|
228
|
+
|
|
229
|
+
Matching is **directional**, which is what separates a suggestion from noise:
|
|
230
|
+
|
|
231
|
+
* the display's tokens inside a candidate's — the display abbreviates a longer title
|
|
232
|
+
(``[[Jordan]]`` → ``Jordan Ellis``). Always offered; a short display naturally names a
|
|
233
|
+
longer note.
|
|
234
|
+
* a candidate's tokens inside the display's — offered only when the candidate accounts for at
|
|
235
|
+
least ``SUGGEST_MIN_COVERAGE`` of the display. Dropping a suffix is a real answer; matching
|
|
236
|
+
one word out of five is a wrong answer that invites a wrong repair.
|
|
237
|
+
* neither — rejected. Partial overlap produced no useful suggestion anywhere in the annotated
|
|
238
|
+
baseline, and rejecting it is what holds the false-suggestion rate at zero.
|
|
239
|
+
|
|
240
|
+
Ranked by how much of the longer name the match accounts for, ties broken by rel_path so the
|
|
241
|
+
output is byte-stable.
|
|
242
|
+
"""
|
|
243
|
+
display_tokens = _content_tokens(display)
|
|
244
|
+
if not display_tokens or k <= 0:
|
|
245
|
+
return ()
|
|
246
|
+
|
|
247
|
+
scored: list[tuple[float, str]] = []
|
|
248
|
+
for rel, name_tokens in _suggestion_keys(catalog):
|
|
249
|
+
if display_tokens <= name_tokens:
|
|
250
|
+
score = len(display_tokens) / len(name_tokens)
|
|
251
|
+
elif name_tokens <= display_tokens:
|
|
252
|
+
score = len(name_tokens) / len(display_tokens)
|
|
253
|
+
if score < SUGGEST_MIN_COVERAGE:
|
|
254
|
+
continue
|
|
255
|
+
else:
|
|
256
|
+
continue
|
|
257
|
+
scored.append((score, rel))
|
|
258
|
+
|
|
259
|
+
# A display that matches half the vault is a topic. Offering its "best" dozen would be
|
|
260
|
+
# confident nonsense, so it gets nothing.
|
|
261
|
+
if len(scored) > SUGGEST_MAX_MATCHES:
|
|
262
|
+
return ()
|
|
263
|
+
|
|
264
|
+
scored.sort(key=lambda pair: (-pair[0], pair[1]))
|
|
265
|
+
return tuple(rel for _, rel in scored[:k])
|
|
266
|
+
|
|
267
|
+
|
|
147
268
|
#: Every value ``LinkDiagnosis.reason`` can take, in the order they are decided. A consumer
|
|
148
269
|
#: switches on these strings, so the set is part of the public contract.
|
|
149
270
|
DIAGNOSIS_REASONS = (
|
|
@@ -167,10 +288,11 @@ class LinkDiagnosis:
|
|
|
167
288
|
|
|
168
289
|
``target`` is the resolved rel_path, set only when ``reason == "resolved"``; the caller recovers
|
|
169
290
|
the note's canonical title from its stem, which is what makes a case repair like
|
|
170
|
-
``[[
|
|
291
|
+
``[[jordan ellis]]`` → ``[[Jordan Ellis]]`` possible.
|
|
171
292
|
|
|
172
293
|
``candidates`` carries the rel_paths in play: the colliding notes for ``ambiguous``, the
|
|
173
|
-
unindexed markdown for ``out-of-scope-note
|
|
294
|
+
unindexed markdown for ``out-of-scope-note``, and — only when ``diagnose`` is asked for them —
|
|
295
|
+
the near-miss suggestions for ``missing``. Empty for every other reason.
|
|
174
296
|
"""
|
|
175
297
|
|
|
176
298
|
display: str
|
|
@@ -184,6 +306,7 @@ def _diagnose(
|
|
|
184
306
|
catalog: dict[str, list[str]],
|
|
185
307
|
out_of_scope: dict[str, list[str]],
|
|
186
308
|
resolver: Resolver,
|
|
309
|
+
aliases: dict[str, str] | None = None,
|
|
187
310
|
) -> LinkDiagnosis:
|
|
188
311
|
"""Classify one display against already-built resolution state.
|
|
189
312
|
|
|
@@ -199,6 +322,22 @@ def _diagnose(
|
|
|
199
322
|
if target is not None:
|
|
200
323
|
return LinkDiagnosis(display=display, target=target, reason="resolved")
|
|
201
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
|
+
|
|
202
341
|
# The resolver declined. Whether it declined because nothing matched or because too much did
|
|
203
342
|
# is the distinction consumers need, and only the catalog can answer it.
|
|
204
343
|
collisions = candidates_for(display, catalog)
|
|
@@ -219,14 +358,24 @@ def _diagnose(
|
|
|
219
358
|
return LinkDiagnosis(display=display, reason="missing")
|
|
220
359
|
|
|
221
360
|
|
|
222
|
-
def diagnose(graph: VaultGraph, display: str) -> LinkDiagnosis:
|
|
361
|
+
def diagnose(graph: VaultGraph, display: str, *, suggest: int = 0) -> LinkDiagnosis:
|
|
223
362
|
"""Explain what ``display`` names in ``graph`` — see :class:`LinkDiagnosis`.
|
|
224
363
|
|
|
225
364
|
Uses the resolver the graph was built with, so a diagnosis can never contradict the graph it
|
|
226
365
|
describes. A directly constructed ``VaultGraph`` with no resolver falls back to
|
|
227
366
|
``NormalizeResolver``.
|
|
367
|
+
|
|
368
|
+
``suggest=k`` fills ``candidates`` with up to k near-miss notes — but only for a ``missing``
|
|
369
|
+
verdict, since every other reason either already carries the rel_paths in play or has nothing
|
|
370
|
+
to look for. The default of 0 does no extra work at all, keeping the vault-health gate's hot
|
|
371
|
+
path free of it.
|
|
228
372
|
"""
|
|
229
|
-
|
|
373
|
+
if suggest < 0:
|
|
374
|
+
raise ValueError(f"suggest must be >= 0, got {suggest}")
|
|
375
|
+
diagnosis = _diagnose(display, graph.catalog, graph.out_of_scope, graph.resolver, graph.aliases)
|
|
376
|
+
if suggest and diagnosis.reason == "missing":
|
|
377
|
+
return replace(diagnosis, candidates=suggest_notes(display, graph.catalog, suggest))
|
|
378
|
+
return diagnosis
|
|
230
379
|
|
|
231
380
|
|
|
232
381
|
class VaultGraph:
|
|
@@ -253,6 +402,7 @@ class VaultGraph:
|
|
|
253
402
|
catalog: dict[str, list[str]] | None = None,
|
|
254
403
|
out_of_scope: dict[str, list[str]] | None = None,
|
|
255
404
|
resolver: Resolver | None = None,
|
|
405
|
+
aliases: dict[str, str] | None = None,
|
|
256
406
|
) -> None:
|
|
257
407
|
self.nodes = nodes
|
|
258
408
|
self.out_links = out_links
|
|
@@ -260,6 +410,7 @@ class VaultGraph:
|
|
|
260
410
|
self.unresolved = unresolved if unresolved is not None else {}
|
|
261
411
|
self.catalog = catalog if catalog is not None else {}
|
|
262
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 {}
|
|
263
414
|
# Retained so diagnose() answers with the same resolver that built the graph; a
|
|
264
415
|
# pluggable Resolver that disagreed with the graph it describes would be worse than none.
|
|
265
416
|
self.resolver: Resolver = resolver if resolver is not None else NormalizeResolver()
|
|
@@ -303,6 +454,7 @@ class VaultGraph:
|
|
|
303
454
|
docs = [parse_document(p, root) for p in md_files]
|
|
304
455
|
nodes = {doc.rel_path: doc for doc in docs}
|
|
305
456
|
catalog = build_catalog(docs)
|
|
457
|
+
aliases = build_aliases(docs, catalog) if config.resolve_aliases else {}
|
|
306
458
|
|
|
307
459
|
out_links: dict[str, set[str]] = {rel: set() for rel in nodes}
|
|
308
460
|
back_links: dict[str, set[str]] = {rel: set() for rel in nodes}
|
|
@@ -316,7 +468,7 @@ class VaultGraph:
|
|
|
316
468
|
broken = {"ambiguous", "missing"}
|
|
317
469
|
for doc in docs:
|
|
318
470
|
for display in extractor.extract(doc.text):
|
|
319
|
-
d = _diagnose(display, catalog, out_of_scope, resolver)
|
|
471
|
+
d = _diagnose(display, catalog, out_of_scope, resolver, aliases)
|
|
320
472
|
if d.reason in broken:
|
|
321
473
|
unresolved.setdefault(doc.rel_path, []).append(display)
|
|
322
474
|
elif d.target is not None and d.target != doc.rel_path:
|
|
@@ -326,4 +478,6 @@ class VaultGraph:
|
|
|
326
478
|
for dst in targets:
|
|
327
479
|
back_links[dst].add(src)
|
|
328
480
|
|
|
329
|
-
return cls(
|
|
481
|
+
return cls(
|
|
482
|
+
nodes, out_links, back_links, unresolved, catalog, out_of_scope, resolver, aliases
|
|
483
|
+
)
|