@@ -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+ "---\n schema_version: 1\n id: {id}\n type: decision\n ---\n "
236+ "# {title}\n \n ## Status\n \n Accepted\n \n ## Category\n \n Architecture\n \n "
237+ "## {heading}\n \n {body}\n \n ## Decision\n \n d\n \n ## Consequences\n \n q\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 \n x\n \n ## Decision\n \n d\n \n ## Consequences\n \n q\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+ "---\n schema_version: 1\n id: RAC-BODYONLY0001\n type: decision\n ---\n "
283+ "# Unrelated Title\n \n ## Status\n \n Accepted\n \n ## Category\n \n Architecture\n \n "
284+ "## Context\n \n The payments gateway must stay idempotent.\n \n "
285+ "## Decision\n \n d\n \n ## Consequences\n \n q\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+ "---\n schema_version: 1\n id: RAC-FIRSTLINE01\n type: decision\n ---\n "
308+ "# T\n \n ## Status\n \n Accepted\n \n ## Category\n \n Architecture\n \n "
309+ "## Context\n \n First widget line.\n Second widget line.\n \n "
310+ "## Decision\n \n Third widget line.\n \n ## Consequences\n \n q\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