Files
paseo/packages/protocol/src/search/text-match.test.ts
T
Mohamed Boudra e55dd5a212 fix(search): match complete paths in file and project search
Treat slash-separated fragments as searchable text and use the same full-path fuzzy matcher for daemon results and remembered project paths.
2026-08-19 12:18:23 +02:00

262 lines
9.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
compareMatchScores,
fuzzyPolicyForToken,
matchRanges,
scoreMatch,
scorePathMatch,
scoreTextFields,
} from "./text-match.js";
function markUp(query: string, text: string, options?: Parameters<typeof scoreMatch>[2]): string {
const score = scoreMatch(query, text, options);
if (!score) return text;
let marked = "";
let cursor = 0;
for (const range of matchRanges(query, text, score)) {
marked += text.slice(cursor, range.start);
marked += `[${text.slice(range.start, range.start + range.length)}]`;
cursor = range.start + range.length;
}
return marked + text.slice(cursor);
}
describe("scoreMatch", () => {
it("returns tier 0 for empty query", () => {
expect(scoreMatch("", "anything")).toEqual({ tier: 0, offset: 0 });
});
it("returns tier 0 for exact match ignoring case", () => {
expect(scoreMatch("pi", "pi")).toEqual({ tier: 0, offset: 0 });
expect(scoreMatch("PI", "pi")).toEqual({ tier: 0, offset: 0 });
expect(scoreMatch("Pi", "pI")).toEqual({ tier: 0, offset: 0 });
});
it("returns tier 1 for whole-word match at start", () => {
expect(scoreMatch("feat", "feat/pi-direct-sdk")).toEqual({ tier: 1, offset: 0 });
});
it("returns tier 1 for whole-word match in middle (word boundaries on both sides)", () => {
expect(scoreMatch("pi", "feat/pi-direct-sdk")).toEqual({ tier: 1, offset: 5 });
expect(scoreMatch("pi", "a b pi c d")).toEqual({ tier: 1, offset: 4 });
});
it("returns tier 2 for string prefix that does not complete a word", () => {
expect(scoreMatch("par", "party")).toEqual({ tier: 2, offset: 0 });
});
it("returns tier 3 for word-boundary start that does not complete a word", () => {
expect(scoreMatch("par", "a/party")).toEqual({ tier: 3, offset: 2 });
});
it("returns tier 4 for substring inside a word", () => {
expect(scoreMatch("art", "party")).toEqual({ tier: 4, offset: 1 });
});
it("returns null when query is not found", () => {
expect(scoreMatch("xyz", "feat/pi-direct-sdk")).toBeNull();
});
it("picks the best tier across multiple occurrences", () => {
expect(scoreMatch("ab", "xabxab-yy")).toEqual({ tier: 4, offset: 1 });
expect(scoreMatch("ab", "xxab x-ab-y")).toEqual({ tier: 1, offset: 7 });
});
it("prefers earlier offset when tiers are equal", () => {
expect(scoreMatch("pi", "pi-a pi-b")).toEqual({ tier: 1, offset: 0 });
});
it("treats common separators as word boundaries", () => {
expect(scoreMatch("pi", "x/pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("pi", "x-pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("pi", "x_pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("pi", "x pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("pi", "x.pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("pi", "x:pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("202", "#202 feat")).toEqual({ tier: 1, offset: 1 });
});
it("scores PR-title-shaped text realistically", () => {
const pr202 = "#202 feat(server): replace Pi ACP with direct SDK provider";
expect(scoreMatch("pi", pr202)?.tier).toBe(1);
expect(scoreMatch("202", pr202)).toEqual({ tier: 1, offset: 1 });
expect(scoreMatch("replace", pr202)?.tier).toBe(1);
});
});
describe("scoreMatch typo tolerance", () => {
const typoTolerant = { fuzzy: { maxEdits: 2, transpositionsOnly: false } };
it("is off by default so exact pickers keep exact behavior", () => {
expect(scoreMatch("bulling", "add stripe billing")).toBeNull();
});
it("matches a transposition", () => {
expect(
scoreMatch("brnach", "brnach is not branch", {
fuzzy: { maxEdits: 1, transpositionsOnly: false },
})?.tier,
).toBe(1);
expect(
scoreMatch("brnach", "switch the branch", {
fuzzy: { maxEdits: 1, transpositionsOnly: false },
})?.tier,
).toBe(6);
});
it("matches a typo the subsequence tier cannot reach", () => {
// Dropped characters already land on the subsequence tier; the fuzzy tier
// exists for the ones that reorder or replace.
expect(scoreMatch("confg", "revamp configuration editor")?.tier).toBe(5);
expect(scoreMatch("confug", "revamp configuration editor")).toBeNull();
expect(scoreMatch("confug", "revamp configuration editor", typoTolerant)?.tier).toBe(6);
});
it("reports the offset and edit distance of the word it matched", () => {
expect(scoreMatch("bulling", "add stripe billing", typoTolerant)).toEqual({
tier: 6,
offset: 11,
spread: 1,
});
});
it("ranks every exact tier ahead of a fuzzy hit", () => {
const fuzzy = scoreMatch("bilingg", "billing", typoTolerant);
const worstExact = scoreMatch("iln", "billing");
if (!fuzzy || !worstExact) throw new Error("expected both scores");
expect(compareMatchScores(worstExact, fuzzy)).toBeLessThan(0);
});
it("refuses to fuzzy-match beyond the budget", () => {
// "stpie" is two edits from "stripe": insert the r, then swap p and i.
expect(
scoreMatch("stpie", "add stripe billing", {
fuzzy: { maxEdits: 2, transpositionsOnly: false },
})?.tier,
).toBe(6);
expect(
scoreMatch("stpie", "add stripe billing", {
fuzzy: { maxEdits: 1, transpositionsOnly: false },
}),
).toBeNull();
expect(
scoreMatch("zzzzzz", "add stripe billing", {
fuzzy: { maxEdits: 2, transpositionsOnly: false },
}),
).toBeNull();
});
it("forgives only transpositions in short tokens, where a substitution matches too much", () => {
expect(fuzzyPolicyForToken("pi")).toBeNull();
expect(fuzzyPolicyForToken("main")).toEqual({ maxEdits: 1, transpositionsOnly: true });
expect(fuzzyPolicyForToken("stipe")).toEqual({ maxEdits: 1, transpositionsOnly: false });
expect(fuzzyPolicyForToken("configration")).toEqual({ maxEdits: 2, transpositionsOnly: false });
// The branch typo that has to work.
expect(scoreMatch("mian", "switch to main", { fuzzy: fuzzyPolicyForToken("mian") })?.tier).toBe(
6,
);
// Words one substitution from "main" stay out.
for (const nearMiss of ["mial", "rain", "maid"]) {
expect(
scoreMatch(nearMiss, "switch to main", { fuzzy: fuzzyPolicyForToken(nearMiss) }),
).toBeNull();
}
});
it("finds a transposed short token at the head of a longer word", () => {
expect(
scoreMatch("mian", "feat/mainline-fix", { fuzzy: fuzzyPolicyForToken("mian") })?.tier,
).toBe(6);
});
});
describe("scorePathMatch", () => {
it("matches a literal fragment anywhere in the complete path", () => {
expect(
scorePathMatch("skills/", "something/something-else/skills/paseo-advisor/SKILL.md"),
).not.toBeNull();
});
it("fuzzy-matches spaced text against a compact path", () => {
expect(scorePathMatch("blank page editor", "blankpage/editor")).toEqual({
tier: 0,
offset: 0,
});
});
it("keeps the original path offset for compact matches", () => {
expect(scorePathMatch("blank page editor", "projects/blankpage/editor")).toEqual({
tier: 4,
offset: 9,
});
});
});
describe("scoreTextFields", () => {
it("requires every token to match some field", () => {
expect(scoreTextFields("stripe billing", ["add stripe billing system"])).not.toBeNull();
expect(scoreTextFields("stripe rosetta", ["add stripe billing system"])).toBeNull();
});
it("lets tokens match across different fields", () => {
expect(scoreTextFields("stripe main", ["add stripe billing", "main"])).not.toBeNull();
});
it("carries typo tolerance down to each token", () => {
expect(
scoreTextFields("stipe bulling", ["add stripe billing"], { typoTolerant: true }),
).not.toBeNull();
expect(scoreTextFields("stipe bulling", ["add stripe billing"])).toBeNull();
});
});
describe("compareMatchScores", () => {
it("sorts by tier ascending", () => {
expect(compareMatchScores({ tier: 1, offset: 10 }, { tier: 2, offset: 0 })).toBeLessThan(0);
expect(compareMatchScores({ tier: 3, offset: 0 }, { tier: 1, offset: 99 })).toBeGreaterThan(0);
});
it("tie-breaks by offset ascending at the same tier", () => {
expect(compareMatchScores({ tier: 1, offset: 0 }, { tier: 1, offset: 5 })).toBeLessThan(0);
expect(compareMatchScores({ tier: 1, offset: 5 }, { tier: 1, offset: 5 })).toBe(0);
});
});
describe("matchRanges", () => {
it("marks a whole-word hit", () => {
expect(markUp("stripe", "add stripe billing")).toBe("add [stripe] billing");
});
it("marks a hit buried inside a word", () => {
expect(markUp("bill", "unbilled usage")).toBe("un[bill]ed usage");
});
it("marks the exact text when the whole field is the query", () => {
expect(markUp("main", "main")).toBe("[main]");
});
it("marks the scattered characters a subsequence walked", () => {
expect(markUp("confg", "configuration")).toBe("[conf]i[g]uration");
});
it("marks the whole word a typo resolved to", () => {
// The characters the user got wrong are not in the text to point at, so
// the word is the smallest honest thing to mark.
expect(markUp("bulling", "add stripe billing", { fuzzy: fuzzyPolicyForToken("bulling") })).toBe(
"add stripe [billing]",
);
});
it("marks a transposed short token", () => {
expect(markUp("mian", "switch to main", { fuzzy: fuzzyPolicyForToken("mian") })).toBe(
"switch to [main]",
);
});
it("returns nothing for an empty query", () => {
expect(matchRanges("", "anything", { tier: 0, offset: 0 })).toEqual([]);
});
});