graphmark 0.6.0__tar.gz → 0.7.1__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 (99) hide show
  1. {graphmark-0.6.0 → graphmark-0.7.1}/CHANGELOG.md +221 -0
  2. {graphmark-0.6.0 → graphmark-0.7.1}/PKG-INFO +41 -1
  3. {graphmark-0.6.0 → graphmark-0.7.1}/README.md +39 -0
  4. {graphmark-0.6.0 → graphmark-0.7.1}/docs/ROADMAP.md +90 -14
  5. {graphmark-0.6.0 → graphmark-0.7.1}/pyproject.toml +2 -2
  6. {graphmark-0.6.0 → graphmark-0.7.1}/src/graphmark/__init__.py +2 -1
  7. {graphmark-0.6.0 → graphmark-0.7.1}/src/graphmark/check.py +34 -2
  8. {graphmark-0.6.0 → graphmark-0.7.1}/src/graphmark/cli.py +11 -1
  9. {graphmark-0.6.0 → graphmark-0.7.1}/src/graphmark/graph.py +56 -5
  10. {graphmark-0.6.0 → graphmark-0.7.1}/src/graphmark/parse.py +9 -0
  11. graphmark-0.7.1/tests/test_bom.py +97 -0
  12. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_check.py +7 -1
  13. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_diagnose.py +3 -1
  14. graphmark-0.7.1/tests/test_link_counts.py +207 -0
  15. graphmark-0.7.1/tests/test_links_report.py +171 -0
  16. graphmark-0.7.1/tests/test_path_suffix.py +114 -0
  17. graphmark-0.7.1/tests/test_properties.py +322 -0
  18. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_public_api.py +1 -0
  19. {graphmark-0.6.0 → graphmark-0.7.1}/.gitignore +0 -0
  20. {graphmark-0.6.0 → graphmark-0.7.1}/LICENSE +0 -0
  21. {graphmark-0.6.0 → graphmark-0.7.1}/src/graphmark/config.py +0 -0
  22. {graphmark-0.6.0 → graphmark-0.7.1}/src/graphmark/dismiss.py +0 -0
  23. {graphmark-0.6.0 → graphmark-0.7.1}/src/graphmark/export.py +0 -0
  24. {graphmark-0.6.0 → graphmark-0.7.1}/src/graphmark/interfaces.py +0 -0
  25. {graphmark-0.6.0 → graphmark-0.7.1}/src/graphmark/metrics.py +0 -0
  26. {graphmark-0.6.0 → graphmark-0.7.1}/src/graphmark/model.py +0 -0
  27. {graphmark-0.6.0 → graphmark-0.7.1}/src/graphmark/py.typed +0 -0
  28. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/README.md +0 -0
  29. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/alt/config.toml +0 -0
  30. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/alt/expected.json +0 -0
  31. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
  32. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
  33. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/docs/center.md +0 -0
  34. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/misc/echo.md +0 -0
  35. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
  36. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
  37. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/misc/stub.md +0 -0
  38. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/refs/beta.md +0 -0
  39. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/refs/delta.md +0 -0
  40. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
  41. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/dismiss/expected.json +0 -0
  42. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
  43. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/dismiss/vault/alpha.md +0 -0
  44. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/dismiss/vault/beta.md +0 -0
  45. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/dismiss/vault/gamma.md +0 -0
  46. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/gaps/config.toml +0 -0
  47. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/gaps/expected.json +0 -0
  48. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/gaps/expected_oog.json +0 -0
  49. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/gaps/similar.json +0 -0
  50. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/gaps/similar_oog.json +0 -0
  51. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/gaps/vault/docs/a.md +0 -0
  52. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
  53. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/gaps/vault/misc/c.md +0 -0
  54. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/gaps/vault/misc/d.md +0 -0
  55. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/gaps/vault/refs/b.md +0 -0
  56. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/gaps/vault/refs/e.md +0 -0
  57. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/scoped/config.toml +0 -0
  58. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/scoped/expected.json +0 -0
  59. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/scoped/vault/docs/one.md +0 -0
  60. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
  61. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
  62. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/scoped/vault/refs/two.md +0 -0
  63. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/selflink/config.toml +0 -0
  64. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/selflink/expected.json +0 -0
  65. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/selflink/vault/a.md +0 -0
  66. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/selflink/vault/b.md +0 -0
  67. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/simple/config.toml +0 -0
  68. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/simple/expected.json +0 -0
  69. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
  70. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/simple/vault/brain/hub.md +0 -0
  71. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/simple/vault/personal/beta.md +0 -0
  72. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
  73. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/simple/vault/reference/island.md +0 -0
  74. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/simple/vault/reference/stub.md +0 -0
  75. {graphmark-0.6.0 → graphmark-0.7.1}/tests/fixtures/suggest/README.md +0 -0
  76. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_aliases.py +0 -0
  77. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_build_filters.py +0 -0
  78. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_check_policy.py +0 -0
  79. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_cli.py +0 -0
  80. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_config.py +0 -0
  81. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_degenerate.py +0 -0
  82. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_dismiss.py +0 -0
  83. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_export.py +0 -0
  84. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_gaps.py +0 -0
  85. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_gaps_banding.py +0 -0
  86. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_gaps_oog.py +0 -0
  87. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_gaps_params.py +0 -0
  88. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_graph.py +0 -0
  89. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_graph_catalogs.py +0 -0
  90. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_metrics.py +0 -0
  91. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_model.py +0 -0
  92. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_packaging.py +0 -0
  93. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_pagerank.py +0 -0
  94. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_parse.py +0 -0
  95. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_scoped.py +0 -0
  96. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_selflink.py +0 -0
  97. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_smoke.py +0 -0
  98. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_suggestions.py +0 -0
  99. {graphmark-0.6.0 → graphmark-0.7.1}/tests/test_unresolved.py +0 -0
