Skip to content

Commit ecf401f

Browse files
committed
test(search): pin boundary cases and re-pin goldens [roadmap:v0.10.3]
Add the token-boundary battery to the resolve battery: tokenization (camelCase, non-alnum, punctuation-only), prefix matching, word-boundary exclusion, multi-term AND, the five-tier ordering (id > title > path > heading > body), body-only match with snippet, first-line determinism, and the metadata-match byte-identical shape. Add the lore-vs-Explorer named regression to the dogfood battery on the dogfood corpus. Extend the mcp battery: a body-match snippet payload byte-equal to the CLI find JSON, the metadata-match four-field shape, and whole-item truncation of a snippet-bearing match. Re-pin the find goldens to the new semantics ('markdown' now also surfaces a roadmap by its body, with a snippet) and update the Explorer adapter's trailing-type-token test, whose punctuation-only query no longer means 'match everything' under token matching.
1 parent e41a4c7 commit ecf401f

6 files changed

Lines changed: 223 additions & 4 deletions

File tree

tests/golden/find_human.txt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1-
RAC-01JY4M8X2QZ7 decision Markdown Is the Canonical Source Format
1+
RAC-01JY4M8X2QZ7 decision Markdown Is the Canonical Source Format
2+
v0-canonical-format roadmap Canonical Format Roadmap
3+
↳ Initiatives: Adopt Markdown everywhere.
24

3-
1 match(es) for 'markdown'.
5+
2 match(es) for 'markdown'.

tests/golden/find_json.txt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,21 @@
22
"schema_version": "1",
33
"query": "markdown",
44
"type": null,
5-
"match_count": 1,
5+
"match_count": 2,
66
"matches": [
77
{
88
"id": "RAC-01JY4M8X2QZ7",
99
"type": "decision",
1010
"title": "Markdown Is the Canonical Source Format",
1111
"path": "tests/fixtures/resolve/markdown-first.md"
12+
},
13+
{
14+
"id": "v0-canonical-format",
15+
"type": "roadmap",
16+
"title": "Canonical Format Roadmap",
17+
"path": "tests/fixtures/resolve/v0-canonical-format.md",
18+
"section": "Initiatives",
19+
"snippet": "Adopt Markdown everywhere."
1220
}
1321
]
1422
}

tests/test_dogfood.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,23 @@ def test_demo_keywords_surface_the_decision():
106106
assert DEMO_DECISION_ID in ids, f"{query!r} did not surface {DEMO_DECISION_ID}: {ids}"
107107

108108

109+
def test_lore_does_not_match_explorer_artifacts():
110+
"""Named regression (ADR-037): `lore` is a substring of "Explorer" but not a
111+
token prefix of it. On the dogfood corpus — which carries both Lore and many
112+
Explorer artifacts — `rac find lore` must return only Lore-related artifacts
113+
and never an Explorer one."""
114+
result = find_artifacts(CORPUS, "lore")
115+
assert result.match_count > 0 # the Lore product-identity artifacts exist
116+
leaked = [m.path for m in result.matches if "explorer" in m.path.casefold()]
117+
assert leaked == [], f"`lore` leaked Explorer artifacts: {leaked}"
118+
# Every match is genuinely Lore-related: the token appears in its content.
119+
for m in result.matches:
120+
text = Path(m.path).read_text(encoding="utf-8").casefold()
121+
from rac.services.resolve import tokenize
122+
123+
assert "lore" in tokenize(text), f"{m.path} matched 'lore' without a lore token"
124+
125+
109126
def test_demo_decision_resolves_with_content():
110127
"""get_artifact's resolver finds the decision and its content carries the
111128
hard-DELETE prohibition the grounded agent must cite."""

tests/test_explorer_adapter.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,11 @@ def test_search_rows_rank_like_rac_find():
236236
def test_search_rows_trailing_type_token_filters():
237237
adapter = ExplorerAdapter(str(FIXTURES / "all_types"))
238238
adapter.load()
239-
filtered = adapter.search_rows(". decision")
239+
# "all" is a token of every artifact's path (the all_types fixture dir); the
240+
# trailing "decision" filters to the one decision. Token-boundary matching
241+
# (ADR-037) replaced substring matching, so a punctuation-only query no
242+
# longer means "match everything" — the query must carry a real token.
243+
filtered = adapter.search_rows("all decision")
240244
assert filtered.rows
241245
assert all(row.type == "decision" for row in filtered.rows)
242246

tests/test_mcp_tools.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,47 @@ def test_search_artifacts_empty_result_is_not_an_error():
129129
assert "error" not in payload
130130

131131

