graphmark 0.7.1__tar.gz → 0.7.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. {graphmark-0.7.1 → graphmark-0.7.2}/CHANGELOG.md +109 -0
  2. {graphmark-0.7.1 → graphmark-0.7.2}/PKG-INFO +1 -1
  3. {graphmark-0.7.1 → graphmark-0.7.2}/docs/ROADMAP.md +22 -0
  4. {graphmark-0.7.1 → graphmark-0.7.2}/pyproject.toml +1 -1
  5. {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/cli.py +81 -12
  6. {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/graph.py +63 -9
  7. graphmark-0.7.2/tests/test_non_note_file.py +90 -0
  8. graphmark-0.7.2/tests/test_properties.py +692 -0
  9. graphmark-0.7.2/tests/test_readme_examples.py +151 -0
  10. graphmark-0.7.2/tests/test_unicode.py +102 -0
  11. graphmark-0.7.2/tests/test_unicode_punctuation.py +113 -0
  12. graphmark-0.7.1/tests/test_properties.py +0 -322
  13. {graphmark-0.7.1 → graphmark-0.7.2}/.gitignore +0 -0
  14. {graphmark-0.7.1 → graphmark-0.7.2}/LICENSE +0 -0
  15. {graphmark-0.7.1 → graphmark-0.7.2}/README.md +0 -0
  16. {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/__init__.py +0 -0
  17. {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/check.py +0 -0
  18. {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/config.py +0 -0
  19. {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/dismiss.py +0 -0
  20. {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/export.py +0 -0
  21. {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/interfaces.py +0 -0
  22. {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/metrics.py +0 -0
  23. {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/model.py +0 -0
  24. {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/parse.py +0 -0
  25. {graphmark-0.7.1 → graphmark-0.7.2}/src/graphmark/py.typed +0 -0
  26. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/README.md +0 -0
  27. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/config.toml +0 -0
  28. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/expected.json +0 -0
  29. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
  30. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
  31. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/vault/docs/center.md +0 -0
  32. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/vault/misc/echo.md +0 -0
  33. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
  34. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
  35. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/vault/misc/stub.md +0 -0
  36. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/vault/refs/beta.md +0 -0
  37. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/vault/refs/delta.md +0 -0
  38. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
  39. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/dismiss/expected.json +0 -0
  40. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
  41. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/dismiss/vault/alpha.md +0 -0
  42. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/dismiss/vault/beta.md +0 -0
  43. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/dismiss/vault/gamma.md +0 -0
  44. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/config.toml +0 -0
  45. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/expected.json +0 -0
  46. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/expected_oog.json +0 -0
  47. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/similar.json +0 -0
  48. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/similar_oog.json +0 -0
  49. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/vault/docs/a.md +0 -0
  50. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
  51. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/vault/misc/c.md +0 -0
  52. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/vault/misc/d.md +0 -0
  53. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/vault/refs/b.md +0 -0
  54. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/gaps/vault/refs/e.md +0 -0
  55. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/scoped/config.toml +0 -0
  56. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/scoped/expected.json +0 -0
  57. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/scoped/vault/docs/one.md +0 -0
  58. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
  59. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
  60. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/scoped/vault/refs/two.md +0 -0
  61. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/selflink/config.toml +0 -0
  62. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/selflink/expected.json +0 -0
  63. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/selflink/vault/a.md +0 -0
  64. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/selflink/vault/b.md +0 -0
  65. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/simple/config.toml +0 -0
  66. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/simple/expected.json +0 -0
  67. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
  68. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/simple/vault/brain/hub.md +0 -0
  69. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/simple/vault/personal/beta.md +0 -0
  70. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
  71. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/simple/vault/reference/island.md +0 -0
  72. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/simple/vault/reference/stub.md +0 -0
  73. {graphmark-0.7.1 → graphmark-0.7.2}/tests/fixtures/suggest/README.md +0 -0
  74. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_aliases.py +0 -0
  75. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_bom.py +0 -0
  76. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_build_filters.py +0 -0
  77. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_check.py +0 -0
  78. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_check_policy.py +0 -0
  79. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_cli.py +0 -0
  80. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_config.py +0 -0
  81. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_degenerate.py +0 -0
  82. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_diagnose.py +0 -0
  83. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_dismiss.py +0 -0
  84. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_export.py +0 -0
  85. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_gaps.py +0 -0
  86. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_gaps_banding.py +0 -0
  87. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_gaps_oog.py +0 -0
  88. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_gaps_params.py +0 -0
  89. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_graph.py +0 -0
  90. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_graph_catalogs.py +0 -0
  91. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_link_counts.py +0 -0
  92. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_links_report.py +0 -0
  93. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_metrics.py +0 -0
  94. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_model.py +0 -0
  95. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_packaging.py +0 -0
  96. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_pagerank.py +0 -0
  97. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_parse.py +0 -0
  98. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_path_suffix.py +0 -0
  99. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_public_api.py +0 -0
  100. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_scoped.py +0 -0
  101. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_selflink.py +0 -0
  102. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_smoke.py +0 -0
  103. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_suggestions.py +0 -0
  104. {graphmark-0.7.1 → graphmark-0.7.2}/tests/test_unresolved.py +0 -0
@@ -1,6 +1,115 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.7.2 (2026-07-25)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **cli**: Accept --config/--root after the subcommand (#130)
9
+ ([#148](https://github.com/cdcoonce/graphmark/pull/148),
10
+ [`1a89e2e`](https://github.com/cdcoonce/graphmark/commit/1a89e2ef542581f076200a29ddd04e0773cbc2dd))
11
+
12
+ Every CLI example in the README failed. `--config` and `--root` were defined on the parent parser
13
+ before `add_subparsers`, so argparse accepted them only BEFORE the subcommand, while all ten
14
+ examples in the CLI block — plus the `check` ones — are written the other way. The README is the
15
+ PyPI landing page, so the first thing a new user copies errored.
16
+
17
+ Both options are now attached to every subparser too, under a separate dest, and reconciled after
18
+ parsing (argparse would otherwise let the subparser's default overwrite a value the top-level
19
+ parser had already read). Both collect with action="append", so a repeat within one position is
20
+ caught as well as one across the two: conflicting values are a usage error, not a silent
21
+ last-wins.
22
+
23
+ The actual defect was that the docs and the parser had no shared test — the suite used the working
24
+ form exclusively. `tests/test_readme_examples.py` now extracts every `graphmark ...` invocation
25
+ from README.md and runs it, so an example that stops working fails the gate instead of shipping.
26
+
27
+ - **graph**: Fold non-ASCII punctuation in _normalize (#139)
28
+ ([#145](https://github.com/cdcoonce/graphmark/pull/145),
29
+ [`cef4484`](https://github.com/cdcoonce/graphmark/commit/cef448405c1d59577c85ff4f8723299f3ee74dbc))
30
+
31
+ `string.punctuation` is ASCII-only, so every non-ASCII mark survived normalization and then had to
32
+ be typed byte-for-byte: an em-dash title was unreachable from a typed hyphen, a curly apostrophe
33
+ from a straight one, `…` from `...`. The catalog key literally retained the mark while an
34
+ ASCII-typed display normalized it away, so the two could never meet — while Obsidian's own
35
+ switcher resolves it.
36
+
37
+ `_fold_punctuation` folds by Unicode category (P* and S*) instead, a superset of the old ASCII
38
+ table, so pure-ASCII results are unchanged. The ASCII translate table stays the fast path; the
39
+ cached per-character category lookup runs only on strings that still hold non-ASCII after it.
40
+
41
+ Builds on #123: NFC composition runs first, so a decomposed accent is a single letter rather than a
42
+ letter plus a combining mark this fold must not touch.
43
+
44
+ Path-suffix matching deliberately still folds only case and form — a "/" is structural there and
45
+ punctuation folding needs the extension handled first. Follow-up filed.
46
+
47
+ Reference vault: 531 notes, link counts identical, build 207 ms.
48
+
49
+ - **graph**: Normalize Unicode form before comparing names (#123)
50
+ ([#144](https://github.com/cdcoonce/graphmark/pull/144),
51
+ [`9251554`](https://github.com/cdcoonce/graphmark/commit/9251554691723e7b6ca1d11bf0b7ddb1dffed002))
52
+
53
+ macOS's HFS+/APFS store filenames decomposed (NFD: `é` is `e` + U+0301) while text typed in an
54
+ editor is composed (NFC: a single U+00E9). `_normalize` lowercased, folded punctuation and
55
+ collapsed whitespace but never normalized Unicode form, so the two strings never compared equal
56
+ and an accented note title was a phantom broken link. Obsidian resolves it fine.
57
+
58
+ `_fold_case` does NFC-then-lowercase and is shared by `_normalize` and by path-suffix matching — the
59
+ one comparison that does not go through `_normalize` — so no comparison in the module can be left
60
+ in the wrong form. Note names, aliases and path-qualified links all fold identically as a result.
61
+
62
+ Eighth false-positive class in `unresolved`. The reference vault is unaffected (its only non-ASCII
63
+ filenames are em-dashes, which have no decomposition — which is exactly why this stayed invisible
64
+ there); link counts byte-identical.
65
+
66
+ - **graph**: Require a letter in a file extension (#138)
67
+ ([#147](https://github.com/cdcoonce/graphmark/pull/147),
68
+ [`4206cc5`](https://github.com/cdcoonce/graphmark/commit/4206cc5f941e5f0967202ac0b99390a55ab0393e))
69
+
70
+ `_targets_non_note_file` treated any trailing `.` + 1-10 alphanumerics as an extension, so
71
+ `[[Meeting 3.5]]` with no such note was suppressed as a file instead of reported as broken. `.5`
72
+ satisfied the pattern as readily as `.md`.
73
+
74
+ The first class found that DEFLATES `check`'s flagship number rather than inflating it, which is
75
+ strictly worse for a gate: an undercount is invisible by construction and reads as health. It hits
76
+ any vault that numbers its notes — phases, specs, chapters, versioned documents.
77
+
78
+ The separating rule is that a real extension contains a letter; ".5" and ".61850" differ only in
79
+ length, so no length bound could tell them apart. The existing 10-character bound and the
80
+ space-after-the-dot rule are both preserved.
81
+
82
+ Reference vault: 17 non-note-file suppressions before and after, all genuine .base/.png
83
+ (hand-audited).
84
+
85
+ ### Testing
86
+
87
+ - **properties**: Add the known-name invariant and an oracle-backed generator (#133)
88
+ ([#149](https://github.com/cdcoonce/graphmark/pull/149),
89
+ [`4cd304f`](https://github.com/cdcoonce/graphmark/commit/4cd304f80da52f2941205986d3b4bb3de783fdab))
90
+
91
+ Two things, one purpose: make Track F's detector actually detect.
92
+
93
+ #133's invariant — no link reported `missing` may name a key the resolver already knows (catalog or
94
+ aliases). Under correct behavior that is impossible, so it is an internal-consistency assertion,
95
+ not a heuristic: no thresholds, no calibration, no false-positive mode. Asserted over generated
96
+ vaults, with resolve_aliases=False (where the alias half is vacuous rather than wrong), and with
97
+ teeth — breaking the lookup while the map is still built must fail it.
98
+
99
+ The generator, measured. With the alphabet widened to today's four defect classes, reverting
100
+ #123/#136/#137/#138/#139 one at a time still left the suite GREEN: 0 of 4. Every existing property
101
+ is a conservation or self-consistency check, and each of those bugs is perfectly self-consistent —
102
+ #136's fabricated edge really is in the graph. Self-consistency cannot see a wrong answer.
103
+
104
+ So the generator now carries an oracle: it builds a note, then writes the link FROM it through a
105
+ transformation Obsidian treats as identity-preserving, so the expected target is known by
106
+ construction. Plus a negative oracle over names known to be absent (the false-negative half, which
107
+ no conservation law can see), narrow per-vault name pools so collisions co-occur, and an assertion
108
+ that an `ambiguous` candidate set is minimal rather than merely non-empty.
109
+
110
+ Catch rate on the same experiment: 6 of 6, adding #119.
111
+
112
+
4
113
  ## v0.7.1 (2026-07-25)
5
114
 
6
115
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphmark
3
- Version: 0.7.1
3
+ Version: 0.7.2
4
4
  Summary: Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults.
5
5
  Project-URL: Homepage, https://github.com/cdcoonce/graphmark
6
6
  Project-URL: Repository, https://github.com/cdcoonce/graphmark
@@ -163,6 +163,28 @@ Two things this says about the track:
163
163
  names that are suffixes of other folder names, BOMs, numeric title suffixes, non-ASCII
164
164
  punctuation. Widening the generator's _alphabet_ — not its vault count — is the follow-on.
165
165
 
166
+ _Follow-on, same day (#133's slice):_ widening the alphabet was **not enough, and the measurement
167
+ says why.** With the alphabet widened, reverting #123/#136/#137/#138/#139 one at a time left the
168
+ whole property suite green — a catch rate of **0 of 4** on the classes it was widened for. Every
169
+ invariant in that file is a _conservation or self-consistency_ property, and each of those bugs is
170
+ perfectly self-consistent: #136's fabricated edge really is in the graph, so nothing vanishes; a
171
+ BOM'd note simply has no frontmatter; a link the normalizer cannot match is genuinely missing by the
172
+ package's own rules. **Self-consistency cannot see a wrong answer.**
173
+
174
+ What was missing is an **oracle** — a link whose intended target is known independently of what the
175
+ resolver says. The generator now builds a note first and writes the link _from_ it, through a
176
+ transformation Obsidian treats as identity-preserving (case, `.md`, anchor, alias display, padding,
177
+ NFD/NFC, punctuation swaps, path qualification, ASCII twins of typographic marks), plus a negative
178
+ oracle over names known to be absent. Two further changes were needed to make it bite: narrow
179
+ per-vault name pools, so the collisions that matter actually co-occur, and an assertion that an
180
+ `ambiguous` candidate set is _minimal_, not merely non-empty.
181
+
182
+ Catch rate on the same experiment: **6 of 6**, adding #119 — the class that started this track.
183
+
184
+ The generalizable lesson, and the one to carry into future test design here: **a property suite over
185
+ generated input still needs a correct answer to compare against.** Invariants prove the package is
186
+ consistent with itself, which is exactly the thing a systematically wrong resolver also is.
187
+
166
188
  Audited alongside: the reference vault's two suppressed buckets, by hand — 17 `non-note-file` (all
167
189
  genuine `.base`/`.png`) and 40 `intra-note` (all genuine heading refs), 0 false suppressions. A
168
190
  suppressed bucket is only trustworthy once someone has read it.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphmark"
3
- version = "0.7.1"
3
+ version = "0.7.2"
4
4
  description = "Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -42,7 +42,39 @@ def _load(args: argparse.Namespace) -> tuple[VaultGraph, VaultConfig]:
42
42
  return graph, config
43
43
 
44
44
 
45
+ def _reconcile_globals(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
46
+ """Merge each global's leading and trailing occurrence into one value.
47
+
48
+ ``--config``/``--root`` are attached to the top-level parser *and*, under a separate dest, to
49
+ every subparser, so both ``graphmark --root X stats`` and ``graphmark stats --root X`` work.
50
+ Trailing-option order is what every comparable CLI accepts and what a user types by reflex — and
51
+ it is what the README documents, so before this the first command a new user copied errored.
52
+
53
+ Repeats with the **same** value are harmless. Two different values are a usage error rather than
54
+ a silent last-wins: guessing which one the user meant is exactly the kind of quiet wrong answer
55
+ this tool exists not to give. Both options collect with ``action="append"``, so this covers a
56
+ repeat *within* one position (``stats --root A --root B``) as well as one across the two —
57
+ argparse's own last-wins would otherwise swallow the first silently.
58
+ """
59
+ for name in ("config", "root"):
60
+ after = f"{name}_after"
61
+ # `after` is absent when no subcommand ran at all — argparse never reached a subparser.
62
+ given = (getattr(args, name) or []) + (getattr(args, after, None) or [])
63
+ distinct = sorted(set(given))
64
+ if len(distinct) > 1:
65
+ parser.error(f"--{name} given more than once with conflicting values: {distinct}")
66
+ setattr(args, name, distinct[0] if distinct else None)
67
+ if hasattr(args, after):
68
+ delattr(args, after)
69
+
70
+
45
71
  def main() -> None:
72
+ # Attached to every subparser under a distinct dest, then reconciled: argparse would otherwise
73
+ # let the subparser's default (None) overwrite a value the top-level parser had already read.
74
+ trailing_globals = argparse.ArgumentParser(add_help=False)
75
+ trailing_globals.add_argument("--config", metavar="PATH", dest="config_after", action="append")
76
+ trailing_globals.add_argument("--root", metavar="PATH", dest="root_after", action="append")
77
+
46
78
  parser = argparse.ArgumentParser(
47
79
  prog="graphmark",
48
80
  description=(
@@ -51,49 +83,86 @@ def main() -> None:
51
83
  ),
52
84
  )
53
85
  parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
54
- parser.add_argument("--config", metavar="PATH", help="TOML config file")
55
- parser.add_argument("--root", metavar="PATH", help="Vault root (overrides --config root)")
86
+ parser.add_argument("--config", metavar="PATH", action="append", help="TOML config file")
87
+ parser.add_argument(
88
+ "--root", metavar="PATH", action="append", help="Vault root (overrides --config root)"
89
+ )
56
90
 
57
91
  sub = parser.add_subparsers(dest="command", metavar="COMMAND")
58
92
 
59
- sub.add_parser("stats", help="Aggregate vault stats: notes, edges, orphans, clusters, density")
60
- sub.add_parser("orphans", help="Notes with no links in or out (degree 0)")
93
+ sub.add_parser(
94
+ "stats",
95
+ parents=[trailing_globals],
96
+ help="Aggregate vault stats: notes, edges, orphans, clusters, density",
97
+ )
98
+ sub.add_parser(
99
+ "orphans", parents=[trailing_globals], help="Notes with no links in or out (degree 0)"
100
+ )
61
101
 
62
- hubs_p = sub.add_parser("hubs", help="Most-connected notes, by undirected degree")
102
+ hubs_p = sub.add_parser(
103
+ "hubs", parents=[trailing_globals], help="Most-connected notes, by undirected degree"
104
+ )
63
105
  hubs_p.add_argument("--n", type=int, default=10, help="How many hubs to return (default: 10)")
64
106
 
65
- sub.add_parser("clusters", help="Connected components of the link graph, largest first")
66
- sub.add_parser("bridges", help="Articulation points: notes whose removal splits the graph")
67
- sub.add_parser("siloed", help="Notes reachable from the mainland only through one bridge")
107
+ sub.add_parser(
108
+ "clusters",
109
+ parents=[trailing_globals],
110
+ help="Connected components of the link graph, largest first",
111
+ )
112
+ sub.add_parser(
113
+ "bridges",
114
+ parents=[trailing_globals],
115
+ help="Articulation points: notes whose removal splits the graph",
116
+ )
117
+ sub.add_parser(
118
+ "siloed",
119
+ parents=[trailing_globals],
120
+ help="Notes reachable from the mainland only through one bridge",
121
+ )
68
122
 
69
- nb_p = sub.add_parser("neighborhood", help="Links in and out of one note")
123
+ nb_p = sub.add_parser(
124
+ "neighborhood", parents=[trailing_globals], help="Links in and out of one note"
125
+ )
70
126
  nb_p.add_argument("--note", required=True, help="Vault-relative path, e.g. brain/hub.md")
71
127
  nb_p.add_argument(
72
128
  "--depth", type=int, default=1, help="1 for direct links, 2 to add two-hop (default: 1)"
73
129
  )
74
130
 
75
- pr_p = sub.add_parser("pagerank", help="PageRank importance ranking over the link graph")
131
+ pr_p = sub.add_parser(
132
+ "pagerank",
133
+ parents=[trailing_globals],
134
+ help="PageRank importance ranking over the link graph",
135
+ )
76
136
  pr_p.add_argument("--n", type=int, default=10, help="How many notes to return (default: 10)")
77
137
  pr_p.add_argument(
78
138
  "--alpha", type=float, default=0.85, help="Damping factor in (0, 1) (default: 0.85)"
79
139
  )
80
140
 
81
- exp_p = sub.add_parser("export", help="Export the graph in another format")
141
+ exp_p = sub.add_parser(
142
+ "export", parents=[trailing_globals], help="Export the graph in another format"
143
+ )
82
144
  exp_p.add_argument("format", choices=["dot"], help="Output format")
83
145
 
84
- sub.add_parser("gaps", help="Link-gap suggestions (library-only; see the README)")
146
+ sub.add_parser(
147
+ "gaps",
148
+ parents=[trailing_globals],
149
+ help="Link-gap suggestions (library-only; see the README)",
150
+ )
85
151
 
86
152
  sub.add_parser(
87
153
  "links",
154
+ parents=[trailing_globals],
88
155
  help="How every wikilink was classified: counts per reason, plus alias resolutions",
89
156
  )
90
157
 
91
158
  sub.add_parser(
92
159
  "check",
160
+ parents=[trailing_globals],
93
161
  help="Gate vault health against the config's [check] thresholds (exit 1 on breach)",
94
162
  )
95
163
 
96
164
  args = parser.parse_args()
165
+ _reconcile_globals(parser, args)
97
166
 
98
167
  # Usage errors all exit 2, matching argparse's own convention (and leaving exit 1 free for
99
168
  # future domain-level outcomes such as a `check` threshold breach). Help for a missing
@@ -4,7 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  import re
6
6
  import string
7
+ import unicodedata
7
8
  from dataclasses import dataclass, field, replace
9
+ from functools import lru_cache
8
10
  from pathlib import Path
9
11
 
10
12
  from graphmark.config import VaultConfig
@@ -15,15 +17,67 @@ from graphmark.parse import parse_document
15
17
  _PUNCT_TABLE = str.maketrans(string.punctuation, " " * len(string.punctuation))
16
18
 
17
19
 
20
+ def _fold_case(text: str) -> str:
21
+ """Compose to NFC, then lowercase — the two form-flattening steps, in that order.
22
+
23
+ macOS's HFS+/APFS store filenames **decomposed** (NFD: ``é`` is ``e`` + U+0301) while text typed
24
+ in an editor is **composed** (NFC: a single U+00E9). Without this the two never compare equal
25
+ and an accented note title is a phantom broken link, which Obsidian resolves fine.
26
+
27
+ NFC rather than NFD because it is what editors emit and what the web standardized on; the
28
+ filesystem's NFD becomes an implementation detail that never escapes the catalog.
29
+
30
+ Shared by ``_normalize`` and by path-suffix matching — the one place that does *not* go through
31
+ ``_normalize`` — so no comparison in this module can be left in the wrong form.
32
+ """
33
+ return unicodedata.normalize("NFC", text).lower()
34
+
35
+
36
+ @lru_cache(maxsize=4096)
37
+ def _is_foldable(char: str) -> bool:
38
+ """True for a Unicode punctuation (``P*``) or symbol (``S*``) character."""
39
+ return unicodedata.category(char)[0] in ("P", "S")
40
+
41
+
42
+ def _fold_punctuation(text: str) -> str:
43
+ """Replace punctuation and symbols with spaces — ASCII **and** non-ASCII.
44
+
45
+ ``string.punctuation`` is ASCII-only, so every non-ASCII mark used to survive normalization and
46
+ then had to be typed byte-for-byte: an em-dash title was unreachable from a typed hyphen, a
47
+ curly apostrophe from a straight one, ``…`` from ``...``. Folding by Unicode *category* is a
48
+ superset of the old ASCII table, so pure-ASCII results are unchanged.
49
+
50
+ The ASCII table stays the fast path; the per-character category lookup runs only on strings that
51
+ still hold non-ASCII after it, and is cached. This function runs once per catalog entry and once
52
+ per link, so it must not become a per-character Python loop over the whole vault.
53
+
54
+ Callers must compose to NFC first (``_fold_case``): a decomposed ``é`` is a letter plus a
55
+ combining mark of category ``Mn``, which this fold correctly leaves alone but which would
56
+ otherwise leave the accent stranded.
57
+ """
58
+ folded = text.translate(_PUNCT_TABLE)
59
+ if folded.isascii():
60
+ return folded
61
+ return "".join(" " if not c.isascii() and _is_foldable(c) else c for c in folded)
62
+
63
+
18
64
  def _normalize(text: str) -> str:
19
- """Lowercase, replace punctuation with spaces, collapse whitespace."""
20
- return " ".join(text.lower().translate(_PUNCT_TABLE).split())
65
+ """Compose to NFC, lowercase, replace punctuation with spaces, collapse whitespace."""
66
+ return " ".join(_fold_punctuation(_fold_case(text)).split())
21
67
 
22
68
 
23
- # A trailing dot plus a short alphanumeric run — a plausible file extension. Deliberately
24
- # strict so a note title like "v1.2 release notes" (spaces after the dot) is not mistaken
25
- # for one.
26
- _FILE_SUFFIX_RE = re.compile(r"\.[A-Za-z0-9]{1,10}$")
69
+ # A trailing dot plus a short alphanumeric run containing at least one letter — a plausible file
70
+ # extension. Deliberately strict in two ways, and both matter:
71
+ #
72
+ # * spaces after the dot disqualify it, so a note title like "v1.2 release notes" is not mistaken
73
+ # for one;
74
+ # * an all-digit run disqualifies it, so a *numbered* title — "[[Meeting 3.5]]", "[[Phase 2.1]]",
75
+ # "[[Spec v0.9]]" — is reported as the broken link it is rather than suppressed as a file.
76
+ #
77
+ # The letter requirement is what separates them: ".5" and ".61850" differ only in length, so no
78
+ # length bound could. Suppressing a genuine break *deflates* the vault-health count, which is worse
79
+ # than inflating it — an undercount is invisible and reads as health.
80
+ _FILE_SUFFIX_RE = re.compile(r"\.(?=[A-Za-z0-9]{1,10}$)[A-Za-z0-9]*[A-Za-z][A-Za-z0-9]*$")
27
81
 
28
82
 
29
83
  def _strip_display(display: str) -> str:
@@ -65,7 +119,7 @@ def _matches_path_suffix(rel_path: str, suffix: str) -> bool:
65
119
 
66
120
  The match is legal only when it consumes the whole rel_path or is preceded by ``/``.
67
121
  """
68
- lowered = rel_path.lower()
122
+ lowered = _fold_case(rel_path)
69
123
  if not lowered.endswith(suffix):
70
124
  return False
71
125
  rest = len(lowered) - len(suffix)
@@ -87,7 +141,7 @@ def candidates_for(display: str, catalog: dict[str, list[str]]) -> list[str]:
87
141
  if not target:
88
142
  return []
89
143
  if "/" in target:
90
- suffix = target.lower() + ".md"
144
+ suffix = _fold_case(target) + ".md"
91
145
  return sorted(
92
146
  path
93
147
  for paths in catalog.values()
@@ -189,7 +243,7 @@ class NormalizeResolver:
189
243
 
190
244
  if "/" in display:
191
245
  # Path-suffix resolution: find unique rel_path ending with "display.md"
192
- suffix = display.lower() + ".md"
246
+ suffix = _fold_case(display) + ".md"
193
247
  all_paths = self._flatten_paths(catalog)
194
248
  matches = [p for p in all_paths if _matches_path_suffix(p, suffix)]
195
249
  return matches[0] if len(matches) == 1 else None
@@ -0,0 +1,90 @@
1
+ """`non-note-file` must not swallow a genuinely broken link (#138).
2
+
3
+ `_targets_non_note_file` treated any trailing `.` + 1-10 alphanumerics as a file extension, so a
4
+ broken link to a note whose title ends in a decimal was silently suppressed instead of reported.
5
+ `.5` satisfies the pattern as readily as `.md` does.
6
+
7
+ This is the first class found that *deflates* `check`'s flagship number rather than inflating it,
8
+ which is strictly worse for a gate: an undercount is invisible by construction and reads as health.
9
+
10
+ The separating rule is that a real extension contains a letter. `.5` and `.61850` differ only in
11
+ length, so no length bound can tell them apart.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ from graphmark.config import VaultConfig
19
+ from graphmark.graph import NormalizeResolver, VaultGraph
20
+ from graphmark.parse import WikilinkExtractor
21
+
22
+
23
+ def _write(root: Path, rel: str, text: str = "") -> None:
24
+ path = root / rel
25
+ path.parent.mkdir(parents=True, exist_ok=True)
26
+ path.write_text(text, encoding="utf-8")
27
+
28
+
29
+ def _build(root: Path, **config_kwargs) -> VaultGraph:
30
+ return VaultGraph.build(
31
+ VaultConfig(root=root, **config_kwargs), WikilinkExtractor(), NormalizeResolver()
32
+ )
33
+
34
+
35
+ class TestNumberedTitlesAreReported:
36
+ def test_a_decimal_suffix_is_a_missing_note_not_a_file(self, tmp_path):
37
+ _write(tmp_path, "src.md", "[[Meeting 3.5]]")
38
+ graph = _build(tmp_path)
39
+ assert graph.link_counts["missing"] == 1
40
+ assert graph.link_counts["non-note-file"] == 0
41
+ assert graph.unresolved == {"src.md": ["Meeting 3.5"]}
42
+
43
+ def test_a_long_all_digit_suffix_is_still_a_note(self, tmp_path):
44
+ # ".61850" and ".5" differ only in length — a length bound cannot separate them.
45
+ _write(tmp_path, "src.md", "[[Standard IEC.61850]]")
46
+ assert _build(tmp_path).link_counts["missing"] == 1
47
+
48
+ def test_a_version_suffix_is_a_note(self, tmp_path):
49
+ _write(tmp_path, "src.md", "[[Phase 2.1]] [[Spec v0.9]] [[Budget FY26.2]]")
50
+ assert _build(tmp_path).link_counts["missing"] == 3
51
+
52
+
53
+ class TestRealExtensionsStillSuppressed:
54
+ def test_canvas_base_and_images(self, tmp_path):
55
+ _write(tmp_path, "src.md", "[[Board.canvas]] [[Archive.base]] [[chart.png]] [[doc.pdf]]")
56
+ graph = _build(tmp_path)
57
+ assert graph.link_counts["non-note-file"] == 4
58
+ assert graph.unresolved == {}
59
+
60
+ def test_an_aliased_non_note_target(self, tmp_path):
61
+ # The vault's real shape: [[Decisions.base|Decisions]].
62
+ _write(tmp_path, "src.md", "[[Decisions.base|Decisions]]")
63
+ assert _build(tmp_path).link_counts["non-note-file"] == 1
64
+
65
+ def test_a_mixed_alphanumeric_extension_is_still_a_file(self, tmp_path):
66
+ _write(tmp_path, "src.md", "[[archive.7z]] [[track.mp3]] [[video.mp4]]")
67
+ assert _build(tmp_path).link_counts["non-note-file"] == 3
68
+
69
+
70
+ class TestResolverStillWinsFirst:
71
+ def test_a_real_note_with_a_dotted_stem_resolves(self, tmp_path):
72
+ # The guard `_targets_non_note_file`'s docstring already claims: the resolver runs first, so
73
+ # a note that genuinely resolves is never suppressed. Asserted rather than assumed.
74
+ _write(tmp_path, "report.v2.md")
75
+ _write(tmp_path, "src.md", "[[report.v2]]")
76
+ graph = _build(tmp_path)
77
+ assert graph.link_counts["resolved"] == 1
78
+ assert graph.out_links["src.md"] == {"report.v2.md"}
79
+
80
+ def test_a_real_note_named_with_a_decimal_resolves(self, tmp_path):
81
+ _write(tmp_path, "Meeting 3.5.md")
82
+ _write(tmp_path, "src.md", "[[Meeting 3.5]]")
83
+ assert _build(tmp_path).out_links["src.md"] == {"Meeting 3.5.md"}
84
+
85
+
86
+ class TestConservation:
87
+ def test_every_display_still_lands_in_exactly_one_bucket(self, tmp_path):
88
+ _write(tmp_path, "src.md", "[[Meeting 3.5]] [[Board.canvas]] [[#Heading]] [[Gone]]")
89
+ graph = _build(tmp_path)
90
+ assert sum(graph.link_counts.values()) == 4