Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/detection/git-branch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export async function getGitBranch(
cwd: projectDir,
timeout: GIT_TIMEOUT_MS,
killSignal: 'SIGTERM',
// The OpenCode desktop background service has no console; without this
// Windows allocates a visible console window for git on every dispatch.
windowsHide: true,
};
execFn(
'git',
Expand Down
13 changes: 13 additions & 0 deletions src/index.rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,19 @@ describe('promptMatchesKeywords', () => {
expect(promptMatchesKeywords('file.test.ts', ['test.ts'])).toBe(true);
expect(promptMatchesKeywords('run tests now', ['test.ts'])).toBe(false);
});

it('should match non-ASCII (Cyrillic) keywords at the start of a word', () => {
expect(promptMatchesKeywords('сделай ревью этого модуля', ['ревью'])).toBe(
true
);
expect(promptMatchesKeywords('поправь форму документа', ['форм'])).toBe(
true
);
});

it('should not match a Cyrillic keyword mid-word', () => {
expect(promptMatchesKeywords('платформа', ['форм'])).toBe(false);
});
});

describe('toolsMatchAvailable', () => {
Expand Down
12 changes: 9 additions & 3 deletions src/rules/rule-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,15 @@ export function promptMatchesKeywords(
return keywords.some(keyword => {
const lowerKeyword = keyword.toLowerCase();
const escaped = lowerKeyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Leading word boundary only: "test" matches "testing".
const regex = new RegExp(`\\b${escaped}`, 'i');
return regex.test(lowerPrompt);
// Unicode-aware leading boundary: JavaScript \b only knows ASCII \w, so
// it never matches before a Cyrillic letter. "test" still matches
// "testing" because only the leading boundary is anchored.
try {
const regex = new RegExp(`(?<![\\p{L}\\p{N}_])${escaped}`, 'iu');
return regex.test(lowerPrompt);
} catch {
return lowerPrompt.includes(lowerKeyword);
}
});
}

Expand Down
168 changes: 168 additions & 0 deletions src/session/file-observation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,171 @@ describe('normalizeToolObservation: lsp', () => {
);
});
});

describe('normalizeToolObservation: MCP file paths', () => {
it('extracts path= from MCP output regardless of language', () => {
const output1C = [
'# scope=core',
'path=Documents/Заказ покупателя/Forms/Форма/Ext/Form/Module.bsl lines=10-25',
'--- code ---',
'Процедура Тест()',
].join('\n');

expect(
normalizeObservations({
tool: 'mcp__1c-index__read_method',
args: {},
output: output1C,
})
).toEqual([
{
path: 'Documents/Заказ покупателя/Forms/Форма/Ext/Form/Module.bsl',
tool: 'mcp__1c-index__read_method',
content: output1C,
},
]);

const outputTS = 'path=src/components/Button.tsx lines=1-20';
expect(
normalizeObservations({
tool: 'mcp__code_indexer__read',
args: {},
output: outputTS,
})
).toEqual([
{
path: 'src/components/Button.tsx',
tool: 'mcp__code_indexer__read',
content: outputTS,
},
]);
});

it('extracts module_path= and file= attributes', () => {
expect(
normalizeObservations({
tool: 'mcp__analyzer__object_info',
args: {},
output: 'module_path=services/auth.py',
})
).toEqual([
{
path: 'services/auth.py',
tool: 'mcp__analyzer__object_info',
content: 'module_path=services/auth.py',
},
]);

expect(
normalizeObservations({
tool: 'mcp__filesystem__get_file',
args: {},
output: 'file="src/My File.rs"',
})
).toEqual([
{
path: 'src/My File.rs',
tool: 'mcp__filesystem__get_file',
content: 'file="src/My File.rs"',
},
]);
});

it('extracts paths from grep-style output and caps at five', () => {
const output = [
'- src/core/a.go:10 found',
'- src/core/b.go:20 found',
'- src/core/c.go:30 found',
'- src/core/d.go:40 found',
'- src/core/e.go:50 found',
'- src/core/f.go:60 found',
].join('\n');

const observations = normalizeObservations({
tool: 'mcp__ripgrep__search',
args: {},
output,
});

expect(observations).toHaveLength(5);
expect(observations.map(o => o.path)).toEqual([
'src/core/a.go',
'src/core/b.go',
'src/core/c.go',
'src/core/d.go',
'src/core/e.go',
]);
});

it('supports JSON MCP results', () => {
const output = JSON.stringify({
path: 'src/app.ts',
results: [{ file_path: 'src/lib/a.py' }],
});

const observations = normalizeObservations({
tool: 'mcp__code_indexer__search',
args: {},
output,
});

expect(observations.map(o => o.path)).toEqual([
'src/app.ts',
'src/lib/a.py',
]);
});

it('normalizes Windows separators and keeps spaces in paths', () => {
const output = 'path=C:\\repo\\src\\My File.ts lines=1-2';

expect(
normalizeObservations({
tool: 'mcp__filesystem__read',
args: {},
output,
})
).toEqual([
{
path: 'C:/repo/src/My File.ts',
tool: 'mcp__filesystem__read',
content: output,
},
]);
});

it('extracts Cyrillic grep rows and ignores URLs and plain text', () => {
expect(
normalizeObservations({
tool: '1c-index_search_code',
args: {},
output: '- Documents/Заказ покупателя/Module.bsl:42 found',
}).map(o => o.path)
).toEqual(['Documents/Заказ покупателя/Module.bsl']);

expect(
normalizeObservations({
tool: 'mcp__search__run',
args: {},
output: 'See https://example.com/docs.ts for details\nJust plain text',
})
).toEqual([]);
});

it('uses path-like MCP arguments when the output has no path', () => {
const output = '<file contents>';

expect(
normalizeObservations({
tool: 'mcp__filesystem__read_file',
args: { path: 'src/App.vue' },
output,
})
).toEqual([
{
path: 'src/App.vue',
tool: 'mcp__filesystem__read_file',
content: output,
},
]);
});
});
Loading