graphmark 0.6.0__tar.gz → 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {graphmark-0.6.0 → graphmark-0.7.0}/CHANGELOG.md +161 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/PKG-INFO +41 -1
- {graphmark-0.6.0 → graphmark-0.7.0}/README.md +39 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/docs/ROADMAP.md +67 -14
- {graphmark-0.6.0 → graphmark-0.7.0}/pyproject.toml +2 -2
- {graphmark-0.6.0 → graphmark-0.7.0}/src/graphmark/__init__.py +2 -1
- {graphmark-0.6.0 → graphmark-0.7.0}/src/graphmark/check.py +34 -2
- {graphmark-0.6.0 → graphmark-0.7.0}/src/graphmark/cli.py +11 -1
- {graphmark-0.6.0 → graphmark-0.7.0}/src/graphmark/graph.py +33 -3
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_check.py +7 -1
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_diagnose.py +3 -1
- graphmark-0.7.0/tests/test_link_counts.py +207 -0
- graphmark-0.7.0/tests/test_links_report.py +171 -0
- graphmark-0.7.0/tests/test_properties.py +322 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_public_api.py +1 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/.gitignore +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/LICENSE +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/src/graphmark/config.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/src/graphmark/dismiss.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/src/graphmark/export.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/src/graphmark/interfaces.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/src/graphmark/metrics.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/src/graphmark/model.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/src/graphmark/parse.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/src/graphmark/py.typed +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/README.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/alt/config.toml +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/alt/expected.json +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/alt/vault/docs/center.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/alt/vault/misc/echo.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/alt/vault/misc/stub.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/alt/vault/refs/beta.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/alt/vault/refs/delta.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/dismiss/expected.json +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/dismiss/vault/alpha.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/dismiss/vault/beta.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/dismiss/vault/gamma.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/gaps/config.toml +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/gaps/expected.json +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/gaps/expected_oog.json +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/gaps/similar.json +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/gaps/similar_oog.json +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/gaps/vault/docs/a.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/gaps/vault/misc/c.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/gaps/vault/misc/d.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/gaps/vault/refs/b.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/gaps/vault/refs/e.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/scoped/config.toml +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/scoped/expected.json +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/scoped/vault/docs/one.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/scoped/vault/refs/two.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/selflink/config.toml +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/selflink/expected.json +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/selflink/vault/a.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/selflink/vault/b.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/simple/config.toml +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/simple/expected.json +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/simple/vault/brain/hub.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/simple/vault/personal/beta.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/simple/vault/reference/island.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/simple/vault/reference/stub.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/fixtures/suggest/README.md +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_aliases.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_build_filters.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_check_policy.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_cli.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_config.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_degenerate.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_dismiss.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_export.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_gaps.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_gaps_banding.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_gaps_oog.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_gaps_params.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_graph.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_graph_catalogs.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_metrics.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_model.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_packaging.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_pagerank.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_parse.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_scoped.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_selflink.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_smoke.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_suggestions.py +0 -0
- {graphmark-0.6.0 → graphmark-0.7.0}/tests/test_unresolved.py +0 -0
|
@@ -1,6 +1,167 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.7.0 (2026-07-25)
|
|
5
|
+
|
|
6
|
+
### Documentation
|
|
7
|
+
|
|
8
|
+
- Record the nine-vault corpus study ([#134](https://github.com/cdcoonce/graphmark/pull/134),
|
|
9
|
+
[`7495934`](https://github.com/cdcoonce/graphmark/commit/749593468e3d57913d89fc00942179786f5f5128))
|
|
10
|
+
|
|
11
|
+
graphmark has had exactly one real vault, and a coverage run showed the problem is worse than n=1:
|
|
12
|
+
that vault now has zero broken links, so it never executes the error handling at all —
|
|
13
|
+
suggest_notes entirely, the ambiguous / missing / out-of-scope verdicts, unresolved recording, and
|
|
14
|
+
diagnose(). 54% of the package is unexercised by it. The healthier it gets, the less it validates.
|
|
15
|
+
|
|
16
|
+
Nine third-party vaults, zero crashes — the first evidence for the README's 'any Obsidian-family
|
|
17
|
+
vault' claim. kepano's vault is 74% .base links, which independently justifies #101 and shows the
|
|
18
|
+
reference vault understated that class by 7x. Third-party vaults sit at 1.8-27% missing against
|
|
19
|
+
the reference vault's 0%.
|
|
20
|
+
|
|
21
|
+
That range confirms #127's closure against real data rather than argument: the largest
|
|
22
|
+
false-positive class this package has had moved missing by 0.4%, which no threshold can separate
|
|
23
|
+
from ordinary variation.
|
|
24
|
+
|
|
25
|
+
No new defects. Two suspicious distributions were both graphmark being right, and the second was my
|
|
26
|
+
own scoping error — I excluded English template directory names but not the Spanish 'Plantillas',
|
|
27
|
+
which is the methodological lesson worth recording for anyone repeating this.
|
|
28
|
+
|
|
29
|
+
- **roadmap**: Close Track E, open Track F — auditable link accounting
|
|
30
|
+
([#128](https://github.com/cdcoonce/graphmark/pull/128),
|
|
31
|
+
[`4ca84de`](https://github.com/cdcoonce/graphmark/commit/4ca84de110dd288cd2778ccdc48a3b558367b269))
|
|
32
|
+
|
|
33
|
+
* docs(roadmap): close Track E, open Track F — auditable link accounting (#124-#127)
|
|
34
|
+
|
|
35
|
+
afk-driver --expand reads this file verbatim, so it has to describe where the work actually is.
|
|
36
|
+
Track E is closed at 0.6.0 (aliases were its last piece), and the baseline line still claimed
|
|
37
|
+
0.3.4.
|
|
38
|
+
|
|
39
|
+
Track F names the real problem, which is not any individual bug but how bugs get found: all seven
|
|
40
|
+
correctness defects in this package's history came from a human reading link lists on the single
|
|
41
|
+
reference vault, six of them in one day. The frozen oracle is a ratchet, not a detector — it has
|
|
42
|
+
never surfaced anything new — and the one consumer's seam actively conceals package defects,
|
|
43
|
+
having hidden a 23-link alias error for six releases.
|
|
44
|
+
|
|
45
|
+
The mechanism of the hiding is that the buckets are silent: build sorts every display into one of
|
|
46
|
+
six reasons and reports one of them, and six of the seven bugs were mis-bucketing. Printing the
|
|
47
|
+
distribution would have made the alias gap obvious at a glance — zero alias-resolved beside 23
|
|
48
|
+
unresolved is visibly wrong.
|
|
49
|
+
|
|
50
|
+
So the epic is to make the package's own answers auditable: every display in exactly one named
|
|
51
|
+
bucket, counted, reported, and bound by a property-tested conservation law. Sliced #124-#127, with
|
|
52
|
+
the detector (#126) as the point of the exercise and its catch rate as the evidence.
|
|
53
|
+
|
|
54
|
+
Records what is deliberately deferred behind it and why: the Track D GitHub Action is an adoption
|
|
55
|
+
play this repo has already decided against, and shipping a gate that fails other people's builds
|
|
56
|
+
while our own accounting is unauditable is backwards ordering. Generality work is real but is a
|
|
57
|
+
feature list, not an epic — and Track F is what will say which parts of it matter.
|
|
58
|
+
|
|
59
|
+
* docs(roadmap): truth up Track D — seven classes, and the Action waits on Track F
|
|
60
|
+
|
|
61
|
+
Track D still read '0.3.4' and 'four false-positive classes', and promised the GitHub Action as the
|
|
62
|
+
next step, which now contradicts Track F's deliberate deferral. afk reads this file verbatim, so a
|
|
63
|
+
contradiction in it is a contradiction injected into the expander's prompt.
|
|
64
|
+
|
|
65
|
+
### Features
|
|
66
|
+
|
|
67
|
+
- **cli**: Graphmark links — surface the classification distribution (#125)
|
|
68
|
+
([#131](https://github.com/cdcoonce/graphmark/pull/131),
|
|
69
|
+
[`3ccf11c`](https://github.com/cdcoonce/graphmark/commit/3ccf11c26028c03739cfa88e67119c230645a22e))
|
|
70
|
+
|
|
71
|
+
Track F slice 2. Slice 1 made the classification countable; this makes it readable. Counts sitting
|
|
72
|
+
in a Python object are worthless to a vault owner, and that gap is not cosmetic: the six-release
|
|
73
|
+
frontmatter-alias defect was legible in this distribution the whole time — 23 links reported
|
|
74
|
+
broken beside zero resolved via alias — and nobody saw it because no surface printed it.
|
|
75
|
+
|
|
76
|
+
`graphmark links` emits the block as JSON on stdout with a one-line summary on stderr, so stdout
|
|
77
|
+
stays pipeable. `check`'s report carries the same block under `links`, appended after `checks` so
|
|
78
|
+
consumers already parsing that report keep working, and provably unable to change the verdict — a
|
|
79
|
+
test pins that a passing and a breaching run produce identical `links`.
|
|
80
|
+
|
|
81
|
+
The byte-stability oracle in test_check.py is updated rather than sidestepped, and now pins a
|
|
82
|
+
cross-check worth having: max_unresolved_links' actual equals counts.missing, so the gate's
|
|
83
|
+
flagship number and the distribution behind it cannot silently disagree.
|
|
84
|
+
|
|
85
|
+
Filed #130 along the way: every CLI example in the README fails. --config and --root live on the
|
|
86
|
+
parent parser, so argparse only accepts them before the subcommand, while the README documents the
|
|
87
|
+
opposite in all ten examples. It survived because the suite exclusively uses the working form, so
|
|
88
|
+
nothing exercised the documented one — the same shape as the rest of Track F, and the reason
|
|
89
|
+
#130's acceptance criteria require every README example to be executed by a test.
|
|
90
|
+
|
|
91
|
+
Mutation testing caught that the report's zero-fill was untested: build() seeds all six keys, so
|
|
92
|
+
only a directly constructed graph exercises it. Covered now.
|
|
93
|
+
|
|
94
|
+
- **graph**: Per-reason link counts and the conservation law (#124)
|
|
95
|
+
([#129](https://github.com/cdcoonce/graphmark/pull/129),
|
|
96
|
+
[`4ba2ab6`](https://github.com/cdcoonce/graphmark/commit/4ba2ab65b32654af98f3987f2688c18af23c7f02))
|
|
97
|
+
|
|
98
|
+
Track F slice 1. build sorted every extracted display into one of the six DIAGNOSIS_REASONS and then
|
|
99
|
+
kept exactly one bucket plus the edges. The other outcomes were decided and discarded, and that
|
|
100
|
+
silence is why six of the seven correctness bugs in this package's history went unnoticed: each
|
|
101
|
+
was a mis-bucketing, and nothing in the output made an implausible distribution visible.
|
|
102
|
+
|
|
103
|
+
graph.link_counts accounts for every display, one bucket each, with all six keys always present in
|
|
104
|
+
DIAGNOSIS_REASONS order — a zero is a finding, so it must be reported rather than absent.
|
|
105
|
+
graph.alias_resolved counts resolutions reached through an alias, which is not a DIAGNOSIS_REASONS
|
|
106
|
+
value but is the distinction that would have made #119 visible.
|
|
107
|
+
|
|
108
|
+
LinkDiagnosis gains `via` ("stem" or "alias") so the counter reads the classifier's own verdict
|
|
109
|
+
instead of re-deriving it. Re-deriving is how the two would eventually disagree, which is the
|
|
110
|
+
failure this repo has spent Track E removing.
|
|
111
|
+
|
|
112
|
+
The conservation law — sum(link_counts.values()) equals the extractor's total — is asserted on a
|
|
113
|
+
vault exercising all six outcomes, on the frozen fixtures, and against the pre-existing surface
|
|
114
|
+
(unresolved occurrences == ambiguous + missing), so the new tally cannot drift from the old one.
|
|
115
|
+
|
|
116
|
+
On the live 521-note vault the payoff is legible in one line. Healthy: missing 0, alias-resolved 23.
|
|
117
|
+
The same vault with aliases disabled, which is what the six-release defect actually looked like:
|
|
118
|
+
missing 23, alias-resolved 0. Same 6226 total either way — nothing vanished, 23 links were in the
|
|
119
|
+
wrong bucket, and now you can see that.
|
|
120
|
+
|
|
121
|
+
Purely additive: no metric changes, frozen fixtures byte-identical.
|
|
122
|
+
|
|
123
|
+
### Testing
|
|
124
|
+
|
|
125
|
+
- Property-based vault generation — Track F's detector (#126)
|
|
126
|
+
([#132](https://github.com/cdcoonce/graphmark/pull/132),
|
|
127
|
+
[`cd76410`](https://github.com/cdcoonce/graphmark/commit/cd76410087157677f6f6515c94d11057060063eb))
|
|
128
|
+
|
|
129
|
+
The frozen oracle prevents regression and has never surfaced anything new, because fixtures only
|
|
130
|
+
encode shapes somebody already thought of, and all seven historical bugs were shapes nobody
|
|
131
|
+
thought of. This generates vaults nobody designed and asserts what must hold for any content at
|
|
132
|
+
all.
|
|
133
|
+
|
|
134
|
+
Two layers, and the second is the one that matters.
|
|
135
|
+
|
|
136
|
+
STRUCTURAL invariants over generated vaults: conservation (every extracted display lands in a
|
|
137
|
+
bucket), edges name real notes and never exceed the resolved count, self-links never become edges,
|
|
138
|
+
back_links mirror out_links, unresolved is exactly ambiguous+missing, aliases never shadow a real
|
|
139
|
+
note name, and build is deterministic — the headline claim of this package, asserted nowhere until
|
|
140
|
+
now.
|
|
141
|
+
|
|
142
|
+
Those all passed on first run, which was the finding. Every one of the seven bugs kept conservation
|
|
143
|
+
intact; each merely filed a link in the WRONG bucket. So the structural layer would have caught
|
|
144
|
+
none of them, and shipping only that would have been a detector that cannot see the thing it
|
|
145
|
+
exists for.
|
|
146
|
+
|
|
147
|
+
METAMORPHIC relations close that gap. Obsidian treats [[X]], [[X|alias]], [[X#Section]] and [[X.md]]
|
|
148
|
+
as the same link, so each rewrite must leave the counts and edges identical. Re-injecting the real
|
|
149
|
+
defects confirms the layer works: #104 (.md), #98 (intra-note), #101 (non-note file), #119
|
|
150
|
+
(aliases) and the path-qualified whitespace bug each fail a property.
|
|
151
|
+
|
|
152
|
+
Two of those properties were over-specified on first write — they compared `unresolved`, which
|
|
153
|
+
echoes the RAW display, so a rewrite legitimately changes it. Comparing it asserts a rewrite does
|
|
154
|
+
not rewrite anything. They now compare counts and edges, with the reason documented at the helper.
|
|
155
|
+
|
|
156
|
+
The whitespace property also had to be sharpened: normalization collapses whitespace on its own, so
|
|
157
|
+
a padded BARE display resolves either way and cannot detect the defect. Only the path-suffix
|
|
158
|
+
branch is sensitive, which is exactly where the 13 live links were reported broken while pointing
|
|
159
|
+
at real notes.
|
|
160
|
+
|
|
161
|
+
hypothesis is a dev dependency only; the shipped runtime dep is still networkx alone. Bounded and
|
|
162
|
+
derandomized so CI stays fast and failures reproduce.
|
|
163
|
+
|
|
164
|
+
|
|
4
165
|
## v0.6.0 (2026-07-25)
|
|
5
166
|
|
|
6
167
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graphmark
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults.
|
|
5
5
|
Project-URL: Homepage, https://github.com/cdcoonce/graphmark
|
|
6
6
|
Project-URL: Repository, https://github.com/cdcoonce/graphmark
|
|
@@ -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.
|
|
26
|
-
`graph.py` (catalog + `NormalizeResolver` + `VaultGraph.build` +
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
(
|
|
30
|
-
|
|
31
|
-
anything in those
|
|
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.
|
|
67
|
-
**credibility of its flagship number**.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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:_
|
|
73
|
-
|
|
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,55 @@ 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
|
+
_Deliberately deferred behind this:_ the Track D GitHub Action. It is an adoption play, and adoption
|
|
148
|
+
is not this repo's game; more to the point, shipping a gate that fails other people's builds while
|
|
149
|
+
the tool's own accounting is unauditable is backwards ordering. Generality work (Windows paths,
|
|
150
|
+
markdown-style `[text](note.md)` links for wikilink-disabled vaults, non-my-brain schemas) is real
|
|
151
|
+
but is a feature list, not an epic — and this track is what will tell us which parts of it matter.
|
|
152
|
+
|
|
100
153
|
### Track B — Judgment the oracle can't cover (human-validated)
|
|
101
154
|
|
|
102
155
|
- 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.
|
|
3
|
+
version = "0.7.0"
|
|
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
|
-
|
|
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)
|
|
@@ -293,12 +293,20 @@ class LinkDiagnosis:
|
|
|
293
293
|
``candidates`` carries the rel_paths in play: the colliding notes for ``ambiguous``, the
|
|
294
294
|
unindexed markdown for ``out-of-scope-note``, and — only when ``diagnose`` is asked for them —
|
|
295
295
|
the near-miss suggestions for ``missing``. Empty for every other reason.
|
|
296
|
+
|
|
297
|
+
``via`` distinguishes the two ways a link can resolve. That distinction is not cosmetic: an
|
|
298
|
+
alias resolution looked identical to a stem resolution, and a vault reporting many broken links
|
|
299
|
+
beside zero alias resolutions is the exact signature of the defect that hid for six releases.
|
|
296
300
|
"""
|
|
297
301
|
|
|
298
302
|
display: str
|
|
299
303
|
target: str | None = None
|
|
300
304
|
reason: str = "missing"
|
|
301
305
|
candidates: tuple[str, ...] = field(default=())
|
|
306
|
+
#: How a ``resolved`` verdict was reached — ``"stem"`` (catalog name or path suffix) or
|
|
307
|
+
#: ``"alias"`` (frontmatter ``aliases:``). ``None`` for every other reason. Recorded here
|
|
308
|
+
#: rather than re-derived by callers, so a counter can never disagree with the classifier.
|
|
309
|
+
via: str | None = None
|
|
302
310
|
|
|
303
311
|
|
|
304
312
|
def _diagnose(
|
|
@@ -320,7 +328,7 @@ def _diagnose(
|
|
|
320
328
|
|
|
321
329
|
target = resolver.resolve(display, catalog)
|
|
322
330
|
if target is not None:
|
|
323
|
-
return LinkDiagnosis(display=display, target=target, reason="resolved")
|
|
331
|
+
return LinkDiagnosis(display=display, target=target, reason="resolved", via="stem")
|
|
324
332
|
|
|
325
333
|
# The resolver runs first, so a real note named X always beats an alias X — otherwise renaming
|
|
326
334
|
# a note could silently hijack live links. An alias hit is a genuine resolution, not a
|
|
@@ -336,7 +344,9 @@ def _diagnose(
|
|
|
336
344
|
if stripped and "/" not in stripped:
|
|
337
345
|
aliased = aliases.get(_normalize(stripped))
|
|
338
346
|
if aliased is not None:
|
|
339
|
-
return LinkDiagnosis(
|
|
347
|
+
return LinkDiagnosis(
|
|
348
|
+
display=display, target=aliased, reason="resolved", via="alias"
|
|
349
|
+
)
|
|
340
350
|
|
|
341
351
|
# The resolver declined. Whether it declined because nothing matched or because too much did
|
|
342
352
|
# is the distinction consumers need, and only the catalog can answer it.
|
|
@@ -403,6 +413,8 @@ class VaultGraph:
|
|
|
403
413
|
out_of_scope: dict[str, list[str]] | None = None,
|
|
404
414
|
resolver: Resolver | None = None,
|
|
405
415
|
aliases: dict[str, str] | None = None,
|
|
416
|
+
link_counts: dict[str, int] | None = None,
|
|
417
|
+
alias_resolved: int = 0,
|
|
406
418
|
) -> None:
|
|
407
419
|
self.nodes = nodes
|
|
408
420
|
self.out_links = out_links
|
|
@@ -411,6 +423,8 @@ class VaultGraph:
|
|
|
411
423
|
self.catalog = catalog if catalog is not None else {}
|
|
412
424
|
self.out_of_scope = out_of_scope if out_of_scope is not None else {}
|
|
413
425
|
self.aliases = aliases if aliases is not None else {}
|
|
426
|
+
self.link_counts = link_counts if link_counts is not None else {}
|
|
427
|
+
self.alias_resolved = alias_resolved
|
|
414
428
|
# Retained so diagnose() answers with the same resolver that built the graph; a
|
|
415
429
|
# pluggable Resolver that disagreed with the graph it describes would be worse than none.
|
|
416
430
|
self.resolver: Resolver = resolver if resolver is not None else NormalizeResolver()
|
|
@@ -466,9 +480,16 @@ class VaultGraph:
|
|
|
466
480
|
# the raw display is recorded once per occurrence, since that is what a human goes and
|
|
467
481
|
# fixes.
|
|
468
482
|
broken = {"ambiguous", "missing"}
|
|
483
|
+
# Every extracted display lands in exactly one bucket. All six keys are seeded so a zero
|
|
484
|
+
# is reported rather than absent — "0 alias-resolved" is a finding, not a non-event.
|
|
485
|
+
link_counts: dict[str, int] = dict.fromkeys(DIAGNOSIS_REASONS, 0)
|
|
486
|
+
alias_resolved = 0
|
|
469
487
|
for doc in docs:
|
|
470
488
|
for display in extractor.extract(doc.text):
|
|
471
489
|
d = _diagnose(display, catalog, out_of_scope, resolver, aliases)
|
|
490
|
+
link_counts[d.reason] += 1
|
|
491
|
+
if d.via == "alias":
|
|
492
|
+
alias_resolved += 1
|
|
472
493
|
if d.reason in broken:
|
|
473
494
|
unresolved.setdefault(doc.rel_path, []).append(display)
|
|
474
495
|
elif d.target is not None and d.target != doc.rel_path:
|
|
@@ -479,5 +500,14 @@ class VaultGraph:
|
|
|
479
500
|
back_links[dst].add(src)
|
|
480
501
|
|
|
481
502
|
return cls(
|
|
482
|
-
nodes,
|
|
503
|
+
nodes,
|
|
504
|
+
out_links,
|
|
505
|
+
back_links,
|
|
506
|
+
unresolved,
|
|
507
|
+
catalog,
|
|
508
|
+
out_of_scope,
|
|
509
|
+
resolver,
|
|
510
|
+
aliases,
|
|
511
|
+
link_counts,
|
|
512
|
+
alias_resolved,
|
|
483
513
|
)
|
|
@@ -108,11 +108,17 @@ class TestThresholdSemantics:
|
|
|
108
108
|
class TestByteStability:
|
|
109
109
|
"""The report must diff cleanly across runs — pinned against a literal."""
|
|
110
110
|
|
|
111
|
+
# The `links` block is appended after `checks`, so existing consumers keep parsing what they
|
|
112
|
+
# already parsed. Note the cross-check the literal now pins: max_unresolved_links' actual (1)
|
|
113
|
+
# equals counts.missing (1) — the gate's flagship number and the distribution behind it cannot
|
|
114
|
+
# silently disagree.
|
|
111
115
|
EXPECTED = (
|
|
112
116
|
'{"pass": false, "checks": ['
|
|
113
117
|
'{"name": "max_orphans", "limit": 1, "actual": 2, "pass": false}, '
|
|
114
118
|
'{"name": "max_unresolved_links", "limit": 0, "actual": 1, "pass": false}, '
|
|
115
|
-
'{"name": "max_siloed", "limit": 0, "actual": 0, "pass": true}]
|
|
119
|
+
'{"name": "max_siloed", "limit": 0, "actual": 0, "pass": true}], '
|
|
120
|
+
'"links": {"total": 7, "counts": {"resolved": 6, "ambiguous": 0, "non-note-file": 0, '
|
|
121
|
+
'"out-of-scope-note": 0, "missing": 1, "intra-note": 0}, "alias_resolved": 0}}'
|
|
116
122
|
)
|
|
117
123
|
|
|
118
124
|
def test_cli_report_is_byte_identical_to_the_oracle(self, tmp_path, capsys):
|