Architecture Review

obsidian-unicode-search · 2026-06-25

module seam leakage / ADR violation deep module

1. Collapse the comparison cluster into a deep Ranking module

Strong in-process
src/libraries/comparison/compareCharacterMatches.ts
src/libraries/comparison/compareCharacters.ts
src/libraries/comparison/compareSearchMatches.ts
src/libraries/comparison/compareSearchMatchScores.ts
src/libraries/comparison/compareCharacterWithUseHistory.ts
src/libraries/comparison/compareFavoriteCharacters.ts
src/libraries/comparison/compareFavorite.ts
src/libraries/comparison/compareCodePoints.ts
src/libraries/comparison/compareUseRecord.ts
src/libraries/comparison/compareNullable.ts
src/libraries/comparison/compareNumbers.ts
src/libraries/comparison/compareDates.ts
src/libraries/comparison/fillNullCharacterMatchScores.ts
src/libraries/comparison/fillNullSearchMatchScores.ts
+ 14 matching spec files in tests/

Before

FuzzySearchModal compareCharacterMatches compareSearchMatches compareCharacters compareSearchMatchScores fillNullSearchMatchScores compareCharWithUseHistory compareFavoriteCharacters compareCodePoints compareUseRecord compareDates compareNumbers compareNullable compareFavorite 14 files · 14 test seams · 236 lines total

After

FuzzySearchModal seam RANKING rank(left, right, cutoff) ↳ compareSearchMatches ↳ compareCharacters ↳ useHistory / favorite / codePoint internal — private to implementation not exposed at seam test 1 interface · 1 test seam internals hidden behind the seam 14 shallow modules → 1 deep module 14 leaf-seam tests → 1 ranking-seam test

Problem

14 micro-files, each 10–29 lines — each shallow per-file, but each one just calls the next. Understanding "how does search ranking work?" requires bouncing through the entire call graph. Tests hit 14 leaf seams instead of the ranking behaviour.

Solution

One Ranking module in libraries/comparison/ with a two-method interface: rank(l, r, cutoff) and optionally sort(results, cutoff). The 14 current functions become private implementation.

Wins

  • Locality: ranking logic in one place
  • Interface shrinks: 14 exports → 1–2
  • Leverage: callers learn one seam
  • Tests target the behaviour, not leaf calls
  • → Deletion test passes: delete individual fns, complexity concentrates here

2. Heal the ADR-0005 boundary: search result types are stranded in unicode-search/components/

Strong in-process

9 files in libraries/ that violate ADR-0005:

libraries/comparison/compareCharacterMatches.ts
libraries/comparison/compareSearchMatchScores.ts
libraries/comparison/compareSearchMatches.ts
libraries/comparison/fillNullCharacterMatchScores.ts
libraries/comparison/fillNullSearchMatchScores.ts
libraries/helpers/matchedNameOrCodePoint.ts
libraries/helpers/toNullMatch.ts
libraries/helpers/toSearchQueryMatch.ts ← also imports obsidian
libraries/types/persistCache.ts ← imports UnicodeSearchError

Root cause: type definitions stranded here:

unicode-search/components/characterSearch.ts
Defines: MetaCharacterSearchResult
MaybeMetaCharacterSearchResult
SearchMatchResult
SearchMatchAttributes
MaybeSearchMatchAttributes

Before — bidirectional dependency

flowchart TB
  subgraph libs ["libraries/ (should be agnostic)"]
    direction TB
    CCM["compareCharacterMatches"]
    CSM["compareSearchMatches"]
    FNS["fillNullSearchMatch\nScores"]
    TSQ["toSearchQueryMatch"]
  end

  subgraph plugin ["unicode-search/components/"]
    CS["characterSearch.ts\n(SearchMatchResult types)"]
    FSM["FuzzySearchModal"]
  end

  OBS["obsidian"]

  CS -->|imports| OBS
  FSM -->|imports| libs
  CCM -->|imports ❌ ADR-0005| CS
  CSM -->|imports ❌| CS
  FNS -->|imports ❌| CS
  TSQ -->|imports ❌| CS
  TSQ -->|imports ❌| OBS

  classDef violation stroke:#dc2626,stroke-width:2px,color:#991b1b;
  classDef ok fill:#f0fdf4,stroke:#86efac;
  class CCM,CSM,FNS,TSQ violation
  class FSM ok
                  

After — types move to libraries/

