frontload-hook.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. /**
  2. * Front-load hook project resolution (#964).
  3. *
  4. * The Claude `UserPromptSubmit` front-load hook must inject CodeGraph context
  5. * for the RIGHT project — including the monorepo case where the agent's cwd is
  6. * an un-indexed workspace root and the index lives in a sub-project. These test
  7. * `planFrontload` / `findIndexedSubprojectRoots` directly (the hook's decision
  8. * logic), since the end-to-end hook is validated by a live agent run, not a
  9. * unit test.
  10. */
  11. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  12. import * as fs from 'fs';
  13. import * as os from 'os';
  14. import * as path from 'path';
  15. import { planFrontload, findIndexedSubprojectRoots, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens } from '../src/directory';
  16. /** Make `dir` look indexed (isInitialized needs `.codegraph/codegraph.db`). */
  17. function mkIndexed(dir: string): string {
  18. fs.mkdirSync(path.join(dir, '.codegraph'), { recursive: true });
  19. fs.writeFileSync(path.join(dir, '.codegraph', 'codegraph.db'), '');
  20. return dir;
  21. }
  22. /** A workspace-root manifest so the down-scan gate (looksLikeProjectRoot) passes. */
  23. function mkWorkspaceRoot(dir: string): string {
  24. fs.mkdirSync(dir, { recursive: true });
  25. fs.writeFileSync(path.join(dir, 'package.json'), '{"private":true,"workspaces":["packages/*"]}');
  26. return dir;
  27. }
  28. describe('planFrontload — front-load hook project resolution (#964)', () => {
  29. let tmp: string;
  30. beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-frontload-'))); });
  31. afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
  32. it('cwd is itself indexed → front-load cwd (the common single-project case)', () => {
  33. mkIndexed(tmp);
  34. const plan = planFrontload(tmp, 'how does login work');
  35. expect(plan.exploreRoot).toBe(tmp);
  36. expect(plan.viaSubScan).toBe(false);
  37. expect(plan.nudgeProjects).toEqual([]);
  38. });
  39. it('a nested file under an indexed project resolves up to that project', () => {
  40. mkIndexed(tmp);
  41. const nested = path.join(tmp, 'src', 'deep');
  42. fs.mkdirSync(nested, { recursive: true });
  43. expect(planFrontload(nested, 'trace the flow').exploreRoot).toBe(tmp);
  44. });
  45. it('un-indexed workspace root with ONE indexed sub-project → front-load it (the #964 case)', () => {
  46. mkWorkspaceRoot(tmp);
  47. const api = mkIndexed(path.join(tmp, 'packages', 'api'));
  48. const plan = planFrontload(tmp, 'how does the request get handled');
  49. expect(plan.exploreRoot).toBe(api);
  50. expect(plan.viaSubScan).toBe(true);
  51. expect(plan.nudgeProjects).toEqual([]);
  52. });
  53. it('multiple indexed sub-projects, prompt names one by path → front-load it, nudge the rest', () => {
  54. mkWorkspaceRoot(tmp);
  55. const api = mkIndexed(path.join(tmp, 'packages', 'api'));
  56. const web = mkIndexed(path.join(tmp, 'packages', 'web'));
  57. const plan = planFrontload(tmp, 'in packages/api, how does the handler validate the token?');
  58. expect(plan.exploreRoot).toBe(api);
  59. expect(plan.viaSubScan).toBe(true);
  60. expect(plan.nudgeProjects).toEqual([web]);
  61. });
  62. it('multiple indexed sub-projects, prompt names one by package name → front-load it', () => {
  63. mkWorkspaceRoot(tmp);
  64. mkIndexed(path.join(tmp, 'packages', 'api'));
  65. const web = mkIndexed(path.join(tmp, 'packages', 'web'));
  66. const plan = planFrontload(tmp, 'how does the web frontend render the dashboard?');
  67. expect(plan.exploreRoot).toBe(web);
  68. });
  69. it('multiple indexed sub-projects, NO clear match → nudge the full list, do not guess', () => {
  70. mkWorkspaceRoot(tmp);
  71. const api = mkIndexed(path.join(tmp, 'packages', 'api'));
  72. const web = mkIndexed(path.join(tmp, 'packages', 'web'));
  73. const plan = planFrontload(tmp, 'how does authentication work end to end?');
  74. expect(plan.exploreRoot).toBeNull();
  75. expect(plan.viaSubScan).toBe(true);
  76. expect(plan.nudgeProjects.sort()).toEqual([api, web].sort());
  77. });
  78. it('un-indexed dir that is NOT a workspace root → no-op (guards $HOME-style crawls)', () => {
  79. // Indexed project exists below, but cwd has no manifest, so the down-scan is skipped.
  80. mkIndexed(path.join(tmp, 'some', 'project'));
  81. const plan = planFrontload(tmp, 'how does it work');
  82. expect(plan.exploreRoot).toBeNull();
  83. expect(plan.nudgeProjects).toEqual([]);
  84. });
  85. it('nothing indexed anywhere → no-op', () => {
  86. mkWorkspaceRoot(tmp);
  87. fs.mkdirSync(path.join(tmp, 'packages', 'api'), { recursive: true });
  88. const plan = planFrontload(tmp, 'how does it work');
  89. expect(plan.exploreRoot).toBeNull();
  90. expect(plan.nudgeProjects).toEqual([]);
  91. });
  92. });
  93. describe('findIndexedSubprojectRoots', () => {
  94. let tmp: string;
  95. beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-subscan-'))); });
  96. afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
  97. it('finds indexed projects a couple levels down and skips node_modules/.git', () => {
  98. mkIndexed(path.join(tmp, 'packages', 'api'));
  99. mkIndexed(path.join(tmp, 'services', 'auth'));
  100. // Decoys that must NOT be scanned into.
  101. mkIndexed(path.join(tmp, 'node_modules', 'dep'));
  102. mkIndexed(path.join(tmp, '.git', 'x'));
  103. const found = findIndexedSubprojectRoots(tmp).map((p) => path.relative(tmp, p)).sort();
  104. expect(found).toEqual([path.join('packages', 'api'), path.join('services', 'auth')].sort());
  105. });
  106. it('does not descend INTO an indexed project (a project\'s sub-dirs are not separate projects)', () => {
  107. const api = mkIndexed(path.join(tmp, 'packages', 'api'));
  108. mkIndexed(path.join(api, 'submodule')); // nested index under an already-indexed project
  109. const found = findIndexedSubprojectRoots(tmp);
  110. expect(found).toEqual([api]);
  111. });
  112. it('respects the depth bound', () => {
  113. mkIndexed(path.join(tmp, 'a', 'b', 'c', 'd', 'e', 'deep'));
  114. expect(findIndexedSubprojectRoots(tmp, { maxDepth: 2 })).toEqual([]);
  115. });
  116. });
  117. describe('hasStructuralKeyword — keyword signal fires the hook directly (#994)', () => {
  118. it('English keywords match with word boundaries so "flow" ≠ "flower"', () => {
  119. expect(hasStructuralKeyword('how does article publish work')).toBe(true);
  120. expect(hasStructuralKeyword('where is the token validated')).toBe(true);
  121. expect(hasStructuralKeyword('trace the request flow')).toBe(true);
  122. expect(hasStructuralKeyword('what calls parseToken')).toBe(true);
  123. expect(hasStructuralKeyword('water the flower')).toBe(false); // "flow" in "flower"
  124. });
  125. it('Chinese keywords match WITHOUT `\\b` — the #994 fix (were silently dropped)', () => {
  126. expect(hasStructuralKeyword('介绍文章发布流程')).toBe(true); // introduce / flow
  127. expect(hasStructuralKeyword('登录是如何实现的')).toBe(true); // how / implement
  128. expect(hasStructuralKeyword('这个函数的调用链')).toBe(true); // call (chain)
  129. expect(hasStructuralKeyword('支付模块依赖哪些服务')).toBe(true); // depend
  130. expect(hasStructuralKeyword('修复这个拼写错误')).toBe(false); // "fix this typo"
  131. });
  132. it('a bare code-token is NOT a keyword — it needs graph verification', () => {
  133. expect(hasStructuralKeyword('看看 get_user 这段逻辑')).toBe(false);
  134. expect(hasStructuralKeyword('I really love JavaScript')).toBe(false);
  135. });
  136. });
  137. describe('hasStructuralKeyword — Latin-script languages, Cyrillic, JA/KO (#1126)', () => {
  138. it('French structural prompts fire — including the prompts from the report', () => {
  139. expect(hasStructuralKeyword('comment marche la state machine des commandes ?')).toBe(true);
  140. expect(hasStructuralKeyword("explique l'architecture du module de stock")).toBe(true);
  141. expect(hasStructuralKeyword('qui appelle cette fonction de parsing ?')).toBe(true);
  142. expect(hasStructuralKeyword('de quoi dépend le module de paiement ?')).toBe(true);
  143. });
  144. it('accented keyword edges match — ASCII `\\b` could never bound "où"', () => {
  145. expect(hasStructuralKeyword('où est validé le token ?')).toBe(true);
  146. expect(hasStructuralKeyword("d'où vient cette valeur ?")).toBe(true);
  147. });
  148. it('Spanish / Portuguese / German / Italian fire', () => {
  149. expect(hasStructuralKeyword('¿cómo funciona la máquina de estados de pedidos?')).toBe(true);
  150. expect(hasStructuralKeyword('¿qué rompe este cambio?')).toBe(true);
  151. expect(hasStructuralKeyword('como funciona a máquina de estados dos pedidos?')).toBe(true);
  152. expect(hasStructuralKeyword('qual é a arquitetura do módulo de estoque?')).toBe(true);
  153. expect(hasStructuralKeyword('wie funktioniert die Zustandsmaschine für Bestellungen?')).toBe(true);
  154. expect(hasStructuralKeyword('wovon hängt das Zahlungsmodul ab?')).toBe(true);
  155. expect(hasStructuralKeyword('come funziona la macchina a stati degli ordini?')).toBe(true);
  156. expect(hasStructuralKeyword('spiegami la struttura del modulo ordini')).toBe(true);
  157. });
  158. it('Russian / Japanese / Korean / traditional Chinese fire', () => {
  159. expect(hasStructuralKeyword('как работает конечный автомат заказов?')).toBe(true);
  160. expect(hasStructuralKeyword('от чего зависит модуль оплаты?')).toBe(true);
  161. expect(hasStructuralKeyword('注文のステートマシンの仕組みを説明して')).toBe(true);
  162. expect(hasStructuralKeyword('この関数の呼び出しの流れは?')).toBe(true);
  163. expect(hasStructuralKeyword('주문 상태 머신은 어떻게 작동하나요?')).toBe(true);
  164. expect(hasStructuralKeyword('訂單狀態機的架構是怎麼實現的?')).toBe(true);
  165. });
  166. it('English derived forms fire — "architecture"/"dependencies" failed the old exact-word list', () => {
  167. expect(hasStructuralKeyword('explain the architecture of the stock module')).toBe(true);
  168. expect(hasStructuralKeyword('what are the dependencies of the parser?')).toBe(true);
  169. });
  170. it('second-tier languages fire — VI/TR/ID/PL/UA/NL/CS/RO/HU/EL/SV/NO/FI/HI', () => {
  171. expect(hasStructuralKeyword('state machine của đơn hàng hoạt động thế nào?')).toBe(true); // Vietnamese
  172. expect(hasStructuralKeyword('sipariş durum makinesi nasıl çalışıyor?')).toBe(true); // Turkish
  173. expect(hasStructuralKeyword('bu fonksiyonun bağımlılıkları neler?')).toBe(true); // Turkish (stem)
  174. expect(hasStructuralKeyword('bagaimana cara kerja mesin status pesanan?')).toBe(true); // Indonesian
  175. expect(hasStructuralKeyword('jak działa maszyna stanów zamówień?')).toBe(true); // Polish
  176. expect(hasStructuralKeyword('co wywołuje tę funkcję?')).toBe(true); // Polish (stem)
  177. expect(hasStructuralKeyword('як працює кінцевий автомат замовлень?')).toBe(true); // Ukrainian
  178. expect(hasStructuralKeyword('від чого залежить модуль оплати?')).toBe(true); // Ukrainian (stem)
  179. expect(hasStructuralKeyword('hoe werkt de state machine van bestellingen?')).toBe(true); // Dutch
  180. expect(hasStructuralKeyword('jak funguje stavový automat objednávek?')).toBe(true); // Czech
  181. expect(hasStructuralKeyword('cum funcționează mașina de stări a comenzilor?')).toBe(true); // Romanian
  182. expect(hasStructuralKeyword('hogyan működik a rendelések állapotgépe?')).toBe(true); // Hungarian
  183. expect(hasStructuralKeyword('πώς λειτουργεί η μηχανή καταστάσεων παραγγελιών;')).toBe(true); // Greek
  184. expect(hasStructuralKeyword('hur fungerar orderns tillståndsmaskin?')).toBe(true); // Swedish
  185. expect(hasStructuralKeyword('hvordan fungerer ordrenes tilstandsmaskin?')).toBe(true); // Norwegian/Danish
  186. expect(hasStructuralKeyword('miten tilausten tilakone toimii?')).toBe(true); // Finnish
  187. expect(hasStructuralKeyword('ऑर्डर स्टेट मशीन कैसे काम करती है?')).toBe(true); // Hindi
  188. });
  189. it('RTL scripts and Thai fire — proclitics/unsegmented text uses substring matching', () => {
  190. expect(hasStructuralKeyword('كيف تعمل آلة حالات الطلبات؟')).toBe(true); // Arabic
  191. expect(hasStructuralKeyword('وكيف يعتمد هذا على قاعدة البيانات؟')).toBe(true); // Arabic, proclitic و attached
  192. expect(hasStructuralKeyword('ماشین وضعیت سفارش‌ها چگونه کار می‌کند؟')).toBe(true); // Farsi
  193. expect(hasStructuralKeyword('איך עובדת מכונת המצבים של ההזמנות?')).toBe(true); // Hebrew
  194. expect(hasStructuralKeyword('สถาปัตยกรรมของระบบทำงานอย่างไร')).toBe(true); // Thai
  195. });
  196. it('terms that collide with English or code words are deliberately excluded', () => {
  197. expect(hasStructuralKeyword('pad the buffer with zeros')).toBe(false); // NL pad=path skipped
  198. expect(hasStructuralKeyword('declare a var for the count')).toBe(false); // SV var=where skipped
  199. expect(hasStructuralKeyword('refresh the token')).toBe(false); // CS tok=flow skipped
  200. expect(hasStructuralKeyword('run the llama model locally')).toBe(false); // ES bare llama skipped
  201. expect(hasStructuralKeyword('come back to this later')).toBe(false); // IT bare come skipped
  202. });
  203. it('stems match only at word start — no mid-word false positives', () => {
  204. expect(hasStructuralKeyword('restructure this paragraph')).toBe(false); // "structur" mid-word
  205. expect(hasStructuralKeyword('an independent module')).toBe(false); // "depend" mid-word
  206. expect(hasStructuralKeyword('water the flower')).toBe(false); // unchanged guarantee
  207. });
  208. it('non-structural prose stays a no-op in every covered language', () => {
  209. expect(hasStructuralKeyword('corrige cette faute de frappe')).toBe(false); // FR "fix this typo"
  210. expect(hasStructuralKeyword('arregla este error tipográfico')).toBe(false); // ES
  211. expect(hasStructuralKeyword('behebe diesen Tippfehler')).toBe(false); // DE
  212. expect(hasStructuralKeyword('исправь эту опечатку')).toBe(false); // RU
  213. expect(hasStructuralKeyword('このタイプミスを直して')).toBe(false); // JA
  214. expect(hasStructuralKeyword('이 오타를 수정해줘')).toBe(false); // KO
  215. expect(hasStructuralKeyword('sửa lỗi chính tả này')).toBe(false); // VI
  216. expect(hasStructuralKeyword('bu yazım hatasını düzelt')).toBe(false); // TR
  217. expect(hasStructuralKeyword('popraw tę literówkę')).toBe(false); // PL
  218. expect(hasStructuralKeyword('صحح هذا الخطأ الإملائي')).toBe(false); // AR
  219. });
  220. });
  221. describe('extractCodeTokens — candidate symbols the hook verifies against the graph', () => {
  222. it('pulls camelCase / PascalCase / snake_case / call / member tokens', () => {
  223. expect(extractCodeTokens('prepareArticlePublish 的调用链')).toContain('prepareArticlePublish');
  224. expect(extractCodeTokens('看看 get_user 这段逻辑')).toContain('get_user'); // snake_case
  225. expect(extractCodeTokens('render() 在哪触发')).toContain('render'); // call form
  226. expect(extractCodeTokens('user.login 做了什么').sort()).toEqual(['login', 'user']); // member access
  227. expect(extractCodeTokens('看看 UserService')).toContain('UserService'); // PascalCase class kept
  228. });
  229. it('a tech brand is extracted as a CANDIDATE — the hook’s graph check is what rejects it', () => {
  230. // This is the #994 follow-up: "JavaScript" is identifier-shaped, so it surfaces
  231. // here as a candidate; the hook only fires if it's a real symbol in the index.
  232. expect(extractCodeTokens('I really love JavaScript')).toEqual(['JavaScript']);
  233. expect(extractCodeTokens('thoughts on GitHub vs GitLab').sort()).toEqual(['GitHub', 'GitLab']);
  234. });
  235. it('ordinary prose and doc/data filenames yield no tokens', () => {
  236. expect(extractCodeTokens('fix typo in readme')).toEqual([]);
  237. expect(extractCodeTokens('fix the typo in README.md')).toEqual([]); // doc filename excluded
  238. expect(extractCodeTokens('bump the version in package.json')).toEqual([]);
  239. expect(extractCodeTokens('water the flower')).toEqual([]);
  240. });
  241. });
  242. describe('isStructuralPrompt — cheap candidate gate (keyword OR code-token)', () => {
  243. it('fires on a keyword prompt in any language', () => {
  244. expect(isStructuralPrompt('how does article publish work')).toBe(true);
  245. expect(isStructuralPrompt('介绍文章发布流程')).toBe(true);
  246. });
  247. it('fires on a code-token prompt with no keyword', () => {
  248. expect(isStructuralPrompt('看看 get_user 这段逻辑')).toBe(true);
  249. expect(isStructuralPrompt('where is prepareArticlePublish 定义')).toBe(true);
  250. expect(isStructuralPrompt('user.login 做了什么')).toBe(true);
  251. });
  252. it('a tech brand passes the CHEAP gate as a candidate — the hook then graph-verifies it', () => {
  253. // Layering, not a bug: isStructuralPrompt is shape-only, so a token-shaped brand
  254. // is a candidate here; the hook rejects it as a non-symbol (proven by the CLI e2e).
  255. expect(isStructuralPrompt('I really love JavaScript')).toBe(true);
  256. });
  257. it('non-structural prose stays a no-op — in either language', () => {
  258. expect(isStructuralPrompt('fix typo in readme')).toBe(false);
  259. expect(isStructuralPrompt('修复这个拼写错误')).toBe(false);
  260. expect(isStructuralPrompt('water the flower')).toBe(false);
  261. expect(isStructuralPrompt('')).toBe(false);
  262. });
  263. });