132+
def test_search_artifacts_body_match_carries_snippet():
133+
# `tightly` appears only in the requirement's body ("Services are tightly
134+
# coupled"). The body tier (ADR-038) surfaces it with section + snippet, and
135+
# the tool payload is byte-identical to the CLI's find JSON.
136+
payload = call(CORPUS, "search_artifacts", {"query": "tightly"})
137+
assert payload["match_count"] == 1
138+
match = payload["matches"][0]
139+
assert match["id"] == REQ
140+
assert match["section"] == "Problem"
141+
assert match["snippet"] == "Services are tightly coupled."
142+
cli = json.loads(json_output.render_find_json(find_artifacts(CORPUS, "tightly")))
143+
assert payload == cli
144+
145+
146+
def test_search_artifacts_metadata_match_omits_snippet_fields():
147+
# A title match (no body hit) keeps the four-field metadata shape exactly
148+
# (ADR-007): no section/snippet keys appear.
149+
payload = call(CORPUS, "search_artifacts", {"query": "decoupled", "type": "requirement"})
150+
assert [m["id"] for m in payload["matches"]] == [REQ]
151+
assert list(payload["matches"][0]) == ["id", "type", "title", "path"]
152+
153+
154+
def test_search_snippet_match_truncates_as_whole_item():
155+
# A snippet-bearing match truncates as one whole item under a small budget:
156+
# the snippet rides inside its entry, so it is never split mid-element.
157+
full = call(CORPUS, "search_artifacts", {"query": "services"})
158+
assert full["match_count"] >= 2 and "truncated" not in full
159+
budget = 220
160+
payload = call(CORPUS, "search_artifacts", {"query": "services"}, budget=budget)
161+
assert payload["truncated"] is True
162+
assert payload["match_count"] == full["match_count"]
163+
assert len(payload["matches"]) < full["match_count"]
164+
# Every kept entry is structurally complete: either a metadata shape or a
165+
# metadata-plus-snippet shape, never a fragment.
166+
for m in payload["matches"]:
167+
assert {"id", "type", "title", "path"} <= set(m)
168+
assert set(m) <= {"id", "type", "title", "path", "section", "snippet"}
169+
if "snippet" in m or "section" in m:
170+
assert "section" in m and "snippet" in m
171+
172+
132173
# --- get_related -------------------------------------------------------------
133174

134175

tests/test_resolve.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,153 @@ def test_search_empty_repository_valid_no_match(tmp_path):
168168
assert result.matches == []
169169

170170

