fix(prompt-hook): fire front-load hook for non-English prompts (#994) (#1004)

The UserPromptSubmit hook's structural-prompt gate was English-only, so a
structural question written in Chinese — or any non-Latin script — silently
injected nothing: JS `\b` is ASCII-only and never matches between Han
characters, so the keyword regex couldn't fire (and couldn't be extended in
place). To the user the hook looked unwired, with no error to explain why.

Make the gate language-aware, split into tested helpers in directory.ts:
- hasStructuralKeyword: English (\b-guarded) + CJK structural keywords.
- extractCodeTokens: identifier-shaped tokens (camelCase / snake_case /
  name() / a.b) in any language — verified against the index via
  getNodesByName before firing, so a tech brand like `JavaScript` that looks
  like a symbol but isn't one here doesn't inject ~16KB of spurious context.
- isStructuralPrompt: the cheap candidate gate (keyword OR code-token).

Adds 21 unit tests for the gate (previously untested) covering the reporter's
verification table plus the false-positive guards.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-26 18:39:34 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 703629edc3
commit b45f309a1b
4 changed files with 174 additions and 7 deletions
+74 -1
View File
@@ -12,7 +12,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { planFrontload, findIndexedSubprojectRoots } from '../src/directory';
import { planFrontload, findIndexedSubprojectRoots, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens } from '../src/directory';
/** Make `dir` look indexed (isInitialized needs `.codegraph/codegraph.db`). */
function mkIndexed(dir: string): string {
@@ -128,3 +128,76 @@ describe('findIndexedSubprojectRoots', () => {
expect(findIndexedSubprojectRoots(tmp, { maxDepth: 2 })).toEqual([]);
});
});
describe('hasStructuralKeyword — keyword signal fires the hook directly (#994)', () => {
it('English keywords match, with `\\b` so "flow" ≠ "flower"', () => {
expect(hasStructuralKeyword('how does article publish work')).toBe(true);
expect(hasStructuralKeyword('where is the token validated')).toBe(true);
expect(hasStructuralKeyword('trace the request flow')).toBe(true);
expect(hasStructuralKeyword('what calls parseToken')).toBe(true);
expect(hasStructuralKeyword('water the flower')).toBe(false); // "flow" in "flower"
});
it('Chinese keywords match WITHOUT `\\b` — the #994 fix (were silently dropped)', () => {
expect(hasStructuralKeyword('介绍文章发布流程')).toBe(true); // introduce / flow
expect(hasStructuralKeyword('登录是如何实现的')).toBe(true); // how / implement
expect(hasStructuralKeyword('这个函数的调用链')).toBe(true); // call (chain)
expect(hasStructuralKeyword('支付模块依赖哪些服务')).toBe(true); // depend
expect(hasStructuralKeyword('修复这个拼写错误')).toBe(false); // "fix this typo"
});
it('a bare code-token is NOT a keyword — it needs graph verification', () => {
expect(hasStructuralKeyword('看看 get_user 这段逻辑')).toBe(false);
expect(hasStructuralKeyword('I really love JavaScript')).toBe(false);
});
});
describe('extractCodeTokens — candidate symbols the hook verifies against the graph', () => {
it('pulls camelCase / PascalCase / snake_case / call / member tokens', () => {
expect(extractCodeTokens('prepareArticlePublish 的调用链')).toContain('prepareArticlePublish');
expect(extractCodeTokens('看看 get_user 这段逻辑')).toContain('get_user'); // snake_case
expect(extractCodeTokens('render() 在哪触发')).toContain('render'); // call form
expect(extractCodeTokens('user.login 做了什么').sort()).toEqual(['login', 'user']); // member access
expect(extractCodeTokens('看看 UserService')).toContain('UserService'); // PascalCase class kept
});
it('a tech brand is extracted as a CANDIDATE — the hooks graph check is what rejects it', () => {
// This is the #994 follow-up: "JavaScript" is identifier-shaped, so it surfaces
// here as a candidate; the hook only fires if it's a real symbol in the index.
expect(extractCodeTokens('I really love JavaScript')).toEqual(['JavaScript']);
expect(extractCodeTokens('thoughts on GitHub vs GitLab').sort()).toEqual(['GitHub', 'GitLab']);
});
it('ordinary prose and doc/data filenames yield no tokens', () => {
expect(extractCodeTokens('fix typo in readme')).toEqual([]);
expect(extractCodeTokens('fix the typo in README.md')).toEqual([]); // doc filename excluded
expect(extractCodeTokens('bump the version in package.json')).toEqual([]);
expect(extractCodeTokens('water the flower')).toEqual([]);
});
});
describe('isStructuralPrompt — cheap candidate gate (keyword OR code-token)', () => {
it('fires on a keyword prompt in any language', () => {
expect(isStructuralPrompt('how does article publish work')).toBe(true);
expect(isStructuralPrompt('介绍文章发布流程')).toBe(true);
});
it('fires on a code-token prompt with no keyword', () => {
expect(isStructuralPrompt('看看 get_user 这段逻辑')).toBe(true);
expect(isStructuralPrompt('where is prepareArticlePublish 定义')).toBe(true);
expect(isStructuralPrompt('user.login 做了什么')).toBe(true);
});
it('a tech brand passes the CHEAP gate as a candidate — the hook then graph-verifies it', () => {
// Layering, not a bug: isStructuralPrompt is shape-only, so a token-shaped brand
// is a candidate here; the hook rejects it as a non-symbol (proven by the CLI e2e).
expect(isStructuralPrompt('I really love JavaScript')).toBe(true);
});
it('non-structural prose stays a no-op — in either language', () => {
expect(isStructuralPrompt('fix typo in readme')).toBe(false);
expect(isStructuralPrompt('修复这个拼写错误')).toBe(false);
expect(isStructuralPrompt('water the flower')).toBe(false);
expect(isStructuralPrompt('')).toBe(false);
});
});