graphmark 0.7.2__tar.gz → 0.8.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.7.2 → graphmark-0.8.0}/CHANGELOG.md +101 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/PKG-INFO +27 -1
- {graphmark-0.7.2 → graphmark-0.8.0}/README.md +26 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/docs/ROADMAP.md +20 -2
- {graphmark-0.7.2 → graphmark-0.8.0}/pyproject.toml +1 -1
- {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/config.py +17 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/graph.py +86 -3
- {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/parse.py +49 -0
- graphmark-0.8.0/tests/test_markdown_links.py +155 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_readme_examples.py +12 -5
- graphmark-0.8.0/tests/test_unread_syntax.py +111 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/.gitignore +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/LICENSE +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/__init__.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/check.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/cli.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/dismiss.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/export.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/interfaces.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/metrics.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/model.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/src/graphmark/py.typed +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/README.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/config.toml +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/expected.json +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/vault/docs/center.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/vault/misc/echo.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/vault/misc/stub.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/vault/refs/beta.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/vault/refs/delta.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/dismiss/expected.json +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/dismiss/vault/alpha.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/dismiss/vault/beta.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/dismiss/vault/gamma.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/config.toml +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/expected.json +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/expected_oog.json +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/similar.json +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/similar_oog.json +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/vault/docs/a.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/vault/misc/c.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/vault/misc/d.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/vault/refs/b.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/gaps/vault/refs/e.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/scoped/config.toml +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/scoped/expected.json +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/scoped/vault/docs/one.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/scoped/vault/refs/two.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/selflink/config.toml +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/selflink/expected.json +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/selflink/vault/a.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/selflink/vault/b.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/simple/config.toml +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/simple/expected.json +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/simple/vault/brain/hub.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/simple/vault/personal/beta.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/simple/vault/reference/island.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/simple/vault/reference/stub.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/fixtures/suggest/README.md +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_aliases.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_bom.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_build_filters.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_check.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_check_policy.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_cli.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_config.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_degenerate.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_diagnose.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_dismiss.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_export.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_gaps.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_gaps_banding.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_gaps_oog.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_gaps_params.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_graph.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_graph_catalogs.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_link_counts.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_links_report.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_metrics.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_model.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_non_note_file.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_packaging.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_pagerank.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_parse.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_path_suffix.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_properties.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_public_api.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_scoped.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_selflink.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_smoke.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_suggestions.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_unicode.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_unicode_punctuation.py +0 -0
- {graphmark-0.7.2 → graphmark-0.8.0}/tests/test_unresolved.py +0 -0
|
@@ -1,6 +1,107 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.8.0 (2026-07-26)
|
|
5
|
+
|
|
6
|
+
### Documentation
|
|
7
|
+
|
|
8
|
+
- Document link_syntax and close the markdown non-goal
|
|
9
|
+
([#158](https://github.com/cdcoonce/graphmark/pull/158),
|
|
10
|
+
[`5523fe3`](https://github.com/cdcoonce/graphmark/commit/5523fe31b548eb5e94001515ab8e8adb7c084849))
|
|
11
|
+
|
|
12
|
+
* docs: document link_syntax and close the markdown non-goal
|
|
13
|
+
|
|
14
|
+
README gains a Link syntax section — the config key, the warning, and the one thing a user must
|
|
15
|
+
understand: a wikilink NAMES a note (ambiguous when two share it) while a markdown link is a PATH
|
|
16
|
+
RELATIVE TO THE LINKING NOTE (never ambiguous for the same reason). Different resolution regimes,
|
|
17
|
+
not two spellings.
|
|
18
|
+
|
|
19
|
+
Roadmap's non-goal narrowed to Logseq, recording that #152 was the "on demand" trigger the clause
|
|
20
|
+
reserved — and recording what it did not do: no basename fallback, because silently trying a
|
|
21
|
+
second rule when the first fails is the shape of #136. The dialect question is #156 with its
|
|
22
|
+
measurement.
|
|
23
|
+
|
|
24
|
+
* fix(test): a console block's output is not a command
|
|
25
|
+
|
|
26
|
+
The README-example extractor treated every line starting with "graphmark " as an invocation,
|
|
27
|
+
including output. Adding the link-syntax section wrapped the #151 warning onto a second line
|
|
28
|
+
beginning "graphmark only reads ..." — collected as a command and run.
|
|
29
|
+
|
|
30
|
+
In a `console` block, `$ ` marks input and everything else is output, so the prefix is now required
|
|
31
|
+
there; `bash` blocks keep bare lines. Caught by the suite it belongs to, on its author's own
|
|
32
|
+
input.
|
|
33
|
+
|
|
34
|
+
- Second corpus run — the null result and the extractor blind spot
|
|
35
|
+
([#153](https://github.com/cdcoonce/graphmark/pull/153),
|
|
36
|
+
[`475b111`](https://github.com/cdcoonce/graphmark/commit/475b1112767bb9ca92d82cb0d391bf20543759c4))
|
|
37
|
+
|
|
38
|
+
Repeated the corpus study after #123/#136/#137/#138/#139 shipped, to ask whether those fixes move
|
|
39
|
+
any real number and whether there is demand for the link-syntax work the roadmap defers. Six
|
|
40
|
+
vaults; two overlap the first run.
|
|
41
|
+
|
|
42
|
+
The five fixes moved NOTHING — arkalim and kepano reproduce the first run's percentages to the
|
|
43
|
+
decimal. Recorded rather than buried: correctness-by-inspection has outrun what this corpus can
|
|
44
|
+
corroborate.
|
|
45
|
+
|
|
46
|
+
The finding that was not theoretical: lyz-code/blue-book, 1120 notes, carries 11,198 markdown-style
|
|
47
|
+
[text](note.md) links (99% targeting a real note) and extracts to ZERO edges. Every note an
|
|
48
|
+
orphan, and `check` looks nearly healthy because those links were never extracted and so are not
|
|
49
|
+
unresolved.
|
|
50
|
+
|
|
51
|
+
That is a third named limit of the Track F thesis: the conservation law sums over what the EXTRACTOR
|
|
52
|
+
produced, so a syntax it does not know is invisible to it by construction. The accounting is
|
|
53
|
+
auditable only within the universe the extractor defines. Filed as #151 (warn) and #152 (the
|
|
54
|
+
markdown-syntax decision).
|
|
55
|
+
|
|
56
|
+
### Features
|
|
57
|
+
|
|
58
|
+
- Read markdown-style [text](note.md) links, config-gated (#152)
|
|
59
|
+
([#155](https://github.com/cdcoonce/graphmark/pull/155),
|
|
60
|
+
[`6716a38`](https://github.com/cdcoonce/graphmark/commit/6716a3845b15665190f03f1bce3605a2927322e2))
|
|
61
|
+
|
|
62
|
+
The roadmap reserved alternate link-syntax adapters for "on demand"; the second corpus run supplied
|
|
63
|
+
the demand — lyz-code/blue-book, 1120 notes, 10,149 markdown-style links, zero extracted edges.
|
|
64
|
+
|
|
65
|
+
Four decisions, all reversible in config:
|
|
66
|
+
|
|
67
|
+
1. `link_syntax = "wikilink" | "markdown" | "both"`, defaulting to "wikilink", so every existing
|
|
68
|
+
vault, every frozen fixture and the reference vault stay byte-identical. An unknown value fails
|
|
69
|
+
loudly — a typo would otherwise produce an empty graph, the exact failure #151 exists to surface.
|
|
70
|
+
2. Resolution is RELATIVE TO THE LINKING NOTE, which is genuinely new: a markdown target is a
|
|
71
|
+
path, not a name. Rather than change the `Resolver` protocol — which never receives the source
|
|
72
|
+
note, and which the roadmap forbids redesigning — the target is normalized into a vault-relative
|
|
73
|
+
path before diagnosis, where the existing path branch resolves it exactly. 3. No new frozen
|
|
74
|
+
fixture: the oracle is wikilink-only and regenerating it is a human's call (Track B). Hand-written
|
|
75
|
+
assertions instead. 4. A target escaping the vault root is `missing` — not resolved, not an error.
|
|
76
|
+
|
|
77
|
+
Relative is default markdown semantics (CommonMark, mkdocs, GitHub) and NOT the wikilink rule.
|
|
78
|
+
blue-book resolves 5.8% that way against 92.7% by basename-anywhere, because it runs the
|
|
79
|
+
mkdocs-autolinks plugin. That dialect is a separate decision, not a fallback slipped in here —
|
|
80
|
+
silently trying a second rule when the first fails is how a link resolves to the wrong note.
|
|
81
|
+
|
|
82
|
+
The #151 warning is suppressed once markdown links are actually read.
|
|
83
|
+
|
|
84
|
+
- **graph**: Warn when the vault's links are in a syntax graphmark cannot read (#151)
|
|
85
|
+
([#154](https://github.com/cdcoonce/graphmark/pull/154),
|
|
86
|
+
[`d94ae63`](https://github.com/cdcoonce/graphmark/commit/d94ae63e7a789fabdb420423eaeaee8e771eb2a7))
|
|
87
|
+
|
|
88
|
+
graphmark reported a confidently EMPTY graph for a densely linked vault, with nothing to say it had
|
|
89
|
+
seen none of the links. Measured on lyz-code/blue-book: 1120 notes, 11,198 markdown-style
|
|
90
|
+
[text](note.md) links (99% targeting a note that exists), 0 extracted edges. Every note an orphan,
|
|
91
|
+
no clusters, no hubs, uniform PageRank — and `check` looks nearly healthy, because links that were
|
|
92
|
+
never extracted are not `unresolved`.
|
|
93
|
+
|
|
94
|
+
The conservation law from #124 cannot catch this: it sums over what the EXTRACTOR produced, so a
|
|
95
|
+
syntax the extractor does not know sits outside the universe being counted. The buckets balance
|
|
96
|
+
perfectly at 39 while 11,198 links go unseen.
|
|
97
|
+
|
|
98
|
+
The test is relational, not calibrated — it fires only when the unread syntax strictly outnumbers
|
|
99
|
+
the read one. No threshold to tune, no false-positive mode, and the reference vault's
|
|
100
|
+
17-against-6226 can never trip it. Same discipline as #133's assertion.
|
|
101
|
+
|
|
102
|
+
Warning only: stderr, one line, and it touches no count, no metric and no verdict.
|
|
103
|
+
|
|
104
|
+
|
|
4
105
|
## v0.7.2 (2026-07-25)
|
|
5
106
|
|
|
6
107
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graphmark
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.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
|
|
@@ -111,6 +111,32 @@ notice the tool is wrong about your vault.
|
|
|
111
111
|
The same block appears in `check`'s report under `links`, as context for the verdict. It never
|
|
112
112
|
changes the verdict.
|
|
113
113
|
|
|
114
|
+
## Link syntax
|
|
115
|
+
|
|
116
|
+
graphmark reads `[[wikilinks]]` by default. A vault written in markdown link syntax —
|
|
117
|
+
`[text](note.md)`, what most non-Obsidian markdown gardens use — would otherwise produce an empty
|
|
118
|
+
graph, so set `link_syntax`:
|
|
119
|
+
|
|
120
|
+
```toml
|
|
121
|
+
root = "."
|
|
122
|
+
link_syntax = "both" # "wikilink" (default) | "markdown" | "both"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The two resolve differently, and the difference is not cosmetic:
|
|
126
|
+
|
|
127
|
+
- a **wikilink** names a note — `[[Note]]` finds `Note.md` anywhere in the vault, and two notes with
|
|
128
|
+
that name make it ambiguous;
|
|
129
|
+
- a **markdown** link is a path **relative to the linking note** — `[x](../other/b.md)` means that
|
|
130
|
+
file and nothing else, so the same filename in two folders is not ambiguous at all.
|
|
131
|
+
|
|
132
|
+
If your links are in a syntax graphmark is not reading, it says so on stderr rather than reporting a
|
|
133
|
+
confidently empty graph:
|
|
134
|
+
|
|
135
|
+
```console
|
|
136
|
+
graphmark: warning: 39 wikilinks extracted but 10149 markdown-style [](.md) links found;
|
|
137
|
+
graphmark only reads [[wikilinks]] — this graph is probably not your vault
|
|
138
|
+
```
|
|
139
|
+
|
|
114
140
|
## `graphmark check` — vault health as a CI gate
|
|
115
141
|
|
|
116
142
|
Declare thresholds in the config's `[check]` table, then run `graphmark check` as a build step. It
|
|
@@ -83,6 +83,32 @@ notice the tool is wrong about your vault.
|
|
|
83
83
|
The same block appears in `check`'s report under `links`, as context for the verdict. It never
|
|
84
84
|
changes the verdict.
|
|
85
85
|
|
|
86
|
+
## Link syntax
|
|
87
|
+
|
|
88
|
+
graphmark reads `[[wikilinks]]` by default. A vault written in markdown link syntax —
|
|
89
|
+
`[text](note.md)`, what most non-Obsidian markdown gardens use — would otherwise produce an empty
|
|
90
|
+
graph, so set `link_syntax`:
|
|
91
|
+
|
|
92
|
+
```toml
|
|
93
|
+
root = "."
|
|
94
|
+
link_syntax = "both" # "wikilink" (default) | "markdown" | "both"
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The two resolve differently, and the difference is not cosmetic:
|
|
98
|
+
|
|
99
|
+
- a **wikilink** names a note — `[[Note]]` finds `Note.md` anywhere in the vault, and two notes with
|
|
100
|
+
that name make it ambiguous;
|
|
101
|
+
- a **markdown** link is a path **relative to the linking note** — `[x](../other/b.md)` means that
|
|
102
|
+
file and nothing else, so the same filename in two folders is not ambiguous at all.
|
|
103
|
+
|
|
104
|
+
If your links are in a syntax graphmark is not reading, it says so on stderr rather than reporting a
|
|
105
|
+
confidently empty graph:
|
|
106
|
+
|
|
107
|
+
```console
|
|
108
|
+
graphmark: warning: 39 wikilinks extracted but 10149 markdown-style [](.md) links found;
|
|
109
|
+
graphmark only reads [[wikilinks]] — this graph is probably not your vault
|
|
110
|
+
```
|
|
111
|
+
|
|
86
112
|
## `graphmark check` — vault health as a CI gate
|
|
87
113
|
|
|
88
114
|
Declare thresholds in the config's `[check]` table, then run `graphmark check` as a build step. It
|
|
@@ -185,6 +185,19 @@ The generalizable lesson, and the one to carry into future test design here: **a
|
|
|
185
185
|
generated input still needs a correct answer to compare against.** Invariants prove the package is
|
|
186
186
|
consistent with itself, which is exactly the thing a systematically wrong resolver also is.
|
|
187
187
|
|
|
188
|
+
_Second corpus run, same day (see `docs/corpus-study.md`):_ a **third** named limit, and a null
|
|
189
|
+
result. The null result first — the five fixes above moved **no** third-party number; the two vaults
|
|
190
|
+
overlapping the first run reproduce it to the decimal. They are correct and pinned, but against real
|
|
191
|
+
data they are theoretical, joining #123 there.
|
|
192
|
+
|
|
193
|
+
The limit: `lyz-code/blue-book` (1120 notes) carries **11,198 markdown-style `[text](note.md)` links,
|
|
194
|
+
99% of them targeting a real note**, and graphmark extracts **zero**. Every note an orphan, and
|
|
195
|
+
`check` looks nearly healthy because those links were never extracted and so are not *unresolved*.
|
|
196
|
+
The conservation law sums over what the **extractor** produced — so a link syntax the extractor does
|
|
197
|
+
not know is invisible to it by construction. **The accounting is auditable only within the universe
|
|
198
|
+
the extractor defines.** Filed as #151 (warn) and #152 (the markdown-syntax decision — the "on
|
|
199
|
+
demand" trigger the non-goals list reserved).
|
|
200
|
+
|
|
188
201
|
Audited alongside: the reference vault's two suppressed buckets, by hand — 17 `non-note-file` (all
|
|
189
202
|
genuine `.base`/`.png`) and 40 `intra-note` (all genuine heading refs), 0 false suppressions. A
|
|
190
203
|
suppressed bucket is only trustworthy once someone has read it.
|
|
@@ -224,8 +237,13 @@ Evaluated against the ecosystem and dropped for zero consumer pull; do not re-li
|
|
|
224
237
|
- Betweenness centrality, Louvain/Leiden community detection.
|
|
225
238
|
- CSV/GML/Cypher export; incremental/cached builds; any LLM "propose" pass.
|
|
226
239
|
- MCP server or retrieval/search surface.
|
|
227
|
-
- Alternate link-syntax adapters (Logseq
|
|
228
|
-
|
|
240
|
+
- Alternate link-syntax adapters (Logseq) — the pluggable interfaces exist so these _can_ be added
|
|
241
|
+
on demand; do not build them speculatively. **Markdown `[text](note.md)` links SHIPPED 2026-07-25**
|
|
242
|
+
(#152) — the second corpus run supplied the demand this clause reserved: a 1120-note vault with
|
|
243
|
+
10,149 of them and zero extracted edges. Config-gated (`link_syntax`), default unchanged,
|
|
244
|
+
`Resolver` untouched. Note what it did *not* do: blue-book resolves 5.8% by strict relative path
|
|
245
|
+
and 92.7% by basename-anywhere (it runs `mkdocs-autolinks`), and no fallback was slipped in —
|
|
246
|
+
silently trying a second rule when the first fails is the shape of #136. The dialect is #156.
|
|
229
247
|
- Re-platforming (no swapping networkx; no async/parallel rewrites).
|
|
230
248
|
- Performance work without a benchmark showing pain (the live consumer is a ~340-note vault). The
|
|
231
249
|
known ceilings — O(L·N) path-suffix resolution, O(A·(V+E)) `siloed_notes`, per-metric graph
|
|
@@ -11,6 +11,10 @@ import tomllib
|
|
|
11
11
|
from dataclasses import dataclass, field, fields
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
|
+
#: Every value ``VaultConfig.link_syntax`` accepts. A consumer may switch on these, so the set is
|
|
15
|
+
#: part of the config contract.
|
|
16
|
+
LINK_SYNTAXES = frozenset({"wikilink", "markdown", "both"})
|
|
17
|
+
|
|
14
18
|
|
|
15
19
|
@dataclass(frozen=True)
|
|
16
20
|
class CheckPolicy:
|
|
@@ -47,6 +51,12 @@ class VaultConfig:
|
|
|
47
51
|
# Obsidian's `aliases:` property names a note, so links written against one resolve by
|
|
48
52
|
# default. Set False for strict basename-only resolution.
|
|
49
53
|
resolve_aliases: bool = True
|
|
54
|
+
#: Which link syntax to read. ``"wikilink"`` (the default) is Obsidian's ``[[Note]]`` and is
|
|
55
|
+
#: what every frozen fixture encodes, so the default keeps existing behavior byte-identical.
|
|
56
|
+
#: ``"markdown"`` reads ``[text](note.md)``, the syntax non-Obsidian markdown vaults use — added
|
|
57
|
+
#: on demand after a corpus vault turned out to have 11,198 of them and zero extracted edges.
|
|
58
|
+
#: ``"both"`` reads each, which is what Obsidian itself accepts.
|
|
59
|
+
link_syntax: str = "wikilink"
|
|
50
60
|
check: CheckPolicy = field(default_factory=CheckPolicy)
|
|
51
61
|
|
|
52
62
|
def __post_init__(self) -> None:
|
|
@@ -54,6 +64,12 @@ class VaultConfig:
|
|
|
54
64
|
# obscure AttributeError on the first Path operation.
|
|
55
65
|
if not isinstance(self.root, Path):
|
|
56
66
|
self.root = Path(self.root)
|
|
67
|
+
# Fail loudly rather than silently reading nothing: a typo here would produce an empty
|
|
68
|
+
# graph, which is exactly the failure #151 exists to make visible.
|
|
69
|
+
if self.link_syntax not in LINK_SYNTAXES:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"link_syntax must be one of {sorted(LINK_SYNTAXES)}, got {self.link_syntax!r}"
|
|
72
|
+
)
|
|
57
73
|
|
|
58
74
|
|
|
59
75
|
def _parse_check(data: dict, path: Path) -> CheckPolicy:
|
|
@@ -122,5 +138,6 @@ def load_config(path: str | Path, *, root_override: str | Path | None = None) ->
|
|
|
122
138
|
rules_files=data.get("rules_files", ["CLAUDE.md", "CLAUDE.local.md"]),
|
|
123
139
|
transient_prefixes=tuple(data.get("transient_prefixes", [])),
|
|
124
140
|
resolve_aliases=bool(data.get("resolve_aliases", True)),
|
|
141
|
+
link_syntax=data.get("link_syntax", "wikilink"),
|
|
125
142
|
check=_parse_check(data, path),
|
|
126
143
|
)
|
|
@@ -2,17 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import posixpath
|
|
5
6
|
import re
|
|
6
7
|
import string
|
|
8
|
+
import sys
|
|
7
9
|
import unicodedata
|
|
8
10
|
from dataclasses import dataclass, field, replace
|
|
9
11
|
from functools import lru_cache
|
|
10
|
-
from pathlib import Path
|
|
12
|
+
from pathlib import Path, PurePosixPath
|
|
11
13
|
|
|
12
14
|
from graphmark.config import VaultConfig
|
|
13
15
|
from graphmark.interfaces import LinkExtractor, Resolver
|
|
14
16
|
from graphmark.model import Document
|
|
15
|
-
from graphmark.parse import parse_document
|
|
17
|
+
from graphmark.parse import MarkdownLinkExtractor, count_markdown_links, parse_document
|
|
16
18
|
|
|
17
19
|
_PUNCT_TABLE = str.maketrans(string.punctuation, " " * len(string.punctuation))
|
|
18
20
|
|
|
@@ -463,6 +465,68 @@ def diagnose(graph: VaultGraph, display: str, *, suggest: int = 0) -> LinkDiagno
|
|
|
463
465
|
return diagnosis
|
|
464
466
|
|
|
465
467
|
|
|
468
|
+
def resolve_relative_target(target: str, source_rel_path: str) -> str | None:
|
|
469
|
+
"""Turn a markdown link's relative target into a vault-relative path, or ``None``.
|
|
470
|
+
|
|
471
|
+
A markdown target is a **path**, not a name: ``[x](../other/b.md)`` means "the file at that
|
|
472
|
+
location relative to me". That is the first resolution rule in this package that depends on
|
|
473
|
+
*where the link is* — information the ``Resolver`` protocol never receives, and which the
|
|
474
|
+
roadmap forbids adding by redesigning that boundary.
|
|
475
|
+
|
|
476
|
+
So the dependence is resolved here instead, before diagnosis: the target is normalized against
|
|
477
|
+
the linking note's folder into a full vault-relative path, and the existing path branch of the
|
|
478
|
+
resolver then matches it exactly. ``Resolver`` is untouched and still answers "what does this
|
|
479
|
+
name?" with no notion of a source.
|
|
480
|
+
|
|
481
|
+
Relative is the **default markdown semantics** (CommonMark, mkdocs, GitHub) and deliberately
|
|
482
|
+
not the wikilink rule. Measured on `lyz-code/blue-book`: 5.8% of its links resolve this way
|
|
483
|
+
against 92.7% by basename-anywhere, because it runs the `mkdocs-autolinks` plugin. That dialect
|
|
484
|
+
is a separate decision, not a fallback to slip in here — silently trying a second rule when the
|
|
485
|
+
first fails is how a link resolves to the wrong note.
|
|
486
|
+
|
|
487
|
+
``None`` for a target that escapes the vault root. That is not an error and not a resolution —
|
|
488
|
+
a link out of the vault names no note in the graph, so it is reported ``missing`` like any other
|
|
489
|
+
target that is not there.
|
|
490
|
+
"""
|
|
491
|
+
combined = PurePosixPath(source_rel_path).parent / target
|
|
492
|
+
normalized = posixpath.normpath(str(combined))
|
|
493
|
+
# normpath leaves leading "..", which is the only way to express "above the root".
|
|
494
|
+
if normalized == ".." or normalized.startswith("../"):
|
|
495
|
+
return None
|
|
496
|
+
return normalized
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _warn_if_unread_syntax_dominates(docs: list[Document], extracted: int) -> None:
|
|
500
|
+
"""Warn once when the vault's links are mostly in a syntax graphmark does not read.
|
|
501
|
+
|
|
502
|
+
graphmark extracts ``[[wikilinks]]`` only. A vault written in markdown link syntax therefore
|
|
503
|
+
produces a confidently *empty* graph — every note an orphan, no clusters, no hubs, uniform
|
|
504
|
+
PageRank — with nothing to tell the user that none of their links were seen. Worse, ``check``
|
|
505
|
+
looks nearly healthy, because links that were never extracted are not ``unresolved``.
|
|
506
|
+
|
|
507
|
+
Measured on a real 1120-note vault: 11,198 markdown-style links, 99% of them targeting a note
|
|
508
|
+
that exists, and 0 extracted edges.
|
|
509
|
+
|
|
510
|
+
The test is **relational, not calibrated** — it fires only when the unread syntax strictly
|
|
511
|
+
outnumbers the read one. There is no threshold to tune and no false-positive mode; a vault with
|
|
512
|
+
a handful of stray markdown links among thousands of wikilinks (the reference vault: 17 against
|
|
513
|
+
6226) can never trip it. Same discipline as #133's assertion, and the reason this is a warning
|
|
514
|
+
rather than anything that touches a count or a verdict.
|
|
515
|
+
|
|
516
|
+
stderr only, so stdout stays pipeable JSON — as ``links_summary_line`` and ``breach_lines``
|
|
517
|
+
already do.
|
|
518
|
+
"""
|
|
519
|
+
unread = sum(count_markdown_links(doc.text) for doc in docs)
|
|
520
|
+
if unread <= extracted:
|
|
521
|
+
return
|
|
522
|
+
print(
|
|
523
|
+
f"graphmark: warning: {extracted} wikilinks extracted but {unread} markdown-style "
|
|
524
|
+
f"[](.md) links found; graphmark only reads [[wikilinks]] — this graph is probably "
|
|
525
|
+
f"not your vault",
|
|
526
|
+
file=sys.stderr,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
|
|
466
530
|
class VaultGraph:
|
|
467
531
|
"""Built graph: all nodes plus resolved out/back adjacency.
|
|
468
532
|
|
|
@@ -559,8 +623,24 @@ class VaultGraph:
|
|
|
559
623
|
# is reported rather than absent — "0 alias-resolved" is a finding, not a non-event.
|
|
560
624
|
link_counts: dict[str, int] = dict.fromkeys(DIAGNOSIS_REASONS, 0)
|
|
561
625
|
alias_resolved = 0
|
|
626
|
+
# Which syntaxes to read. Composed rather than swapped, because the two need different
|
|
627
|
+
# pre-processing: a wikilink display is a *name*, a markdown target is a *path relative to
|
|
628
|
+
# the linking note*, and only the latter can be normalized once the source note is known.
|
|
629
|
+
read_wikilinks = config.link_syntax in ("wikilink", "both")
|
|
630
|
+
read_markdown = config.link_syntax in ("markdown", "both")
|
|
631
|
+
md_extractor = MarkdownLinkExtractor() if read_markdown else None
|
|
632
|
+
|
|
562
633
|
for doc in docs:
|
|
563
|
-
|
|
634
|
+
displays = list(extractor.extract(doc.text)) if read_wikilinks else []
|
|
635
|
+
if md_extractor is not None:
|
|
636
|
+
for target in md_extractor.extract(doc.text):
|
|
637
|
+
resolved = resolve_relative_target(target, doc.rel_path)
|
|
638
|
+
# A target above the vault root names no note here. Kept in the stream as the
|
|
639
|
+
# raw target so it is counted and reported `missing`, never silently dropped —
|
|
640
|
+
# the conservation law holds for every syntax that is read.
|
|
641
|
+
displays.append(resolved if resolved is not None else target)
|
|
642
|
+
|
|
643
|
+
for display in displays:
|
|
564
644
|
d = _diagnose(display, catalog, out_of_scope, resolver, aliases)
|
|
565
645
|
link_counts[d.reason] += 1
|
|
566
646
|
if d.via == "alias":
|
|
@@ -570,6 +650,9 @@ class VaultGraph:
|
|
|
570
650
|
elif d.target is not None and d.target != doc.rel_path:
|
|
571
651
|
out_links[doc.rel_path].add(d.target)
|
|
572
652
|
|
|
653
|
+
if not read_markdown:
|
|
654
|
+
_warn_if_unread_syntax_dominates(docs, sum(link_counts.values()))
|
|
655
|
+
|
|
573
656
|
for src, targets in out_links.items():
|
|
574
657
|
for dst in targets:
|
|
575
658
|
back_links[dst].add(src)
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import re
|
|
6
6
|
import sys
|
|
7
7
|
from pathlib import Path
|
|
8
|
+
from urllib.parse import unquote
|
|
8
9
|
|
|
9
10
|
from graphmark.model import Document
|
|
10
11
|
|
|
@@ -93,6 +94,33 @@ def _parse_frontmatter(raw: str) -> dict:
|
|
|
93
94
|
return result
|
|
94
95
|
|
|
95
96
|
|
|
97
|
+
#: A markdown-style link to a local markdown file: ``[text](note.md)`` or
|
|
98
|
+
#: ``[text](../a/b.md#Anchor)``.
|
|
99
|
+
#: Not an image (``![...]``) and not an absolute URL — a link to somebody's README on the web is not
|
|
100
|
+
#: a link into this vault. Deliberately narrow: this counts a *signal*, so a false positive here
|
|
101
|
+
#: would produce a warning about nothing.
|
|
102
|
+
_MD_LINK_RE = re.compile(r"(?<!!)\[[^\]]*\]\((?!\w+:)([^)\s]+?\.md)(?:#[^)]*)?\)")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def count_markdown_links(text: str) -> int:
|
|
106
|
+
"""How many ``[text](note.md)`` links the text holds — a syntax graphmark does not read.
|
|
107
|
+
|
|
108
|
+
graphmark extracts ``[[wikilinks]]`` only, so a vault written in markdown link syntax produces
|
|
109
|
+
an empty graph with no indication that anything was missed: every note an orphan, no clusters,
|
|
110
|
+
and a `check` that looks healthy because links which were never extracted are not *unresolved*.
|
|
111
|
+
|
|
112
|
+
The conservation law in ``VaultGraph.build`` cannot see this — it sums over what the extractor
|
|
113
|
+
produced, so a syntax the extractor does not know sits outside the universe being counted. This
|
|
114
|
+
function exists to make that universe's edge visible; it feeds a warning and nothing else.
|
|
115
|
+
|
|
116
|
+
Code spans and fenced blocks are skipped, exactly as wikilink extraction skips them: a
|
|
117
|
+
documented example is not a link.
|
|
118
|
+
"""
|
|
119
|
+
text = _strip_fenced_blocks(text)
|
|
120
|
+
text = _INLINE_CODE_RE.sub("", text)
|
|
121
|
+
return len(_MD_LINK_RE.findall(text))
|
|
122
|
+
|
|
123
|
+
|
|
96
124
|
class WikilinkExtractor:
|
|
97
125
|
"""Extracts raw wikilink displays from note text, excluding code spans."""
|
|
98
126
|
|
|
@@ -102,6 +130,27 @@ class WikilinkExtractor:
|
|
|
102
130
|
return _WIKILINK_RE.findall(text)
|
|
103
131
|
|
|
104
132
|
|
|
133
|
+
class MarkdownLinkExtractor:
|
|
134
|
+
"""Extracts ``[text](note.md)`` targets — the syntax non-Obsidian markdown vaults use.
|
|
135
|
+
|
|
136
|
+
Returns the **target**, not the display text, and without its anchor: unlike a wikilink, where
|
|
137
|
+
the visible text names the note, here the parenthesized path is the only thing that identifies
|
|
138
|
+
it. The display text is prose and never resolves to anything.
|
|
139
|
+
|
|
140
|
+
Percent-encoding is decoded, because markdown encodes spaces (``my%20note.md``) while the vault
|
|
141
|
+
stores them literally — a link no editor would consider broken.
|
|
142
|
+
|
|
143
|
+
The returned target is **relative to the linking note**, which callers must resolve; this class
|
|
144
|
+
cannot, since ``extract`` sees text and never learns which note it came from. See
|
|
145
|
+
``VaultGraph.build``.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def extract(self, text: str) -> list[str]:
|
|
149
|
+
text = _strip_fenced_blocks(text)
|
|
150
|
+
text = _INLINE_CODE_RE.sub("", text)
|
|
151
|
+
return [unquote(target) for target in _MD_LINK_RE.findall(text)]
|
|
152
|
+
|
|
153
|
+
|
|
105
154
|
def parse_document(path: Path, root: Path) -> Document:
|
|
106
155
|
"""Parse a markdown note into a Document, splitting YAML frontmatter from body.
|
|
107
156
|
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Markdown-style `[text](note.md)` links, config-gated (#152).
|
|
2
|
+
|
|
3
|
+
The roadmap lists alternate link-syntax adapters under non-goals with a precise escape hatch — "the
|
|
4
|
+
pluggable interfaces exist so these _can_ be added on demand; do not build them speculatively." The
|
|
5
|
+
second corpus run supplied the demand: `lyz-code/blue-book`, 1120 notes, 11,198 markdown-style links
|
|
6
|
+
(99% targeting a real note), zero extracted edges.
|
|
7
|
+
|
|
8
|
+
Four decisions this encodes, all reversible in config:
|
|
9
|
+
|
|
10
|
+
1. **Selection** is `link_syntax = "wikilink" | "markdown" | "both"`, defaulting to `"wikilink"`.
|
|
11
|
+
Every existing vault, every frozen fixture and the reference vault are byte-identical untouched.
|
|
12
|
+
2. **Resolution is relative to the linking note**, which is genuinely new: a markdown target is a
|
|
13
|
+
path, not a name. Rather than change the `Resolver` protocol — which never receives the source
|
|
14
|
+
note, and which the roadmap forbids redesigning — the target is normalized into a vault-relative
|
|
15
|
+
path *before* diagnosis, where the existing path branch resolves it exactly.
|
|
16
|
+
3. **No new frozen fixture.** The oracle is wikilink-only and regenerating it is a human's call
|
|
17
|
+
(Track B); these are hand-written assertions instead.
|
|
18
|
+
4. **A target that escapes the vault root is `missing`**, not resolved and not an error.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from graphmark.config import VaultConfig
|
|
26
|
+
from graphmark.graph import NormalizeResolver, VaultGraph
|
|
27
|
+
from graphmark.parse import MarkdownLinkExtractor, WikilinkExtractor
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _write(root: Path, rel: str, text: str = "") -> None:
|
|
31
|
+
path = root / rel
|
|
32
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
path.write_text(text, encoding="utf-8")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _build(root: Path, **config_kwargs) -> VaultGraph:
|
|
37
|
+
return VaultGraph.build(
|
|
38
|
+
VaultConfig(root=root, **config_kwargs), WikilinkExtractor(), NormalizeResolver()
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestExtractor:
|
|
43
|
+
def test_extracts_a_relative_target(self):
|
|
44
|
+
assert MarkdownLinkExtractor().extract("[the note](other.md)") == ["other.md"]
|
|
45
|
+
|
|
46
|
+
def test_keeps_the_target_not_the_display_text(self):
|
|
47
|
+
assert MarkdownLinkExtractor().extract("[Some Title](a/b.md)") == ["a/b.md"]
|
|
48
|
+
|
|
49
|
+
def test_drops_an_anchor(self):
|
|
50
|
+
assert MarkdownLinkExtractor().extract("[x](note.md#Section)") == ["note.md"]
|
|
51
|
+
|
|
52
|
+
def test_ignores_images_urls_and_non_markdown_targets(self):
|
|
53
|
+
text = " [u](https://x.com/a.md) [p](doc.pdf) [[Wiki]]"
|
|
54
|
+
assert MarkdownLinkExtractor().extract(text) == []
|
|
55
|
+
|
|
56
|
+
def test_ignores_code_spans_and_fences(self):
|
|
57
|
+
assert MarkdownLinkExtractor().extract("`[a](n.md)`\n```\n[b](n.md)\n```\n") == []
|
|
58
|
+
|
|
59
|
+
def test_decodes_percent_encoding(self):
|
|
60
|
+
# Real markdown encodes spaces; the vault stores them literally.
|
|
61
|
+
assert MarkdownLinkExtractor().extract("[x](my%20note.md)") == ["my note.md"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestDefaultIsUnchanged:
|
|
65
|
+
def test_markdown_links_are_ignored_by_default(self, tmp_path):
|
|
66
|
+
_write(tmp_path, "a.md", "[one](b.md)\n")
|
|
67
|
+
_write(tmp_path, "b.md")
|
|
68
|
+
graph = _build(tmp_path)
|
|
69
|
+
assert graph.out_links["a.md"] == set()
|
|
70
|
+
assert sum(graph.link_counts.values()) == 0
|
|
71
|
+
|
|
72
|
+
def test_wikilinks_still_work_in_markdown_mode_only_if_asked(self, tmp_path):
|
|
73
|
+
_write(tmp_path, "a.md", "[[b]]\n")
|
|
74
|
+
_write(tmp_path, "b.md")
|
|
75
|
+
assert _build(tmp_path, link_syntax="markdown").out_links["a.md"] == set()
|
|
76
|
+
assert _build(tmp_path, link_syntax="both").out_links["a.md"] == {"b.md"}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestRelativeResolution:
|
|
80
|
+
def test_a_sibling_target_resolves(self, tmp_path):
|
|
81
|
+
_write(tmp_path, "docs/a.md", "[one](b.md)\n")
|
|
82
|
+
_write(tmp_path, "docs/b.md")
|
|
83
|
+
assert _build(tmp_path, link_syntax="markdown").out_links["docs/a.md"] == {"docs/b.md"}
|
|
84
|
+
|
|
85
|
+
def test_a_parent_relative_target_resolves(self, tmp_path):
|
|
86
|
+
_write(tmp_path, "docs/deep/a.md", "[one](../other/b.md)\n")
|
|
87
|
+
_write(tmp_path, "docs/other/b.md")
|
|
88
|
+
graph = _build(tmp_path, link_syntax="markdown")
|
|
89
|
+
assert graph.out_links["docs/deep/a.md"] == {"docs/other/b.md"}
|
|
90
|
+
|
|
91
|
+
def test_an_explicit_dot_slash_resolves(self, tmp_path):
|
|
92
|
+
_write(tmp_path, "docs/a.md", "[one](./b.md)\n")
|
|
93
|
+
_write(tmp_path, "docs/b.md")
|
|
94
|
+
assert _build(tmp_path, link_syntax="markdown").out_links["docs/a.md"] == {"docs/b.md"}
|
|
95
|
+
|
|
96
|
+
def test_the_same_name_in_two_folders_does_not_collide(self, tmp_path):
|
|
97
|
+
# The point of relative resolution: a bare name is NOT ambiguous here, because the
|
|
98
|
+
# linking note's folder decides. Under wikilink rules this same pair is ambiguous.
|
|
99
|
+
_write(tmp_path, "one/a.md", "[x](n.md)\n")
|
|
100
|
+
_write(tmp_path, "one/n.md")
|
|
101
|
+
_write(tmp_path, "two/n.md")
|
|
102
|
+
graph = _build(tmp_path, link_syntax="markdown")
|
|
103
|
+
assert graph.out_links["one/a.md"] == {"one/n.md"}
|
|
104
|
+
assert graph.link_counts["ambiguous"] == 0
|
|
105
|
+
|
|
106
|
+
def test_a_target_escaping_the_vault_is_missing(self, tmp_path):
|
|
107
|
+
_write(tmp_path, "a.md", "[out](../outside.md)\n")
|
|
108
|
+
graph = _build(tmp_path, link_syntax="markdown")
|
|
109
|
+
assert graph.link_counts["missing"] == 1
|
|
110
|
+
assert graph.out_links["a.md"] == set()
|
|
111
|
+
|
|
112
|
+
def test_a_target_that_does_not_exist_is_missing(self, tmp_path):
|
|
113
|
+
_write(tmp_path, "a.md", "[gone](nowhere.md)\n")
|
|
114
|
+
assert _build(tmp_path, link_syntax="markdown").link_counts["missing"] == 1
|
|
115
|
+
|
|
116
|
+
def test_a_self_link_is_not_an_edge(self, tmp_path):
|
|
117
|
+
_write(tmp_path, "a.md", "[self](a.md)\n")
|
|
118
|
+
graph = _build(tmp_path, link_syntax="markdown")
|
|
119
|
+
assert graph.out_links["a.md"] == set()
|
|
120
|
+
assert graph.link_counts["resolved"] == 1
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TestBothModes:
|
|
124
|
+
def test_both_syntaxes_are_counted_and_conserved(self, tmp_path):
|
|
125
|
+
_write(tmp_path, "a.md", "[[b]]\n[one](b.md)\n[gone](nope.md)\n")
|
|
126
|
+
_write(tmp_path, "b.md")
|
|
127
|
+
graph = _build(tmp_path, link_syntax="both")
|
|
128
|
+
assert sum(graph.link_counts.values()) == 3
|
|
129
|
+
assert graph.link_counts["resolved"] == 2
|
|
130
|
+
assert graph.link_counts["missing"] == 1
|
|
131
|
+
assert graph.out_links["a.md"] == {"b.md"}
|
|
132
|
+
|
|
133
|
+
def test_the_unread_syntax_warning_is_silent_once_markdown_is_read(self, tmp_path, capsys):
|
|
134
|
+
_write(tmp_path, "a.md", "[1](b.md)\n[2](b.md)\n[3](b.md)\n")
|
|
135
|
+
_write(tmp_path, "b.md")
|
|
136
|
+
_build(tmp_path, link_syntax="both")
|
|
137
|
+
assert capsys.readouterr().err == ""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TestConfig:
|
|
141
|
+
def test_an_unknown_syntax_is_rejected(self, tmp_path):
|
|
142
|
+
_write(tmp_path, "a.md")
|
|
143
|
+
try:
|
|
144
|
+
_build(tmp_path, link_syntax="logseq")
|
|
145
|
+
except ValueError as e:
|
|
146
|
+
assert "link_syntax" in str(e)
|
|
147
|
+
else:
|
|
148
|
+
raise AssertionError("an unknown link_syntax must fail loudly")
|
|
149
|
+
|
|
150
|
+
def test_it_loads_from_toml(self, tmp_path):
|
|
151
|
+
from graphmark.config import load_config
|
|
152
|
+
|
|
153
|
+
cfg = tmp_path / "v.toml"
|
|
154
|
+
cfg.write_text(f'root = "{tmp_path}"\nlink_syntax = "both"\n', encoding="utf-8")
|
|
155
|
+
assert load_config(cfg).link_syntax == "both"
|
|
@@ -44,14 +44,21 @@ def _readme_commands() -> list[str]:
|
|
|
44
44
|
# non-greedy match begin at a *closing* fence and swallow prose between blocks, which silently
|
|
45
45
|
# yields zero commands — caught by the guard test below rather than shipping a vacuous suite.
|
|
46
46
|
blocks = re.findall(
|
|
47
|
-
r"^```(
|
|
47
|
+
r"^```(bash|console|sh)\n(.*?)^```", README.read_text(), re.DOTALL | re.MULTILINE
|
|
48
48
|
)
|
|
49
49
|
commands = []
|
|
50
|
-
for block in blocks:
|
|
50
|
+
for lang, block in blocks:
|
|
51
51
|
for line in block.splitlines():
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
# In a `console` block, `$ ` marks input and everything else is *output* — which is why
|
|
53
|
+
# the prefix is required there. Without that rule a wrapped warning line beginning
|
|
54
|
+
# "graphmark only reads ..." is collected as a command and run, which is exactly what
|
|
55
|
+
# happened when the link-syntax section was added.
|
|
56
|
+
if lang == "console":
|
|
57
|
+
if not line.startswith("$ "):
|
|
58
|
+
continue
|
|
59
|
+
line = line.removeprefix("$ ")
|
|
60
|
+
if line.strip().startswith("graphmark "):
|
|
61
|
+
commands.append(line.strip())
|
|
55
62
|
return commands
|
|
56
63
|
|
|
57
64
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Warn when the vault's links are in a syntax graphmark does not read (#151).
|
|
2
|
+
|
|
3
|
+
graphmark can report a confidently empty graph for a densely linked vault. Measured on
|
|
4
|
+
`lyz-code/blue-book`: 1120 notes, 11,198 markdown-style `[text](note.md)` links (99% of them
|
|
5
|
+
targeting a note that exists), and **zero** extracted edges. Every note an orphan, and `check` looks
|
|
6
|
+
nearly healthy — `max_unresolved_links` sees 38, because links that were never extracted are not
|
|
7
|
+
*unresolved*.
|
|
8
|
+
|
|
9
|
+
The conservation law from #124 cannot catch this: it sums over what the extractor produced, so a
|
|
10
|
+
syntax the extractor does not know is invisible to it by construction. The buckets balance perfectly
|
|
11
|
+
at 39 while 11,198 links sit outside the universe being counted.
|
|
12
|
+
|
|
13
|
+
The signal is deliberately relational, not calibrated — it fires only when the unread syntax
|
|
14
|
+
*outnumbers* the read one. No threshold to tune and no false-positive mode, in the same spirit as
|
|
15
|
+
#133's assertion.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from graphmark.config import VaultConfig
|
|
23
|
+
from graphmark.graph import NormalizeResolver, VaultGraph
|
|
24
|
+
from graphmark.parse import WikilinkExtractor, count_markdown_links
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _write(root: Path, rel: str, text: str = "") -> None:
|
|
28
|
+
path = root / rel
|
|
29
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
path.write_text(text, encoding="utf-8")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _build(root: Path, **config_kwargs) -> VaultGraph:
|
|
34
|
+
return VaultGraph.build(
|
|
35
|
+
VaultConfig(root=root, **config_kwargs), WikilinkExtractor(), NormalizeResolver()
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestCounting:
|
|
40
|
+
def test_counts_a_relative_markdown_link(self):
|
|
41
|
+
assert count_markdown_links("see [the note](other.md) here") == 1
|
|
42
|
+
|
|
43
|
+
def test_counts_a_path_and_an_anchor(self):
|
|
44
|
+
assert count_markdown_links("[a](../deep/note.md) [b](note.md#Section)") == 2
|
|
45
|
+
|
|
46
|
+
def test_ignores_absolute_urls(self):
|
|
47
|
+
text = "[spec](https://example.com/docs/README.md) [m](mailto:x@y.md)"
|
|
48
|
+
assert count_markdown_links(text) == 0
|
|
49
|
+
|
|
50
|
+
def test_ignores_images_and_non_markdown_targets(self):
|
|
51
|
+
assert count_markdown_links(" [pdf](doc.pdf) [c](board.canvas)") == 0
|
|
52
|
+
|
|
53
|
+
def test_ignores_wikilinks(self):
|
|
54
|
+
assert count_markdown_links("[[Note]] and [[folder/Note.md]]") == 0
|
|
55
|
+
|
|
56
|
+
def test_ignores_a_link_inside_a_code_fence(self):
|
|
57
|
+
# Same exclusion the wikilink extractor applies; a documented example is not a link.
|
|
58
|
+
assert count_markdown_links("```\n[a](note.md)\n```\n") == 0
|
|
59
|
+
|
|
60
|
+
def test_ignores_a_link_inside_an_inline_code_span(self):
|
|
61
|
+
assert count_markdown_links("`[a](note.md)`") == 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestWarning:
|
|
65
|
+
def test_warns_when_the_unread_syntax_outnumbers_the_read_one(self, tmp_path, capsys):
|
|
66
|
+
_write(tmp_path, "a.md", "[one](b.md)\n[two](c.md)\n[three](b.md)\n[[b]]\n")
|
|
67
|
+
_write(tmp_path, "b.md")
|
|
68
|
+
_write(tmp_path, "c.md")
|
|
69
|
+
_build(tmp_path)
|
|
70
|
+
captured = capsys.readouterr()
|
|
71
|
+
assert captured.out == "", "the JSON surface must stay pipeable"
|
|
72
|
+
assert "3" in captured.err and "markdown" in captured.err.lower()
|
|
73
|
+
assert captured.err.count("warning") == 1, "exactly one line, not one per note"
|
|
74
|
+
|
|
75
|
+
def test_is_silent_when_wikilinks_dominate(self, tmp_path, capsys):
|
|
76
|
+
# The reference vault's real ratio: a handful of markdown links against thousands of
|
|
77
|
+
# wikilinks. This must never fire there.
|
|
78
|
+
body = "[[b]]\n" * 20 + "[stray](b.md)\n"
|
|
79
|
+
_write(tmp_path, "a.md", body)
|
|
80
|
+
_write(tmp_path, "b.md")
|
|
81
|
+
_build(tmp_path)
|
|
82
|
+
assert capsys.readouterr().err == ""
|
|
83
|
+
|
|
84
|
+
def test_is_silent_with_no_markdown_links_at_all(self, tmp_path, capsys):
|
|
85
|
+
_write(tmp_path, "a.md", "[[b]]\n")
|
|
86
|
+
_write(tmp_path, "b.md")
|
|
87
|
+
_build(tmp_path)
|
|
88
|
+
assert capsys.readouterr().err == ""
|
|
89
|
+
|
|
90
|
+
def test_is_silent_on_an_empty_vault(self, tmp_path, capsys):
|
|
91
|
+
_write(tmp_path, "a.md", "no links here\n")
|
|
92
|
+
_build(tmp_path)
|
|
93
|
+
assert capsys.readouterr().err == ""
|
|
94
|
+
|
|
95
|
+
def test_an_equal_count_does_not_warn(self, tmp_path, capsys):
|
|
96
|
+
# Strictly greater, so the signal cannot fire on a tie — deliberately conservative.
|
|
97
|
+
_write(tmp_path, "a.md", "[[b]]\n[one](b.md)\n")
|
|
98
|
+
_write(tmp_path, "b.md")
|
|
99
|
+
_build(tmp_path)
|
|
100
|
+
assert capsys.readouterr().err == ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class TestNoBehaviorChange:
|
|
104
|
+
def test_the_warning_changes_no_count_and_no_edge(self, tmp_path, capsys):
|
|
105
|
+
_write(tmp_path, "a.md", "[one](b.md)\n[two](b.md)\n[[b]]\n")
|
|
106
|
+
_write(tmp_path, "b.md")
|
|
107
|
+
graph = _build(tmp_path)
|
|
108
|
+
capsys.readouterr()
|
|
109
|
+
assert graph.link_counts["resolved"] == 1
|
|
110
|
+
assert sum(graph.link_counts.values()) == 1, "markdown links are counted nowhere"
|
|
111
|
+
assert graph.out_links["a.md"] == {"b.md"}
|
|
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.7.2 → graphmark-0.8.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|