171+
# --- token-boundary battery (v0.10.3, ADR-037/ADR-038) --------------------------
172+
173+
174+
def test_tokenize_splits_on_boundaries_and_camelcase():
175+
from rac.services.resolve import tokenize
176+
177+
assert tokenize("soft-delete") == ["soft", "delete"]
178+
assert tokenize("relationships") == ["relationships"]
179+
assert tokenize("Explorer") == ["explorer"]
180+
assert tokenize("camelCaseWord") == ["camel", "case", "word"]
181+
assert tokenize("adr-002-legacy.md") == ["adr", "002", "legacy", "md"]
182+
assert tokenize("...") == []
183+
184+
185+
def test_prefix_matching_finds_whole_token(repo):
186+
# `relation` must match a `relationships` token by prefix (ADR-037).
187+
(repo / "decisions" / "relationships.md").write_text(
188+
LEGACY_DECISION.replace("A Legacy Decision", "Relationship Validation"),
189+
encoding="utf-8",
190+
)
191+
matches = find_artifacts(str(repo), "relation").matches
192+
assert any(m.path.endswith("relationships.md") for m in matches)
193+
194+
195+
def test_word_boundary_excludes_substring_false_positive(repo):
196+
# `lore` is a substring of "Explorer" but not a token prefix of it; the
197+
# named regression (lore vs Explorer) lives in test_dogfood against the
198+
# dogfood corpus. Here the unit form: a mid-word substring no longer hits.
199+
(repo / "decisions" / "explorer.md").write_text(
200+
LEGACY_DECISION.replace("A Legacy Decision", "The Explorer Surface"),
201+
encoding="utf-8",
202+
)
203+
matches = find_artifacts(str(repo), "lore").matches
204+
assert not any("explorer" in m.path.casefold() for m in matches)
205+
assert not any((m.title or "").casefold().find("explorer") >= 0 for m in matches)
206+
207+
208+
def test_camelcase_split_is_searchable(repo):
209+
(repo / "decisions" / "camel.md").write_text(
210+
LEGACY_DECISION.replace("A Legacy Decision", "Use camelCase Identifiers"),
211+
encoding="utf-8",
212+
)
213+
# `camel` and `case` are separate tokens after the camelCase split.
214+
assert find_artifacts(str(repo), "camel").match_count >= 1
215+
assert any(m.path.endswith("camel.md") for m in find_artifacts(str(repo), "case").matches)
216+
217+
218+
def test_multi_term_requires_every_term(repo):
219+
(repo / "decisions" / "ab.md").write_text(
220+
LEGACY_DECISION.replace("A Legacy Decision", "Alpha Bravo Decision"),
221+
encoding="utf-8",
222+
)
223+
# Both terms present -> match; one term absent -> no match (AND semantics).
224+
assert any(m.path.endswith("ab.md") for m in find_artifacts(str(repo), "alpha bravo").matches)
225+
assert not any(
226+
m.path.endswith("ab.md") for m in find_artifacts(str(repo), "alpha charlie").matches
227+
)
228+
229+
230+
def test_tier_ordering_id_title_path_heading_body(tmp_path):
231+
# Five artifacts, each making "needle" match at exactly one tier; the result
232+
# order must be id, title, path, heading, body (ADR-038 ladder). Sorted-path
233+
# tiebreak is irrelevant here — every artifact wins at a distinct tier.
234+
base = (
235+
"---\nschema_version: 1\nid: {id}\ntype: decision\n---\n"
236+
"# {title}\n\n## Status\n\nAccepted\n\n## Category\n\nArchitecture\n\n"
237+
"## {heading}\n\n{body}\n\n## Decision\n\nd\n\n## Consequences\n\nq\n"
238+
)
239+
# id tier: a legacy artifact whose filename stem (its identifier) carries the
240+
# token. Path also carries it, but the id tier (rank 0) is the win.
241+
(tmp_path / "needle-by-id.md").write_text(
242+
"# Aaa\n\n## Context\n\nx\n\n## Decision\n\nd\n\n## Consequences\n\nq\n",
243+
encoding="utf-8",
244+
)
245+
# title tier: token only in the title.
246+
(tmp_path / "btitle.md").write_text(
247+
base.format(id="RAC-TIT000000001", title="Needle Title", heading="Context", body="x"),
248+
encoding="utf-8",
249+
)
250+
# path tier: token only in a directory component (not the stem, not id/title).
251+
(tmp_path / "needledir").mkdir()
252+
(tmp_path / "needledir" / "plain.md").write_text(
253+
base.format(id="RAC-PTH000000001", title="Ccc", heading="Context", body="x"),
254+
encoding="utf-8",
255+
)
256+
# heading tier: token only in a section heading.
257+
(tmp_path / "dhead.md").write_text(
258+
base.format(id="RAC-HED000000001", title="Ddd", heading="Needle Heading", body="x"),
259+
encoding="utf-8",
260+
)
261+
# body tier: token only in body text.
262+
(tmp_path / "ebody.md").write_text(
263+
base.format(id="RAC-BOD000000001", title="Eee", heading="Context", body="needle in body"),
264+
encoding="utf-8",
265+
)
266+
matches = find_artifacts(str(tmp_path), "needle").matches
267+
order = [m.path.split("/")[-1] for m in matches]
268+
assert order == ["needle-by-id.md", "btitle.md", "plain.md", "dhead.md", "ebody.md"]
269+
# Only the heading and body matches carry snippets.
270+
by_name = {m.path.split("/")[-1]: m for m in matches}
271+
assert by_name["needle-by-id.md"].snippet is None
272+
assert by_name["btitle.md"].snippet is None
273+
assert by_name["plain.md"].snippet is None
274+
assert by_name["dhead.md"].section == "Needle Heading"
275+
assert by_name["ebody.md"].snippet == "needle in body"
276+
277+
278+
def test_body_only_match_carries_snippet(tmp_path):
279+
# A decision whose body, not its title/path/id, holds the query term is
280+
# found, with the section heading and matching line as its snippet (ADR-038).
281+
(tmp_path / "dec.md").write_text(
282+
"---\nschema_version: 1\nid: RAC-BODYONLY0001\ntype: decision\n---\n"
283+
"# Unrelated Title\n\n## Status\n\nAccepted\n\n## Category\n\nArchitecture\n\n"
284+
"## Context\n\nThe payments gateway must stay idempotent.\n\n"
285+
"## Decision\n\nd\n\n## Consequences\n\nq\n",
286+
encoding="utf-8",
287+
)
288+
matches = find_artifacts(str(tmp_path), "idempotent").matches
289+
assert len(matches) == 1
290+
m = matches[0]
291+
assert m.section == "Context"
292+
assert m.snippet == "The payments gateway must stay idempotent."
293+
# The snippet fields ride inside the match dict (additive, ADR-007).
294+
assert m.to_dict()["section"] == "Context"
295+
assert m.to_dict()["snippet"] == "The payments gateway must stay idempotent."
296+
297+
298+
def test_metadata_match_has_no_snippet_fields(repo):
299+
# An id/title/path match's dict is byte-identical to the pre-v0.10.3 shape.
300+
match = find_artifacts(str(repo), CANONICAL_ID).matches[0]
301+
assert match.section is None and match.snippet is None
302+
assert set(match.to_dict()) == {"id", "type", "title", "path"}
303+
304+
305+
def test_body_snippet_is_first_matching_line_in_document_order(tmp_path):
306+
(tmp_path / "dec.md").write_text(
307+
"---\nschema_version: 1\nid: RAC-FIRSTLINE01\ntype: decision\n---\n"
308+
"# T\n\n## Status\n\nAccepted\n\n## Category\n\nArchitecture\n\n"
309+
"## Context\n\nFirst widget line.\nSecond widget line.\n\n"
310+
"## Decision\n\nThird widget line.\n\n## Consequences\n\nq\n",
311+
encoding="utf-8",
312+
)
313+
match = find_artifacts(str(tmp_path), "widget").matches[0]
314+
assert match.section == "Context"
315+
assert match.snippet == "First widget line."
316+
317+
171318
# --- index seams (v0.8.1): same semantics without a directory walk --------------
172319

173320

0 commit comments

Comments
 (0)