graphmark 0.7.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.
- {graphmark-0.7.0 → graphmark-0.7.1}/CHANGELOG.md +60 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/PKG-INFO +1 -1
- {graphmark-0.7.0 → graphmark-0.7.1}/docs/ROADMAP.md +23 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/pyproject.toml +1 -1
- {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/graph.py +23 -2
- {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/parse.py +9 -0
- graphmark-0.7.1/tests/test_bom.py +97 -0
- graphmark-0.7.1/tests/test_path_suffix.py +114 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/.gitignore +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/LICENSE +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/README.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/__init__.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/check.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/cli.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/config.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/dismiss.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/export.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/interfaces.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/metrics.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/model.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/py.typed +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/README.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/config.toml +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/expected.json +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/docs/center.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/misc/echo.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/misc/stub.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/refs/beta.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/refs/delta.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/dismiss/expected.json +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/dismiss/vault/alpha.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/dismiss/vault/beta.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/dismiss/vault/gamma.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/config.toml +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/expected.json +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/expected_oog.json +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/similar.json +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/similar_oog.json +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/vault/docs/a.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/vault/misc/c.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/vault/misc/d.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/vault/refs/b.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/vault/refs/e.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/scoped/config.toml +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/scoped/expected.json +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/scoped/vault/docs/one.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/scoped/vault/refs/two.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/selflink/config.toml +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/selflink/expected.json +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/selflink/vault/a.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/selflink/vault/b.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/simple/config.toml +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/simple/expected.json +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/simple/vault/brain/hub.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/simple/vault/personal/beta.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/simple/vault/reference/island.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/simple/vault/reference/stub.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/suggest/README.md +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_aliases.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_build_filters.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_check.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_check_policy.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_cli.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_config.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_degenerate.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_diagnose.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_dismiss.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_export.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_gaps.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_gaps_banding.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_gaps_oog.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_gaps_params.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_graph.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_graph_catalogs.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_link_counts.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_links_report.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_metrics.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_model.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_packaging.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_pagerank.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_parse.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_properties.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_public_api.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_scoped.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_selflink.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_smoke.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_suggestions.py +0 -0
- {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_unresolved.py +0 -0
|
@@ -1,6 +1,66 @@
|
|
|
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
|
+
|
|
4
64
|
## v0.7.0 (2026-07-25)
|
|
5
65
|
|
|
6
66
|
### Documentation
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graphmark
|
|
3
|
-
Version: 0.7.
|
|
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
|
|
@@ -144,6 +144,29 @@ 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
|
+
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
|
+
|
|
147
170
|
_Deliberately deferred behind this:_ the Track D GitHub Action. It is an adoption play, and adoption
|
|
148
171
|
is not this repo's game; more to the point, shipping a gate that fails other people's builds while
|
|
149
172
|
the tool's own accounting is unauditable is backwards ordering. Generality work (Windows paths,
|
|
@@ -54,6 +54,24 @@ def _targets_non_note_file(display: str) -> bool:
|
|
|
54
54
|
return bool(match) and match.group(0).lower() != ".md"
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
def _matches_path_suffix(rel_path: str, suffix: str) -> bool:
|
|
58
|
+
"""True when ``rel_path`` ends with ``suffix`` **on a path-component boundary**.
|
|
59
|
+
|
|
60
|
+
``suffix`` is a lowercased ``folder/note.md``. A raw ``str.endswith`` is not enough: it accepts
|
|
61
|
+
``homework/Tasks.md`` for ``[[work/Tasks]]``, because nothing requires the character before the
|
|
62
|
+
match to be a separator. That produced an edge to the wrong note — silently, since the link then
|
|
63
|
+
counts as ``resolved`` — and, when the real folder also existed, a spurious second match that
|
|
64
|
+
made the resolver decline a correct link.
|
|
65
|
+
|
|
66
|
+
The match is legal only when it consumes the whole rel_path or is preceded by ``/``.
|
|
67
|
+
"""
|
|
68
|
+
lowered = rel_path.lower()
|
|
69
|
+
if not lowered.endswith(suffix):
|
|
70
|
+
return False
|
|
71
|
+
rest = len(lowered) - len(suffix)
|
|
72
|
+
return rest == 0 or lowered[rest - 1] == "/"
|
|
73
|
+
|
|
74
|
+
|
|
57
75
|
def candidates_for(display: str, catalog: dict[str, list[str]]) -> list[str]:
|
|
58
76
|
"""Every rel_path in ``catalog`` that ``display`` names, sorted; empty if it names none.
|
|
59
77
|
|
|
@@ -71,7 +89,10 @@ def candidates_for(display: str, catalog: dict[str, list[str]]) -> list[str]:
|
|
|
71
89
|
if "/" in target:
|
|
72
90
|
suffix = target.lower() + ".md"
|
|
73
91
|
return sorted(
|
|
74
|
-
path
|
|
92
|
+
path
|
|
93
|
+
for paths in catalog.values()
|
|
94
|
+
for path in paths
|
|
95
|
+
if _matches_path_suffix(path, suffix)
|
|
75
96
|
)
|
|
76
97
|
return list(catalog.get(_normalize(target), ()))
|
|
77
98
|
|
|
@@ -170,7 +191,7 @@ class NormalizeResolver:
|
|
|
170
191
|
# Path-suffix resolution: find unique rel_path ending with "display.md"
|
|
171
192
|
suffix = display.lower() + ".md"
|
|
172
193
|
all_paths = self._flatten_paths(catalog)
|
|
173
|
-
matches = [p for p in all_paths if p
|
|
194
|
+
matches = [p for p in all_paths if _matches_path_suffix(p, suffix)]
|
|
174
195
|
return matches[0] if len(matches) == 1 else None
|
|
175
196
|
|
|
176
197
|
# 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"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Path-suffix resolution must match on path-component boundaries (#136).
|
|
2
|
+
|
|
3
|
+
`[[folder/note]]` is resolved by finding the rel_path that *ends with* `folder/note.md`. That test
|
|
4
|
+
was a raw string suffix, so `homework/Tasks.md` satisfied `[[work/Tasks]]` — the character before
|
|
5
|
+
the match was never required to be a separator.
|
|
6
|
+
|
|
7
|
+
This is the first defect class in this package that fabricates an **edge** rather than moving a
|
|
8
|
+
link between reported buckets. A wrong edge is invisible: the link counts `resolved`, so no bucket
|
|
9
|
+
looks implausible, and every downstream metric (orphans, hubs, clusters, bridges, siloed_notes,
|
|
10
|
+
PageRank) reads a graph that does not describe the vault. The mirror case is just as bad — when
|
|
11
|
+
both `work/Tasks.md` and `homework/Tasks.md` exist the spurious second match makes the resolver
|
|
12
|
+
decline, turning a *correct* link into a reported break.
|
|
13
|
+
|
|
14
|
+
`candidates_for` mirrors the resolver's matching and is exercised alongside it here: the ambiguity
|
|
15
|
+
set a consumer is shown must never contain a path the resolver would not have considered.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from graphmark.config import VaultConfig
|
|
23
|
+
from graphmark.graph import NormalizeResolver, VaultGraph, candidates_for
|
|
24
|
+
from graphmark.parse import WikilinkExtractor
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _write(root: Path, rel: str, text: str = "") -> None:
|
|
28
|
+
path = root / rel
|
|
29
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
path.write_text(text, encoding="utf-8")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _build(root: Path, **config_kwargs) -> VaultGraph:
|
|
34
|
+
return VaultGraph.build(
|
|
35
|
+
VaultConfig(root=root, **config_kwargs), WikilinkExtractor(), NormalizeResolver()
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestFolderBoundary:
|
|
40
|
+
def test_a_longer_folder_name_does_not_satisfy_a_shorter_one(self, tmp_path):
|
|
41
|
+
# There is no work/ folder in this vault, so the link is broken. Before #136 it silently
|
|
42
|
+
# produced an edge to homework/Tasks.md.
|
|
43
|
+
_write(tmp_path, "homework/Tasks.md")
|
|
44
|
+
_write(tmp_path, "src.md", "[[work/Tasks]]")
|
|
45
|
+
graph = _build(tmp_path)
|
|
46
|
+
assert graph.out_links["src.md"] == set()
|
|
47
|
+
assert graph.unresolved == {"src.md": ["work/Tasks"]}
|
|
48
|
+
|
|
49
|
+
def test_the_real_folder_still_wins_when_both_exist(self, tmp_path):
|
|
50
|
+
# The mirror failure: the spurious homework/ match made the match list length 2, the
|
|
51
|
+
# resolver declined, and a correct link was reported ambiguous.
|
|
52
|
+
_write(tmp_path, "work/Tasks.md")
|
|
53
|
+
_write(tmp_path, "homework/Tasks.md")
|
|
54
|
+
_write(tmp_path, "src.md", "[[work/Tasks]]")
|
|
55
|
+
graph = _build(tmp_path)
|
|
56
|
+
assert graph.out_links["src.md"] == {"work/Tasks.md"}
|
|
57
|
+
assert graph.unresolved == {}
|
|
58
|
+
|
|
59
|
+
def test_a_longer_stem_does_not_satisfy_the_note_part(self, tmp_path):
|
|
60
|
+
# Same missing boundary one component to the right: "a/b.md" must not be matched by
|
|
61
|
+
# "dir/xa/b.md" NOR by a file whose stem merely ends in the target's.
|
|
62
|
+
_write(tmp_path, "notes/my-tasks.md")
|
|
63
|
+
_write(tmp_path, "src.md", "[[notes/tasks]]")
|
|
64
|
+
graph = _build(tmp_path)
|
|
65
|
+
assert graph.unresolved == {"src.md": ["notes/tasks"]}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestStillResolves:
|
|
69
|
+
def test_any_depth_of_prefix_above_the_named_folder(self, tmp_path):
|
|
70
|
+
_write(tmp_path, "a/b/work/Tasks.md")
|
|
71
|
+
_write(tmp_path, "src.md", "[[work/Tasks]]")
|
|
72
|
+
assert _build(tmp_path).out_links["src.md"] == {"a/b/work/Tasks.md"}
|
|
73
|
+
|
|
74
|
+
def test_a_whole_path_match_at_the_root(self, tmp_path):
|
|
75
|
+
# The match is the entire rel_path — there is no preceding "/" to require.
|
|
76
|
+
_write(tmp_path, "work/Tasks.md")
|
|
77
|
+
_write(tmp_path, "src.md", "[[work/Tasks]]")
|
|
78
|
+
assert _build(tmp_path).out_links["src.md"] == {"work/Tasks.md"}
|
|
79
|
+
|
|
80
|
+
def test_a_fully_qualified_path_from_the_vault_root(self, tmp_path):
|
|
81
|
+
_write(tmp_path, "a/b/work/Tasks.md")
|
|
82
|
+
_write(tmp_path, "src.md", "[[a/b/work/Tasks]]")
|
|
83
|
+
assert _build(tmp_path).out_links["src.md"] == {"a/b/work/Tasks.md"}
|
|
84
|
+
|
|
85
|
+
def test_case_insensitivity_survives(self, tmp_path):
|
|
86
|
+
_write(tmp_path, "Work/Tasks.md")
|
|
87
|
+
_write(tmp_path, "src.md", "[[work/tasks]]")
|
|
88
|
+
assert _build(tmp_path).out_links["src.md"] == {"Work/Tasks.md"}
|
|
89
|
+
|
|
90
|
+
def test_a_genuine_collision_is_still_ambiguous(self, tmp_path):
|
|
91
|
+
_write(tmp_path, "a/work/Tasks.md")
|
|
92
|
+
_write(tmp_path, "b/work/Tasks.md")
|
|
93
|
+
_write(tmp_path, "src.md", "[[work/Tasks]]")
|
|
94
|
+
graph = _build(tmp_path)
|
|
95
|
+
assert graph.unresolved == {"src.md": ["work/Tasks"]}
|
|
96
|
+
assert graph.link_counts["ambiguous"] == 1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TestCandidatesAgree:
|
|
100
|
+
"""candidates_for is the ambiguity set a consumer is shown; it must see exactly what the
|
|
101
|
+
resolver saw. Two matchers that disagree is the drift this package removed from its consumer."""
|
|
102
|
+
|
|
103
|
+
def test_a_non_boundary_path_is_not_offered_as_a_candidate(self, tmp_path):
|
|
104
|
+
_write(tmp_path, "homework/Tasks.md")
|
|
105
|
+
_write(tmp_path, "src.md", "[[work/Tasks]]")
|
|
106
|
+
catalog = _build(tmp_path).catalog
|
|
107
|
+
assert candidates_for("work/Tasks", catalog) == []
|
|
108
|
+
|
|
109
|
+
def test_boundary_matches_are_still_offered(self, tmp_path):
|
|
110
|
+
_write(tmp_path, "a/work/Tasks.md")
|
|
111
|
+
_write(tmp_path, "b/work/Tasks.md")
|
|
112
|
+
_write(tmp_path, "homework/Tasks.md")
|
|
113
|
+
catalog = _build(tmp_path).catalog
|
|
114
|
+
assert candidates_for("work/Tasks", catalog) == ["a/work/Tasks.md", "b/work/Tasks.md"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|