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.
Files changed (99) hide show
  1. {graphmark-0.7.0 → graphmark-0.7.1}/CHANGELOG.md +60 -0
  2. {graphmark-0.7.0 → graphmark-0.7.1}/PKG-INFO +1 -1
  3. {graphmark-0.7.0 → graphmark-0.7.1}/docs/ROADMAP.md +23 -0
  4. {graphmark-0.7.0 → graphmark-0.7.1}/pyproject.toml +1 -1
  5. {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/graph.py +23 -2
  6. {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/parse.py +9 -0
  7. graphmark-0.7.1/tests/test_bom.py +97 -0
  8. graphmark-0.7.1/tests/test_path_suffix.py +114 -0
  9. {graphmark-0.7.0 → graphmark-0.7.1}/.gitignore +0 -0
  10. {graphmark-0.7.0 → graphmark-0.7.1}/LICENSE +0 -0
  11. {graphmark-0.7.0 → graphmark-0.7.1}/README.md +0 -0
  12. {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/__init__.py +0 -0
  13. {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/check.py +0 -0
  14. {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/cli.py +0 -0
  15. {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/config.py +0 -0
  16. {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/dismiss.py +0 -0
  17. {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/export.py +0 -0
  18. {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/interfaces.py +0 -0
  19. {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/metrics.py +0 -0
  20. {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/model.py +0 -0
  21. {graphmark-0.7.0 → graphmark-0.7.1}/src/graphmark/py.typed +0 -0
  22. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/README.md +0 -0
  23. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/config.toml +0 -0
  24. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/expected.json +0 -0
  25. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
  26. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
  27. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/docs/center.md +0 -0
  28. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/misc/echo.md +0 -0
  29. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
  30. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
  31. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/misc/stub.md +0 -0
  32. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/refs/beta.md +0 -0
  33. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/refs/delta.md +0 -0
  34. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
  35. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/dismiss/expected.json +0 -0
  36. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
  37. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/dismiss/vault/alpha.md +0 -0
  38. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/dismiss/vault/beta.md +0 -0
  39. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/dismiss/vault/gamma.md +0 -0
  40. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/config.toml +0 -0
  41. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/expected.json +0 -0
  42. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/expected_oog.json +0 -0
  43. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/similar.json +0 -0
  44. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/similar_oog.json +0 -0
  45. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/vault/docs/a.md +0 -0
  46. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
  47. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/vault/misc/c.md +0 -0
  48. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/vault/misc/d.md +0 -0
  49. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/vault/refs/b.md +0 -0
  50. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/gaps/vault/refs/e.md +0 -0
  51. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/scoped/config.toml +0 -0
  52. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/scoped/expected.json +0 -0
  53. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/scoped/vault/docs/one.md +0 -0
  54. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
  55. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
  56. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/scoped/vault/refs/two.md +0 -0
  57. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/selflink/config.toml +0 -0
  58. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/selflink/expected.json +0 -0
  59. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/selflink/vault/a.md +0 -0
  60. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/selflink/vault/b.md +0 -0
  61. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/simple/config.toml +0 -0
  62. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/simple/expected.json +0 -0
  63. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
  64. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/simple/vault/brain/hub.md +0 -0
  65. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/simple/vault/personal/beta.md +0 -0
  66. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
  67. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/simple/vault/reference/island.md +0 -0
  68. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/simple/vault/reference/stub.md +0 -0
  69. {graphmark-0.7.0 → graphmark-0.7.1}/tests/fixtures/suggest/README.md +0 -0
  70. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_aliases.py +0 -0
  71. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_build_filters.py +0 -0
  72. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_check.py +0 -0
  73. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_check_policy.py +0 -0
  74. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_cli.py +0 -0
  75. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_config.py +0 -0
  76. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_degenerate.py +0 -0
  77. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_diagnose.py +0 -0
  78. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_dismiss.py +0 -0
  79. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_export.py +0 -0
  80. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_gaps.py +0 -0
  81. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_gaps_banding.py +0 -0
  82. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_gaps_oog.py +0 -0
  83. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_gaps_params.py +0 -0
  84. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_graph.py +0 -0
  85. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_graph_catalogs.py +0 -0
  86. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_link_counts.py +0 -0
  87. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_links_report.py +0 -0
  88. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_metrics.py +0 -0
  89. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_model.py +0 -0
  90. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_packaging.py +0 -0
  91. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_pagerank.py +0 -0
  92. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_parse.py +0 -0
  93. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_properties.py +0 -0
  94. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_public_api.py +0 -0
  95. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_scoped.py +0 -0
  96. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_selflink.py +0 -0
  97. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_smoke.py +0 -0
  98. {graphmark-0.7.0 → graphmark-0.7.1}/tests/test_suggestions.py +0 -0
  99. {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.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
@@ -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,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphmark"
3
- version = "0.7.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"
@@ -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 for paths in catalog.values() for path in paths if path.lower().endswith(suffix)
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.lower().endswith(suffix)]
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