Skip to content

Commit 1022f12

Browse files
committed
fix: improve search quality and break circular type dependency
1 parent 904a75b commit 1022f12

File tree

10 files changed

+195
-41
lines changed

10 files changed

+195
-41
lines changed

src/server.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import { handleFetchDocs } from "./tools/fetch-docs.js";
1010
import { formatUtilitiesList, handleListUtilities } from "./tools/list-utilities.js";
1111
import { formatSearchResults, handleSearchDocs } from "./tools/search-docs.js";
1212
import type { Config } from "./utils/config.js";
13+
import type { EmbedderStatus, IndexingStatus } from "./utils/types.js";
14+
15+
export type { EmbedderStatus, IndexingStatus } from "./utils/types.js";
1316

1417
const { version: SERVER_VERSION } = JSON.parse(
1518
readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
@@ -25,16 +28,6 @@ export const TOOL_NAMES = {
2528
CHECK_STATUS: "check_status",
2629
} as const;
2730

28-
/**
29-
* Embedder loading status for observability.
30-
*/
31-
export type EmbedderStatus = "pending" | "downloading" | "ready" | "failed";
32-
33-
/**
34-
* Documentation indexing status for observability.
35-
*/
36-
export type IndexingStatus = "idle" | "indexing" | "complete" | "failed";
37-
3831
/**
3932
* Server dependencies injected at startup.
4033
* Embedder may be null if the model is still downloading.

src/storage/search.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,16 @@ export async function hybridSearch(
116116
// keywordSearch is synchronous but wrapped in Promise.all alongside the async
117117
// semanticSearch for uniform destructuring. No performance impact — sync
118118
// functions in Promise.all resolve in the same microtask.
119+
// Semantic search benefits from expansion (extra terms help the embedding model).
120+
// FTS5 keyword search uses the original query — AND-joining expansion terms
121+
// (e.g., "font" AND "size") would over-constrain and hurt recall.
119122
const [semantic, keyword] = await Promise.all([
120123
semanticSearch(embedder, chunks, expandedQuery, fetchLimit),
121-
keywordSearch(db, expandedQuery, version, fetchLimit),
124+
keywordSearch(db, query, version, fetchLimit),
122125
]);
123126

124-
// Boost keyword weight when query contains Tailwind class names (e.g., text-lg, pt-6)
125-
const hasClassNames = /\b[a-z]+(?:-[a-z0-9]+)+\b/.test(query);
127+
// Boost keyword weight when query contains Tailwind class names (e.g., text-lg, pt-6, -mx-4)
128+
const hasClassNames = /\b-?[a-z]+(?:-[a-z0-9]+)+\b/.test(query);
126129
const weights: FusionWeights = hasClassNames
127130
? { semantic: 1.0, keyword: 1.5 }
128131
: { semantic: 1.0, keyword: 1.0 };

src/tools/check-status.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import type { EmbedderStatus, IndexingStatus } from "../server.js";
21
import type { Database, IndexStatus } from "../storage/database.js";
32
import type { TailwindVersion } from "../utils/config.js";
3+
import type { EmbedderStatus, IndexingStatus } from "../utils/types.js";
44

55
/**
66
* Input parameters for the check_status MCP tool.

src/tools/search-docs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { Embedder } from "../pipeline/embedder.js";
2-
import type { IndexingStatus } from "../server.js";
32
import type { Database } from "../storage/database.js";
43
import { type SearchResult, hybridSearch } from "../storage/search.js";
54
import type { TailwindVersion } from "../utils/config.js";
5+
import type { IndexingStatus } from "../utils/types.js";
66

77
/**
88
* Input parameters for the search_docs MCP tool.

src/utils/query-expansion.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
* class prefixes (e.g., "mx-auto") and documentation titles (e.g., "Margin").
77
*/
88

9-
/** Regex to detect Tailwind class names: hyphenated lowercase tokens like text-lg, grid-cols-3 */
10-
const TAILWIND_CLASS_RE = /\b[a-z]+(?:-[a-z0-9]+)+\b/g;
9+
/** Regex to detect Tailwind class names: hyphenated lowercase tokens like text-lg, grid-cols-3, -mx-4 */
10+
const TAILWIND_CLASS_RE = /\b-?[a-z]+(?:-[a-z0-9]+)+\b/g;
1111

1212
/** Font size suffixes used by Tailwind's text-{size} utilities */
1313
const TEXT_SIZE_RE = /^text-(xs|sm|base|lg|xl|\d+xl)$/;
@@ -27,9 +27,15 @@ const PREFIX_MAP = new Map<string, string>([
2727
["auto-cols", "grid auto columns"],
2828
["auto-rows", "grid auto rows"],
2929
["col-span", "grid column"],
30+
["col-start", "grid column"],
31+
["col-end", "grid column"],
3032
["row-span", "grid row"],
33+
["row-start", "grid row"],
34+
["row-end", "grid row"],
3135
["space-x", "space between horizontal"],
3236
["space-y", "space between vertical"],
37+
["gap-x", "gap horizontal"],
38+
["gap-y", "gap vertical"],
3339
["min-w", "min-width"],
3440
["max-w", "max-width"],
3541
["min-h", "min-height"],
@@ -39,6 +45,8 @@ const PREFIX_MAP = new Map<string, string>([
3945
["snap-start", "scroll snap align"],
4046
["snap-end", "scroll snap align"],
4147
["snap-center", "scroll snap align"],
48+
["line-clamp", "line clamp"],
49+
["will-change", "will-change"],
4250

4351
// ── Single-segment prefixes ────────────────────────────────────
4452
// Spacing
@@ -63,8 +71,11 @@ const PREFIX_MAP = new Map<string, string>([
6371
["font", "font"],
6472
["tracking", "letter spacing"],
6573
["leading", "line height"],
74+
["decoration", "text decoration"],
75+
["whitespace", "whitespace"],
6676

6777
// Flex & Grid
78+
["flex", "flex"],
6879
["gap", "gap"],
6980
["justify", "justify content"],
7081
["items", "align items"],
@@ -107,6 +118,13 @@ const PREFIX_MAP = new Map<string, string>([
107118
["clear", "clear"],
108119
["object", "object fit"],
109120
["overflow", "overflow"],
121+
["break", "word break"],
122+
123+
// Position
124+
["top", "position"],
125+
["right", "position"],
126+
["bottom", "position"],
127+
["left", "position"],
110128

111129
// Transforms
112130
["scale", "scale transform"],
@@ -135,9 +153,15 @@ const PREFIX_MAP = new Map<string, string>([
135153
["accent", "accent color"],
136154
["caret", "caret color"],
137155

138-
// Position
156+
// Position (z-index, inset)
139157
["z", "z-index"],
140158
["inset", "position"],
159+
160+
// Animation
161+
["animate", "animation"],
162+
163+
// Accessibility
164+
["sr", "screen reader"],
141165
]);
142166

143167
/** text- variants that map to text-align */
@@ -224,7 +248,9 @@ export function expandQuery(query: string): string {
224248
const expansionTerms: string[] = [];
225249

226250
for (const className of classNames) {
227-
const terms = resolveExpansion(className);
251+
// Strip leading `-` from negative utilities (e.g., -mx-4 → mx-4)
252+
const normalized = className.startsWith("-") ? className.slice(1) : className;
253+
const terms = resolveExpansion(normalized);
228254
if (!terms) continue;
229255

230256
for (const term of terms.split(/\s+/)) {

src/utils/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Shared types used across server and tool modules.
3+
*
4+
* Extracted here to avoid circular imports between server.ts and tool modules.
5+
*/
6+
7+
/**
8+
* Embedder loading status for observability.
9+
*/
10+
export type EmbedderStatus = "pending" | "downloading" | "ready" | "failed";
11+
12+
/**
13+
* Documentation indexing status for observability.
14+
*/
15+
export type IndexingStatus = "idle" | "indexing" | "complete" | "failed";

test/tools/tools.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,12 @@ describe("MCP Tool Handlers", () => {
154154
expect(formatted).toContain("fetch_docs");
155155
});
156156

157+
it("returns 'No results found' for empty results when indexed", () => {
158+
const result = { results: [], notIndexed: false };
159+
const formatted = formatSearchResults(result);
160+
expect(formatted).toBe("No results found for this query.");
161+
});
162+
157163
it("returns search results for valid query", async () => {
158164
await indexTestDoc(db, config, PADDING_MDX, "padding");
159165
const embedder = createMockEmbedder(384);

test/unit/auto-index.test.ts

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
22
import { join } from "node:path";
3-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44
import { maybeAutoIndex } from "../../src/auto-index.js";
55
import type { Database } from "../../src/storage/database.js";
66
import { createDatabase } from "../../src/storage/database.js";
@@ -89,27 +89,24 @@ describe("maybeAutoIndex", () => {
8989

9090
it("calls onError when indexing fails", async () => {
9191
// No fixture files and mock fetch to reject
92-
const originalFetch = globalThis.fetch;
93-
globalThis.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
94-
95-
try {
96-
const embedder = createMockEmbedder(384);
97-
const onStart = vi.fn();
98-
const onComplete = vi.fn();
99-
const onError = vi.fn();
100-
101-
await maybeAutoIndex(config, db, embedder, {
102-
onStart,
103-
onComplete,
104-
onError,
105-
});
106-
107-
expect(onStart).toHaveBeenCalledOnce();
108-
expect(onComplete).not.toHaveBeenCalled();
109-
expect(onError).toHaveBeenCalledOnce();
110-
} finally {
111-
globalThis.fetch = originalFetch;
112-
}
92+
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Network error")));
93+
94+
const embedder = createMockEmbedder(384);
95+
const onStart = vi.fn();
96+
const onComplete = vi.fn();
97+
const onError = vi.fn();
98+
99+
await maybeAutoIndex(config, db, embedder, {
100+
onStart,
101+
onComplete,
102+
onError,
103+
});
104+
105+
expect(onStart).toHaveBeenCalledOnce();
106+
expect(onComplete).not.toHaveBeenCalled();
107+
expect(onError).toHaveBeenCalledOnce();
108+
109+
vi.unstubAllGlobals();
113110
});
114111

115112
it("uses the configured default version", async () => {

test/unit/query-expansion.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,5 +267,74 @@ describe("Query Expansion", () => {
267267
expect(result).toContain("translate");
268268
expect(result).toContain("transform");
269269
});
270+
271+
// ── Negative class names ──────────────────────────────────────
272+
it("expands -mx-4 to margin (negative value)", () => {
273+
const result = expandQuery("-mx-4 centering");
274+
expect(result).toContain("margin");
275+
});
276+
277+
it("expands -translate-x-4 to translate transform (negative value)", () => {
278+
const result = expandQuery("-translate-x-4 animation");
279+
expect(result).toContain("translate");
280+
expect(result).toContain("transform");
281+
});
282+
283+
it("expands -mt-2 to margin (negative value)", () => {
284+
const result = expandQuery("-mt-2 spacing");
285+
expect(result).toContain("margin");
286+
});
287+
288+
// ── New prefix expansions (round 2) ───────────────────────────
289+
it("expands flex-col to flex", () => {
290+
const result = expandQuery("flex-col layout");
291+
expect(result).toContain("flex");
292+
});
293+
294+
it("expands animate-spin to animation", () => {
295+
const result = expandQuery("animate-spin loading");
296+
expect(result).toContain("animation");
297+
});
298+
299+
it("expands decoration-wavy to text decoration", () => {
300+
const result = expandQuery("decoration-wavy link");
301+
expect(result).toContain("text");
302+
expect(result).toContain("decoration");
303+
});
304+
305+
it("expands top-4 to position", () => {
306+
const result = expandQuery("top-4 absolute");
307+
expect(result).toContain("position");
308+
});
309+
310+
it("expands line-clamp-3 to line clamp", () => {
311+
const result = expandQuery("line-clamp-3 truncation");
312+
expect(result).toContain("line");
313+
expect(result).toContain("clamp");
314+
});
315+
316+
it("expands break-words to word break", () => {
317+
const result = expandQuery("break-words text");
318+
expect(result).toContain("word");
319+
expect(result).toContain("break");
320+
});
321+
322+
it("expands sr-only to screen reader", () => {
323+
const result = expandQuery("sr-only accessibility");
324+
expect(result).toContain("screen");
325+
expect(result).toContain("reader");
326+
});
327+
328+
it("expands gap-x-4 to gap horizontal", () => {
329+
const result = expandQuery("gap-x-4 grid");
330+
expect(result).toContain("gap");
331+
expect(result).toContain("horizontal");
332+
});
333+
334+
it("expands col-start-1 to grid column", () => {
335+
const result = expandQuery("col-start-1 layout");
336+
expect(result).toContain("grid");
337+
expect(result).toContain("column");
338+
});
270339
});
271340
});

test/unit/search.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,4 +480,49 @@ describe("Search", () => {
480480
expect(results).toHaveLength(5);
481481
});
482482
});
483+
484+
describe("invalidateChunkCache (version-specific)", () => {
485+
it("invalidates only the specified version", async () => {
486+
const db = await createDatabase(testConfig());
487+
const embedder = createMockEmbedder(384);
488+
489+
// Index a chunk for v3
490+
const docId = db.upsertDoc(makeDoc({ version: "v3" }));
491+
const emb = await embedder.embed("padding");
492+
db.upsertChunk(makeChunk({ id: "h1", content: "Use p-4 for padding." }), docId, emb);
493+
494+
// Warm the cache by searching v3
495+
invalidateChunkCache();
496+
await hybridSearch(db, embedder, {
497+
query: "padding",
498+
version: "v3",
499+
limit: 5,
500+
});
501+
502+
// Invalidate only v4 — v3 cache should remain
503+
invalidateChunkCache("v4");
504+
505+
// v3 search should still work (cache intact or rebuilt)
506+
const results = await hybridSearch(db, embedder, {
507+
query: "padding",
508+
version: "v3",
509+
limit: 5,
510+
});
511+
expect(results.length).toBeGreaterThan(0);
512+
513+
db.close();
514+
});
515+
});
516+
517+
describe("searchFts error handling", () => {
518+
it("returns empty array when FTS query fails", async () => {
519+
const db = await createDatabase(testConfig());
520+
521+
// Close the DB to force FTS query to fail
522+
db.close();
523+
524+
const results = db.searchFts("padding", "v3", 10);
525+
expect(results).toHaveLength(0);
526+
});
527+
});
483528
});

0 commit comments

Comments
 (0)