graphmark 0.7.1__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.1 → graphmark-0.7.2}/CHANGELOG.md +109 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/PKG-INFO +1 -1
- {graphmark-0.7.1 → graphmark-0.7.2}/docs/ROADMAP.md +22 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/pyproject.toml +1 -1
- {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/cli.py +81 -12
- {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/graph.py +63 -9
- graphmark-0.7.2/tests/test_non_note_file.py +90 -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.1/tests/test_properties.py +0 -322
- {graphmark-0.7.1 → graphmark-0.7.2}/.gitignore +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/LICENSE +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/README.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/__init__.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/check.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/config.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/dismiss.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/export.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/interfaces.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/metrics.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/model.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/parse.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/py.typed +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/README.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/config.toml +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/expected.json +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/vault/docs/center.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/vault/misc/echo.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/vault/misc/stub.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/vault/refs/beta.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/vault/refs/delta.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/dismiss/expected.json +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/dismiss/vault/alpha.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/dismiss/vault/beta.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/dismiss/vault/gamma.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/config.toml +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/expected.json +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/expected_oog.json +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/similar.json +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/similar_oog.json +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/vault/docs/a.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/vault/misc/c.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/vault/misc/d.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/vault/refs/b.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/vault/refs/e.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/scoped/config.toml +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/scoped/expected.json +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/scoped/vault/docs/one.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/scoped/vault/refs/two.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/selflink/config.toml +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/selflink/expected.json +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/selflink/vault/a.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/selflink/vault/b.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/simple/config.toml +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/simple/expected.json +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/simple/vault/brain/hub.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/simple/vault/personal/beta.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/simple/vault/reference/island.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/simple/vault/reference/stub.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/suggest/README.md +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_aliases.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_bom.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_build_filters.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_check.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_check_policy.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_cli.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_config.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_degenerate.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_diagnose.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_dismiss.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_export.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_gaps.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_gaps_banding.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_gaps_oog.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_gaps_params.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_graph.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_graph_catalogs.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_link_counts.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_links_report.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_metrics.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_model.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_packaging.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_pagerank.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_parse.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_path_suffix.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_public_api.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_scoped.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_selflink.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_smoke.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_suggestions.py +0 -0
- {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_unresolved.py +0 -0
|
@@ -1,6 +1,115 @@
|
|
|
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
|
+
|
|
4
113
|
## v0.7.1 (2026-07-25)
|
|
5
114
|
|
|
6
115
|
### Bug Fixes
|
|
@@ -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
|
|
@@ -163,6 +163,28 @@ Two things this says about the track:
|
|
|
163
163
|
names that are suffixes of other folder names, BOMs, numeric title suffixes, non-ASCII
|
|
164
164
|
punctuation. Widening the generator's _alphabet_ — not its vault count — is the follow-on.
|
|
165
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
|
+
|
|
166
188
|
Audited alongside: the reference vault's two suppressed buckets, by hand — 17 `non-note-file` (all
|
|
167
189
|
genuine `.base`/`.png`) and 40 `intra-note` (all genuine heading refs), 0 false suppressions. A
|
|
168
190
|
suppressed bucket is only trustworthy once someone has read it.
|
|
@@ -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:
|
|
@@ -65,7 +119,7 @@ def _matches_path_suffix(rel_path: str, suffix: str) -> bool:
|
|
|
65
119
|
|
|
66
120
|
The match is legal only when it consumes the whole rel_path or is preceded by ``/``.
|
|
67
121
|
"""
|
|
68
|
-
lowered = rel_path
|
|
122
|
+
lowered = _fold_case(rel_path)
|
|
69
123
|
if not lowered.endswith(suffix):
|
|
70
124
|
return False
|
|
71
125
|
rest = len(lowered) - len(suffix)
|
|
@@ -87,7 +141,7 @@ def candidates_for(display: str, catalog: dict[str, list[str]]) -> list[str]:
|
|
|
87
141
|
if not target:
|
|
88
142
|
return []
|
|
89
143
|
if "/" in target:
|
|
90
|
-
suffix = target
|
|
144
|
+
suffix = _fold_case(target) + ".md"
|
|
91
145
|
return sorted(
|
|
92
146
|
path
|
|
93
147
|
for paths in catalog.values()
|
|
@@ -189,7 +243,7 @@ class NormalizeResolver:
|
|
|
189
243
|
|
|
190
244
|
if "/" in display:
|
|
191
245
|
# Path-suffix resolution: find unique rel_path ending with "display.md"
|
|
192
|
-
suffix = display
|
|
246
|
+
suffix = _fold_case(display) + ".md"
|
|
193
247
|
all_paths = self._flatten_paths(catalog)
|
|
194
248
|
matches = [p for p in all_paths if _matches_path_suffix(p, suffix)]
|
|
195
249
|
return matches[0] if len(matches) == 1 else None
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""`non-note-file` must not swallow a genuinely broken link (#138).
|
|
2
|
+
|
|
3
|
+
`_targets_non_note_file` treated any trailing `.` + 1-10 alphanumerics as a file extension, so a
|
|
4
|
+
broken link to a note whose title ends in a decimal was silently suppressed instead of reported.
|
|
5
|
+
`.5` satisfies the pattern as readily as `.md` does.
|
|
6
|
+
|
|
7
|
+
This is the first class found that *deflates* `check`'s flagship number rather than inflating it,
|
|
8
|
+
which is strictly worse for a gate: an undercount is invisible by construction and reads as health.
|
|
9
|
+
|
|
10
|
+
The separating rule is that a real extension contains a letter. `.5` and `.61850` differ only in
|
|
11
|
+
length, so no length bound can tell them apart.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from graphmark.config import VaultConfig
|
|
19
|
+
from graphmark.graph import NormalizeResolver, VaultGraph
|
|
20
|
+
from graphmark.parse import WikilinkExtractor
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _write(root: Path, rel: str, text: str = "") -> None:
|
|
24
|
+
path = root / rel
|
|
25
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
path.write_text(text, encoding="utf-8")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build(root: Path, **config_kwargs) -> VaultGraph:
|
|
30
|
+
return VaultGraph.build(
|
|
31
|
+
VaultConfig(root=root, **config_kwargs), WikilinkExtractor(), NormalizeResolver()
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestNumberedTitlesAreReported:
|
|
36
|
+
def test_a_decimal_suffix_is_a_missing_note_not_a_file(self, tmp_path):
|
|
37
|
+
_write(tmp_path, "src.md", "[[Meeting 3.5]]")
|
|
38
|
+
graph = _build(tmp_path)
|
|
39
|
+
assert graph.link_counts["missing"] == 1
|
|
40
|
+
assert graph.link_counts["non-note-file"] == 0
|
|
41
|
+
assert graph.unresolved == {"src.md": ["Meeting 3.5"]}
|
|
42
|
+
|
|
43
|
+
def test_a_long_all_digit_suffix_is_still_a_note(self, tmp_path):
|
|
44
|
+
# ".61850" and ".5" differ only in length — a length bound cannot separate them.
|
|
45
|
+
_write(tmp_path, "src.md", "[[Standard IEC.61850]]")
|
|
46
|
+
assert _build(tmp_path).link_counts["missing"] == 1
|
|
47
|
+
|
|
48
|
+
def test_a_version_suffix_is_a_note(self, tmp_path):
|
|
49
|
+
_write(tmp_path, "src.md", "[[Phase 2.1]] [[Spec v0.9]] [[Budget FY26.2]]")
|
|
50
|
+
assert _build(tmp_path).link_counts["missing"] == 3
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestRealExtensionsStillSuppressed:
|
|
54
|
+
def test_canvas_base_and_images(self, tmp_path):
|
|
55
|
+
_write(tmp_path, "src.md", "[[Board.canvas]] [[Archive.base]] [[chart.png]] [[doc.pdf]]")
|
|
56
|
+
graph = _build(tmp_path)
|
|
57
|
+
assert graph.link_counts["non-note-file"] == 4
|
|
58
|
+
assert graph.unresolved == {}
|
|
59
|
+
|
|
60
|
+
def test_an_aliased_non_note_target(self, tmp_path):
|
|
61
|
+
# The vault's real shape: [[Decisions.base|Decisions]].
|
|
62
|
+
_write(tmp_path, "src.md", "[[Decisions.base|Decisions]]")
|
|
63
|
+
assert _build(tmp_path).link_counts["non-note-file"] == 1
|
|
64
|
+
|
|
65
|
+
def test_a_mixed_alphanumeric_extension_is_still_a_file(self, tmp_path):
|
|
66
|
+
_write(tmp_path, "src.md", "[[archive.7z]] [[track.mp3]] [[video.mp4]]")
|
|
67
|
+
assert _build(tmp_path).link_counts["non-note-file"] == 3
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestResolverStillWinsFirst:
|
|
71
|
+
def test_a_real_note_with_a_dotted_stem_resolves(self, tmp_path):
|
|
72
|
+
# The guard `_targets_non_note_file`'s docstring already claims: the resolver runs first, so
|
|
73
|
+
# a note that genuinely resolves is never suppressed. Asserted rather than assumed.
|
|
74
|
+
_write(tmp_path, "report.v2.md")
|
|
75
|
+
_write(tmp_path, "src.md", "[[report.v2]]")
|
|
76
|
+
graph = _build(tmp_path)
|
|
77
|
+
assert graph.link_counts["resolved"] == 1
|
|
78
|
+
assert graph.out_links["src.md"] == {"report.v2.md"}
|
|
79
|
+
|
|
80
|
+
def test_a_real_note_named_with_a_decimal_resolves(self, tmp_path):
|
|
81
|
+
_write(tmp_path, "Meeting 3.5.md")
|
|
82
|
+
_write(tmp_path, "src.md", "[[Meeting 3.5]]")
|
|
83
|
+
assert _build(tmp_path).out_links["src.md"] == {"Meeting 3.5.md"}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TestConservation:
|
|
87
|
+
def test_every_display_still_lands_in_exactly_one_bucket(self, tmp_path):
|
|
88
|
+
_write(tmp_path, "src.md", "[[Meeting 3.5]] [[Board.canvas]] [[#Heading]] [[Gone]]")
|
|
89
|
+
graph = _build(tmp_path)
|
|
90
|
+
assert sum(graph.link_counts.values()) == 4
|