Files
codegraph/__tests__/frontload-hook.test.ts
T
b45f309a1b 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>
2026-06-26 18:39:34 -05:00

204 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Front-load hook project resolution (#964).
*
* The Claude `UserPromptSubmit` front-load hook must inject CodeGraph context
* 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.
*/
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, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens } from '../src/directory';
/** Make `dir` look indexed (isInitialized needs `.codegraph/codegraph.db`). */
function mkIndexed(dir: string): string {
fs.mkdirSync(path.join(dir, '.codegraph'), { recursive: true });
fs.writeFileSync(path.join(dir, '.codegraph', 'codegraph.db'), '');
return dir;
}
/** A workspace-root manifest so the down-scan gate (looksLikeProjectRoot) passes. */
function mkWorkspaceRoot(dir: string): string {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'package.json'), '{"private":true,"workspaces":["packages/*"]}');
return dir;
}
describe('planFrontload — front-load hook project resolution (#964)', () => {
let tmp: string;
beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-frontload-'))); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
it('cwd is itself indexed → front-load cwd (the common single-project case)', () => {
mkIndexed(tmp);
const plan = planFrontload(tmp, 'how does login work');
expect(plan.exploreRoot).toBe(tmp);
expect(plan.viaSubScan).toBe(false);
expect(plan.nudgeProjects).toEqual([]);
});
it('a nested file under an indexed project resolves up to that project', () => {
mkIndexed(tmp);
const nested = path.join(tmp, 'src', 'deep');
fs.mkdirSync(nested, { recursive: true });
expect(planFrontload(nested, 'trace the flow').exploreRoot).toBe(tmp);
});
it('un-indexed workspace root with ONE indexed sub-project → front-load it (the #964 case)', () => {
mkWorkspaceRoot(tmp);
const api = mkIndexed(path.join(tmp, 'packages', 'api'));
const plan = planFrontload(tmp, 'how does the request get handled');
expect(plan.exploreRoot).toBe(api);
expect(plan.viaSubScan).toBe(true);
expect(plan.nudgeProjects).toEqual([]);
});
it('multiple indexed sub-projects, prompt names one by path → front-load it, nudge the rest', () => {
mkWorkspaceRoot(tmp);
const api = mkIndexed(path.join(tmp, 'packages', 'api'));
const web = mkIndexed(path.join(tmp, 'packages', 'web'));
const plan = planFrontload(tmp, 'in packages/api, how does the handler validate the token?');
expect(plan.exploreRoot).toBe(api);
expect(plan.viaSubScan).toBe(true);
expect(plan.nudgeProjects).toEqual([web]);
});
it('multiple indexed sub-projects, prompt names one by package name → front-load it', () => {
mkWorkspaceRoot(tmp);
mkIndexed(path.join(tmp, 'packages', 'api'));
const web = mkIndexed(path.join(tmp, 'packages', 'web'));
const plan = planFrontload(tmp, 'how does the web frontend render the dashboard?');
expect(plan.exploreRoot).toBe(web);
});
it('multiple indexed sub-projects, NO clear match → nudge the full list, do not guess', () => {
mkWorkspaceRoot(tmp);
const api = mkIndexed(path.join(tmp, 'packages', 'api'));
const web = mkIndexed(path.join(tmp, 'packages', 'web'));
const plan = planFrontload(tmp, 'how does authentication work end to end?');
expect(plan.exploreRoot).toBeNull();
expect(plan.viaSubScan).toBe(true);
expect(plan.nudgeProjects.sort()).toEqual([api, web].sort());
});
it('un-indexed dir that is NOT a workspace root → no-op (guards $HOME-style crawls)', () => {
// Indexed project exists below, but cwd has no manifest, so the down-scan is skipped.
mkIndexed(path.join(tmp, 'some', 'project'));
const plan = planFrontload(tmp, 'how does it work');
expect(plan.exploreRoot).toBeNull();
expect(plan.nudgeProjects).toEqual([]);
});
it('nothing indexed anywhere → no-op', () => {
mkWorkspaceRoot(tmp);
fs.mkdirSync(path.join(tmp, 'packages', 'api'), { recursive: true });
const plan = planFrontload(tmp, 'how does it work');
expect(plan.exploreRoot).toBeNull();
expect(plan.nudgeProjects).toEqual([]);
});
});
describe('findIndexedSubprojectRoots', () => {
let tmp: string;
beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-subscan-'))); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
it('finds indexed projects a couple levels down and skips node_modules/.git', () => {
mkIndexed(path.join(tmp, 'packages', 'api'));
mkIndexed(path.join(tmp, 'services', 'auth'));
// Decoys that must NOT be scanned into.
mkIndexed(path.join(tmp, 'node_modules', 'dep'));
mkIndexed(path.join(tmp, '.git', 'x'));
const found = findIndexedSubprojectRoots(tmp).map((p) => path.relative(tmp, p)).sort();
expect(found).toEqual([path.join('packages', 'api'), path.join('services', 'auth')].sort());
});
it('does not descend INTO an indexed project (a project\'s sub-dirs are not separate projects)', () => {
const api = mkIndexed(path.join(tmp, 'packages', 'api'));
mkIndexed(path.join(api, 'submodule')); // nested index under an already-indexed project
const found = findIndexedSubprojectRoots(tmp);
expect(found).toEqual([api]);
});
it('respects the depth bound', () => {
mkIndexed(path.join(tmp, 'a', 'b', 'c', 'd', 'e', 'deep'));
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);
});
});