Skip to content

Commit 9397625

Browse files
committed
fix(common): harden relation_aliases — NFC-normalize tokens, require -> separator
1 parent 3197afc commit 9397625

2 files changed

Lines changed: 63 additions & 4 deletions

File tree

factlog/common.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -656,11 +656,12 @@ def relation_aliases(root: Path | None = None) -> dict[str, str]:
656656
stripped = re.sub(r"^\s*[-*]\s+", "", line.strip()).strip()
657657
if not stripped or stripped.startswith("#"):
658658
continue
659-
# Expect exactly two backtick groups: `raw` -> `canonical`
660-
groups = re.findall(r"`([^`]+)`", stripped)
661-
if len(groups) != 2:
659+
# Expect exactly `raw` -> `canonical` — arrow is required.
660+
m = re.fullmatch(r"`([^`]+)`\s*->\s*`([^`]+)`", stripped)
661+
if not m:
662662
continue
663-
raw, canonical = groups[0].strip(), groups[1].strip()
663+
raw = unicodedata.normalize("NFC", m.group(1).strip())
664+
canonical = unicodedata.normalize("NFC", m.group(2).strip())
664665
if not raw or not canonical:
665666
continue
666667
# self-map

tests/unit/test_relation_aliases.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
"""Unit tests for relation_aliases() parser, surface_variants(), and collision guard."""
33
from __future__ import annotations
44

5+
import unicodedata
6+
57
import pytest
68

79
import common
@@ -77,6 +79,27 @@ def test_line_with_not_two_backtick_groups_skipped(self, tmp_path):
7779
))
7880
assert common.relation_aliases(tmp_path) == {"a": "b"}
7981

82+
def test_missing_arrow_not_parsed_as_mapping(self, tmp_path):
83+
# Two backtick groups but NO `->` between them must NOT become a mapping
84+
# (the arrow is required — two stray backticks are not a declaration).
85+
_write_alias_file(tmp_path, (
86+
"- `a` `b`\n"
87+
"- `x` -> `y`\n"
88+
))
89+
aliases = common.relation_aliases(tmp_path)
90+
assert "a" not in aliases
91+
assert aliases == {"x": "y"}
92+
93+
def test_wrong_arrow_not_parsed_as_mapping(self, tmp_path):
94+
# A non-`->` separator (e.g. `<-`) must NOT become a mapping.
95+
_write_alias_file(tmp_path, (
96+
"- `a` <- `b`\n"
97+
"- `x` -> `y`\n"
98+
))
99+
aliases = common.relation_aliases(tmp_path)
100+
assert "a" not in aliases
101+
assert aliases == {"x": "y"}
102+
80103

81104
# ---------------------------------------------------------------------------
82105
# surface_variants
@@ -138,3 +161,38 @@ def test_duplicate_raw_same_canonical_allowed(self, tmp_path):
138161
))
139162
aliases = common.relation_aliases(tmp_path)
140163
assert aliases == {"a": "b"}
164+
165+
166+
# ---------------------------------------------------------------------------
167+
# NFC normalization (macOS-authored NFD files must match NFC CSV relations)
168+
# ---------------------------------------------------------------------------
169+
170+
class TestRelationAliasesNFC:
171+
"""A KB file authored on macOS is often NFD (decomposed) Hangul, but the CSV
172+
relations cmd_provenance compares against are NFC-normalized. So the parsed
173+
keys/values MUST be NFC or the canonical lookup and surface match silently
174+
miss for exactly the Korean predicates this feature targets (#188)."""
175+
176+
def test_nfd_key_normalized_to_nfc(self, tmp_path):
177+
nfd = unicodedata.normalize("NFD", "게재연도")
178+
assert nfd != unicodedata.normalize("NFC", "게재연도") # sanity: forms differ
179+
_write_alias_file(tmp_path, f"- `{nfd}` -> `published_year`\n")
180+
key = next(iter(common.relation_aliases(tmp_path)))
181+
assert unicodedata.is_normalized("NFC", key)
182+
assert key == unicodedata.normalize("NFC", "게재연도")
183+
184+
def test_nfd_canonical_normalized_to_nfc(self, tmp_path):
185+
nfd = unicodedata.normalize("NFD", "게재연도")
186+
_write_alias_file(tmp_path, f"- `publication_year` -> `{nfd}`\n")
187+
canonical = common.relation_aliases(tmp_path)["publication_year"]
188+
assert unicodedata.is_normalized("NFC", canonical)
189+
assert canonical == unicodedata.normalize("NFC", "게재연도")
190+
191+
def test_nfc_query_matches_nfd_authored_surface(self, tmp_path):
192+
# A canonical queried in NFC finds an NFD-authored surface variant.
193+
nfd = unicodedata.normalize("NFD", "게재연도")
194+
_write_alias_file(tmp_path, f"- `{nfd}` -> `published_year`\n")
195+
aliases = common.relation_aliases(tmp_path)
196+
assert unicodedata.normalize("NFC", "게재연도") in common.surface_variants(
197+
"published_year", aliases
198+
)

0 commit comments

Comments
 (0)