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.
Files changed (104) hide show
  1. {graphmark-0.7.0 → graphmark-0.7.2}/CHANGELOG.md +169 -0
  2. {graphmark-0.7.0 → graphmark-0.7.2}/PKG-INFO +1 -1
  3. {graphmark-0.7.0 → graphmark-0.7.2}/docs/ROADMAP.md +45 -0
  4. {graphmark-0.7.0 → graphmark-0.7.2}/pyproject.toml +1 -1
  5. {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/cli.py +81 -12
  6. {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/graph.py +85 -10
  7. {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/parse.py +9 -0
  8. graphmark-0.7.2/tests/test_bom.py +97 -0
  9. graphmark-0.7.2/tests/test_non_note_file.py +90 -0
  10. graphmark-0.7.2/tests/test_path_suffix.py +114 -0
  11. graphmark-0.7.2/tests/test_properties.py +692 -0
  12. graphmark-0.7.2/tests/test_readme_examples.py +151 -0
  13. graphmark-0.7.2/tests/test_unicode.py +102 -0
  14. graphmark-0.7.2/tests/test_unicode_punctuation.py +113 -0
  15. graphmark-0.7.0/tests/test_properties.py +0 -322
  16. {graphmark-0.7.0 → graphmark-0.7.2}/.gitignore +0 -0
  17. {graphmark-0.7.0 → graphmark-0.7.2}/LICENSE +0 -0
  18. {graphmark-0.7.0 → graphmark-0.7.2}/README.md +0 -0
  19. {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/__init__.py +0 -0
  20. {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/check.py +0 -0
  21. {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/config.py +0 -0
  22. {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/dismiss.py +0 -0
  23. {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/export.py +0 -0
  24. {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/interfaces.py +0 -0
  25. {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/metrics.py +0 -0
  26. {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/model.py +0 -0
  27. {graphmark-0.7.0 → graphmark-0.7.2}/src/graphmark/py.typed +0 -0
  28. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/README.md +0 -0
  29. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/config.toml +0 -0
  30. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/expected.json +0 -0
  31. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
  32. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
  33. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/vault/docs/center.md +0 -0
  34. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/vault/misc/echo.md +0 -0
  35. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
  36. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
  37. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/vault/misc/stub.md +0 -0
  38. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/vault/refs/beta.md +0 -0
  39. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/vault/refs/delta.md +0 -0
  40. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
  41. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/dismiss/expected.json +0 -0
  42. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
  43. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/dismiss/vault/alpha.md +0 -0
  44. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/dismiss/vault/beta.md +0 -0
  45. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/dismiss/vault/gamma.md +0 -0
  46. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/config.toml +0 -0
  47. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/expected.json +0 -0
  48. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/expected_oog.json +0 -0
  49. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/similar.json +0 -0
  50. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/similar_oog.json +0 -0
  51. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/vault/docs/a.md +0 -0
  52. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
  53. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/vault/misc/c.md +0 -0
  54. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/vault/misc/d.md +0 -0
  55. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/vault/refs/b.md +0 -0
  56. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/gaps/vault/refs/e.md +0 -0
  57. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/scoped/config.toml +0 -0
  58. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/scoped/expected.json +0 -0
  59. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/scoped/vault/docs/one.md +0 -0
  60. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
  61. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
  62. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/scoped/vault/refs/two.md +0 -0
  63. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/selflink/config.toml +0 -0
  64. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/selflink/expected.json +0 -0
  65. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/selflink/vault/a.md +0 -0
  66. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/selflink/vault/b.md +0 -0
  67. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/simple/config.toml +0 -0
  68. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/simple/expected.json +0 -0
  69. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
  70. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/simple/vault/brain/hub.md +0 -0
  71. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/simple/vault/personal/beta.md +0 -0
  72. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
  73. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/simple/vault/reference/island.md +0 -0
  74. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/simple/vault/reference/stub.md +0 -0
  75. {graphmark-0.7.0 → graphmark-0.7.2}/tests/fixtures/suggest/README.md +0 -0
  76. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_aliases.py +0 -0
  77. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_build_filters.py +0 -0
  78. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_check.py +0 -0
  79. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_check_policy.py +0 -0
  80. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_cli.py +0 -0
  81. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_config.py +0 -0
  82. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_degenerate.py +0 -0
  83. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_diagnose.py +0 -0
  84. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_dismiss.py +0 -0
  85. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_export.py +0 -0
  86. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_gaps.py +0 -0
  87. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_gaps_banding.py +0 -0
  88. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_gaps_oog.py +0 -0
  89. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_gaps_params.py +0 -0
  90. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_graph.py +0 -0
  91. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_graph_catalogs.py +0 -0
  92. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_link_counts.py +0 -0
  93. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_links_report.py +0 -0
  94. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_metrics.py +0 -0
  95. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_model.py +0 -0
  96. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_packaging.py +0 -0
  97. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_pagerank.py +0 -0
  98. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_parse.py +0 -0
  99. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_public_api.py +0 -0
  100. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_scoped.py +0 -0
  101. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_selflink.py +0 -0
  102. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_smoke.py +0 -0
  103. {graphmark-0.7.0 → graphmark-0.7.2}/tests/test_suggestions.py +0 -0
  104. {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.0
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,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphmark"
3
- version = "0.7.0"
3
+ version = "0.7.2"
4
4
  description = "Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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("--root", metavar="PATH", help="Vault root (overrides --config root)")
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("stats", help="Aggregate vault stats: notes, edges, orphans, clusters, density")
60
- sub.add_parser("orphans", help="Notes with no links in or out (degree 0)")
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("hubs", help="Most-connected notes, by undirected degree")
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("clusters", help="Connected components of the link graph, largest first")
66
- sub.add_parser("bridges", help="Articulation points: notes whose removal splits the graph")
67
- sub.add_parser("siloed", help="Notes reachable from the mainland only through one bridge")
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("neighborhood", help="Links in and out of one note")
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("pagerank", help="PageRank importance ranking over the link graph")
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("export", help="Export the graph in another format")
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("gaps", help="Link-gap suggestions (library-only; see the README)")
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
- """Lowercase, replace punctuation with spaces, collapse whitespace."""
20
- return " ".join(text.lower().translate(_PUNCT_TABLE).split())
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 extension. Deliberately
24
- # strict so a note title like "v1.2 release notes" (spaces after the dot) is not mistaken
25
- # for one.
26
- _FILE_SUFFIX_RE = re.compile(r"\.[A-Za-z0-9]{1,10}$")
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.lower() + ".md"
144
+ suffix = _fold_case(target) + ".md"
73
145
  return sorted(
74
- path for paths in catalog.values() for path in paths if path.lower().endswith(suffix)
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.lower() + ".md"
246
+ suffix = _fold_case(display) + ".md"
172
247
  all_paths = self._flatten_paths(catalog)
173
- matches = [p for p in all_paths if p.lower().endswith(suffix)]
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"