Skip to content

Commit 246025f

Browse files
authored
Merge pull request #168 from DevMello/fix/whole-word-per-file-cap
fix(search): find whole-word matches past the per-file substring cap
2 parents 4dc02a0 + a54c97b commit 246025f

5 files changed

Lines changed: 143 additions & 49 deletions

File tree

code-review/data-lifecycle.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ search; only explicit Start clears it.
8989
delete or move, immediate status/search cannot observe rows reported removed.
9090
- Retrieval filters unavailable sources and always remaps evidence to a live
9191
visible source before it crosses HTTP or MCP.
92+
- Exact retrieval applies whole-token filtering before its per-file result cap;
93+
raw substring density cannot hide later eligible evidence.
9294
- Local Milvus collect-all reads use a complete scalar snapshot. Segment order
9395
cannot be treated as a globally ordered primary-key cursor.
9496
- Closing or failing to open the store releases the client, shared pymilvus

design-docs/design/search.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ user-managed results.
1818
## Current Experience
1919

2020
- Exact text search works without AI Index, including raw JSON and current
21-
prepared text.
21+
prepared text. Whole-token search applies its result cap after token
22+
filtering, so substring-heavy files do not hide later eligible evidence.
2223
- AI Index provides meaning-based retrieval when an embedding source is
2324
configured. Product copy says **AI Index**; engineering terms such as
2425
semantic indexing and embeddings appear only where technically necessary.

design-docs/user-journeys.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,9 @@ the visible source.
7878
1. Open library search and enter a query.
7979
2. Use exact text without AI Index, or meaning-based search when configured.
8080
3. Optionally narrow to one member folder.
81-
4. Review grouped evidence and readiness guidance.
81+
4. Review grouped evidence and readiness guidance. Whole-token search does not
82+
hide a later eligible result merely because a file contains earlier
83+
substring-only hits.
8284
5. Open the result without exposing a hidden derived artifact or unexpectedly
8385
replacing the window's active folder.
8486

server/keyword-search.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,66 @@ test('keyword search includes malformed case-variant JSON and applies data befor
3636
}
3737
});
3838