flowchart TB
  subgraph libs ["libraries/ (agnostic)"]
    direction TB
    ST["types/characterSearch.ts\n(SearchMatchResult types)"]
    CCM["compareCharacterMatches"]
    CSM["compareSearchMatches"]
    FNS["fillNullSearchMatchScores"]
    TSQ["toSearchQueryMatch\n(search fns injected)"]
    CCM --> ST
    CSM --> ST
    FNS --> ST
    TSQ --> ST
  end

  subgraph plugin ["unicode-search/components/"]
    CS["characterSearch.ts\n(re-exports only)"]
    FSM["FuzzySearchModal"]
  end

  OBS["obsidian"]

  CS --> ST
  CS --> OBS
  FSM --> libs
  FSM --> CS

  classDef clean fill:#f0fdf4,stroke:#86efac,color:#166534;
  class CCM,CSM,FNS,TSQ,ST clean
                  

Problem

ADR-0005 forbids libraries/ importing from unicode-search/. But 9 library files do exactly that — because SearchMatchResult and friends live in unicode-search/components/. toSearchQueryMatch even imports from obsidian directly.

Solution

Move the search result types from characterSearch.ts into libraries/types/characterSearch.ts. Make unicode-search/components/characterSearch.ts a thin re-export. Extract the Obsidian search functions out of toSearchQueryMatch as injected parameters.

Wins

  • → ADR-0005 enforced in code, not just docs
  • Locality: all search types in one place
  • libraries/ becomes genuinely portable
  • → Prerequisite for extracting a testable search module (Candidate 3)
  • → Surgical — 9 import fixes, no logic changes

3. Extract the fuzzy search pipeline from FuzzySearchModal into a composable module

Worth exploring ports & adapters
ADR-0010 alignment: ADR-0010 already explicitly proposes this refactor ("the architecture review flags this as Candidate 3: extract a SearchModule via composition"). This candidate executes on that noted opportunity.
src/unicode-search/components/fuzzySearchModal.ts (143 lines — owns search + rendering)
src/unicode-search/components/insertCharacterModal.ts (28 lines)
src/unicode-search/components/pickCharacterModal.ts (44 lines)
src/libraries/helpers/toSearchQueryMatch.ts (imports obsidian)

Before — search logic locked in inheritance

SuggestModal (Obsidian) ← hard inheritance dep FuzzySearchModal (abstract) getSuggestions() ← search pipeline renderSuggestion() ← UI rendering usageStatistics (ReadCache) ⚠ untestable without App instance InsertCharacterModal onChooseSuggestion PickCharacterModal ⚠ fights onClose order search pipeline: 0 tests possible without Obsidian App mock

After — search extracted, modals become thin

FUZZY SEARCH search(query, characters) ↳ query routing (internal) ↳ candidate retrieval (internal) ↳ scoring + ranking (internal) → MetaCharacterSearchResult[] test seam SuggestModal FuzzySearchModal (thin) delegates to FuzzySearch renders results only InsertCharacterModal PickCharacterModal search pipeline: fully testable without Obsidian App

Problem

The fuzzy search pipeline — two-phase candidate retrieval, scoring on name and code point axes, use-history ranking — lives entirely inside FuzzySearchModal.getSuggestions(). It inherits from Obsidian's SuggestModal. Zero tests are possible without an Obsidian App instance.

Solution

Extract a FuzzySearch module with interface search(query, chars) → MetaCharacterSearchResult[]. Inject the Obsidian search functions as parameters. FuzzySearchModal delegates to it. Both modal subclasses keep their thin overrides.

Wins

  • Locality: search pipeline testable in isolation
  • → Seam cleanly separates search logic from Obsidian rendering
  • FuzzySearchModal shrinks to renderer + delegates
  • → Two adapters (InsertModal, PickModal) justify the seam
  • → Unblocks Candidate 1's ranking module
Note: Candidate 2 (type migration) is a prerequisite — move the search result types to libraries/types/ before extracting FuzzySearch.

Top Recommendation

Start with Candidate 1 — collapse the comparison cluster

It's pure TypeScript, no Obsidian entanglement, and no prerequisites. The 14 files already have matching spec files — redirecting those tests to a single ranking seam is mechanical. Every caller in the codebase today touches only compareCharacterMatches at the top; the rest of the cluster is already internal in practice. Formalising that as a deep module is a low-risk, high-locality win. After it lands, Candidate 2 (ADR-0005 type migration) is the next smallest independent change — surgical import fixes, no logic moves. Candidate 3 (extract FuzzySearch) follows naturally once the types are clean.