graphmark 0.7.0__tar.gz → 0.7.2__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.0 → graphmark-0.7.2}/CHANGELOG.md +169 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/PKG-INFO +1 -1
- {graphmark-0.7.0 → graphmark-0.7.2}/docs/ROADMAP.md +45 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/pyproject.toml +1 -1
- {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/cli.py +81 -12
- {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/graph.py +85 -10
- {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/parse.py +9 -0
- graphmark-0.7.2/tests/test_bom.py +97 -0
- graphmark-0.7.2/tests/test_non_note_file.py +90 -0
- graphmark-0.7.2/tests/test_path_suffix.py +114 -0
- graphmark-0.7.2/tests/test_properties.py +692 -0
- graphmark-0.7.2/tests/test_readme_examples.py +151 -0
- graphmark-0.7.2/tests/test_unicode.py +102 -0
- graphmark-0.7.2/tests/test_unicode_punctuation.py +113 -0
- graphmark-0.7.0/tests/test_properties.py +0 -322
- {graphmark-0.7.0 → graphmark-0.7.2}/.gitignore +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/LICENSE +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/README.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/__init__.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/check.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/config.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/dismiss.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/export.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/interfaces.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/metrics.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/model.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/py.typed +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/README.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/config.toml +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/expected.json +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/vault/docs/center.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/vault/misc/echo.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/vault/misc/stub.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/vault/refs/beta.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/vault/refs/delta.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/dismiss/expected.json +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/dismiss/vault/alpha.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/dismiss/vault/beta.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/dismiss/vault/gamma.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/config.toml +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/expected.json +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/expected_oog.json +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/similar.json +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/similar_oog.json +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/vault/docs/a.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/vault/misc/c.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/vault/misc/d.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/vault/refs/b.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/vault/refs/e.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/scoped/config.toml +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/scoped/expected.json +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/scoped/vault/docs/one.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/scoped/vault/refs/two.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/selflink/config.toml +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/selflink/expected.json +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/selflink/vault/a.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/selflink/vault/b.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/simple/config.toml +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/simple/expected.json +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/simple/vault/brain/hub.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/simple/vault/personal/beta.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/simple/vault/reference/island.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/simple/vault/reference/stub.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/suggest/README.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_aliases.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_build_filters.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_check.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_check_policy.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_cli.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_config.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_degenerate.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_diagnose.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_dismiss.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_export.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_gaps.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_gaps_banding.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_gaps_oog.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_gaps_params.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_graph.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_graph_catalogs.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_link_counts.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_links_report.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_metrics.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_model.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_packaging.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_pagerank.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_parse.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_public_api.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_scoped.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_selflink.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_smoke.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_suggestions.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_unresolved.py +0 -0
|
@@ -1,6 +1,175 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.7.2 (2026-07-25)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- **cli**: Accept --config/--root after the subcommand (#130)
|
|
9
|
+
([#148](https://github.com/cdcoonce/graphmark/pull/148),
|
|
10
|
+
[`1a89e2e`](https://github.com/cdcoonce/graphmark/commit/1a89e2ef542581f076200a29ddd04e0773cbc2dd))
|
|
11
|
+
|
|
12
|
+
Every CLI example in the README failed. `--config` and `--root` were defined on the parent parser
|
|
13
|
+
before `add_subparsers`, so argparse accepted them only BEFORE the subcommand, while all ten
|
|
14
|
+
examples in the CLI block — plus the `check` ones — are written the other way. The README is the
|
|
15
|
+
PyPI landing page, so the first thing a new user copies errored.
|
|
16
|
+
|
|
17
|
+
Both options are now attached to every subparser too, under a separate dest, and reconciled after
|
|
18
|
+
parsing (argparse would otherwise let the subparser's default overwrite a value the top-level
|
|
19
|
+
parser had already read). Both collect with action="append", so a repeat within one position is
|
|
20
|
+
caught as well as one across the two: conflicting values are a usage error, not a silent
|
|
21
|
+
last-wins.
|
|
22
|
+
|
|
23
|
+
The actual defect was that the docs and the parser had no shared test — the suite used the working
|
|
24
|
+
form exclusively. `tests/test_readme_examples.py` now extracts every `graphmark ...` invocation
|
|
25
|
+
from README.md and runs it, so an example that stops working fails the gate instead of shipping.
|
|
26
|
+
|
|
27
|
+
- **graph**: Fold non-ASCII punctuation in _normalize (#139)
|
|
28
|
+
([#145](https://github.com/cdcoonce/graphmark/pull/145),
|
|
29
|
+
[`cef4484`](https://github.com/cdcoonce/graphmark/commit/cef448405c1d59577c85ff4f8723299f3ee74dbc))
|
|
30
|
+
|
|
31
|
+
`string.punctuation` is ASCII-only, so every non-ASCII mark survived normalization and then had to
|
|
32
|
+
be typed byte-for-byte: an em-dash title was unreachable from a typed hyphen, a curly apostrophe
|
|
33
|
+
from a straight one, `…` from `...`. The catalog key literally retained the mark while an
|
|
34
|
+
ASCII-typed display normalized it away, so the two could never meet — while Obsidian's own
|
|
35
|
+
switcher resolves it.
|
|
36
|
+
|
|
37
|
+
`_fold_punctuation` folds by Unicode category (P* and S*) instead, a superset of the old ASCII
|
|
38
|
+
table, so pure-ASCII results are unchanged. The ASCII translate table stays the fast path; the
|
|
39
|
+
cached per-character category lookup runs only on strings that still hold non-ASCII after it.
|
|
40
|
+
|
|
41
|
+
Builds on #123: NFC composition runs first, so a decomposed accent is a single letter rather than a
|
|
42
|
+
letter plus a combining mark this fold must not touch.
|
|
43
|
+
|
|
44
|
+
Path-suffix matching deliberately still folds only case and form — a "/" is structural there and
|
|
45
|
+
punctuation folding needs the extension handled first. Follow-up filed.
|
|
46
|
+
|
|
47
|
+
Reference vault: 531 notes, link counts identical, build 207 ms.
|
|
48
|
+
|
|
49
|
+
- **graph**: Normalize Unicode form before comparing names (#123)
|
|
50
|
+
([#144](https://github.com/cdcoonce/graphmark/pull/144),
|
|
51
|
+
[`9251554`](https://github.com/cdcoonce/graphmark/commit/9251554691723e7b6ca1d11bf0b7ddb1dffed002))
|
|
52
|
+
|
|
53
|
+
macOS's HFS+/APFS store filenames decomposed (NFD: `é` is `e` + U+0301) while text typed in an
|
|
54
|
+
editor is composed (NFC: a single U+00E9). `_normalize` lowercased, folded punctuation and
|
|
55
|
+
collapsed whitespace but never normalized Unicode form, so the two strings never compared equal
|
|
56
|
+
and an accented note title was a phantom broken link. Obsidian resolves it fine.
|
|
57
|
+
|
|
58
|
+
`_fold_case` does NFC-then-lowercase and is shared by `_normalize` and by path-suffix matching — the
|
|
59
|
+
one comparison that does not go through `_normalize` — so no comparison in the module can be left
|
|
60
|
+
in the wrong form. Note names, aliases and path-qualified links all fold identically as a result.
|
|
61
|
+
|
|
62
|
+
Eighth false-positive class in `unresolved`. The reference vault is unaffected (its only non-ASCII
|
|
63
|
+
filenames are em-dashes, which have no decomposition — which is exactly why this stayed invisible
|
|
64
|
+
there); link counts byte-identical.
|
|
65
|
+
|
|
66
|
+
- **graph**: Require a letter in a file extension (#138)
|
|
67
|
+
([#147](https://github.com/cdcoonce/graphmark/pull/147),
|
|
68
|
+
[`4206cc5`](https://github.com/cdcoonce/graphmark/commit/4206cc5f941e5f0967202ac0b99390a55ab0393e))
|
|
69
|
+
|
|
70
|
+
`_targets_non_note_file` treated any trailing `.` + 1-10 alphanumerics as an extension, so
|
|
71
|
+
`[[Meeting 3.5]]` with no such note was suppressed as a file instead of reported as broken. `.5`
|
|
72
|
+
satisfied the pattern as readily as `.md`.
|
|
73
|
+
|
|
74
|
+
The first class found that DEFLATES `check`'s flagship number rather than inflating it, which is
|
|
75
|
+
strictly worse for a gate: an undercount is invisible by construction and reads as health. It hits
|
|
76
|
+
any vault that numbers its notes — phases, specs, chapters, versioned documents.
|
|
77
|
+
|
|
78
|
+
The separating rule is that a real extension contains a letter; ".5" and ".61850" differ only in
|
|
79
|
+
length, so no length bound could tell them apart. The existing 10-character bound and the
|
|
80
|
+
space-after-the-dot rule are both preserved.
|
|
81
|
+
|
|
82
|
+
Reference vault: 17 non-note-file suppressions before and after, all genuine .base/.png
|
|
83
|
+
(hand-audited).
|
|
84
|
+
|
|
85
|
+
### Testing
|
|
86
|
+
|
|
87
|
+
- **properties**: Add the known-name invariant and an oracle-backed generator (#133)
|
|
88
|
+
([#149](https://github.com/cdcoonce/graphmark/pull/149),
|
|
89
|
+
[`4cd304f`](https://github.com/cdcoonce/graphmark/commit/4cd304f80da52f2941205986d3b4bb3de783fdab))
|
|
90
|
+
|
|
91
|
+
Two things, one purpose: make Track F's detector actually detect.
|
|
92
|
+
|
|
93
|
+
#133's invariant — no link reported `missing` may name a key the resolver already knows (catalog or
|
|
94
|
+
aliases). Under correct behavior that is impossible, so it is an internal-consistency assertion,
|
|
95
|
+
not a heuristic: no thresholds, no calibration, no false-positive mode. Asserted over generated
|
|
96
|
+
vaults, with resolve_aliases=False (where the alias half is vacuous rather than wrong), and with
|
|
97
|
+
teeth — breaking the lookup while the map is still built must fail it.
|
|
98
|
+
|
|
99
|
+
The generator, measured. With the alphabet widened to today's four defect classes, reverting
|
|
100
|
+
#123/#136/#137/#138/#139 one at a time still left the suite GREEN: 0 of 4. Every existing property
|
|
101
|
+
is a conservation or self-consistency check, and each of those bugs is perfectly self-consistent —
|
|
102
|
+
#136's fabricated edge really is in the graph. Self-consistency cannot see a wrong answer.
|
|
103
|
+
|
|
104
|
+
So the generator now carries an oracle: it builds a note, then writes the link FROM it through a
|
|
105
|
+
transformation Obsidian treats as identity-preserving, so the expected target is known by
|
|
106
|
+
construction. Plus a negative oracle over names known to be absent (the false-negative half, which
|
|
107
|
+
no conservation law can see), narrow per-vault name pools so collisions co-occur, and an assertion
|
|
108
|
+
that an `ambiguous` candidate set is minimal rather than merely non-empty.
|
|
109
|
+
|
|
110
|
+
Catch rate on the same experiment: 6 of 6, adding #119.
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
## v0.7.1 (2026-07-25)
|
|
114
|
+
|
|
115
|
+
### Bug Fixes
|
|
116
|
+
|
|
117
|
+
- **graph**: Match path-suffix links on component boundaries (#136)
|
|
118
|
+
([#140](https://github.com/cdcoonce/graphmark/pull/140),
|
|
119
|
+
[`3c23b1a`](https://github.com/cdcoonce/graphmark/commit/3c23b1a04a86cdf1c482075c976ed5db86f4e18e))
|
|
120
|
+
|
|
121
|
+
`[[work/Tasks]]` resolved to `homework/Tasks.md`. Path-suffix resolution tested a raw string suffix,
|
|
122
|
+
so nothing required the character before the match to be `/`.
|
|
123
|
+
|
|
124
|
+
This is the first defect class in this package that fabricates an *edge* rather than moving a link
|
|
125
|
+
between reported buckets, and it is invisible by construction: the link counts `resolved`, so no
|
|
126
|
+
bucket looks implausible, while orphans, hubs, clusters, bridges, siloed_notes and PageRank all
|
|
127
|
+
read a graph that does not describe the vault. The mirror case is equally wrong — when the real
|
|
128
|
+
folder also exists, the spurious second match makes the resolver decline a correct link and report
|
|
129
|
+
it ambiguous.
|
|
130
|
+
|
|
131
|
+
`_matches_path_suffix` requires the match to consume the whole rel_path or be preceded by `/`, and
|
|
132
|
+
both the resolver and `candidates_for` go through it, so the ambiguity set a consumer is shown can
|
|
133
|
+
never contain a path the resolver would not have considered.
|
|
134
|
+
|
|
135
|
+
Measured on the reference vault: 0 occurrences, graph byte-identical. Latent, not absent — it needs
|
|
136
|
+
one folder whose name ends with another's (`work`/`homework`, `ops`/`devops`,
|
|
137
|
+
`<x>`/`archive-<x>`), which is ordinary.
|
|
138
|
+
|
|
139
|
+
- **parse**: Strip a leading UTF-8 BOM before the frontmatter split (#137)
|
|
140
|
+
([#141](https://github.com/cdcoonce/graphmark/pull/141),
|
|
141
|
+
[`e92afd6`](https://github.com/cdcoonce/graphmark/commit/e92afd6484d4d8b966122df1db858cd791b33e0a))
|
|
142
|
+
|
|
143
|
+
`_FM_RE` is anchored with `.match` and decoding leaves U+FEFF at index 0, so a BOM'd note had no
|
|
144
|
+
frontmatter at all. One cause, two opposite symptoms: its `aliases:` never registered, so links
|
|
145
|
+
written against them were phantom breaks (#119's class, reintroduced through the parser); and its
|
|
146
|
+
frontmatter wikilinks stayed in the body and became phantom edges — the exact failure `_FM_RE`'s
|
|
147
|
+
own docstring says it exists to prevent. That regex was hardened for CRLF and for a block ending
|
|
148
|
+
at EOF, but not for the byte that can precede the block.
|
|
149
|
+
|
|
150
|
+
Stripped on the decoded text, so the frontmatter split, the body and the extractor all see the same
|
|
151
|
+
string on both decode paths. Leading only: elsewhere U+FEFF is a zero-width no-break space and is
|
|
152
|
+
legitimate content.
|
|
153
|
+
|
|
154
|
+
Reference vault: 0 BOM'd notes. Latent, not absent — Windows editors, PowerShell's default Out-File
|
|
155
|
+
and some git filters emit BOMs.
|
|
156
|
+
|
|
157
|
+
### Documentation
|
|
158
|
+
|
|
159
|
+
- **roadmap**: Record Track F's interim finding-method result
|
|
160
|
+
([#142](https://github.com/cdcoonce/graphmark/pull/142),
|
|
161
|
+
[`2e6c368`](https://github.com/cdcoonce/graphmark/commit/2e6c368aa16cd3b0cd5dbf4fe6912dcbe7bb1427))
|
|
162
|
+
|
|
163
|
+
Four defects (#136 #137 #138 #139) found in one pass after #124-#126 shipped — none by reading link
|
|
164
|
+
lists, none by the property generator. The method that worked was adversarial reading of the
|
|
165
|
+
resolver and parser, confirmed by probe. All four measure 0 occurrences on the reference vault.
|
|
166
|
+
|
|
167
|
+
Names two limits this exposes: the counts cannot surface a wrong answer INSIDE a bucket (#136 files
|
|
168
|
+
as `resolved`, #138 hides a break in `non-note-file`), and #126's generator draws from an alphabet
|
|
169
|
+
that never reaches these input classes. Also records the hand audit of both suppressed buckets: 0
|
|
170
|
+
false suppressions.
|
|
171
|
+
|
|
172
|
+
|
|
4
173
|
## v0.7.0 (2026-07-25)
|
|
5
174
|
|
|
6
175
|
### Documentation
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graphmark
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
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
|
|
@@ -144,6 +144,51 @@ calibration gate #112 used).
|
|
|
144
144
|
Track F's own success measure is not slices merged: it is **bugs found by machinery rather than by
|
|
145
145
|
a human reading link lists.** #126's catch rate is the evidence.
|
|
146
146
|
|
|
147
|
+
_Interim result (2026-07-25, after #124–#126 shipped):_ four more defects — #136, #137, #138, #139 —
|
|
148
|
+
found in one pass, none of them by reading link lists and **none of them by the property generator
|
|
149
|
+
either**. The method that worked was adversarial reading of the resolver and parser against the
|
|
150
|
+
question _"what input could this classify wrongly?"_, then a scripted probe to confirm. All four
|
|
151
|
+
measure **0 occurrences on the reference vault**, which is the point: a single-vault count is not
|
|
152
|
+
evidence of absence, and every one of them is ordinary on some other vault.
|
|
153
|
+
|
|
154
|
+
Two things this says about the track:
|
|
155
|
+
|
|
156
|
+
- **The counts alone would not have surfaced these.** #136 fabricates an edge to the wrong note and
|
|
157
|
+
is filed `resolved`; #138 hides a genuine break inside `non-note-file`. The distribution looks
|
|
158
|
+
healthy in both cases. Auditable accounting makes _mis-bucketing between reported buckets_
|
|
159
|
+
visible; it does not make a wrong answer _inside_ a bucket visible. That is a real limit of the
|
|
160
|
+
Track F thesis, not a gap in its execution.
|
|
161
|
+
- **#126's generator is under-powered where it matters.** It generates vaults from names the
|
|
162
|
+
resolver already handles. The defect classes live in the input space it does not reach: folder
|
|
163
|
+
names that are suffixes of other folder names, BOMs, numeric title suffixes, non-ASCII
|
|
164
|
+
punctuation. Widening the generator's _alphabet_ — not its vault count — is the follow-on.
|
|
165
|
+
|
|
166
|
+
_Follow-on, same day (#133's slice):_ widening the alphabet was **not enough, and the measurement
|
|
167
|
+
says why.** With the alphabet widened, reverting #123/#136/#137/#138/#139 one at a time left the
|
|
168
|
+
whole property suite green — a catch rate of **0 of 4** on the classes it was widened for. Every
|
|
169
|
+
invariant in that file is a _conservation or self-consistency_ property, and each of those bugs is
|
|
170
|
+
perfectly self-consistent: #136's fabricated edge really is in the graph, so nothing vanishes; a
|
|
171
|
+
BOM'd note simply has no frontmatter; a link the normalizer cannot match is genuinely missing by the
|
|
172
|
+
package's own rules. **Self-consistency cannot see a wrong answer.**
|
|
173
|
+
|
|
174
|
+
What was missing is an **oracle** — a link whose intended target is known independently of what the
|
|
175
|
+
resolver says. The generator now builds a note first and writes the link _from_ it, through a
|
|
176
|
+
transformation Obsidian treats as identity-preserving (case, `.md`, anchor, alias display, padding,
|
|
177
|
+
NFD/NFC, punctuation swaps, path qualification, ASCII twins of typographic marks), plus a negative
|
|
178
|
+
oracle over names known to be absent. Two further changes were needed to make it bite: narrow
|
|
179
|
+
per-vault name pools, so the collisions that matter actually co-occur, and an assertion that an
|
|
180
|
+
`ambiguous` candidate set is _minimal_, not merely non-empty.
|
|
181
|
+
|
|
182
|
+
Catch rate on the same experiment: **6 of 6**, adding #119 — the class that started this track.
|
|
183
|
+
|
|
184
|
+
The generalizable lesson, and the one to carry into future test design here: **a property suite over
|
|
185
|
+
generated input still needs a correct answer to compare against.** Invariants prove the package is
|
|
186
|
+
consistent with itself, which is exactly the thing a systematically wrong resolver also is.
|
|
187
|
+
|
|
188
|
+
Audited alongside: the reference vault's two suppressed buckets, by hand — 17 `non-note-file` (all
|
|
189
|
+
genuine `.base`/`.png`) and 40 `intra-note` (all genuine heading refs), 0 false suppressions. A
|
|
190
|
+
suppressed bucket is only trustworthy once someone has read it.
|
|
191
|
+
|
|
147
192
|
_Deliberately deferred behind this:_ the Track D GitHub Action. It is an adoption play, and adoption
|
|
148
193
|
is not this repo's game; more to the point, shipping a gate that fails other people's builds while
|
|
149
194
|
the tool's own accounting is unauditable is backwards ordering. Generality work (Windows paths,
|
|
@@ -42,7 +42,39 @@ def _load(args: argparse.Namespace) -> tuple[VaultGraph, VaultConfig]:
|
|
|
42
42
|
return graph, config
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
def _reconcile_globals(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
|
|
46
|
+
"""Merge each global's leading and trailing occurrence into one value.
|
|
47
|
+
|
|
48
|
+
``--config``/``--root`` are attached to the top-level parser *and*, under a separate dest, to
|
|
49
|
+
every subparser, so both ``graphmark --root X stats`` and ``graphmark stats --root X`` work.
|
|
50
|
+
Trailing-option order is what every comparable CLI accepts and what a user types by reflex — and
|
|
51
|
+
it is what the README documents, so before this the first command a new user copied errored.
|
|
52
|
+
|
|
53
|
+
Repeats with the **same** value are harmless. Two different values are a usage error rather than
|
|
54
|
+
a silent last-wins: guessing which one the user meant is exactly the kind of quiet wrong answer
|
|
55
|
+
this tool exists not to give. Both options collect with ``action="append"``, so this covers a
|
|
56
|
+
repeat *within* one position (``stats --root A --root B``) as well as one across the two —
|
|
57
|
+
argparse's own last-wins would otherwise swallow the first silently.
|
|
58
|
+
"""
|
|
59
|
+
for name in ("config", "root"):
|
|
60
|
+
after = f"{name}_after"
|
|
61
|
+
# `after` is absent when no subcommand ran at all — argparse never reached a subparser.
|
|
62
|
+
given = (getattr(args, name) or []) + (getattr(args, after, None) or [])
|
|
63
|
+
distinct = sorted(set(given))
|
|
64
|
+
if len(distinct) > 1:
|
|
65
|
+
parser.error(f"--{name} given more than once with conflicting values: {distinct}")
|
|
66
|
+
setattr(args, name, distinct[0] if distinct else None)
|
|
67
|
+
if hasattr(args, after):
|
|
68
|
+
delattr(args, after)
|
|
69
|
+
|
|
70
|
+
|
|
45
71
|
def main() -> None:
|
|
72
|
+
# Attached to every subparser under a distinct dest, then reconciled: argparse would otherwise
|
|
73
|
+
# let the subparser's default (None) overwrite a value the top-level parser had already read.
|
|
74
|
+
trailing_globals = argparse.ArgumentParser(add_help=False)
|
|
75
|
+
trailing_globals.add_argument("--config", metavar="PATH", dest="config_after", action="append")
|
|
76
|
+
trailing_globals.add_argument("--root", metavar="PATH", dest="root_after", action="append")
|
|
77
|
+
|
|
46
78
|
parser = argparse.ArgumentParser(
|
|
47
79
|
prog="graphmark",
|
|
48
80
|
description=(
|
|
@@ -51,49 +83,86 @@ def main() -> None:
|
|
|
51
83
|
),
|
|
52
84
|
)
|
|
53
85
|
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
54
|
-
parser.add_argument("--config", metavar="PATH", help="TOML config file")
|
|
55
|
-
parser.add_argument(
|
|
86
|
+
parser.add_argument("--config", metavar="PATH", action="append", help="TOML config file")
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--root", metavar="PATH", action="append", help="Vault root (overrides --config root)"
|
|
89
|
+
)
|
|
56
90
|
|
|
57
91
|
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
58
92
|
|
|
59
|
-
sub.add_parser(
|
|
60
|
-
|
|
93
|
+
sub.add_parser(
|
|
94
|
+
"stats",
|
|
95
|
+
parents=[trailing_globals],
|
|
96
|
+
help="Aggregate vault stats: notes, edges, orphans, clusters, density",
|
|
97
|
+
)
|
|
98
|
+
sub.add_parser(
|
|
99
|
+
"orphans", parents=[trailing_globals], help="Notes with no links in or out (degree 0)"
|
|
100
|
+
)
|
|
61
101
|
|
|
62
|
-
hubs_p = sub.add_parser(
|
|
102
|
+
hubs_p = sub.add_parser(
|
|
103
|
+
"hubs", parents=[trailing_globals], help="Most-connected notes, by undirected degree"
|
|
104
|
+
)
|
|
63
105
|
hubs_p.add_argument("--n", type=int, default=10, help="How many hubs to return (default: 10)")
|
|
64
106
|
|
|
65
|
-
sub.add_parser(
|
|
66
|
-
|
|
67
|
-
|
|
107
|
+
sub.add_parser(
|
|
108
|
+
"clusters",
|
|
109
|
+
parents=[trailing_globals],
|
|
110
|
+
help="Connected components of the link graph, largest first",
|
|
111
|
+
)
|
|
112
|
+
sub.add_parser(
|
|
113
|
+
"bridges",
|
|
114
|
+
parents=[trailing_globals],
|
|
115
|
+
help="Articulation points: notes whose removal splits the graph",
|
|
116
|
+
)
|
|
117
|
+
sub.add_parser(
|
|
118
|
+
"siloed",
|
|
119
|
+
parents=[trailing_globals],
|
|
120
|
+
help="Notes reachable from the mainland only through one bridge",
|
|
121
|
+
)
|
|
68
122
|
|
|
69
|
-
nb_p = sub.add_parser(
|
|
123
|
+
nb_p = sub.add_parser(
|
|
124
|
+
"neighborhood", parents=[trailing_globals], help="Links in and out of one note"
|
|
125
|
+
)
|
|
70
126
|
nb_p.add_argument("--note", required=True, help="Vault-relative path, e.g. brain/hub.md")
|
|
71
127
|
nb_p.add_argument(
|
|
72
128
|
"--depth", type=int, default=1, help="1 for direct links, 2 to add two-hop (default: 1)"
|
|
73
129
|
)
|
|
74
130
|
|
|
75
|
-
pr_p = sub.add_parser(
|
|
131
|
+
pr_p = sub.add_parser(
|
|
132
|
+
"pagerank",
|
|
133
|
+
parents=[trailing_globals],
|
|
134
|
+
help="PageRank importance ranking over the link graph",
|
|
135
|
+
)
|
|
76
136
|
pr_p.add_argument("--n", type=int, default=10, help="How many notes to return (default: 10)")
|
|
77
137
|
pr_p.add_argument(
|
|
78
138
|
"--alpha", type=float, default=0.85, help="Damping factor in (0, 1) (default: 0.85)"
|
|
79
139
|
)
|
|
80
140
|
|
|
81
|
-
exp_p = sub.add_parser(
|
|
141
|
+
exp_p = sub.add_parser(
|
|
142
|
+
"export", parents=[trailing_globals], help="Export the graph in another format"
|
|
143
|
+
)
|
|
82
144
|
exp_p.add_argument("format", choices=["dot"], help="Output format")
|
|
83
145
|
|
|
84
|
-
sub.add_parser(
|
|
146
|
+
sub.add_parser(
|
|
147
|
+
"gaps",
|
|
148
|
+
parents=[trailing_globals],
|
|
149
|
+
help="Link-gap suggestions (library-only; see the README)",
|
|
150
|
+
)
|
|
85
151
|
|
|
86
152
|
sub.add_parser(
|
|
87
153
|
"links",
|
|
154
|
+
parents=[trailing_globals],
|
|
88
155
|
help="How every wikilink was classified: counts per reason, plus alias resolutions",
|
|
89
156
|
)
|
|
90
157
|
|
|
91
158
|
sub.add_parser(
|
|
92
159
|
"check",
|
|
160
|
+
parents=[trailing_globals],
|
|
93
161
|
help="Gate vault health against the config's [check] thresholds (exit 1 on breach)",
|
|
94
162
|
)
|
|
95
163
|
|
|
96
164
|
args = parser.parse_args()
|
|
165
|
+
_reconcile_globals(parser, args)
|
|
97
166
|
|
|
98
167
|
# Usage errors all exit 2, matching argparse's own convention (and leaving exit 1 free for
|
|
99
168
|
# future domain-level outcomes such as a `check` threshold breach). Help for a missing
|
|
@@ -4,7 +4,9 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
6
|
import string
|
|
7
|
+
import unicodedata
|
|
7
8
|
from dataclasses import dataclass, field, replace
|
|
9
|
+
from functools import lru_cache
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
|
|
10
12
|
from graphmark.config import VaultConfig
|
|
@@ -15,15 +17,67 @@ from graphmark.parse import parse_document
|
|
|
15
17
|
_PUNCT_TABLE = str.maketrans(string.punctuation, " " * len(string.punctuation))
|
|
16
18
|
|
|
17
19
|
|
|
20
|
+
def _fold_case(text: str) -> str:
|
|
21
|
+
"""Compose to NFC, then lowercase — the two form-flattening steps, in that order.
|
|
22
|
+
|
|
23
|
+
macOS's HFS+/APFS store filenames **decomposed** (NFD: ``é`` is ``e`` + U+0301) while text typed
|
|
24
|
+
in an editor is **composed** (NFC: a single U+00E9). Without this the two never compare equal
|
|
25
|
+
and an accented note title is a phantom broken link, which Obsidian resolves fine.
|
|
26
|
+
|
|
27
|
+
NFC rather than NFD because it is what editors emit and what the web standardized on; the
|
|
28
|
+
filesystem's NFD becomes an implementation detail that never escapes the catalog.
|
|
29
|
+
|
|
30
|
+
Shared by ``_normalize`` and by path-suffix matching — the one place that does *not* go through
|
|
31
|
+
``_normalize`` — so no comparison in this module can be left in the wrong form.
|
|
32
|
+
"""
|
|
33
|
+
return unicodedata.normalize("NFC", text).lower()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@lru_cache(maxsize=4096)
|
|
37
|
+
def _is_foldable(char: str) -> bool:
|
|
38
|
+
"""True for a Unicode punctuation (``P*``) or symbol (``S*``) character."""
|
|
39
|
+
return unicodedata.category(char)[0] in ("P", "S")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _fold_punctuation(text: str) -> str:
|
|
43
|
+
"""Replace punctuation and symbols with spaces — ASCII **and** non-ASCII.
|
|
44
|
+
|
|
45
|
+
``string.punctuation`` is ASCII-only, so every non-ASCII mark used to survive normalization and
|
|
46
|
+
then had to be typed byte-for-byte: an em-dash title was unreachable from a typed hyphen, a
|
|
47
|
+
curly apostrophe from a straight one, ``…`` from ``...``. Folding by Unicode *category* is a
|
|
48
|
+
superset of the old ASCII table, so pure-ASCII results are unchanged.
|
|
49
|
+
|
|
50
|
+
The ASCII table stays the fast path; the per-character category lookup runs only on strings that
|
|
51
|
+
still hold non-ASCII after it, and is cached. This function runs once per catalog entry and once
|
|
52
|
+
per link, so it must not become a per-character Python loop over the whole vault.
|
|
53
|
+
|
|
54
|
+
Callers must compose to NFC first (``_fold_case``): a decomposed ``é`` is a letter plus a
|
|
55
|
+
combining mark of category ``Mn``, which this fold correctly leaves alone but which would
|
|
56
|
+
otherwise leave the accent stranded.
|
|
57
|
+
"""
|
|
58
|
+
folded = text.translate(_PUNCT_TABLE)
|
|
59
|
+
if folded.isascii():
|
|
60
|
+
return folded
|
|
61
|
+
return "".join(" " if not c.isascii() and _is_foldable(c) else c for c in folded)
|
|
62
|
+
|
|
63
|
+
|
|
18
64
|
def _normalize(text: str) -> str:
|
|
19
|
-
"""
|
|
20
|
-
return " ".join(
|
|
65
|
+
"""Compose to NFC, lowercase, replace punctuation with spaces, collapse whitespace."""
|
|
66
|
+
return " ".join(_fold_punctuation(_fold_case(text)).split())
|
|
21
67
|
|
|
22
68
|
|
|
23
|
-
# A trailing dot plus a short alphanumeric run — a plausible file
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
|
|
69
|
+
# A trailing dot plus a short alphanumeric run containing at least one letter — a plausible file
|
|
70
|
+
# extension. Deliberately strict in two ways, and both matter:
|
|
71
|
+
#
|
|
72
|
+
# * spaces after the dot disqualify it, so a note title like "v1.2 release notes" is not mistaken
|
|
73
|
+
# for one;
|
|
74
|
+
# * an all-digit run disqualifies it, so a *numbered* title — "[[Meeting 3.5]]", "[[Phase 2.1]]",
|
|
75
|
+
# "[[Spec v0.9]]" — is reported as the broken link it is rather than suppressed as a file.
|
|
76
|
+
#
|
|
77
|
+
# The letter requirement is what separates them: ".5" and ".61850" differ only in length, so no
|
|
78
|
+
# length bound could. Suppressing a genuine break *deflates* the vault-health count, which is worse
|
|
79
|
+
# than inflating it — an undercount is invisible and reads as health.
|
|
80
|
+
_FILE_SUFFIX_RE = re.compile(r"\.(?=[A-Za-z0-9]{1,10}$)[A-Za-z0-9]*[A-Za-z][A-Za-z0-9]*$")
|
|
27
81
|
|
|
28
82
|
|
|
29
83
|
def _strip_display(display: str) -> str:
|
|
@@ -54,6 +108,24 @@ def _targets_non_note_file(display: str) -> bool:
|
|
|
54
108
|
return bool(match) and match.group(0).lower() != ".md"
|
|
55
109
|
|
|
56
110
|
|
|
111
|
+
def _matches_path_suffix(rel_path: str, suffix: str) -> bool:
|
|
112
|
+
"""True when ``rel_path`` ends with ``suffix`` **on a path-component boundary**.
|
|
113
|
+
|
|
114
|
+
``suffix`` is a lowercased ``folder/note.md``. A raw ``str.endswith`` is not enough: it accepts
|
|
115
|
+
``homework/Tasks.md`` for ``[[work/Tasks]]``, because nothing requires the character before the
|
|
116
|
+
match to be a separator. That produced an edge to the wrong note — silently, since the link then
|
|
117
|
+
counts as ``resolved`` — and, when the real folder also existed, a spurious second match that
|
|
118
|
+
made the resolver decline a correct link.
|
|
119
|
+
|
|
120
|
+
The match is legal only when it consumes the whole rel_path or is preceded by ``/``.
|
|
121
|
+
"""
|
|
122
|
+
lowered = _fold_case(rel_path)
|
|
123
|
+
if not lowered.endswith(suffix):
|
|
124
|
+
return False
|
|
125
|
+
rest = len(lowered) - len(suffix)
|
|
126
|
+
return rest == 0 or lowered[rest - 1] == "/"
|
|
127
|
+
|
|
128
|
+
|
|
57
129
|
def candidates_for(display: str, catalog: dict[str, list[str]]) -> list[str]:
|
|
58
130
|
"""Every rel_path in ``catalog`` that ``display`` names, sorted; empty if it names none.
|
|
59
131
|
|
|
@@ -69,9 +141,12 @@ def candidates_for(display: str, catalog: dict[str, list[str]]) -> list[str]:
|
|
|
69
141
|
if not target:
|
|
70
142
|
return []
|
|
71
143
|
if "/" in target:
|
|
72
|
-
suffix = target
|
|
144
|
+
suffix = _fold_case(target) + ".md"
|
|
73
145
|
return sorted(
|
|
74
|
-
path
|
|
146
|
+
path
|
|
147
|
+
for paths in catalog.values()
|
|
148
|
+
for path in paths
|
|
149
|
+
if _matches_path_suffix(path, suffix)
|
|
75
150
|
)
|
|
76
151
|
return list(catalog.get(_normalize(target), ()))
|
|
77
152
|
|
|
@@ -168,9 +243,9 @@ class NormalizeResolver:
|
|
|
168
243
|
|
|
169
244
|
if "/" in display:
|
|
170
245
|
# Path-suffix resolution: find unique rel_path ending with "display.md"
|
|
171
|
-
suffix = display
|
|
246
|
+
suffix = _fold_case(display) + ".md"
|
|
172
247
|
all_paths = self._flatten_paths(catalog)
|
|
173
|
-
matches = [p for p in all_paths if p
|
|
248
|
+
matches = [p for p in all_paths if _matches_path_suffix(p, suffix)]
|
|
174
249
|
return matches[0] if len(matches) == 1 else None
|
|
175
250
|
|
|
176
251
|
# Bare-link resolution: normalize and look up in catalog
|
|
@@ -119,6 +119,15 @@ def parse_document(path: Path, root: Path) -> Document:
|
|
|
119
119
|
f"graphmark: warning: {rel_path}: invalid UTF-8, decoded with replacement",
|
|
120
120
|
file=sys.stderr,
|
|
121
121
|
)
|
|
122
|
+
# A UTF-8 BOM sits ahead of the `---` and defeats _FM_RE's anchored match, so a BOM'd note
|
|
123
|
+
# would have no frontmatter at all: its aliases would never register (a phantom break) and its
|
|
124
|
+
# frontmatter wikilinks would stay in the body (a phantom edge — the very failure the regex
|
|
125
|
+
# above exists to prevent). Stripped here, on the decoded text, so the frontmatter split, the
|
|
126
|
+
# body and the extractor all see the same string on both decode paths. Leading only: elsewhere
|
|
127
|
+
# U+FEFF is a zero-width no-break space, which is legitimate content. lstrip rather than a
|
|
128
|
+
# single removal because double-encoding produces doubled BOMs, and one survivor still breaks
|
|
129
|
+
# the match.
|
|
130
|
+
raw = raw.lstrip("")
|
|
122
131
|
m = _FM_RE.match(raw)
|
|
123
132
|
if m:
|
|
124
133
|
frontmatter = _parse_frontmatter(m.group(1))
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""A UTF-8 BOM must not defeat frontmatter parsing (#137).
|
|
2
|
+
|
|
3
|
+
`_FM_RE` is anchored with `.match`, and decoding leaves `U+FEFF` at index 0, so a BOM'd note has no
|
|
4
|
+
frontmatter as far as graphmark is concerned. One cause, two opposite symptoms:
|
|
5
|
+
|
|
6
|
+
* its `aliases:` never register, so links written against them are phantom breaks — #119's class,
|
|
7
|
+
reintroduced through the parser;
|
|
8
|
+
* its frontmatter wikilinks stay in the body and become phantom edges — the exact failure `_FM_RE`'s
|
|
9
|
+
own docstring says it exists to prevent. That regex was hardened for CRLF and for a block ending
|
|
10
|
+
at EOF, but not for the byte that can precede the block.
|
|
11
|
+
|
|
12
|
+
The reference vault has no BOM'd notes. A vault synced from a Windows machine ordinarily does.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from graphmark.config import VaultConfig
|
|
20
|
+
from graphmark.graph import NormalizeResolver, VaultGraph
|
|
21
|
+
from graphmark.parse import WikilinkExtractor, parse_document
|
|
22
|
+
|
|
23
|
+
BOM = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _write_bytes(root: Path, rel: str, data: bytes) -> Path:
|
|
27
|
+
path = root / rel
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
path.write_bytes(data)
|
|
30
|
+
return path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _write(root: Path, rel: str, text: str = "") -> Path:
|
|
34
|
+
return _write_bytes(root, rel, text.encode("utf-8"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _build(root: Path, **config_kwargs) -> VaultGraph:
|
|
38
|
+
return VaultGraph.build(
|
|
39
|
+
VaultConfig(root=root, **config_kwargs), WikilinkExtractor(), NormalizeResolver()
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestFrontmatterSplits:
|
|
44
|
+
def test_a_bom_note_still_parses_its_frontmatter(self, tmp_path):
|
|
45
|
+
path = _write(tmp_path, "a.md", f"{BOM}---\ntitle: A\n---\nbody\n")
|
|
46
|
+
doc = parse_document(path, tmp_path)
|
|
47
|
+
assert doc.frontmatter == {"title": "A"}
|
|
48
|
+
assert doc.text == "body\n"
|
|
49
|
+
|
|
50
|
+
def test_the_bom_never_reaches_the_body(self, tmp_path):
|
|
51
|
+
# A BOM left in the text would ride along in an extracted display and in every downstream
|
|
52
|
+
# normalization, so it has to be gone before the frontmatter split, not merely after it.
|
|
53
|
+
path = _write(tmp_path, "a.md", f"{BOM}body [[Note]]\n")
|
|
54
|
+
doc = parse_document(path, tmp_path)
|
|
55
|
+
assert BOM not in doc.text
|
|
56
|
+
assert WikilinkExtractor().extract(doc.text) == ["Note"]
|
|
57
|
+
|
|
58
|
+
def test_an_interior_zero_width_no_break_space_is_content(self, tmp_path):
|
|
59
|
+
# U+FEFF is only a byte-order mark at position 0; elsewhere it is legitimate text.
|
|
60
|
+
path = _write(tmp_path, "a.md", f"before{BOM}after\n")
|
|
61
|
+
assert parse_document(path, tmp_path).text == f"before{BOM}after\n"
|
|
62
|
+
|
|
63
|
+
def test_a_bom_survives_the_replacement_decode_path(self, tmp_path):
|
|
64
|
+
# Invalid UTF-8 falls back to errors="replace"; the BOM must still be stripped there.
|
|
65
|
+
path = _write_bytes(tmp_path, "a.md", BOM.encode("utf-8") + b"---\ntitle: A\n---\n\xff\n")
|
|
66
|
+
doc = parse_document(path, tmp_path)
|
|
67
|
+
assert doc.frontmatter == {"title": "A"}
|
|
68
|
+
assert not doc.text.startswith(BOM)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TestGraphEffects:
|
|
72
|
+
def test_a_bom_note_s_aliases_still_resolve(self, tmp_path):
|
|
73
|
+
_write(tmp_path, "Target.md", f"{BOM}---\naliases: [TGT]\n---\nbody\n")
|
|
74
|
+
_write(tmp_path, "src.md", "[[TGT]]")
|
|
75
|
+
graph = _build(tmp_path)
|
|
76
|
+
assert graph.aliases == {"tgt": "Target.md"}
|
|
77
|
+
assert graph.unresolved == {}
|
|
78
|
+
assert graph.out_links["src.md"] == {"Target.md"}
|
|
79
|
+
|
|
80
|
+
def test_a_bom_note_s_frontmatter_links_are_not_edges(self, tmp_path):
|
|
81
|
+
_write(tmp_path, "a.md", f'{BOM}---\nup: "[[Ghost]]"\n---\nbody\n')
|
|
82
|
+
_write(tmp_path, "Ghost.md", "")
|
|
83
|
+
graph = _build(tmp_path)
|
|
84
|
+
assert graph.out_links["a.md"] == set()
|
|
85
|
+
assert graph.link_counts["resolved"] == 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TestUnaffected:
|
|
89
|
+
def test_a_bom_free_note_is_byte_identical(self, tmp_path):
|
|
90
|
+
path = _write(tmp_path, "a.md", "---\ntitle: A\n---\nbody [[Note]]\n")
|
|
91
|
+
doc = parse_document(path, tmp_path)
|
|
92
|
+
assert doc.frontmatter == {"title": "A"}
|
|
93
|
+
assert doc.text == "body [[Note]]\n"
|
|
94
|
+
|
|
95
|
+
def test_a_bom_with_no_frontmatter_leaves_the_body_intact(self, tmp_path):
|
|
96
|
+
path = _write(tmp_path, "a.md", f"{BOM}# Heading\n")
|
|
97
|
+
assert parse_document(path, tmp_path).text == "# Heading\n"
|