@@ -1,6 +1,227 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.7.1 (2026-07-25)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **graph**: Match path-suffix links on component boundaries (#136)
9
+ ([#140](https://github.com/cdcoonce/graphmark/pull/140),
10
+ [`3c23b1a`](https://github.com/cdcoonce/graphmark/commit/3c23b1a04a86cdf1c482075c976ed5db86f4e18e))
11
+
12
+ `[[work/Tasks]]` resolved to `homework/Tasks.md`. Path-suffix resolution tested a raw string suffix,
13
+ so nothing required the character before the match to be `/`.
14
+
15
+ This is the first defect class in this package that fabricates an *edge* rather than moving a link
16
+ between reported buckets, and it is invisible by construction: the link counts `resolved`, so no
17
+ bucket looks implausible, while orphans, hubs, clusters, bridges, siloed_notes and PageRank all
18
+ read a graph that does not describe the vault. The mirror case is equally wrong — when the real
19
+ folder also exists, the spurious second match makes the resolver decline a correct link and report
20
+ it ambiguous.
21
+
22
+ `_matches_path_suffix` requires the match to consume the whole rel_path or be preceded by `/`, and
23
+ both the resolver and `candidates_for` go through it, so the ambiguity set a consumer is shown can
24
+ never contain a path the resolver would not have considered.
25
+
26
+ Measured on the reference vault: 0 occurrences, graph byte-identical. Latent, not absent — it needs
27
+ one folder whose name ends with another's (`work`/`homework`, `ops`/`devops`,
28
+ `<x>`/`archive-<x>`), which is ordinary.
29
+
30
+ - **parse**: Strip a leading UTF-8 BOM before the frontmatter split (#137)
31
+ ([#141](https://github.com/cdcoonce/graphmark/pull/141),
32
+ [`e92afd6`](https://github.com/cdcoonce/graphmark/commit/e92afd6484d4d8b966122df1db858cd791b33e0a))
33
+
34
+ `_FM_RE` is anchored with `.match` and decoding leaves U+FEFF at index 0, so a BOM'd note had no
35
+ frontmatter at all. One cause, two opposite symptoms: its `aliases:` never registered, so links
36
+ written against them were phantom breaks (#119's class, reintroduced through the parser); and its
37
+ frontmatter wikilinks stayed in the body and became phantom edges — the exact failure `_FM_RE`'s
38
+ own docstring says it exists to prevent. That regex was hardened for CRLF and for a block ending
39
+ at EOF, but not for the byte that can precede the block.
40
+
41
+ Stripped on the decoded text, so the frontmatter split, the body and the extractor all see the same
42
+ string on both decode paths. Leading only: elsewhere U+FEFF is a zero-width no-break space and is
43
+ legitimate content.
44
+
45
+ Reference vault: 0 BOM'd notes. Latent, not absent — Windows editors, PowerShell's default Out-File
46
+ and some git filters emit BOMs.
47
+
48
+ ### Documentation
49
+
50
+ - **roadmap**: Record Track F's interim finding-method result
51
+ ([#142](https://github.com/cdcoonce/graphmark/pull/142),
52
+ [`2e6c368`](https://github.com/cdcoonce/graphmark/commit/2e6c368aa16cd3b0cd5dbf4fe6912dcbe7bb1427))
53
+
54
+ Four defects (#136 #137 #138 #139) found in one pass after #124-#126 shipped — none by reading link
55
+ lists, none by the property generator. The method that worked was adversarial reading of the
56
+ resolver and parser, confirmed by probe. All four measure 0 occurrences on the reference vault.
57
+
58
+ Names two limits this exposes: the counts cannot surface a wrong answer INSIDE a bucket (#136 files
59
+ as `resolved`, #138 hides a break in `non-note-file`), and #126's generator draws from an alphabet
60
+ that never reaches these input classes. Also records the hand audit of both suppressed buckets: 0
61
+ false suppressions.
62
+
63
+
64
+ ## v0.7.0 (2026-07-25)
65
+
66
+ ### Documentation
67
+
68
+ - Record the nine-vault corpus study ([#134](https://github.com/cdcoonce/graphmark/pull/134),
69
+ [`7495934`](https://github.com/cdcoonce/graphmark/commit/749593468e3d57913d89fc00942179786f5f5128))
70
+
71
+ graphmark has had exactly one real vault, and a coverage run showed the problem is worse than n=1:
72
+ that vault now has zero broken links, so it never executes the error handling at all —
73
+ suggest_notes entirely, the ambiguous / missing / out-of-scope verdicts, unresolved recording, and
74
+ diagnose(). 54% of the package is unexercised by it. The healthier it gets, the less it validates.
75
+
76
+ Nine third-party vaults, zero crashes — the first evidence for the README's 'any Obsidian-family
77
+ vault' claim. kepano's vault is 74% .base links, which independently justifies #101 and shows the
78
+ reference vault understated that class by 7x. Third-party vaults sit at 1.8-27% missing against
79
+ the reference vault's 0%.
80
+
81
+ That range confirms #127's closure against real data rather than argument: the largest
82
+ false-positive class this package has had moved missing by 0.4%, which no threshold can separate
83
+ from ordinary variation.
84
+
85
+ No new defects. Two suspicious distributions were both graphmark being right, and the second was my
86
+ own scoping error — I excluded English template directory names but not the Spanish 'Plantillas',
87
+ which is the methodological lesson worth recording for anyone repeating this.
88
+
89
+ - **roadmap**: Close Track E, open Track F — auditable link accounting
90
+ ([#128](https://github.com/cdcoonce/graphmark/pull/128),
91
+ [`4ca84de`](https://github.com/cdcoonce/graphmark/commit/4ca84de110dd288cd2778ccdc48a3b558367b269))
92
+
93
+ * docs(roadmap): close Track E, open Track F — auditable link accounting (#124-#127)
94
+
95
+ afk-driver --expand reads this file verbatim, so it has to describe where the work actually is.
96
+ Track E is closed at 0.6.0 (aliases were its last piece), and the baseline line still claimed
97
+ 0.3.4.
98
+
99
+ Track F names the real problem, which is not any individual bug but how bugs get found: all seven
100
+ correctness defects in this package's history came from a human reading link lists on the single
101
+ reference vault, six of them in one day. The frozen oracle is a ratchet, not a detector — it has
102
+ never surfaced anything new — and the one consumer's seam actively conceals package defects,
103
+ having hidden a 23-link alias error for six releases.
104
+
105
+ The mechanism of the hiding is that the buckets are silent: build sorts every display into one of
106
+ six reasons and reports one of them, and six of the seven bugs were mis-bucketing. Printing the
107
+ distribution would have made the alias gap obvious at a glance — zero alias-resolved beside 23
108
+ unresolved is visibly wrong.
109
+
110
+ So the epic is to make the package's own answers auditable: every display in exactly one named
111
+ bucket, counted, reported, and bound by a property-tested conservation law. Sliced #124-#127, with
112
+ the detector (#126) as the point of the exercise and its catch rate as the evidence.
113
+
114
+ Records what is deliberately deferred behind it and why: the Track D GitHub Action is an adoption
115
+ play this repo has already decided against, and shipping a gate that fails other people's builds
116
+ while our own accounting is unauditable is backwards ordering. Generality work is real but is a
117
+ feature list, not an epic — and Track F is what will say which parts of it matter.
118
+
119
+ * docs(roadmap): truth up Track D — seven classes, and the Action waits on Track F
120
+
121
+ Track D still read '0.3.4' and 'four false-positive classes', and promised the GitHub Action as the
122
+ next step, which now contradicts Track F's deliberate deferral. afk reads this file verbatim, so a
123
+ contradiction in it is a contradiction injected into the expander's prompt.
124
+
125
+ ### Features
126
+
127
+ - **cli**: Graphmark links — surface the classification distribution (#125)
128
+ ([#131](https://github.com/cdcoonce/graphmark/pull/131),
129
+ [`3ccf11c`](https://github.com/cdcoonce/graphmark/commit/3ccf11c26028c03739cfa88e67119c230645a22e))
130
+
131
+ Track F slice 2. Slice 1 made the classification countable; this makes it readable. Counts sitting
132
+ in a Python object are worthless to a vault owner, and that gap is not cosmetic: the six-release
133
+ frontmatter-alias defect was legible in this distribution the whole time — 23 links reported
134
+ broken beside zero resolved via alias — and nobody saw it because no surface printed it.
135
+
136
+ `graphmark links` emits the block as JSON on stdout with a one-line summary on stderr, so stdout
137
+ stays pipeable. `check`'s report carries the same block under `links`, appended after `checks` so
138
+ consumers already parsing that report keep working, and provably unable to change the verdict — a
139
+ test pins that a passing and a breaching run produce identical `links`.
140
+
141
+ The byte-stability oracle in test_check.py is updated rather than sidestepped, and now pins a
142
+ cross-check worth having: max_unresolved_links' actual equals counts.missing, so the gate's
143
+ flagship number and the distribution behind it cannot silently disagree.
144
+
145
+ Filed #130 along the way: every CLI example in the README fails. --config and --root live on the
146
+ parent parser, so argparse only accepts them before the subcommand, while the README documents the
147
+ opposite in all ten examples. It survived because the suite exclusively uses the working form, so
148
+ nothing exercised the documented one — the same shape as the rest of Track F, and the reason
149
+ #130's acceptance criteria require every README example to be executed by a test.
150
+
151
+ Mutation testing caught that the report's zero-fill was untested: build() seeds all six keys, so
152
+ only a directly constructed graph exercises it. Covered now.
153
+
154
+ - **graph**: Per-reason link counts and the conservation law (#124)
155
+ ([#129](https://github.com/cdcoonce/graphmark/pull/129),
156
+ [`4ba2ab6`](https://github.com/cdcoonce/graphmark/commit/4ba2ab65b32654af98f3987f2688c18af23c7f02))
157
+
158
+ Track F slice 1. build sorted every extracted display into one of the six DIAGNOSIS_REASONS and then
159
+ kept exactly one bucket plus the edges. The other outcomes were decided and discarded, and that
160
+ silence is why six of the seven correctness bugs in this package's history went unnoticed: each
161
+ was a mis-bucketing, and nothing in the output made an implausible distribution visible.
162
+
163
+ graph.link_counts accounts for every display, one bucket each, with all six keys always present in
164
+ DIAGNOSIS_REASONS order — a zero is a finding, so it must be reported rather than absent.
165
+ graph.alias_resolved counts resolutions reached through an alias, which is not a DIAGNOSIS_REASONS
166
+ value but is the distinction that would have made #119 visible.
167
+
168
+ LinkDiagnosis gains `via` ("stem" or "alias") so the counter reads the classifier's own verdict
169
+ instead of re-deriving it. Re-deriving is how the two would eventually disagree, which is the
170
+ failure this repo has spent Track E removing.
171
+
172
+ The conservation law — sum(link_counts.values()) equals the extractor's total — is asserted on a
173
+ vault exercising all six outcomes, on the frozen fixtures, and against the pre-existing surface
174
+ (unresolved occurrences == ambiguous + missing), so the new tally cannot drift from the old one.
175
+
176
+ On the live 521-note vault the payoff is legible in one line. Healthy: missing 0, alias-resolved 23.
177
+ The same vault with aliases disabled, which is what the six-release defect actually looked like:
178
+ missing 23, alias-resolved 0. Same 6226 total either way — nothing vanished, 23 links were in the
179
+ wrong bucket, and now you can see that.
180
+
181
+ Purely additive: no metric changes, frozen fixtures byte-identical.
182
+
183
+ ### Testing
184
+
185
+ - Property-based vault generation — Track F's detector (#126)
186
+ ([#132](https://github.com/cdcoonce/graphmark/pull/132),
187
+ [`cd76410`](https://github.com/cdcoonce/graphmark/commit/cd76410087157677f6f6515c94d11057060063eb))
188
+
189
+ The frozen oracle prevents regression and has never surfaced anything new, because fixtures only
190
+ encode shapes somebody already thought of, and all seven historical bugs were shapes nobody
191
+ thought of. This generates vaults nobody designed and asserts what must hold for any content at
192
+ all.
193
+
194
+ Two layers, and the second is the one that matters.
195
+
196
+ STRUCTURAL invariants over generated vaults: conservation (every extracted display lands in a
197
+ bucket), edges name real notes and never exceed the resolved count, self-links never become edges,
198
+ back_links mirror out_links, unresolved is exactly ambiguous+missing, aliases never shadow a real
199
+ note name, and build is deterministic — the headline claim of this package, asserted nowhere until
200
+ now.
201
+
202
+ Those all passed on first run, which was the finding. Every one of the seven bugs kept conservation
203
+ intact; each merely filed a link in the WRONG bucket. So the structural layer would have caught
204
+ none of them, and shipping only that would have been a detector that cannot see the thing it
205
+ exists for.
206
+
207
+ METAMORPHIC relations close that gap. Obsidian treats [[X]], [[X|alias]], [[X#Section]] and [[X.md]]
208
+ as the same link, so each rewrite must leave the counts and edges identical. Re-injecting the real
209
+ defects confirms the layer works: #104 (.md), #98 (intra-note), #101 (non-note file), #119
210
+ (aliases) and the path-qualified whitespace bug each fail a property.
211
+
212
+ Two of those properties were over-specified on first write — they compared `unresolved`, which
213
+ echoes the RAW display, so a rewrite legitimately changes it. Comparing it asserts a rewrite does
214
+ not rewrite anything. They now compare counts and edges, with the reason documented at the helper.
215
+
216
+ The whitespace property also had to be sharpened: normalization collapses whitespace on its own, so
217
+ a padded BARE display resolves either way and cannot detect the defect. Only the path-suffix
218
+ branch is sensitive, which is exactly where the 13 live links were reported broken while pointing
219
+ at real notes.
220
+
221
+ hypothesis is a dev dependency only; the shipped runtime dep is still networkx alone. Bounded and
222
+ derandomized so CI stays fast and failures reproduce.
223
+
224
+
4
225
  ## v0.6.0 (2026-07-25)
5
226
 
6
227
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphmark
3
- Version: 0.6.0
3
+ Version: 0.7.1
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
@@ -21,6 +21,7 @@ Classifier: Typing :: Typed
21
21
  Requires-Python: >=3.11
22
22
  Requires-Dist: networkx>=3.0
23
23
  Provides-Extra: dev
24
+ Requires-Dist: hypothesis>=6.100; extra == 'dev'
24
25
  Requires-Dist: pytest>=8.0; extra == 'dev'
25
26
  Requires-Dist: ruff>=0.6; extra == 'dev'
26
27
  Description-Content-Type: text/markdown
@@ -84,12 +85,32 @@ graphmark siloed --root /path/to/vault
84
85
  graphmark neighborhood --note a/b.md --depth 2 --root /path/to/vault
85
86
  graphmark pagerank --n 10 --alpha 0.85 --root /path/to/vault
86
87
  graphmark export dot --root /path/to/vault > graph.dot
88
+ graphmark links --root /path/to/vault
87
89
  graphmark check --config vault.toml
88
90
  ```
89
91
 
90
92
  Exit codes: `0` success, `1` a `check` threshold breach, `2` a usage or config error. Nothing else
91
93
  uses `1`, so CI can trust it.
92
94
 
95
+ ## `graphmark links` — how every link was classified
96
+
97
+ ```console
98
+ $ graphmark --root /path/to/vault links
99
+ {"total": 6226, "counts": {"resolved": 6169, "ambiguous": 0, "non-note-file": 17, "out-of-scope-note": 0, "missing": 0, "intra-note": 40}, "alias_resolved": 23}
100
+ resolved 6169 · ambiguous 0 · non-note-file 17 · out-of-scope-note 0 · missing 0 · intra-note 40 · alias-resolved 23
101
+ ```
102
+
103
+ JSON on stdout, the one-line summary on stderr, so the output stays pipeable.
104
+
105
+ Every wikilink lands in exactly one bucket and the buckets sum to `total` — nothing is silently
106
+ dropped. All six reasons are always present, because a zero is a finding: the line above with
107
+ `missing 23 · alias-resolved 0` instead is what a real defect looked like before frontmatter aliases
108
+ resolved, on the same vault with the same 6226 total. Reading the distribution is the fastest way to
109
+ notice the tool is wrong about your vault.
110
+
111
+ The same block appears in `check`'s report under `links`, as context for the verdict. It never
112
+ changes the verdict.
113
+
93
114
  ## `graphmark check` — vault health as a CI gate
94
115
 
95
116
  Declare thresholds in the config's `[check]` table, then run `graphmark check` as a build step. It
@@ -186,7 +207,26 @@ graph.unresolved # {rel_path: [broken link displays
186
207
  graph.catalog # {normalized stem: [rel_paths]} — 2+ means ambiguous
187
208
  graph.out_of_scope # same, for markdown outside the configured scope
188
209
  graph.aliases # {normalized alias: rel_path} — frontmatter aliases
210
+ graph.link_counts # {reason: occurrences} — every link, one bucket each
211
+ graph.alias_resolved # how many resolutions came via an alias
212
+ ```
213
+
214
+ `link_counts` accounts for **every** display the extractor produced, one bucket each, and always
215
+ carries all six `DIAGNOSIS_REASONS` — a zero is a finding, not a non-event. The buckets sum to the
216
+ extraction count, so nothing is silently dropped. Reading the distribution is the fastest way to
217
+ notice the tool is wrong about your vault:
218
+
219
+ ```
220
+ resolved 6169 · ambiguous 0 · non-note-file 17 · out-of-scope-note 0 · missing 0 · intra-note 40 · alias-resolved 23
221
+ ```
222
+
223
+ versus the same vault with alias resolution disabled — the shape of a real defect that once shipped:
224
+
189
225
  ```
226
+ resolved 6146 · ... · missing 23 · ... · alias-resolved 0
227
+ ```
228
+
229
+ Same total, 23 links in the wrong bucket.
190
230
 
191
231
  `catalog` and `out_of_scope` are the resolution state the build consulted. They are what you need to
192
232
  explain a link rather than just resolve it — which notes a bare `[[link]]` collided with, or whether
@@ -57,12 +57,32 @@ graphmark siloed --root /path/to/vault
57
57
  graphmark neighborhood --note a/b.md --depth 2 --root /path/to/vault
58
58
  graphmark pagerank --n 10 --alpha 0.85 --root /path/to/vault
59
59
  graphmark export dot --root /path/to/vault > graph.dot
60
+ graphmark links --root /path/to/vault
60
61
  graphmark check --config vault.toml
61
62
  ```
62
63
 
63
64
  Exit codes: `0` success, `1` a `check` threshold breach, `2` a usage or config error. Nothing else
64
65
  uses `1`, so CI can trust it.
65
66
 
67
+ ## `graphmark links` — how every link was classified
68
+
69
+ ```console
70
+ $ graphmark --root /path/to/vault links
71
+ {"total": 6226, "counts": {"resolved": 6169, "ambiguous": 0, "non-note-file": 17, "out-of-scope-note": 0, "missing": 0, "intra-note": 40}, "alias_resolved": 23}
72
+ resolved 6169 · ambiguous 0 · non-note-file 17 · out-of-scope-note 0 · missing 0 · intra-note 40 · alias-resolved 23
73
+ ```
74
+
75
+ JSON on stdout, the one-line summary on stderr, so the output stays pipeable.
76
+
77
+ Every wikilink lands in exactly one bucket and the buckets sum to `total` — nothing is silently
78
+ dropped. All six reasons are always present, because a zero is a finding: the line above with
79
+ `missing 23 · alias-resolved 0` instead is what a real defect looked like before frontmatter aliases
80
+ resolved, on the same vault with the same 6226 total. Reading the distribution is the fastest way to
81
+ notice the tool is wrong about your vault.
82
+
83
+ The same block appears in `check`'s report under `links`, as context for the verdict. It never
84
+ changes the verdict.
85
+
66
86
  ## `graphmark check` — vault health as a CI gate
67
87
 
68
88
  Declare thresholds in the config's `[check]` table, then run `graphmark check` as a build step. It
@@ -159,7 +179,26 @@ graph.unresolved # {rel_path: [broken link displays
159
179
  graph.catalog # {normalized stem: [rel_paths]} — 2+ means ambiguous
160
180
  graph.out_of_scope # same, for markdown outside the configured scope
161
181
  graph.aliases # {normalized alias: rel_path} — frontmatter aliases
182
+ graph.link_counts # {reason: occurrences} — every link, one bucket each
183
+ graph.alias_resolved # how many resolutions came via an alias
184
+ ```
185
+
186
+ `link_counts` accounts for **every** display the extractor produced, one bucket each, and always
187
+ carries all six `DIAGNOSIS_REASONS` — a zero is a finding, not a non-event. The buckets sum to the
188
+ extraction count, so nothing is silently dropped. Reading the distribution is the fastest way to
189
+ notice the tool is wrong about your vault:
190
+
191
+ ```
192
+ resolved 6169 · ambiguous 0 · non-note-file 17 · out-of-scope-note 0 · missing 0 · intra-note 40 · alias-resolved 23
193
+ ```
194
+
195
+ versus the same vault with alias resolution disabled — the shape of a real defect that once shipped:
196
+
162
197
  ```
198
+ resolved 6146 · ... · missing 23 · ... · alias-resolved 0
199
+ ```
200
+
201
+ Same total, 23 links in the wrong bucket.
163
202
 
164
203
  `catalog` and `out_of_scope` are the resolution state the build consulted. They are what you need to
165
204
  explain a link rather than just resolve it — which notes a bare `[[link]]` collided with, or whether
@@ -22,13 +22,14 @@ story** — the priority consumer is the owner's vault seam (`graph_cli.py`), no
22
22
 
23
23
  ## What's shipped (baseline — do not re-propose)
24
24
 
25
- - **The full engine, v0.3.4 on PyPI**: `parse.py` (wikilink extraction, code-span skipping),
26
- `graph.py` (catalog + `NormalizeResolver` + `VaultGraph.build` + `unresolved`), `metrics.py`
27
- (stats, orphans, hubs, clusters, bridges, siloed_notes, neighborhood, pure-python pagerank,
28
- gaps), `check.py` (policy evaluation), `export.py` (JSON/DOT), `cli.py`, `config.py`
29
- (`VaultConfig` + `CheckPolicy` + `load_config`), and `dismiss.py` (content-hash weaklink
30
- dismissal store). Tracks A, C and D are all closed — read their sections before proposing
31
- anything in those areas.
25
+ - **The full engine, v0.6.0 on PyPI**: `parse.py` (wikilink extraction, code-span skipping,
26
+ frontmatter incl. block lists), `graph.py` (catalog + `NormalizeResolver` + `VaultGraph.build` +
27
+ `unresolved` + `diagnose`/`LinkDiagnosis` + frontmatter aliases), `metrics.py` (stats, orphans,
28
+ hubs, clusters, bridges, siloed_notes, neighborhood, pure-python pagerank, gaps), `check.py`
29
+ (policy evaluation), `export.py` (JSON/DOT), `cli.py`, `config.py` (`VaultConfig` +
30
+ `CheckPolicy` + `load_config`), and `dismiss.py` (content-hash weaklink dismissal store).
31
+ Tracks A, C, D and E are all closed — read their sections before proposing anything in those
32
+ areas.
32
33
  - **Six frozen differential fixtures** — simple, alt, scoped, selflink, gaps (+ out-of-graph),
33
34
  dismiss — plus live-vault parity diffs against the reference engine.
34
35
  - **`gaps()` with injected similarity** — graphmark owns the deterministic ranking/filtering
@@ -63,14 +64,17 @@ Shipped: `graph.unresolved`, the `[check]` policy block (`max_orphans` /
63
64
  reserved for breach and a byte-stable JSON report, `graphmark.build()` + curated top-level
64
65
  re-exports, and a README rewritten around the real surface.
65
66
 
66
- _Where we are (0.3.4):_ the gate works and the vault dogfoods it. The remaining risk is the
67
- **credibility of its flagship number**. Four false-positive classes have been found and fixed by
68
- triaging a real vault against the metric — same-note anchors (#98, 19% of the count),
69
- non-markdown targets (#101, 10%), the `.md` extension (#104), and links to notes that exist but
70
- are out of graph scope (#107, 7%). A threshold nobody trusts is not a gate.
67
+ _Where we are (0.6.0):_ the gate works and the vault dogfoods it. The remaining risk is the
68
+ **credibility of its flagship number**. Seven false-positive classes have been found and fixed
69
+ same-note anchors (#98, 19% of the count), non-markdown targets (#101, 10%), whitespace-padded
70
+ displays, the `.md` extension (#104), out-of-scope notes (#107, 7%), frontmatter aliases (#119,
71
+ **23 phantom breaks against an actual 0**), and Unicode NFD/NFC (#123). A threshold nobody trusts
72
+ is not a gate.
71
73
 
72
- _Where we're going:_ keep truthing the metric against live vaults, then the thin GitHub Action
73
- wrapper but only once the count is trustworthy enough to fail someone's build.
74
+ _Where we're going:_ **Track F, not the GitHub Action.** Every one of those seven was found by a
75
+ human reading link lists on one vault, which does not scale and has no reason to be finished. The
76
+ Action ships once the accounting underneath it is auditable — see Track F for why that ordering is
77
+ deliberate.
74
78
 
75
79
  ### Track E — One resolver: absorb the consumer's second link stack (feature track)
76
80
 
@@ -97,6 +101,78 @@ point of it). The consumer-side deletion is hand-coded in the vault; the package
97
101
 
98
102
  _Constraint:_ this is additive. Nothing here may change what resolves or what an edge is.
99
103
 
104
+ **CLOSED at v0.6.0.** #110/#111/#112 shipped, and frontmatter aliases moved in-package (#119) —
105
+ the last piece of the consumer's parallel stack. the-workshop#474 deletes the consumer side.
106
+
107
+ ### Track F — Auditable link accounting (the current epic)
108
+
109
+ _The problem is not any individual bug; it is how they get found._ Every correctness bug in this
110
+ package's history was found the same way: a human reading link lists from **one** vault. Seven so
111
+ far — #98 same-note anchors, #101 non-markdown targets, #104 the `.md` extension, #107 out-of-scope
112
+ notes, #119 frontmatter aliases, #123 Unicode NFD/NFC, plus the whitespace-padded displays fixed
113
+ alongside #107. Six of those seven landed in a single day of hand-triage.
114
+
115
+ The frozen differential oracle is a **ratchet, not a detector.** It prevents regression superbly and
116
+ has never once surfaced something new. Worse, the single consumer's seam actively _conceals_
117
+ package defects: its `AliasResolver` hid a 23-link error for six releases, because the only vault
118
+ that could have exposed the gap had already patched around it.
119
+
120
+ _Why the bugs hide:_ the buckets are silent. `build` sorts every extracted display into one of the
121
+ six `DIAGNOSIS_REASONS`, then reports **one** of them. A vault owner sees `N unresolved` and nothing
122
+ about what was suppressed — and six of the seven bugs were _mis-bucketing_, a link filed as
123
+ `unresolved` that belonged in `resolved` or the reverse. Had graphmark printed
124
+
125
+ ```
126
+ 3677 edges · 23 unresolved · 0 alias-resolved · 12 out-of-scope · 8 non-note-file · 19 intra-note
127
+ ```
128
+
129
+ the alias gap would have been obvious at a glance: zero alias-resolved beside 23 unresolved is
130
+ visibly wrong. Nobody could see it, so nobody saw it for six releases.
131
+
132
+ _Where we're going:_ **every extracted display lands in exactly one named bucket; the buckets are
133
+ counted, reported, and bound by a property-tested conservation law.** The aim is not another metric
134
+ — it is to make the package's own answers auditable, so an implausible distribution is visible to
135
+ its owner without a day of triage. That is the mechanism that scales past one vault and one human,
136
+ and it is the strongest possible expression of the "public correctness story" identity.
137
+
138
+ Sliced as #124 (per-reason counts + the conservation law), #125 (`graphmark links` surfaces the
139
+ distribution), #126 (property-based vault generation — **the detector**; expect it to fail on first
140
+ run, and file what it finds), #127 (implausibility heuristics — advisory only, **not
141
+ autonomous-safe past the exact checks**, since the statistical thresholds need the same human
142
+ calibration gate #112 used).
143
+
144
+ Track F's own success measure is not slices merged: it is **bugs found by machinery rather than by
145
+ a human reading link lists.** #126's catch rate is the evidence.
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
+ Audited alongside: the reference vault's two suppressed buckets, by hand — 17 `non-note-file` (all
167
+ genuine `.base`/`.png`) and 40 `intra-note` (all genuine heading refs), 0 false suppressions. A
168
+ suppressed bucket is only trustworthy once someone has read it.
169
+
170
+ _Deliberately deferred behind this:_ the Track D GitHub Action. It is an adoption play, and adoption
171
+ is not this repo's game; more to the point, shipping a gate that fails other people's builds while
172
+ the tool's own accounting is unauditable is backwards ordering. Generality work (Windows paths,
173
+ markdown-style `[text](note.md)` links for wikilink-disabled vaults, non-my-brain schemas) is real
174
+ but is a feature list, not an epic — and this track is what will tell us which parts of it matter.
175
+
100
176
  ### Track B — Judgment the oracle can't cover (human-validated)
101
177
 
102
178
  - New fixtures on a **different schema** than my-brain have hand-authored expected values → a human
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphmark"
3
- version = "0.6.0"
3
+ version = "0.7.1"
4
4
  description = "Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -27,7 +27,7 @@ Repository = "https://github.com/cdcoonce/graphmark"
27
27
  Issues = "https://github.com/cdcoonce/graphmark/issues"
28
28
 
29
29
  [project.optional-dependencies]
30
- dev = ["pytest>=8.0", "ruff>=0.6"]
30
+ dev = ["pytest>=8.0", "ruff>=0.6", "hypothesis>=6.100"]
31
31
 
32
32
  [project.scripts]
33
33
  graphmark = "graphmark.cli:main"
@@ -17,7 +17,7 @@ from __future__ import annotations
17
17
  from importlib.metadata import PackageNotFoundError, version
18
18
  from pathlib import Path
19
19
 
20
- from graphmark.check import run_check, unresolved_link_count
20
+ from graphmark.check import links_report, run_check, unresolved_link_count
21
21
  from graphmark.config import CheckPolicy, VaultConfig, load_config
22
22
  from graphmark.dismiss import (
23
23
  active_dismissed_sigs,
@@ -128,6 +128,7 @@ __all__ = [
128
128
  "pagerank",
129
129
  "parse_document",
130
130
  "record_dismissal",
131
+ "links_report",
131
132
  "run_check",
132
133
  "siloed_notes",
133
134
  "stats",
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
  from dataclasses import fields
12
12
 
13
13
  from graphmark.config import VaultConfig
14
- from graphmark.graph import VaultGraph
14
+ from graphmark.graph import DIAGNOSIS_REASONS, VaultGraph
15
15
  from graphmark.metrics import orphans, siloed_notes
16
16
 
17
17
 
@@ -20,6 +20,32 @@ def unresolved_link_count(graph: VaultGraph) -> int:
20
20
  return sum(len(displays) for displays in graph.unresolved.values())
21
21
 
22
22
 
23
+ def links_report(graph: VaultGraph) -> dict:
24
+ """How every wikilink in the vault was classified, as a byte-stable block.
25
+
26
+ The counts exist on the graph after a build, but a vault owner cannot read a Python object.
27
+ That gap is not cosmetic: the six-release frontmatter-alias defect was legible in this
28
+ distribution the entire time — many links reported broken beside zero resolved via alias — and
29
+ nobody saw it because no surface printed it.
30
+
31
+ Key order is fixed here and reasons follow ``DIAGNOSIS_REASONS``, so two runs over an unchanged
32
+ vault diff to nothing, matching ``run_check``'s existing contract.
33
+ """
34
+ counts = {reason: graph.link_counts.get(reason, 0) for reason in DIAGNOSIS_REASONS}
35
+ return {
36
+ "total": sum(counts.values()),
37
+ "counts": counts,
38
+ "alias_resolved": graph.alias_resolved,
39
+ }
40
+
41
+
42
+ def links_summary_line(report: dict) -> str:
43
+ """One human-readable line of the distribution, for stderr."""
44
+ parts = [f"{reason} {count}" for reason, count in report["counts"].items()]
45
+ parts.append(f"alias-resolved {report['alias_resolved']}")
46
+ return " · ".join(parts)
47
+
48
+
23
49
  def _actual(name: str, graph: VaultGraph, config: VaultConfig) -> int:
24
50
  if name == "max_orphans":
25
51
  # Honors transient_prefixes, so scratch/daily notes do not fail the gate.
@@ -53,7 +79,13 @@ def run_check(graph: VaultGraph, config: VaultConfig) -> dict:
53
79
  # "max" is inclusive: exactly at the limit passes.
54
80
  checks.append({"name": f.name, "limit": limit, "actual": actual, "pass": actual <= limit})
55
81
 
56
- return {"pass": all(c["pass"] for c in checks), "checks": checks}
82
+ # `links` is appended, never interleaved, so consumers already parsing this report keep
83
+ # working. It is context for reading the verdict and can never change it.
84
+ return {
85
+ "pass": all(c["pass"] for c in checks),
86
+ "checks": checks,
87
+ "links": links_report(graph),
88
+ }
57
89
 
58
90
 
59
91
  def breach_lines(report: dict) -> list[str]:
@@ -10,7 +10,7 @@ from pathlib import Path
10
10
  import networkx as nx
11
11
 
12
12
  from graphmark import __version__, build
13
- from graphmark.check import breach_lines, run_check
13
+ from graphmark.check import breach_lines, links_report, links_summary_line, run_check
14
14
  from graphmark.config import VaultConfig, load_config
15
15
  from graphmark.export import to_dot, to_json
16
16
  from graphmark.graph import VaultGraph
@@ -83,6 +83,11 @@ def main() -> None:
83
83
 
84
84
  sub.add_parser("gaps", help="Link-gap suggestions (library-only; see the README)")
85
85
 
86
+ sub.add_parser(
87
+ "links",
88
+ help="How every wikilink was classified: counts per reason, plus alias resolutions",
89
+ )
90
+
86
91
  sub.add_parser(
87
92
  "check",
88
93
  help="Gate vault health against the config's [check] thresholds (exit 1 on breach)",
@@ -140,6 +145,11 @@ def main() -> None:
140
145
  print(to_json(result))
141
146
  elif args.command == "export" and args.format == "dot":
142
147
  print(to_dot(graph))
148
+ elif args.command == "links":
149
+ report = links_report(graph)
150
+ print(to_json(report))
151
+ # stdout stays pipeable JSON; the at-a-glance line goes to stderr, as breach_lines does.
152
+ print(links_summary_line(report), file=sys.stderr)
143
153
  elif args.command == "check":
144
154
  try:
145
155
  report = run_check(graph, config)