Improve fuzzy memory name matching logic and UX in CLI

This commit is contained in:
Michael Panchenko authored and Dominik Jain committed 2026-05-14 22:49:06 +02:00
1 parent 342b9b0382
commit 27b3622c38
4 files changed
+320 -64

No files matched your search

+3 -3
View File
@@ -189,9 +189,9 @@ Serena also offers memory-related CLI commands.
The following commands have **no MCP tool counterpart** and are intended for human execution:
- `serena memories check` — referential-integrity report. By default reports only stale
`` `mem:NAME` `` references whose target does not resolve. Pass `--include-unmarked` to
also report bare occurrences of existing memory names found through a heuristic.
- `serena memories check` — referential-integrity report. By default reports stale
`` `mem:NAME` `` references; additional scans (bare occurrences and fuzzy near-misses)
are opt-in via flags. Run `serena memories check --help` for the full flag list.
- `serena memories auto-prefix-references` — heuristic rewrite of bare occurrences to add
the `mem:` prefix; supports `--dry-run`.
- `serena memories initialize` will seed the `memory_maintenance` memory for the project.
+24 -6
View File
@@ -1308,9 +1308,10 @@ class MemoryCommands(AutoRegisteringGroup):
"check",
help=(
"Check referential integrity across all memories of the project (and global memories). "
"Reports stale `mem:` references. With --include-unmarked, also reports bare occurrences "
"of existing memory names that lack the `mem:` prefix (split into high- and low-confidence). "
"Read-only and never writes. Always exits 0."
"By default reports only stale `mem:` references. Pass --include-unmarked to also report "
"bare occurrences of existing memory names (exact matches) and --fuzzy-matching (only "
"meaningful in combination with --include-unmarked) to additionally report fuzzy "
"near-misses. Read-only and never writes. Always exits 0."
),
context_settings={"max_content_width": _MAX_CONTENT_WIDTH},
)
@@ -1319,11 +1320,28 @@ class MemoryCommands(AutoRegisteringGroup):
"--include-unmarked",
is_flag=True,
default=False,
help="Also report potentially unmarked references (bare occurrences of memory names without the `mem:` prefix).",
help="Also report bare exact occurrences of existing memory names (i.e. without the `mem:` prefix).",
)
def check(project: str, include_unmarked: bool) -> None:
@click.option(
"--fuzzy-matching",
is_flag=True,
default=False,
help=(
"Additionally report fuzzy near-misses (long bare tokens that similarity-match an existing "
"memory name). Only meaningful together with --include-unmarked; ignored otherwise."
),
)
def check(project: str, include_unmarked: bool, fuzzy_matching: bool) -> None:
if fuzzy_matching and not include_unmarked:
click.echo(
"Warning: --fuzzy-matching has no effect without --include-unmarked; ignoring it.",
err=True,
)
manager = MemoryCommands._load_memories_manager(project)
report = manager.validate_referential_integrity(include_unmarked=include_unmarked)
report = manager.validate_referential_integrity(
include_unmarked=include_unmarked,
include_fuzzy_matching=fuzzy_matching,
)
click.echo(report.format())
@staticmethod
+107 -47
View File
@@ -165,7 +165,17 @@ class MemoriesManager:
# tokenized name parts. Containment / SequenceMatcher signals alone over-match — e.g. a
# generic prose word like "repository" is a substring of "serena_repository_structure"
# but their token Jaccard is only 1/3, so we reject it at this floor.
_FUZZY_BARE_TOKEN_JACCARD_FLOOR: float = 0.5
_FUZZY_BARE_TOKEN_JACCARD_FLOOR: float = 0.6
# In :meth:`compute_name_similarity` case 2 (basenames differ), when the two names also
# carry *differing* topic prefixes, demand at least one strong basename signal: token
# Jaccard >= this floor, basename containment, or typo-level seq ratio. Without this
# gate, names like ``frontend/x-subtleties`` and ``backend/y-subtleties`` get matched
# purely on the shared trailing token via the SequenceMatcher signal.
_BASENAME_JACCARD_FLOOR: float = 0.34
_BASENAME_TYPO_SEQ_FLOOR: float = 0.75
# Hard cap on candidates proposed per stale reference. Beyond a small number the list
# stops aiding disambiguation and becomes noise.
MAX_STALE_REFERENCE_CANDIDATES: int = 3
_WORDS_TO_IGNORE_AS_MEMORY_NAME_CANDIDATES: frozenset[str] = frozenset({"core"})
"""Names that we specifically mention in our instructions and that coincide with common words"""
@@ -204,6 +214,8 @@ class MemoriesManager:
auth/login ~ security/login -> 0.50 (basename only; disjoint topics)
auth ~ authentication -> 0.64 (substring containment)
foo ~ for -> 0.00 (short-name floor)
frontend/x-subtleties ~ backend/y-subtleties -> 0.00 (different topics + only a
shared generic trailing token)
:param a: first memory name
:param b: second memory name
@@ -234,22 +246,34 @@ class MemoriesManager:
)
return 0.5 + 0.5 * prefix_jaccard
# case 2: basenames differ -- combine generic similarity signals via max
tokens_a, tokens_b = cls._tokenize_name(norm_a), cls._tokenize_name(norm_b)
jaccard = len(tokens_a & tokens_b) / len(tokens_a | tokens_b) if (tokens_a | tokens_b) else 0.0
seq = SequenceMatcher(None, norm_a, norm_b).ratio()
# case 2: basenames differ -- score the *basenames* only, so that a shared topic prefix
# plus a generic trailing token (e.g. "frontend/X-subtleties" vs "frontend/Y-subtleties")
# does not inflate the SequenceMatcher signal into a false positive.
basename_tokens_a, basename_tokens_b = cls._tokenize_name(basename_a), cls._tokenize_name(basename_b)
union_basename = basename_tokens_a | basename_tokens_b
basename_jaccard = (len(basename_tokens_a & basename_tokens_b) / len(union_basename)) if union_basename else 0.0
basename_contained = bool(basename_a) and bool(basename_b) and (basename_a in basename_b or basename_b in basename_a)
seq = SequenceMatcher(None, basename_a, basename_b).ratio()
# when both names carry *differing* topic prefixes, demand at least one strong basename
# signal — otherwise a single coincidental shared token at the end of long basenames
# under different topic roots slips through purely on the seq-ratio
if prefix_a and prefix_b and prefix_a != prefix_b:
if basename_jaccard < cls._BASENAME_JACCARD_FLOOR and not basename_contained and seq < cls._BASENAME_TYPO_SEQ_FLOOR:
return 0.0
# containment is a meaningful rename signal on its own (e.g. auth -> authentication),
# so we lift it above the threshold floor when one name fully contains the other,
# so we lift it above the threshold floor when one basename fully contains the other,
# with the length-ratio gating how confident we are
contained = 0.0
if norm_a and norm_b and (norm_a in norm_b or norm_b in norm_a):
length_ratio = min(len(norm_a), len(norm_b)) / max(len(norm_a), len(norm_b))
if basename_contained:
length_ratio = min(len(basename_a), len(basename_b)) / max(len(basename_a), len(basename_b))
contained = 0.5 + 0.5 * length_ratio
# short-name floor: for very short names, only accept candidates with a strong token signal
if min(len(norm_a), len(norm_b)) <= cls._SHORT_NAME_FLOOR and jaccard < 1.0:
# short-name floor: for very short basenames, only accept candidates with a strong token signal
if min(len(basename_a), len(basename_b)) <= cls._SHORT_NAME_FLOOR and basename_jaccard < 1.0:
return 0.0
return max(jaccard, seq, contained)
return max(basename_jaccard, seq, contained)
@classmethod
def _find_stale_reference_candidates(cls, missing_name: str, existing_names: list[str], threshold: float | None = None) -> list[str]:
@@ -259,7 +283,9 @@ class MemoriesManager:
:param threshold: optional override for the minimum similarity required; defaults to
:attr:`NAME_SIMILARITY_THRESHOLD`.
:return: existing memory names whose similarity to ``missing_name`` meets or exceeds
``threshold``, sorted in descending order of similarity (ties broken alphabetically).
``threshold``, sorted in descending order of similarity (ties broken alphabetically)
and capped at :attr:`MAX_STALE_REFERENCE_CANDIDATES` entries to keep the output
focused on the most plausible matches.
"""
cutoff = cls.NAME_SIMILARITY_THRESHOLD if threshold is None else threshold
scored: list[tuple[float, str]] = []
@@ -268,7 +294,7 @@ class MemoriesManager:
if score >= cutoff:
scored.append((score, existing))
scored.sort(key=lambda pair: (-pair[0], pair[1]))
return [name for _, name in scored]
return [name for _, name in scored[: cls.MAX_STALE_REFERENCE_CANDIDATES]]
# character class delimiting a memory name; used to anchor regex matches so we never
# consume a partial name (kept in sync with the boundary rule in rename_references_to_memory)
@@ -544,28 +570,41 @@ class MemoriesManager:
tb.with_line(" candidates: (none above similarity threshold)")
tb.with_line("")
# unmarked-reference warnings, split by confidence
for label, warnings in (
("High-confidence unmarked references", self.high_confidence_unmarked_memories),
("Low-confidence unmarked references", self.low_confidence_unmarked_memories),
):
if not warnings:
continue
tb.with_line(f"{label} ({len(warnings)}):")
for w in warnings:
ro_tag = " [read-only source]" if w.source_is_read_only else ""
occ = "occurrence" if w.occurrences == 1 else "occurrences"
if w.is_exact_match:
# unmarked-reference warnings — split exact vs fuzzy near-miss, then by confidence
exact_high = [w for w in self.high_confidence_unmarked_memories if w.is_exact_match]
exact_low = [w for w in self.low_confidence_unmarked_memories if w.is_exact_match]
fuzzy = [w for w in self.high_confidence_unmarked_memories if not w.is_exact_match] + [
w for w in self.low_confidence_unmarked_memories if not w.is_exact_match
]
# the "may be intentional" caveat is meaningful only for exact matches (where the bare
# text equals a real memory name and may simply be ordinary prose); print it once,
# before the first exact-match section
if exact_high or exact_low:
tb.with_line("Possibly unmarked references — exact matches:")
tb.with_line(" (note: bare matches in prose may be intentional and not actual references)")
for label, warnings in (("high confidence", exact_high), ("low confidence", exact_low)):
if not warnings:
continue
tb.with_line(f" {label} ({len(warnings)}):")
for w in warnings:
ro_tag = " [read-only source]" if w.source_is_read_only else ""
occ = "occurrence" if w.occurrences == 1 else "occurrences"
tb.with_line(
f" - {w.occurrences} {occ} of `{w.suspected_name}` in `{w.source_memory}`{ro_tag}"
f" - {w.occurrences} {occ} of `{w.suspected_name}` in `{w.source_memory}`{ro_tag}"
f" (suggested: `mem:{w.suspected_name}`)"
)
else:
# fuzzy near-miss: the bare text differs from the proposed target
tb.with_line(
f" - {w.occurrences} {occ} of `{w.actual_token}` in `{w.source_memory}`{ro_tag}"
f" — near-miss (suggested: `mem:{w.suspected_name}`)"
)
tb.with_line("")
if fuzzy:
tb.with_line(f"Possibly unmarked references — fuzzy near-misses ({len(fuzzy)}):")
for w in fuzzy:
ro_tag = " [read-only source]" if w.source_is_read_only else ""
occ = "occurrence" if w.occurrences == 1 else "occurrences"
tb.with_line(
f" - {w.occurrences} {occ} of `{w.actual_token}` in `{w.source_memory}`{ro_tag}"
f" — near-miss (suggested: `mem:{w.suspected_name}`)"
)
tb.with_line("")
return tb.build()
@@ -788,36 +827,42 @@ class MemoriesManager:
source_basename = source_memory.rsplit("/", 1)[-1]
return suspected_name == source_basename
def validate_referential_integrity(self, include_unmarked: bool = True) -> "MemoriesManager.ReferentialIntegrityReport":
def validate_referential_integrity(
self, include_unmarked: bool = True, include_fuzzy_matching: bool = True
) -> "MemoriesManager.ReferentialIntegrityReport":
"""
Scans every (non-ignored) memory's content for referential integrity issues.
The scan covers both project-local and global memories. Three kinds of finding are
produced:
produced, each independently gated:
* **stale references** — occurrences of ``mem:NAME`` where ``NAME`` does not resolve
to an existing memory. For each, a list of similarly-named existing memories is
proposed as candidate intended targets (see :meth:`compute_name_similarity`).
proposed as candidate intended targets (see :meth:`compute_name_similarity`),
capped at :attr:`MAX_STALE_REFERENCE_CANDIDATES`. Always reported.
* **exact unmarked-reference warnings** — bare occurrences of an existing memory
name that appear without the ``mem:`` prefix. Split into high-confidence (the
suspected name contains a ``/`` or exceeds :attr:`_HIGH_CONFIDENCE_NAME_LENGTH`)
and low-confidence groups. Candidate names whose basename matches
:attr:`WORDS_TO_IGNORE_AS_MEMORY_NAME_CANDIDATES` are skipped.
and low-confidence groups. Candidate names whose basename is in
:attr:`_WORDS_TO_IGNORE_AS_MEMORY_NAME_CANDIDATES` are skipped. Gated by
``include_unmarked``.
* **fuzzy near-miss warnings** — long, distinctive bare tokens in a memory body
that *do not* match an existing name exactly but similarity-match a
high-confidence existing name AND share at least :attr:`_FUZZY_BARE_TOKEN_JACCARD_FLOOR`
of their tokenized name parts (e.g. ``adding_new_language_support`` in prose when
the memory is named ``adding_new_language_support_guide``). Always reported as
high-confidence; the actual bare text is preserved on
:attr:`UnmarkedReferenceWarning.actual_token`.
high-confidence existing name AND share at least
:attr:`_FUZZY_BARE_TOKEN_JACCARD_FLOOR` of their tokenized name parts. Always
reported as high-confidence; the actual bare text is preserved on
:attr:`UnmarkedReferenceWarning.actual_token`. Gated by ``include_unmarked
and include_fuzzy_matching`` — fuzzy matches are noisy and only meaningful when
unmarked-reference checking is enabled.
Self-references (a memory's content mentioning its own name or basename) and empty
memories are skipped silently.
memories are skipped silently. This method has no side effects.
This method has no side effects.
:param include_unmarked: if False, skip the two unmarked-reference scans (exact and
fuzzy near-miss) entirely; only stale references are reported.
:param include_unmarked: if False, skip both the exact unmarked scan and the fuzzy
near-miss scan; only stale references are reported.
:param include_fuzzy_matching: if False, skip the fuzzy near-miss scan even when
unmarked-reference checking is otherwise enabled. Has no effect when
``include_unmarked`` is False.
:return: a :class:`ReferentialIntegrityReport` summarizing all findings.
"""
report = MemoriesManager.ReferentialIntegrityReport()
@@ -885,11 +930,19 @@ class MemoriesManager:
else:
report.low_confidence_unmarked_memories.append(warning)
if not include_fuzzy_matching:
continue
# fuzzy near-misses: long bare tokens that similarity-match a high-confidence existing name
for token, n in self._iter_long_bare_tokens(content):
if token in existing_set:
# exact-match path already handled this token (or self-referenced and was skipped)
continue
# filter bare tokens that coincide with common English words; the same filter is
# applied to candidate names in the exact-match loop, this is the symmetric side
token_norm = self._normalize_for_similarity(token)
if token_norm in self._WORDS_TO_IGNORE_AS_MEMORY_NAME_CANDIDATES:
continue
token_tokens = self._tokenize_name(token)
best_name: str | None = None
best_score = 0.0
@@ -898,6 +951,13 @@ class MemoriesManager:
continue
if existing.rsplit("/", 1)[-1] in self._WORDS_TO_IGNORE_AS_MEMORY_NAME_CANDIDATES:
continue
# require substring containment between bare token and existing name: fuzzy
# matches without a containment relationship are rarely useful as rewrite
# suggestions (auto_prefix_bare_references can't fix them anyway) and dominate
# the false-positive rate, e.g. "grid-layout" vs "layout-grid-subtleties".
existing_norm = self._normalize_for_similarity(existing)
if token_norm not in existing_norm and existing_norm not in token_norm:
continue
score = self.compute_name_similarity(token, existing)
if score < self.NAME_SIMILARITY_THRESHOLD:
continue
+186 -8
View File
@@ -134,17 +134,12 @@ class TestRenameReferencesToMemory:
@pytest.mark.parametrize(
"delimiter_pair",
[
# one case per structural class of surrounding character (asymmetric pair, whitespace,
# punctuation, none); the boundary regex only distinguishes "name char" vs "non-name char",
# so further variants exercise the same code path.
("`", "`"),
('"', '"'),
("'", "'"),
("(", ")"),
("[", "]"),
("{", "}"),
(" ", " "),
("\n", "\n"),
("\t", "\t"),
(",", ","),
(";", ";"),
("", ""), # raw, no delimiters
],
)
@@ -217,6 +212,27 @@ class TestNameSimilarity:
"security/login", "auth/login"
)
def test_cross_topic_suffix_coincidence_is_rejected(self, manager: MemoriesManager) -> None:
"""
Regression: when two long names live under *different* topic prefixes and share
only a single trailing token (e.g. ``-subtleties``), the score must collapse to
zero. Without the cross-topic gate, SequenceMatcher returns ~0.55+ on these
purely on shared characters, flooding stale-reference candidate lists with
unrelated memories from neighbouring subsystems.
"""
target = "frontend/plugin-api-runtime-subtleties"
# different topic prefix + only the generic "subtleties" trailing token shared -> 0
for other in (
"backend/rpc-db-worker-subtleties",
"render-wasm/ffi-rendering-subtleties",
"common/grid-layout-subtleties",
):
assert manager.compute_name_similarity(target, other) == 0.0, f"cross-topic suffix coincidence with {other} must be rejected"
# contrast: *same* prefix is treated leniently — the gate must not apply, so the
# score is non-zero even if it still falls under the similarity threshold
same_prefix_score = manager.compute_name_similarity(target, "frontend/routing-app-shell-subtleties")
assert same_prefix_score > 0.0
def test_find_candidates_ranks_by_descending_similarity(self, manager: MemoriesManager) -> None:
existing = ["auth/login", "security/login", "database", "authentication"]
candidates = manager._find_stale_reference_candidates("login", existing)
@@ -359,6 +375,168 @@ class TestValidateReferentialIntegrity:
assert warning.suspected_name == existing
assert warning.source_memory == "docs"
def test_fuzzy_accepts_bare_token_when_jaccard_just_above_floor(self, fs_manager: MemoriesManager) -> None:
"""
Edge case: bare token is a clean substring of the existing memory name AND
their tokenized Jaccard sits just above ``_FUZZY_BARE_TOKEN_JACCARD_FLOOR``
(with floor = 0.6, this means 2/3 ≈ 0.667).
"""
# existing has 3 name-tokens; bare has 2 of them -> jaccard = 2/3 = 0.667
existing = "data-model-extra"
bare = "data-model"
assert len(bare) >= MemoriesManager._HIGH_CONFIDENCE_NAME_LENGTH
_write(fs_manager, existing, "# extra notes")
_write(fs_manager, "docs", f"see {bare} for naming")
report = fs_manager.validate_referential_integrity()
fuzzy = [w for w in report.high_confidence_unmarked_memories if not w.is_exact_match]
assert len(fuzzy) == 1
assert fuzzy[0].suspected_name == existing
assert fuzzy[0].actual_token == bare
def test_fuzzy_rejects_bare_token_when_jaccard_just_below_floor(self, fs_manager: MemoriesManager) -> None:
"""
Edge case: bare token is still a clean substring of the existing memory name,
but their tokenized Jaccard is just below ``_FUZZY_BARE_TOKEN_JACCARD_FLOOR``
(2/4 = 0.5 with the floor at 0.6). Must be rejected — without this gate,
long candidate names would attract many borderline matches.
"""
existing = "data-model-extra-stuff" # 4 tokens
bare = "data-model" # 2 tokens; jaccard = 2/4 = 0.5
assert len(bare) >= MemoriesManager._HIGH_CONFIDENCE_NAME_LENGTH
_write(fs_manager, existing, "# notes")
_write(fs_manager, "docs", f"see {bare} for naming")
report = fs_manager.validate_referential_integrity()
assert report.high_confidence_unmarked_memories == []
assert report.low_confidence_unmarked_memories == []
def test_fuzzy_requires_substring_containment(self, fs_manager: MemoriesManager) -> None:
"""
Edge case: bare token and existing name share several tokens (and even a topic
prefix) but neither is a substring of the other. Without the containment
requirement, sequence-ratio alone would mis-rank long names like
``frontend/X-subtleties`` vs ``frontend/Y-subtleties`` as near-misses.
"""
existing = "frontend/text-editor-workflow"
bare = "frontend/text-editor-features" # shares "frontend", "text", "editor"; not a substring
assert len(bare) >= MemoriesManager._HIGH_CONFIDENCE_NAME_LENGTH
_write(fs_manager, existing, "# workflow notes")
_write(fs_manager, "docs", f"see {bare} for layout")
report = fs_manager.validate_referential_integrity()
assert report.high_confidence_unmarked_memories == []
assert report.low_confidence_unmarked_memories == []
def test_fuzzy_accepts_reverse_containment(self, fs_manager: MemoriesManager) -> None:
"""
Edge case: the *existing* memory name is a substring of the longer bare token
(rather than the other way around). The containment gate must hold in both
directions, so this is still flagged.
"""
existing = "playwright-gestures"
bare = "playwright-gestures-guide" # existing ⊂ bare
assert len(bare) >= MemoriesManager._HIGH_CONFIDENCE_NAME_LENGTH
_write(fs_manager, existing, "# notes")
_write(fs_manager, "docs", f"see {bare} for details")
report = fs_manager.validate_referential_integrity()
fuzzy = [w for w in report.high_confidence_unmarked_memories if not w.is_exact_match]
assert len(fuzzy) == 1
assert fuzzy[0].suspected_name == existing
assert fuzzy[0].actual_token == bare
def test_unmarked_exact_match_suppressed_for_ignored_word(self, fs_manager: MemoriesManager) -> None:
"""
Names in ``_WORDS_TO_IGNORE_AS_MEMORY_NAME_CANDIDATES`` (e.g. ``core``) are
common-word collisions and must not produce bare-occurrence warnings even
when an exact memory of that name exists.
"""
_write(fs_manager, "core", "# project core notes")
_write(fs_manager, "docs", "this is the core of the system")
report = fs_manager.validate_referential_integrity()
# bare "core" matches the candidate name `core` literally but is filtered out
assert all(w.suspected_name != "core" for w in report.high_confidence_unmarked_memories)
assert all(w.suspected_name != "core" for w in report.low_confidence_unmarked_memories)
def test_unmarked_exact_match_suppressed_for_topic_with_ignored_basename(self, fs_manager: MemoriesManager) -> None:
"""
The ignore filter applies to the *basename* of a candidate name, so a memory
named ``frontend/core`` is also exempt from bare-occurrence warnings (the
bare text ``frontend/core`` is more likely a directory reference than a
forgotten memory link).
"""
_write(fs_manager, "frontend/core", "# notes")
_write(fs_manager, "docs", "the frontend/core module is documented here")
report = fs_manager.validate_referential_integrity()
assert all(w.suspected_name != "frontend/core" for w in report.high_confidence_unmarked_memories)
assert all(w.suspected_name != "frontend/core" for w in report.low_confidence_unmarked_memories)
def test_unmarked_exact_match_not_suppressed_for_non_ignored_basename(self, fs_manager: MemoriesManager) -> None:
"""
Sanity: the ignore filter must not over-apply to names whose basename happens
to share a prefix with an ignored word.
"""
_write(fs_manager, "frontend/core-utils", "# notes")
_write(fs_manager, "docs", "the frontend/core-utils module is documented here")
report = fs_manager.validate_referential_integrity()
assert any(w.suspected_name == "frontend/core-utils" for w in report.high_confidence_unmarked_memories)
def test_stale_reference_candidates_are_capped(self, fs_manager: MemoriesManager) -> None:
"""
The candidate list proposed for an unresolved ``mem:`` reference is capped at
:attr:`MAX_STALE_REFERENCE_CANDIDATES`. Beyond a small number the list stops
aiding disambiguation and becomes noise.
"""
cap = MemoriesManager.MAX_STALE_REFERENCE_CANDIDATES
# five memories all sharing basename `login` -> all tie at compute_name_similarity == 1.0
candidate_names = ["admin/login", "api/login", "auth/login", "security/login", "users/login"]
for name in candidate_names:
_write(fs_manager, name, "# notes")
_write(fs_manager, "docs", "old reference: `mem:login`")
report = fs_manager.validate_referential_integrity()
assert len(report.stale_references) == 1
candidates = report.stale_references[0].candidates
# cap enforced; alphabetical tiebreak among equally-scored candidates
assert len(candidates) == cap
assert candidates == candidate_names[:cap]
def test_include_unmarked_false_skips_both_scans(self, fs_manager: MemoriesManager) -> None:
"""
With ``include_unmarked=False`` only stale references are reported; both the
exact unmarked scan and the fuzzy near-miss scan are entirely skipped.
"""
_write(fs_manager, "auth/login", "# notes")
_write(fs_manager, "playwright-gestures", "# notes")
_write(
fs_manager,
"docs",
"bare auth/login mention and a playwright-gestures-guide near-miss; also `mem:nope`",
)
report = fs_manager.validate_referential_integrity(include_unmarked=False)
assert report.high_confidence_unmarked_memories == []
assert report.low_confidence_unmarked_memories == []
# stale reference is still reported regardless of the flag
assert len(report.stale_references) == 1
assert report.stale_references[0].referenced_name == "nope"
def test_include_fuzzy_matching_false_keeps_exact_only(self, fs_manager: MemoriesManager) -> None:
"""
With ``include_unmarked=True`` but ``include_fuzzy_matching=False``, exact
bare-occurrence warnings still fire but fuzzy near-misses are suppressed.
"""
_write(fs_manager, "auth/login", "# notes")
_write(fs_manager, "playwright-gestures", "# notes")
_write(
fs_manager,
"docs",
"bare auth/login mention and a playwright-gestures-guide near-miss",
)
report = fs_manager.validate_referential_integrity(include_unmarked=True, include_fuzzy_matching=False)
# exact match for auth/login is kept
exact = [w for w in report.high_confidence_unmarked_memories if w.is_exact_match]
fuzzy = [w for w in report.high_confidence_unmarked_memories if not w.is_exact_match]
assert len(exact) == 1
assert exact[0].suspected_name == "auth/login"
# fuzzy near-miss for `playwright-gestures-guide` is suppressed
assert fuzzy == []
def test_self_reference_is_ignored(self, fs_manager: MemoriesManager) -> None:
# the basename `login` appears in `auth/login`'s own content -> must not be flagged
_write(fs_manager, "auth/login", "this login flow ...")