From 4fd3e6d86b2a379c84c4d774391fe3b8f82efb1b Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sun, 27 Sep 2026 13:06:42 +0000 Subject: [PATCH] fix(prompt-hook): require corroboration for ambiguous words (#1654) (#2012) Everyday uses of como and wie were treated as sufficient structural evidence for HIGH. Remove these words from the unconditional keyword list so existing index verification determines HIGH, MEDIUM, or silence. Add real-index CLI regressions while preserving existing English and multilingual structural tests. Co-authored-by: Claude Opus 5.5 --- __tests__/frontload-hook.test.ts | 83 +++++++++++++++++++++++++++++++- src/directory.ts | 16 +++--- 2 files changed, 89 insertions(+), 10 deletions(-) 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)