diff --git a/__tests__/frontload-hook.test.ts b/__tests__/frontload-hook.test.ts index 5c03557ce..6868e4c75 100644 --- a/__tests__/frontload-hook.test.ts +++ b/__tests__/frontload-hook.test.ts @@ -5,13 +5,14 @@ * for the RIGHT project — including the monorepo case where the agent's cwd is * an un-indexed workspace root and the index lives in a sub-project. These test * `planFrontload` / `findIndexedSubprojectRoots` directly (the hook's decision - * logic), since the end-to-end hook is validated by a live agent run, not a - * unit test. + * logic), plus the built CLI's confidence tiers against a real index. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { spawnSync } from 'node:child_process'; +import { CodeGraph } from '../src'; import { planFrontload, isTaskNotification, findIndexedSubprojectRoots, unsafeIndexRootReason, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens, PROMPT_HOOK_INJECTION_MAX, CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT, capPromptHookInjection } from '../src/directory'; // Make the built-in exports configurable so HOME can point at a real temp @@ -302,6 +303,84 @@ describe('hasStructuralKeyword — Latin-script languages, Cyrillic, JA/KO (#112 }); }); +describe('prompt-hook ambiguous words need corroboration (#1654)', () => { + let tmp: string; + + beforeEach(async () => { + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-hook-ambiguous-'))); + fs.writeFileSync(path.join(tmp, 'orders.ts'), ` +export class OrderStateMachine { + submitOrder() { return true; } +} +`); + const cg = await CodeGraph.init(tmp, { silent: true }); + try { await cg.indexAll(); } finally { cg.destroy(); } + }); + afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); + + function hook(prompt: string): string { + const result = spawnSync(process.execPath, [path.resolve(__dirname, '../dist/bin/codegraph.js'), 'prompt-hook'], { + cwd: tmp, + input: JSON.stringify({ cwd: tmp, prompt }), + encoding: 'utf8', + timeout: 15_000, + env: { + ...process.env, + CODEGRAPH_TELEMETRY: '0', DO_NOT_TRACK: '1', CODEGRAPH_NO_DAEMON: '1', + CODEGRAPH_NO_RELAUNCH: '1', CODEGRAPH_WASM_RELAUNCHED: '1', + // Enable only the hook subprocess under test. + CODEGRAPH_NO_PROMPT_HOOK: '0', CODEGRAPH_PROMPT_HOOK: '1', + }, + }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + return result.stdout; + } + + it('stays silent for everyday Portuguese, Spanish and German without indexed evidence', () => { + for (const prompt of [ + 'eu como pizza toda sexta', + 'faz como a gente combinou ontem', + 'roda os testes como esta e me diz o resultado', + 'commita isso como fix, nao como feat', + 'hazlo como ayer', + 'mach es wie gestern', + 'COMO combinado ontem', + 'Wie gestern bitte', + 'como JavaScript?', + 'wie MissingService?', + 'run the tests and report back', + ]) { + expect.soft(hasStructuralKeyword(prompt), prompt).toBe(false); + expect.soft(hook(prompt), prompt).toBe(''); + } + }); + + it('keeps HIGH for verified identifiers or another structural keyword', () => { + for (const prompt of [ + 'como OrderStateMachine?', + 'como submitOrder()?', + 'wie OrderStateMachine?', + 'como funciona a máquina de estados?', + 'wie funktioniert die Zustandsmaschine?', + 'onde fica a lógica dos pedidos?', + '¿cómo se procesan los pedidos?', + 'how does OrderStateMachine work?', + ]) { + expect.soft(hook(prompt), prompt).toContain('Structural context from CodeGraph'); + } + }); + + it('uses MEDIUM for indexed prose segments without a strong keyword or verified token', () => { + for (const prompt of ['como state machine?', 'wie state machine?']) { + const output = hook(prompt); + expect(output, prompt).toContain('CodeGraph found indexed symbols matching this prompt'); + expect(output).toContain('OrderStateMachine'); + expect(output).not.toContain('Structural context from CodeGraph'); + } + }); +}); + describe('extractCodeTokens — candidate symbols the hook verifies against the graph', () => { it('pulls camelCase / PascalCase / snake_case / call / member tokens', () => { expect(extractCodeTokens('prepareArticlePublish 的调用链')).toContain('prepareArticlePublish'); diff --git a/src/directory.ts b/src/directory.ts index 160ac9c68..c07b71039 100644 --- a/src/directory.ts +++ b/src/directory.ts @@ -310,8 +310,9 @@ const NOT_WORD_AFTER = /(?![\p{L}\p{N}_])/u.source; * Structural keywords matched as EXACT words (boundary on both sides): short * or ambiguous tokens where prefix matching would false-positive ("flow" in * "flower", "path" in "pathological"). Grouped by language; a term appears once - * even when several languages share it ("como" is Portuguese for how AND - * unaccented-typed Spanish "cómo"). + * even when several languages share it. Ambiguous everyday words like PT/ES + * "como" and DE "wie" are excluded: the hook instead requires another strong + * keyword, a verified code token, or indexed prose segments (#1654). */ const STRUCTURAL_WORDS = [ // English — the pre-#1126 list minus what moved to STRUCTURAL_STEMS: the @@ -320,17 +321,16 @@ const STRUCTURAL_WORDS = [ 'how', 'where', 'tracing', 'flows?', 'paths?', 'reach(?:es|ed)?', 'wired?', 'breaks?', 'why does', // French (où=where, flux=flow, chemin=path, casse=breaks) 'comment', 'où', 'flux', 'chemins?', 'casse', - // Spanish (cómo/como=how, dónde/donde=where, flujo=flow, ruta/camino=path, + // Spanish (cómo=how, dónde/donde=where, flujo=flow, ruta/camino=path, // rompe=breaks, llaman / quién llama = call(s) — bare "llama" is excluded: // it's also the animal/model name in English prompts) 'cómo', 'dónde', 'donde', 'flujos?', 'rutas?', 'caminos?', 'rompe', 'llaman', 'quién llama', 'quien llama', - // Portuguese (como=how — also covers unaccented Spanish; onde=where, - // fluxo=flow, caminho=path) - 'como', 'onde', 'fluxos?', 'caminhos?', - // German (wie=how, wo/woher/wohin=where, Pfad=path, Fluss/Ablauf=flow, + // Portuguese (onde=where, fluxo=flow, caminho=path) + 'onde', 'fluxos?', 'caminhos?', + // German (wo/woher/wohin=where, Pfad=path, Fluss/Ablauf=flow, // bricht/kaputt=breaks, ruft=calls, hängt=depends — "hängt … von X ab" // splits the separable verb "abhängen", so the "abhäng" stem can't catch it) - 'wie', 'wo', 'woher', 'wohin', 'pfade?', 'fluss', 'ablauf', 'bricht', 'kaputt', 'ruft', 'hängt', + 'wo', 'woher', 'wohin', 'pfade?', 'fluss', 'ablauf', 'bricht', 'kaputt', 'ruft', 'hängt', // Italian (dove=where, flusso=flow, percorso/i=path) 'dove', 'flusso', 'percors[oi]', // Russian (как=how, где=where, путь/пути=path, работает=works)