diff --git a/docs/02-usage/045_memories.md b/docs/02-usage/045_memories.md index b578ed3c..e14d3b31 100644 --- a/docs/02-usage/045_memories.md +++ b/docs/02-usage/045_memories.md @@ -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. diff --git a/src/serena/cli.py b/src/serena/cli.py index ae7a2380..27022662 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -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 diff --git a/src/serena/project.py b/src/serena/project.py index 21bea043..480a4a37 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -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 diff --git a/test/serena/test_memories_manager.py b/test/serena/test_memories_manager.py index 0ff428eb..3974d159 100644 --- a/test/serena/test_memories_manager.py +++ b/test/serena/test_memories_manager.py @@ -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 ...")