/** * file_editor 操作矩阵 + dev-tools/code-search 纯解析器测试(v0.7.0 → v0.7.5 扩充) * * file_editor:replace/insert/delete/regex/find_replace 五操作、 * dry_run 预览、backup 落盘、ReDoS 启发式拦截、原子写失败回滚、 * multiline 100K 上限、越界行号钳制、未知操作拒绝。 * dev-tools.parseCounts/parseTestResults、code-search.parseRipgrepJsonOutput: * 已 @visibleForTesting 导出,直接锁定输出格式契约。 */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); import { FileEditorTool } from '../file-editor'; import { LintCodeTool, RunTestsTool } from '../dev-tools'; import { CodeSearchTool } from '../code-search'; import type { ToolExecutionContext } from '../../../types/metona-tool'; function ctxFor(ws: string): ToolExecutionContext { return { sessionId: 't', workspacePath: ws, iteration: 1, requestId: 'r' }; } let ws: string; beforeAll(() => { ws = mkdtempSync(join(tmpdir(), 'metona-edit-')); writeFileSync(join(ws, 'src.txt'), ['alpha', 'beta', 'gamma', 'delta'].join('\n')); }); afterAll(() => rmSync(ws, { recursive: true, force: true })); const editor = new FileEditorTool(); describe('file_editor — 五种 operation', () => { it('find_replace:replace_all=true 全量;缺省亦全量(split/join 契约)', async () => { writeFileSync(join(ws, 'fr.txt'), 'cat dog cat dog'); const all = (await editor.execute( { file_path: 'fr.txt', operation: 'find_replace', find: 'cat', replace: 'CAT', replace_all: true, }, ctxFor(ws), )) as { success: boolean }; expect(all.success).toBe(true); expect(readFileSync(join(ws, 'fr.txt'), 'utf-8')).toBe('CAT dog CAT dog'); // 实况契约:split/join 实现 → 缺省 replace_all 即为全量替换 writeFileSync(join(ws, 'fr.txt'), 'cat dog cat dog'); const first = (await editor.execute( { file_path: 'fr.txt', operation: 'find_replace', find: 'dog', replace: 'BIRD' }, ctxFor(ws), )) as { success: boolean }; expect(first.success).toBe(true); expect(readFileSync(join(ws, 'fr.txt'), 'utf-8')).toBe('cat BIRD cat BIRD'); void all; }); it('find_replace replace_all=false 仅替换第一个匹配', async () => { writeFileSync(join(ws, 'fr2.txt'), 'cat dog cat dog'); const r = (await editor.execute( { file_path: 'fr2.txt', operation: 'find_replace', find: 'cat', replace: 'CAT', replace_all: false, }, ctxFor(ws), )) as { success: boolean; replacements?: number }; expect(r.success).toBe(true); expect(r.replacements).toBe(1); expect(readFileSync(join(ws, 'fr2.txt'), 'utf-8')).toBe('CAT dog cat dog'); }); it('find_replace 无匹配 → 返回提示且文件不变', async () => { writeFileSync(join(ws, 'fr3.txt'), 'hello world'); const before = readFileSync(join(ws, 'fr3.txt'), 'utf-8'); const r = (await editor.execute( { file_path: 'fr3.txt', operation: 'find_replace', find: 'zzz', replace: 'YYY' }, ctxFor(ws), )) as { success: boolean; message?: string; replacements?: number; }; expect(r.success).toBe(true); expect(r.message).toContain('No matches found'); expect(r.replacements).toBe(0); expect(readFileSync(join(ws, 'fr3.txt'), 'utf-8')).toBe(before); }); it('find_replace 缺 find 参数 → 报错', async () => { const r = (await editor.execute( { file_path: 'fr3.txt', operation: 'find_replace', replace: 'x' }, ctxFor(ws), )) as { success: boolean }; expect(r.success).toBe(false); }); it('find_replace 空 find 字符串 → 报错', async () => { const r = (await editor.execute( { file_path: 'fr3.txt', operation: 'find_replace', find: '', replace: 'x' }, ctxFor(ws), )) as { success: boolean }; expect(r.success).toBe(false); }); it('find_replace 中正则元字符按字面量处理(不解析)', async () => { writeFileSync(join(ws, 'fr4.txt'), 'a.b a.b'); const r = (await editor.execute( { file_path: 'fr4.txt', operation: 'find_replace', find: 'a.b', replace: 'A.B' }, ctxFor(ws), )) as { success: boolean }; expect(r.success).toBe(true); expect(readFileSync(join(ws, 'fr4.txt'), 'utf-8')).toBe('A.B A.B'); }); it('replace 区间替换:start/end_line 契约', async () => { const r = (await editor.execute( { file_path: 'src.txt', operation: 'replace', start_line: 2, end_line: 3, content: 'BETA2\nGAMMA2', }, ctxFor(ws), )) as { success: boolean }; expect(r.success).toBe(true); expect(readFileSync(join(ws, 'src.txt'), 'utf-8').split('\n')).toEqual([ 'alpha', 'BETA2', 'GAMMA2', 'delta', ]); // 还原 writeFileSync(join(ws, 'src.txt'), ['alpha', 'beta', 'gamma', 'delta'].join('\n')); }); it('replace 行号越界(start > len)→ endLine { const r = (await editor.execute( { file_path: 'src.txt', operation: 'replace', start_line: 99, end_line: 99, content: 'appended', }, ctxFor(ws), )) as { success: boolean; error?: string }; expect(r.success).toBe(false); expect(String((r as { error?: string }).error)).toContain('end_line'); }); it('replace end_line < start_line → 报错', async () => { const r = (await editor.execute( { file_path: 'src.txt', operation: 'replace', start_line: 3, end_line: 1, content: 'x' }, ctxFor(ws), )) as { success: boolean; error?: string }; expect(r.success).toBe(false); expect(String((r as { error?: string }).error)).toContain('end_line'); }); it('replace 缺 start_line/end_line 默认替换第 1 行', async () => { writeFileSync(join(ws, 'repl.txt'), 'a\nb\nc'); const r = (await editor.execute( { file_path: 'repl.txt', operation: 'replace', content: 'A\nB' }, ctxFor(ws), )) as { success: boolean }; expect(r.success).toBe(true); expect(readFileSync(join(ws, 'repl.txt'), 'utf-8')).toBe('A\nB\nb\nc'); }); it('insert 支持追加到文件末尾(end_line=len+1 形态)与中间插入', async () => { const mid = (await editor.execute( { file_path: 'src.txt', operation: 'insert', start_line: 2, content: 'inserted' }, ctxFor(ws), )) as { success: boolean }; expect(mid.success).toBe(true); expect(readFileSync(join(ws, 'src.txt'), 'utf-8')).toBe( ['alpha', 'inserted', 'beta', 'gamma', 'delta'].join('\n'), ); const tail = (await editor.execute( { file_path: 'src.txt', operation: 'delete', start_line: 2, end_line: 2 }, ctxFor(ws), )) as { success: boolean }; expect(tail.success).toBe(true); expect(readFileSync(join(ws, 'src.txt'), 'utf-8')).toBe( ['alpha', 'beta', 'gamma', 'delta'].join('\n'), ); }); it('insert 到末尾(start_line 超出 len+1 被钳制为追加)', async () => { const r = (await editor.execute( { file_path: 'src.txt', operation: 'insert', start_line: 99, content: 'at-end' }, ctxFor(ws), )) as { success: boolean }; expect(r.success).toBe(true); expect(readFileSync(join(ws, 'src.txt'), 'utf-8')).toBe( ['alpha', 'beta', 'gamma', 'delta', 'at-end'].join('\n'), ); writeFileSync(join(ws, 'src.txt'), ['alpha', 'beta', 'gamma', 'delta'].join('\n')); }); it('insert 多行 content 产生多行插入', async () => { const r = (await editor.execute( { file_path: 'src.txt', operation: 'insert', start_line: 1, content: 'x\ny\nz' }, ctxFor(ws), )) as { success: boolean }; expect(r.success).toBe(true); expect(readFileSync(join(ws, 'src.txt'), 'utf-8').split('\n')).toEqual([ 'x', 'y', 'z', 'alpha', 'beta', 'gamma', 'delta', ]); writeFileSync(join(ws, 'src.txt'), ['alpha', 'beta', 'gamma', 'delta'].join('\n')); }); it('delete 区间删除行', async () => { const r = (await editor.execute( { file_path: 'src.txt', operation: 'delete', start_line: 1, end_line: 1 }, ctxFor(ws), )) as { success: boolean }; expect(r.success).toBe(true); expect(readFileSync(join(ws, 'src.txt'), 'utf-8').startsWith('beta')).toBe(true); writeFileSync(join(ws, 'src.txt'), ['alpha', 'beta', 'gamma', 'delta'].join('\n')); }); it('delete 单行(缺省 end_line=start_line)', async () => { writeFileSync(join(ws, 'del.txt'), 'a\nb\nc'); const r = (await editor.execute( { file_path: 'del.txt', operation: 'delete', start_line: 2 }, ctxFor(ws), )) as { success: boolean }; expect(r.success).toBe(true); expect(readFileSync(join(ws, 'del.txt'), 'utf-8')).toBe('a\nc'); }); it('delete end_line 越界被钳制到文件末尾', async () => { writeFileSync(join(ws, 'del2.txt'), 'a\nb\nc'); const r = (await editor.execute( { file_path: 'del2.txt', operation: 'delete', start_line: 2, end_line: 999 }, ctxFor(ws), )) as { success: boolean }; expect(r.success).toBe(true); expect(readFileSync(join(ws, 'del2.txt'), 'utf-8')).toBe('a'); }); it('regex 替换强制 g 标志保证计数一致', async () => { writeFileSync(join(ws, 're.txt'), 'aaa bbb aaa ccc'); const r = (await editor.execute( { file_path: 're.txt', operation: 'regex', pattern: 'a{3}', replacement: 'XXX' }, ctxFor(ws), )) as Record; expect(r.success).toBe(true); expect(readFileSync(join(ws, 're.txt'), 'utf-8')).toContain('XXX bbb XXX'); }); it('regex 指定行范围只替换该区间', async () => { writeFileSync(join(ws, 're2.txt'), 'aaa\naaa\naaa'); const r = (await editor.execute( { file_path: 're2.txt', operation: 'regex', pattern: 'aaa', replacement: 'X', start_line: 2, end_line: 3, }, ctxFor(ws), )) as { success: boolean }; expect(r.success).toBe(true); expect(readFileSync(join(ws, 're2.txt'), 'utf-8')).toBe('aaa\nX\nX'); }); it('regex 无匹配 → 返回提示且文件不变', async () => { writeFileSync(join(ws, 're3.txt'), 'abc'); const before = readFileSync(join(ws, 're3.txt'), 'utf-8'); const r = (await editor.execute( { file_path: 're3.txt', operation: 'regex', pattern: 'zzz', replacement: 'x' }, ctxFor(ws), )) as { success: boolean; message?: string; replacements?: number; }; expect(r.success).toBe(true); expect(r.message).toContain('No matches found'); expect(r.replacements).toBe(0); expect(readFileSync(join(ws, 're3.txt'), 'utf-8')).toBe(before); }); it('regex 缺 pattern → 报错', async () => { const r = (await editor.execute( { file_path: 're3.txt', operation: 'regex', replacement: 'x' }, ctxFor(ws), )) as { success: boolean }; expect(r.success).toBe(false); }); it('regex 非法 pattern → Invalid regex', async () => { const r = (await editor.execute( { file_path: 're3.txt', operation: 'regex', pattern: '([unclosed', replacement: 'x' }, ctxFor(ws), )) as { success: boolean; error?: string }; expect(r.success).toBe(false); expect(String((r as { error?: string }).error)).toContain('Invalid regex'); }); it('regex pattern 超过 500 字符 → 拒绝', async () => { const r = (await editor.execute( { file_path: 're3.txt', operation: 'regex', pattern: 'a'.repeat(501), replacement: 'x' }, ctxFor(ws), )) as { success: boolean; error?: string }; expect(r.success).toBe(false); expect(String((r as { error?: string }).error)).toContain('max 500'); }); it('regex end_line < start_line → 报错', async () => { const r = (await editor.execute( { file_path: 're3.txt', operation: 'regex', pattern: 'a', replacement: 'b', start_line: 5, end_line: 2, }, ctxFor(ws), )) as { success: boolean }; expect(r.success).toBe(false); }); it('multiline=true 跨行匹配', async () => { writeFileSync(join(ws, 'multi.txt'), 'start\nBEGIN\nBODY\nEND\nfinish'); const r = (await editor.execute( { file_path: 'multi.txt', operation: 'regex', pattern: 'BEGIN\\nBODY\\nEND', replacement: 'REPLACED', multiline: true, }, ctxFor(ws), )) as { success: boolean; replacements?: number }; expect(r.success).toBe(true); expect(r.replacements).toBe(1); expect(readFileSync(join(ws, 'multi.txt'), 'utf-8')).toBe('start\nREPLACED\nfinish'); }); it('multiline 目标内容超过 100K → 拒绝', async () => { const big = Array.from({ length: 30000 }, (_, i) => `line-${i}-${'x'.repeat(10)}`).join('\n'); writeFileSync(join(ws, 'big-multi.txt'), big); const r = (await editor.execute( { file_path: 'big-multi.txt', operation: 'regex', pattern: 'needle', replacement: 'y', multiline: true, }, ctxFor(ws), )) as { success: boolean; error?: string }; expect(r.success).toBe(false); expect(String((r as { error?: string }).error)).toContain('100000'); }); it('dry_run=true 不落盘并给出预览', async () => { const before = readFileSync(join(ws, 'src.txt'), 'utf-8'); const r = (await editor.execute( { file_path: 'src.txt', operation: 'find_replace', find: 'alpha', replace: 'ALPHA', dry_run: true, }, ctxFor(ws), )) as { success: boolean }; expect(r.success).toBe(true); expect(readFileSync(join(ws, 'src.txt'), 'utf-8')).toBe(before); }); it('dry_run 返回 lines_before/lines_after 与 preview 结构', async () => { const r = (await editor.execute( { file_path: 'src.txt', operation: 'replace', start_line: 2, end_line: 2, content: 'BETA-NEW', dry_run: true, }, ctxFor(ws), )) as { success: boolean; dry_run?: boolean; lines_before?: number; lines_after?: number; preview?: { original: string; modified: string }; }; expect(r.success).toBe(true); expect(r.dry_run).toBe(true); expect(r.lines_before).toBe(4); expect(r.lines_after).toBe(4); expect(r.preview?.original).toBe('beta'); expect(r.preview?.modified).toBe('BETA-NEW'); }); it('backup=true 产出 .bak 且内容为改动前快照', async () => { writeFileSync(join(ws, 'bak.txt'), 'orig-line'); void (await editor.execute( { file_path: 'bak.txt', operation: 'find_replace', find: 'orig', replace: 'new', backup: true, }, ctxFor(ws), )); expect(existsSync(join(ws, 'bak.txt.bak'))).toBe(true); expect(readFileSync(join(ws, 'bak.txt.bak'), 'utf-8')).toBe('orig-line'); }); it('backup=false(默认)不产生 .bak', async () => { writeFileSync(join(ws, 'nobak.txt'), 'orig'); void (await editor.execute( { file_path: 'nobak.txt', operation: 'find_replace', find: 'orig', replace: 'new' }, ctxFor(ws), )); expect(existsSync(join(ws, 'nobak.txt.bak'))).toBe(false); }); it('backup 与 dry_run 同用:dry_run 不写 .bak', async () => { writeFileSync(join(ws, 'bdd.txt'), 'orig'); void (await editor.execute( { file_path: 'bdd.txt', operation: 'find_replace', find: 'orig', replace: 'new', backup: true, dry_run: true, }, ctxFor(ws), )); expect(existsSync(join(ws, 'bdd.txt.bak'))).toBe(false); }); it('未知 operation → 报错', async () => { const r = (await editor.execute( { file_path: 'src.txt', operation: 'frobnicate' }, ctxFor(ws), )) as { success: boolean; error?: string }; expect(r.success).toBe(false); expect(String((r as { error?: string }).error)).toContain('Unknown operation'); }); it('file_path 缺失 → 报错', async () => { const r = (await editor.execute( { operation: 'find_replace', find: 'x', replace: 'y' }, ctxFor(ws), )) as { success: boolean }; expect(r.success).toBe(false); }); it('文件不存在 → 提示用 write_file 创建', async () => { const r = (await editor.execute( { file_path: 'ghost-file.txt', operation: 'insert', start_line: 1, content: 'x' }, ctxFor(ws), )) as { success: boolean; error?: string }; expect(r.success).toBe(false); expect(String((r as { error?: string }).error)).toContain('File not found'); }); it('路径越界 → 拒绝', async () => { const outside = process.platform === 'win32' ? 'C:\\Windows\\notepad.exe' : '/etc/passwd'; const r = (await editor.execute( { file_path: outside, operation: 'find_replace', find: 'x', replace: 'y' }, ctxFor(ws), )) as { success: boolean }; expect(r.success).toBe(false); }); it('根 MEMORY.md 不可编辑', async () => { writeFileSync(join(ws, 'MEMORY.md'), '# m'); const r = (await editor.execute( { file_path: 'MEMORY.md', operation: 'find_replace', find: 'm', replace: 'M' }, ctxFor(ws), )) as { success: boolean }; expect(r.success).toBe(false); }); it('10MB 文件闸门拒绝编辑', async () => { writeFileSync(join(ws, 'huge.txt'), Buffer.alloc(10 * 1024 * 1024 + 5, 0x61)); const r = (await editor.execute( { file_path: 'huge.txt', operation: 'find_replace', find: 'a', replace: 'b' }, ctxFor(ws), )) as { success: boolean; error?: string }; expect(r.success).toBe(false); expect(String((r as { error?: string }).error)).toContain('File too large'); }); it('ReDoS 启发式拦截嵌套量词 pattern', async () => { const r = (await editor.execute( { file_path: 're.txt', operation: 'regex', pattern: '(a+)+$', replacement: 'x' }, ctxFor(ws), )) as { success: boolean; error?: string }; expect(r.success).toBe(false); expect(String((r as { error?: string }).error).toLowerCase()).toMatch( /catastrophic|unsafe|complex|pattern/i, ); }); it('ReDoS 拦截重叠量词(a+a+)与交替分支((a|a)*)', async () => { for (const evil of ['a+a+', '(a|a)*', '(a+)*']) { const r = (await editor.execute( { file_path: 're.txt', operation: 'regex', pattern: evil, replacement: 'x' }, ctxFor(ws), )) as { success: boolean }; expect(r.success, `expected ReDoS block for ${evil}`).toBe(false); } }); it('正常 pattern((\\d+)? 前缀量词)不误伤', async () => { writeFileSync(join(ws, 'safe-re.txt'), 'v1 v2'); const r = (await editor.execute( { file_path: 'safe-re.txt', operation: 'regex', pattern: 'v(\\d+)', replacement: 'V' }, ctxFor(ws), )) as { success: boolean }; expect(r.success).toBe(true); expect(readFileSync(join(ws, 'safe-re.txt'), 'utf-8')).toBe('V V'); }); }); // ===== dev-tools 解析器 ===== const lintDevTools = new LintCodeTool(); const parseCounts = lintDevTools.parseCounts.bind(lintDevTools); const parseTests = new RunTestsTool().parseTestResults.bind(new RunTestsTool()); describe('dev-tools.parseCounts / parseTestResults 输出契约', () => { it('tsc 格式:error TS#### 行计数,warning 恒 0', () => { const out = [ 'src/a.ts(1,7): error TS2304: Cannot find name', 'src/b.ts(5,1): warning TS6133: unused var', 'src/c.ts(9,9): error TS2551: typo', ].join('\n'); expect(parseCounts(out, 'tsc')).toEqual({ errorCount: 2, warningCount: 0 }); }); it('tsc 无 error 行 → 0', () => { expect(parseCounts('No errors found', 'tsc')).toEqual({ errorCount: 0, warningCount: 0 }); }); it('eslint 汇总行 "✖ N problems (X errors, Y warnings)" 解析', () => { expect(parseCounts('✖ 7 problems (5 errors, 2 warnings)', 'eslint')).toEqual({ errorCount: 5, warningCount: 2, }); expect(parseCounts('All clean', 'eslint')).toEqual({ errorCount: 0, warningCount: 0 }); }); it('eslint 单数问题形态(1 problem / 1 error / 1 warning)', () => { expect(parseCounts('✖ 1 problem (1 error, 0 warnings)', 'eslint')).toEqual({ errorCount: 1, warningCount: 0, }); }); it.each([ ['Tests: 5 passed, 2 failed, 7 total', { passed: 5, failed: 2 }], ['Tests: 9 passed, 9 total', { passed: 9, failed: 0 }], ['42 passing (3.5s)', { passed: 42, failed: 0 }], ['3 failing (1.2s)', { passed: 0, failed: 3 }], ['Tests: 10 passed, 2 failed, 12 total\nTime: 5.2 s', { passed: 10, failed: 2 }], ])('%s → %j', (output, expected) => { const parsed = parseTests(output); expect(parsed.passed).toBe(expected.passed); expect(parsed.failed).toBe(expected.failed); expect(typeof parsed.duration).toBe('string'); }); it('耗时优先 Time:/Duration:/耗时: 标签,回退括号形态', () => { expect(parseTests('Time: 12.3 s').duration).toMatch(/^12\.3\s*s$/); expect(parseTests('(3.5s)').duration).toContain('3.5'); expect(parseTests('Duration: 120ms').duration).toBe('120ms'); expect(parseTests('耗时: 3.5s').duration).toContain('3.5'); }); it('空输出 → 全零与默认耗时', () => { expect(parseTests('')).toEqual({ passed: 0, failed: 0, duration: '0s' }); }); }); // ===== code-search ripgrep JSON 状态机 ===== const cs = new CodeSearchTool(); const parseRipgrep = cs.parseRipgrepJsonOutput.bind(cs); describe('parseRipgrepJsonOutput — rg --json 上下文状态机', () => { it('match/context 状态机(首个 match 的 before / 最后残留 after)', () => { const raw = [ JSON.stringify({ type: 'context', data: { lines: { text: 'before line 1' } } }), JSON.stringify({ type: 'context', data: { lines: { text: 'before line 2' } } }), JSON.stringify({ type: 'match', data: { path: { text: 'a.ts' }, line_number: 10, submatches: [{ match: { text: 'needle' }, start: 4 }], }, }), JSON.stringify({ type: 'context', data: { lines: { text: 'after line 1' } } }), JSON.stringify({ type: 'context', data: { lines: { text: 'after line 2' } } }), ].join('\n'); const results = parseRipgrep(raw); expect(results).toHaveLength(1); const hit = results[0]; expect(hit.path).toBe('a.ts'); expect(hit.line).toBe(10); expect(hit.column).toBe(5); // start=4 → column 从 1 计数 expect(hit.match).toBe('needle'); expect(hit.before?.map((l: string) => l.trim())).toEqual(['before line 1', 'before line 2']); // 结尾残留的 context 属于最后一个 match 的 after expect(hit.after?.map((l: string) => l.trim())).toEqual(['after line 1', 'after line 2']); }); it('多 match 相邻排布:每个 match 的 before/after 各自正确收敛', () => { const raw = [ JSON.stringify({ type: 'match', data: { path: { text: 'b.ts' }, line_number: 1, submatches: [{ match: { text: 'one' }, start: 0 }], }, }), JSON.stringify({ type: 'context', data: { lines: { text: 'gap line' } } }), JSON.stringify({ type: 'match', data: { path: { text: 'b.ts' }, line_number: 3, submatches: [{ match: { text: 'two' }, start: 2 }], }, }), ].join('\n'); const results = parseRipgrep(raw); expect(results.map((r: { match: string }) => r.match)).toEqual(['one', 'two']); expect(results[0].after?.map((l: string) => l.trim())).toEqual(['gap line']); expect(results[1].before).toBeUndefined(); }); it('坏行静默跳过不中断状态机', () => { const raw = [ 'not-json-at-all', JSON.stringify({ type: 'match', data: { path: { text: 'c.ts' }, line_number: 2 } }), ].join('\n'); const results = parseRipgrep(raw); expect(results).toHaveLength(1); expect(results[0].path).toBe('c.ts'); expect(results[0].column).toBe(1); // 无 submatches 时列号兜底 1 }); it('多 submatches 取第一个作为 match 文本与列号', () => { const raw = JSON.stringify({ type: 'match', data: { path: { text: 'd.ts' }, line_number: 7, submatches: [ { match: { text: 'first' }, start: 3 }, { match: { text: 'second' }, start: 20 }, ], }, }); const results = parseRipgrep(raw); expect(results[0].match).toBe('first'); expect(results[0].column).toBe(4); }); it('空输出 → 空结果', () => { expect(parseRipgrep('')).toHaveLength(0); expect(parseRipgrep('\n\n')).toHaveLength(0); }); it('context 在无 match 时全部作为残留 before 丢弃', () => { const raw = [JSON.stringify({ type: 'context', data: { lines: { text: 'orphan' } } })].join( '\n', ); expect(parseRipgrep(raw)).toHaveLength(0); }); });