39+
test('whole-word keyword search finds a match past the per-file substring cap', async () => {
40+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stashbase-whole-word-cap-'));
41+
try {
42+
const lines = Array.from({ length: 60 }, (_, index) => `row ${index} mentions agents here`);
43+
lines.push('the final line names one agent alone');
44+
fs.writeFileSync(path.join(root, 'note.md'), `${lines.join('\n')}\n`);
45+
46+
const result = await runKeywordSearch('agent', root, { caseStrict: false, wholeWord: true });
47+
48+
assert.deepEqual(result.files.map((file) => file.path), ['note.md']);
49+
assert.deepEqual(result.files[0].matches.map((match) => match.line), [61]);
50+
} finally {
51+
fs.rmSync(root, { recursive: true, force: true });
52+
}
53+
});
54+
55+
test('whole-word keyword search streams substring-heavy files before filtering', async () => {
56+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stashbase-whole-word-stream-'));
57+
try {
58+
fs.writeFileSync(path.join(root, 'note.md'), `${'agents\n'.repeat(250_000)}agent\n`);
59+
60+
const result = await runKeywordSearch('agent', root, { caseStrict: false, wholeWord: true });
61+
62+
assert.deepEqual(result.files.map((file) => file.path), ['note.md']);
63+
assert.deepEqual(result.files[0].matches.map((match) => match.line), [250_001]);
64+
} finally {
65+
fs.rmSync(root, { recursive: true, force: true });
66+
}
67+
});
68+
69+
test('whole-word keyword search caps matches per file and reports the truncation', async () => {
70+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stashbase-whole-word-truncated-'));
71+
try {
72+
const lines = Array.from({ length: 60 }, (_, index) => `row ${index} names one agent alone`);
73+
fs.writeFileSync(path.join(root, 'note.md'), `${lines.join('\n')}\n`);
74+
75+
const result = await runKeywordSearch('agent', root, { caseStrict: false, wholeWord: true });
76+
77+
assert.equal(result.files[0].matches.length, 50);
78+
assert.equal(result.truncated, true);
79+
} finally {
80+
fs.rmSync(root, { recursive: true, force: true });
81+
}
82+
});
83+
84+
test('whole-word keyword search caps ranges within one matching line', async () => {
85+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stashbase-whole-word-range-cap-'));
86+
try {
87+
fs.writeFileSync(path.join(root, 'note.md'), `${Array.from({ length: 60 }, () => 'agent').join(' ')}\n`);
88+
89+
const result = await runKeywordSearch('agent', root, { caseStrict: false, wholeWord: true });
90+
91+
assert.equal(result.files[0].totalMatches, 50);
92+
assert.equal(result.files[0].matches.length, 1);
93+
assert.equal(result.truncated, true);
94+
} finally {
95+
fs.rmSync(root, { recursive: true, force: true });
96+
}
97+
});
98+
3999
test('ripgrep byte offsets map to UTF-16 ranges for multibyte text', () => {
40100
const line = '前缀 alpha 结果';
41101
const start = Buffer.byteLength('前缀 ', 'utf8');

server/keyword-search.ts

Lines changed: 76 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { execFile } from 'node:child_process';
1+
import { spawn } from 'node:child_process';
22
import fs from 'node:fs';
33
import path from 'node:path';
44
import { rgPath } from '@vscode/ripgrep';
@@ -69,9 +69,13 @@ function runRipgrep(query: string, cwd: string, opts: KeywordSearchOpts): Promis
6969
'--json',
7070
opts.caseStrict ? '--case-sensitive' : '--smart-case',
7171
'--fixed-strings',
72-
'--max-count', String(RG_PER_FILE_CAP),
7372
'--max-filesize', '5M',
7473
];
74+
// `--max-count` budgets ripgrep's own hits, which are still unfiltered.
75+
// Whole-token filtering runs app-side, so a file whose first
76+
// RG_PER_FILE_CAP hits are all substring-only would report nothing while
77+
// real whole-token matches sit further down. Cap after filtering instead.
78+
if (!opts.wholeWord) args.push('--max-count', String(RG_PER_FILE_CAP));
7579
const selected = searchExtensionsForTypes(opts.types ?? []);
7680
const directExtensions = selected == null
7781
? DIRECT_TEXT_EXTENSIONS.map((extension) => `.${extension}`)
@@ -80,55 +84,80 @@ function runRipgrep(query: string, cwd: string, opts: KeywordSearchOpts): Promis
8084
);
8185
for (const extension of directExtensions) args.push('--iglob', `*${extension}`);
8286
args.push('-e', query, opts.pathPrefix ? `./${opts.pathPrefix}` : '.');
83-
execFile(RESOLVED_RG_PATH, args, {
87+
const child = spawn(RESOLVED_RG_PATH, args, {
8488
cwd,
85-
maxBuffer: 32 * 1024 * 1024,
86-
timeout: RG_TIMEOUT_MS,
87-
}, (err, stdout) => {
88-
if (err) {
89-
const code = (err as NodeJS.ErrnoException & { code?: number | string }).code;
90-
const codeStr = String(code ?? '');
91-
if (codeStr !== '1') {
92-
if (codeStr === '2') {
93-
return reject(new Error(`invalid query: ${query}`));
94-
}
95-
return reject(new Error(`ripgrep failed (code ${codeStr}): ${err.message}`));
96-
}
97-
}
98-
const byFile = new Map<string, KeywordHitFile>();
99-
let total = 0;
100-
let truncated = false;
101-
for (const line of stdout.split('\n')) {
102-
if (!line) continue;
103-
let evt: any;
104-
try { evt = JSON.parse(line); } catch { continue; }
105-
if (evt.type !== 'match') continue;
106-
const dataPath = evt.data?.path?.text;
107-
const lineNum = evt.data?.line_number;
108-
const rawText = evt.data?.lines?.text;
109-
if (typeof dataPath !== 'string' || typeof lineNum !== 'number' || typeof rawText !== 'string') continue;
110-
const relPath = normalizeRipgrepPath(dataPath);
111-
const stripped = rawText.replace(/\r?\n$/, '');
112-
const subs = Array.isArray(evt.data?.submatches) ? evt.data.submatches : [];
113-
const matchRanges = normalizeRipgrepSubmatches(stripped, subs)
114-
.filter(([start, end]) => !opts.wholeWord || hasWholeTokenBoundaries(stripped, start, end));
115-
if (matchRanges.length === 0) continue;
116-
const snippet = snippetForLine(stripped, matchRanges);
117-
let bucket = byFile.get(relPath);
118-
if (!bucket) {
119-
bucket = { path: relPath, matches: [], totalMatches: 0 };
120-
byFile.set(relPath, bucket);
121-
}
122-
bucket.totalMatches += matchRanges.length;
123-
if (total < RG_TOTAL_CAP) {
124-
bucket.matches.push({ line: lineNum, text: snippet.text, ranges: snippet.ranges });
125-
total += matchRanges.length;
126-
} else {
127-
truncated = true;
128-
}
89+
});
90+
const byFile = new Map<string, KeywordHitFile>();
91+
let total = 0;
92+
let truncated = false;
93+
let stdoutRemainder = '';
94+
let settled = false;
95+
let timedOut = false;
96+
const timeout = setTimeout(() => {
97+
timedOut = true;
98+
child.kill();
99+
}, RG_TIMEOUT_MS);
100+
101+
const finish = (error?: Error) => {
102+
if (settled) return;
103+
settled = true;
104+
clearTimeout(timeout);
105+
if (error) {
106+
reject(error);
107+
return;
129108
}
130109
const files = Array.from(byFile.values()).sort((a, b) => a.path.localeCompare(b.path));
131110
resolve({ files, totalMatches: total, truncated });
111+
};
112+
const consumeLine = (line: string) => {
113+
if (!line) return;
114+
let evt: any;
115+
try { evt = JSON.parse(line); } catch { return; }
116+
if (evt.type !== 'match') return;
117+
const dataPath = evt.data?.path?.text;
118+
const lineNum = evt.data?.line_number;
119+
const rawText = evt.data?.lines?.text;
120+
if (typeof dataPath !== 'string' || typeof lineNum !== 'number' || typeof rawText !== 'string') return;
121+
const relPath = normalizeRipgrepPath(dataPath);
122+
const stripped = rawText.replace(/\r?\n$/, '');
123+
const subs = Array.isArray(evt.data?.submatches) ? evt.data.submatches : [];
124+
const filteredRanges = normalizeRipgrepSubmatches(stripped, subs)
125+
.filter(([start, end]) => !opts.wholeWord || hasWholeTokenBoundaries(stripped, start, end));
126+
if (filteredRanges.length === 0) return;
127+
let bucket = byFile.get(relPath);
128+
if (!bucket) {
129+
bucket = { path: relPath, matches: [], totalMatches: 0 };
130+
byFile.set(relPath, bucket);
131+
}
132+
const remainingInFile = opts.wholeWord ? RG_PER_FILE_CAP - bucket.totalMatches : Infinity;
133+
const matchRanges = filteredRanges.slice(0, Math.max(0, remainingInFile));
134+
if (matchRanges.length < filteredRanges.length) truncated = true;
135+
if (matchRanges.length === 0) return;
136+
const snippet = snippetForLine(stripped, matchRanges);
137+
bucket.totalMatches += matchRanges.length;
138+
if (total < RG_TOTAL_CAP) {
139+
bucket.matches.push({ line: lineNum, text: snippet.text, ranges: snippet.ranges });
140+
total += matchRanges.length;
141+
} else {
142+
truncated = true;
143+
}
144+
};
145+
146+
child.stdout.setEncoding('utf8');
147+
child.stdout.on('data', (chunk: string) => {
148+
stdoutRemainder += chunk;
149+
const lines = stdoutRemainder.split('\n');
150+
stdoutRemainder = lines.pop() ?? '';
151+
lines.forEach(consumeLine);
152+
});
153+
child.stderr.resume();
154+
child.on('error', (err) => finish(new Error(`ripgrep failed: ${err.message}`)));
155+
child.on('close', (code) => {
156+
if (stdoutRemainder) consumeLine(stdoutRemainder);
157+
if (timedOut) return finish(new Error(`ripgrep failed (timeout after ${RG_TIMEOUT_MS}ms)`));
158+
if (code === 0 || code === 1) return finish();
159+
if (code === 2) return finish(new Error(`invalid query: ${query}`));
160+
return finish(new Error(`ripgrep failed (code ${String(code ?? '')})`));
132161
});
133162
});
134163
}

0 commit comments

Comments
 (0)