From b4d03eaa1dd412157b1260e19e55a423767e1bab Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 3 Apr 2026 16:32:39 +0000 Subject: [PATCH 01/16] checkpoint: pre-yolo 20260403-163239 --- ...d6e968bcc3e3f9fb8eaecdf7ea2f3d2ab41deb.png | Bin 0 -> 72307 bytes ...e22f62153becae158a32a0ef8aa8037da1508.webm | Bin 0 -> 158252 bytes ...c72b585fa3d6ad6b85b44878d2427a335bdf9de.md | 432 ++++++++++++++++++ ...2072c8aa5cd97f91093ec8cad3e5f7b48b55c66.md | 222 +++++++++ ...d00512fbfc05f51c50987f6e805c0156f31b2d.png | Bin 0 -> 13387 bytes ...b10ff67df8fe6b39c93a7519699a0d4e67b55.webm | Bin 0 -> 234633 bytes .../frontend/playwright-report/index.html | 2 +- src/main/frontend/test-results/.last-run.json | 4 + ...page@333818398abd7970554a5643c2e3eeb2.webm | 0 ...page@1643517e96d910eac709a898d06616e6.webm | 0 .../error-context.md | 134 ------ .../test-failed-1.png | Bin 4254 -> 0 bytes .../video.webm | Bin 1990 -> 0 bytes .../error-context.md | 150 ------ .../test-failed-1.png | Bin 4254 -> 0 bytes .../video.webm | Bin 1924 -> 0 bytes .../error-context.md | 185 -------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../video.webm | Bin 1924 -> 0 bytes .../error-context.md | 173 ------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../video.webm | Bin 2056 -> 0 bytes .../error-context.md | 160 ------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../video.webm | Bin 1990 -> 0 bytes .../error-context.md | 226 --------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../video.webm | Bin 1924 -> 0 bytes .../error-context.md | 226 --------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../video.webm | Bin 1924 -> 0 bytes .../error-context.md | 214 --------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../video.webm | Bin 2089 -> 0 bytes .../error-context.md | 197 -------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../video.webm | Bin 1924 -> 0 bytes .../error-context.md | 225 --------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../video.webm | Bin 2122 -> 0 bytes .../error-context.md | 226 --------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../video.webm | Bin 1990 -> 0 bytes .../error-context.md | 226 --------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../video.webm | Bin 2188 -> 0 bytes .../error-context.md | 226 --------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../video.webm | Bin 1924 -> 0 bytes .../error-context.md | 206 --------- .../test-failed-1.png | Bin 4254 -> 0 bytes .../video.webm | Bin 1924 -> 0 bytes .../error-context.md" | 189 -------- .../test-failed-1.png" | Bin 4254 -> 0 bytes .../video.webm" | Bin 30516 -> 0 bytes .../test-failed-1.png" | Bin 4254 -> 0 bytes .../error-context.md" | 189 -------- .../test-failed-1.png" | Bin 4254 -> 0 bytes .../video.webm" | Bin 29697 -> 0 bytes .../test-failed-1.png" | Bin 4254 -> 0 bytes 60 files changed, 659 insertions(+), 3153 deletions(-) create mode 100644 src/main/frontend/playwright-report/data/52d6e968bcc3e3f9fb8eaecdf7ea2f3d2ab41deb.png create mode 100644 src/main/frontend/playwright-report/data/813e22f62153becae158a32a0ef8aa8037da1508.webm create mode 100644 src/main/frontend/playwright-report/data/8c72b585fa3d6ad6b85b44878d2427a335bdf9de.md create mode 100644 src/main/frontend/playwright-report/data/b2072c8aa5cd97f91093ec8cad3e5f7b48b55c66.md create mode 100644 src/main/frontend/playwright-report/data/f1d00512fbfc05f51c50987f6e805c0156f31b2d.png create mode 100644 src/main/frontend/playwright-report/data/f6eb10ff67df8fe6b39c93a7519699a0d4e67b55.webm create mode 100644 src/main/frontend/test-results/.last-run.json delete mode 100644 src/main/frontend/test-results/.playwright-artifacts-16/page@333818398abd7970554a5643c2e3eeb2.webm delete mode 100644 src/main/frontend/test-results/.playwright-artifacts-17/page@1643517e96d910eac709a898d06616e6.webm delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-0093f-tree-has-correct-ARIA-roles-chromium/error-context.md delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-0093f-tree-has-correct-ARIA-roles-chromium/test-failed-1.png delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-0093f-tree-has-correct-ARIA-roles-chromium/video.webm delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-0398c-on-graph-view-removes-badge-chromium/error-context.md delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-0398c-on-graph-view-removes-badge-chromium/test-failed-1.png delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-0398c-on-graph-view-removes-badge-chromium/video.webm delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-2bb63-ation-ArrowDown-moves-focus-chromium/error-context.md delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-2bb63-ation-ArrowDown-moves-focus-chromium/test-failed-1.png delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-2bb63-ation-ArrowDown-moves-focus-chromium/video.webm delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-36508-Enter-selects-and-navigates-chromium/error-context.md delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-36508-Enter-selects-and-navigates-chromium/test-failed-1.png delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-36508-Enter-selects-and-navigates-chromium/video.webm delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-50ba7-e-when-sidebar-is-collapsed-chromium/error-context.md delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-50ba7-e-when-sidebar-is-collapsed-chromium/test-failed-1.png delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-50ba7-e-when-sidebar-is-collapsed-chromium/video.webm delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-58a5d-d-directory-on-second-click-chromium/error-context.md delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-58a5d-d-directory-on-second-click-chromium/test-failed-1.png delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-58a5d-d-directory-on-second-click-chromium/video.webm delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-9e7a2-rectory-expanded-by-default-chromium/error-context.md delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-9e7a2-rectory-expanded-by-default-chromium/test-failed-1.png delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-9e7a2-rectory-expanded-by-default-chromium/video.webm delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-a07f8-message-for-unmatched-query-chromium/error-context.md delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-a07f8-message-for-unmatched-query-chromium/test-failed-1.png delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-a07f8-message-for-unmatched-query-chromium/video.webm delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-b3d13-to-graph-view-on-file-click-chromium/error-context.md delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-b3d13-to-graph-view-on-file-click-chromium/test-failed-1.png delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tre-b3d13-to-graph-view-on-file-click-chromium/video.webm delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tree-clears-search-with-X-button-chromium/error-context.md delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tree-clears-search-with-X-button-chromium/test-failed-1.png delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tree-clears-search-with-X-button-chromium/video.webm delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tree-expands-directory-on-click-chromium/error-context.md delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tree-expands-directory-on-click-chromium/test-failed-1.png delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tree-expands-directory-on-click-chromium/video.webm delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tree-filters-tree-with-search-input-chromium/error-context.md delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tree-filters-tree-with-search-input-chromium/test-failed-1.png delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tree-filters-tree-with-search-input-chromium/video.webm delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tree-renders-file-tree-in-sidebar-chromium/error-context.md delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tree-renders-file-tree-in-sidebar-chromium/test-failed-1.png delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tree-renders-file-tree-in-sidebar-chromium/video.webm delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tree-shows-node-count-badges-chromium/error-context.md delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tree-shows-node-count-badges-chromium/test-failed-1.png delete mode 100644 src/main/frontend/test-results/file-tree-Project-File-Tree-shows-node-count-badges-chromium/video.webm delete mode 100644 "src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-4a1c2-controls-toolbar-is-visible-chromium/error-context.md" delete mode 100644 "src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-4a1c2-controls-toolbar-is-visible-chromium/test-failed-1.png" delete mode 100644 "src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-4a1c2-controls-toolbar-is-visible-chromium/video.webm" delete mode 100644 "src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-9271c-mb-shows-Level-0-landscape--chromium/test-failed-1.png" delete mode 100644 "src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-graph-container-is-visible-chromium/error-context.md" delete mode 100644 "src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-graph-container-is-visible-chromium/test-failed-1.png" delete mode 100644 "src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-graph-container-is-visible-chromium/video.webm" delete mode 100644 "src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-minimap-is-visible-chromium/test-failed-1.png" diff --git a/src/main/frontend/playwright-report/data/52d6e968bcc3e3f9fb8eaecdf7ea2f3d2ab41deb.png b/src/main/frontend/playwright-report/data/52d6e968bcc3e3f9fb8eaecdf7ea2f3d2ab41deb.png new file mode 100644 index 0000000000000000000000000000000000000000..dba53727248618b9657b7dbee70c115599d531a1 GIT binary patch literal 72307 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

k(#H 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 UF5=6n-H%yJNa|gofTq{~18aajod5s; literal 0 HcmV?d00001 diff --git a/src/main/frontend/playwright-report/data/813e22f62153becae158a32a0ef8aa8037da1508.webm b/src/main/frontend/playwright-report/data/813e22f62153becae158a32a0ef8aa8037da1508.webm new file mode 100644 index 0000000000000000000000000000000000000000..65ff4263913ad2700dd90e595300644ab9be6a27 GIT binary patch literal 158252 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�yng-RDAq;7Xrxv8;bn>yh4(&pgLUPRk;yb93b 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 Mt^jw(4~hMM0XqCOkpKVy literal 0 HcmV?d00001 diff --git a/src/main/frontend/playwright-report/data/8c72b585fa3d6ad6b85b44878d2427a335bdf9de.md b/src/main/frontend/playwright-report/data/8c72b585fa3d6ad6b85b44878d2427a335bdf9de.md new file mode 100644 index 00000000..e3e09aae --- /dev/null +++ b/src/main/frontend/playwright-report/data/8c72b585fa3d6ad6b85b44878d2427a335bdf9de.md @@ -0,0 +1,432 @@ +# Instructions + +- Following Playwright test failed. +- Explain why, be concise, respect Playwright best practices. +- Provide a snippet of code with the fix, if possible. + +# Test info + +- Name: search.spec.ts >> Global search >> keyboard navigation in search results (ArrowDown / Enter) +- Location: tests/e2e/search.spec.ts:106:3 + +# Error details + +``` +Error: expect(page).toHaveURL(expected) failed + +Expected pattern: /\/explorer|\/graph/ +Received string: "http://localhost:8080/" +Timeout: 5000ms + +Call log: + - Expect "toHaveURL" with timeout 5000ms + 8 × unexpected value "http://localhost:8080/" + +``` + +# Page snapshot + +```yaml +- generic [ref=e3]: + - complementary "Main navigation" [ref=e4]: + - generic [ref=e5]: + - generic [ref=e6]: + - img [ref=e7] + - generic [ref=e10]: IQ + - generic [ref=e11]: + - heading "Code IQ" [level=1] [ref=e12] + - paragraph [ref=e13]: Knowledge Graph + - button "Collapse sidebar" [ref=e14] [cursor=pointer]: + - img + - navigation [ref=e15]: + - link "Dashboard" [ref=e16] [cursor=pointer]: + - /url: / + - img [ref=e17] + - generic [ref=e22]: Dashboard + - link "Code Graph" [ref=e23] [cursor=pointer]: + - /url: /graph + - img [ref=e24] + - generic [ref=e29]: Code Graph + - link "Explorer" [ref=e30] [cursor=pointer]: + - /url: /explorer + - img [ref=e31] + - generic [ref=e35]: Explorer + - link "Console" [ref=e36] [cursor=pointer]: + - /url: /console + - img [ref=e37] + - generic [ref=e39]: Console + - link "API Docs" [ref=e40] [cursor=pointer]: + - /url: /api-docs + - img [ref=e41] + - generic [ref=e43]: API Docs + - generic [ref=e45]: + - generic [ref=e47]: + - paragraph [ref=e48]: Project Files + - generic [ref=e49]: 401 files + - generic [ref=e53]: + - img [ref=e54] + - textbox "Filter files" [ref=e57]: + - /placeholder: Filter files… + - tree "Project file tree" [ref=e58]: + - treeitem "Project 401" [expanded] [ref=e59] [cursor=pointer]: + - img [ref=e61] + - img [ref=e64] + - generic [ref=e66]: Project + - generic "401 graph nodes" [ref=e67]: "401" + - treeitem ".claude 3" [ref=e68] [cursor=pointer]: + - img [ref=e70] + - img [ref=e73] + - generic [ref=e75]: .claude + - generic "3 graph nodes" [ref=e76]: "3" + - treeitem ".github 31" [ref=e77] [cursor=pointer]: + - img [ref=e79] + - img [ref=e82] + - generic [ref=e84]: .github + - generic "31 graph nodes" [ref=e85]: "31" + - treeitem "pytest-of-dev 1" [ref=e86] [cursor=pointer]: + - img [ref=e88] + - img [ref=e91] + - generic [ref=e93]: pytest-of-dev + - generic "1 graph node" [ref=e94]: "1" + - treeitem "src 1.4k" [ref=e95] [cursor=pointer]: + - img [ref=e97] + - img [ref=e100] + - generic [ref=e102]: src + - generic "1439 graph nodes" [ref=e103]: 1.4k + - treeitem ". 1" [ref=e104] [cursor=pointer]: + - img [ref=e107] + - generic [ref=e110]: . + - generic "1 graph node" [ref=e111]: "1" + - treeitem "CLAUDE.md 43" [ref=e112] [cursor=pointer]: + - img [ref=e115] + - generic [ref=e118]: CLAUDE.md + - generic "43 graph nodes" [ref=e119]: "43" + - treeitem "README.md 53" [ref=e120] [cursor=pointer]: + - img [ref=e123] + - generic [ref=e126]: README.md + - generic "53 graph nodes" [ref=e127]: "53" + - treeitem "pom.xml 1" [ref=e128] [cursor=pointer]: + - img [ref=e131] + - generic [ref=e134]: pom.xml + - generic "1 graph node" [ref=e135]: "1" + - treeitem "sonar-project.properties 9" [ref=e136] [cursor=pointer]: + - img [ref=e139] + - generic [ref=e142]: sonar-project.properties + - generic "9 graph nodes" [ref=e143]: "9" + - generic [ref=e144]: + - banner [ref=e145]: + - generic [ref=e147]: + - generic [ref=e148]: + - img [ref=e149] + - searchbox "Search nodes, kinds, files..." [active] [ref=e152]: User + - button "Clear search" [ref=e153] [cursor=pointer]: + - img [ref=e154] + - listbox [ref=e157]: + - option "class UserService" [ref=e158] [cursor=pointer]: + - generic [ref=e159]: class + - generic [ref=e160]: UserService + - option "method findById" [ref=e161] [cursor=pointer]: + - generic [ref=e162]: method + - generic [ref=e163]: findById + - 'option "endpoint GET /users/{id}" [ref=e164] [cursor=pointer]': + - generic [ref=e165]: endpoint + - generic [ref=e166]: "GET /users/{id}" + - generic [ref=e167]: + - button "Toggle theme" [ref=e168] [cursor=pointer]: + - img + - generic [ref=e169]: Toggle theme + - button "User profile" [ref=e170] [cursor=pointer]: + - img + - main [ref=e174]: + - generic [ref=e175]: + - generic [ref=e176]: + - generic [ref=e177]: + - heading "Dashboard" [level=1] [ref=e178] + - paragraph [ref=e179]: Code knowledge graph overview + - button "Refresh stats" [ref=e180] [cursor=pointer]: + - img + - generic [ref=e181]: + - button "View Nodes in Explorer" [ref=e182] [cursor=pointer]: + - generic [ref=e185]: + - generic [ref=e186]: + - paragraph [ref=e187]: Nodes + - paragraph [ref=e188]: "0" + - paragraph [ref=e189]: Total graph nodes + - img [ref=e191] + - generic [ref=e197]: + - generic [ref=e198]: + - paragraph [ref=e199]: Edges + - paragraph [ref=e200]: "0" + - paragraph [ref=e201]: Relationships + - img [ref=e203] + - button "View Files in Explorer" [ref=e207] [cursor=pointer]: + - generic [ref=e210]: + - generic [ref=e211]: + - paragraph [ref=e212]: Files + - paragraph [ref=e213]: "0" + - paragraph [ref=e214]: Source files scanned + - img [ref=e216] + - generic [ref=e224]: + - generic [ref=e225]: + - paragraph [ref=e226]: Languages + - paragraph [ref=e227]: "0" + - paragraph [ref=e228]: Detected languages + - img [ref=e230] + - generic [ref=e235]: + - heading "Node Kinds" [level=3] [ref=e237]: + - img [ref=e238] + - text: Node Kinds + - list [ref=e241]: + - button "method 671 0" [ref=e242] [cursor=pointer]: + - generic [ref=e243]: + - generic [ref=e244]: method + - generic [ref=e245]: "671" + - progressbar [ref=e246] + - button "class 421 0" [ref=e247] [cursor=pointer]: + - generic [ref=e248]: + - generic [ref=e249]: class + - generic [ref=e250]: "421" + - progressbar [ref=e251] + - button "config_key 166 0" [ref=e252] [cursor=pointer]: + - generic [ref=e253]: + - generic [ref=e254]: config_key + - generic [ref=e255]: "166" + - progressbar [ref=e256] + - button "endpoint 74 0" [ref=e257] [cursor=pointer]: + - generic [ref=e258]: + - generic [ref=e259]: endpoint + - generic [ref=e260]: "74" + - progressbar [ref=e261] + - button "module 56 0" [ref=e262] [cursor=pointer]: + - generic [ref=e263]: + - generic [ref=e264]: module + - generic [ref=e265]: "56" + - progressbar [ref=e266] + - button "interface 54 0" [ref=e267] [cursor=pointer]: + - generic [ref=e268]: + - generic [ref=e269]: interface + - generic [ref=e270]: "54" + - progressbar [ref=e271] + - button "middleware 32 0" [ref=e272] [cursor=pointer]: + - generic [ref=e273]: + - generic [ref=e274]: middleware + - generic [ref=e275]: "32" + - progressbar [ref=e276] + - button "component 26 0" [ref=e277] [cursor=pointer]: + - generic [ref=e278]: + - generic [ref=e279]: component + - generic [ref=e280]: "26" + - progressbar [ref=e281] + - button "query 23 0" [ref=e282] [cursor=pointer]: + - generic [ref=e283]: + - generic [ref=e284]: query + - generic [ref=e285]: "23" + - progressbar [ref=e286] + - button "guard 19 0" [ref=e287] [cursor=pointer]: + - generic [ref=e288]: + - generic [ref=e289]: guard + - generic [ref=e290]: "19" + - progressbar [ref=e291] + - button "abstract_class 17 0" [ref=e292] [cursor=pointer]: + - generic [ref=e293]: + - generic [ref=e294]: abstract_class + - generic [ref=e295]: "17" + - progressbar [ref=e296] + - button "config_file 15 0" [ref=e297] [cursor=pointer]: + - generic [ref=e298]: + - generic [ref=e299]: config_file + - generic [ref=e300]: "15" + - progressbar [ref=e301] + - button "event 12 0" [ref=e302] [cursor=pointer]: + - generic [ref=e303]: + - generic [ref=e304]: event + - generic [ref=e305]: "12" + - progressbar [ref=e306] + - button "queue 10 0" [ref=e307] [cursor=pointer]: + - generic [ref=e308]: + - generic [ref=e309]: queue + - generic [ref=e310]: "10" + - progressbar [ref=e311] +``` + +# Test source + +```ts + 19 | await mockStats(page); + 20 | + 21 | // Mock search API + 22 | await page.route('**/api/search**', route => + 23 | route.fulfill({ + 24 | status: 200, + 25 | contentType: 'application/json', + 26 | body: JSON.stringify(MOCK_SEARCH_RESULTS), + 27 | }) + 28 | ); + 29 | }); + 30 | + 31 | test('search box is visible in header on all views', async ({ page }) => { + 32 | for (const route of Object.values(ROUTES)) { + 33 | await gotoRoute(page, route); + 34 | await expect(page.getByRole('searchbox')).toBeVisible(); + 35 | } + 36 | }); + 37 | + 38 | test('typing fewer than 2 characters does not trigger search', async ({ page }) => { + 39 | await gotoRoute(page, ROUTES.dashboard); + 40 | let searchCalled = false; + 41 | await page.route('**/api/search**', () => { searchCalled = true; }); + 42 | + 43 | await page.getByRole('searchbox').fill('U'); + 44 | await page.waitForTimeout(400); // debounce window + 45 | + 46 | expect(searchCalled).toBe(false); + 47 | }); + 48 | + 49 | test('typing 2+ characters triggers search with debounce', async ({ page }) => { + 50 | await gotoRoute(page, ROUTES.dashboard); + 51 | const searchBox = page.getByRole('searchbox'); + 52 | await searchBox.fill('User'); + 53 | + 54 | // Wait for debounce (300ms) + render + 55 | const dropdown = page.locator('[data-testid="search-dropdown"]'); + 56 | await expect(dropdown).toBeVisible({ timeout: 1000 }); + 57 | }); + 58 | + 59 | test('search results show correct names and kinds', async ({ page }) => { + 60 | await gotoRoute(page, ROUTES.dashboard); + 61 | await page.getByRole('searchbox').fill('User'); + 62 | + 63 | const dropdown = page.locator('[data-testid="search-dropdown"]'); + 64 | await expect(dropdown).toBeVisible({ timeout: 1000 }); + 65 | + 66 | await expect(dropdown.getByText('UserService')).toBeVisible(); + 67 | await expect(dropdown.getByText('findById')).toBeVisible(); + 68 | await expect(dropdown.getByText('GET /users/{id}')).toBeVisible(); + 69 | }); + 70 | + 71 | test('clicking a result navigates to the Explorer view', async ({ page }) => { + 72 | await gotoRoute(page, ROUTES.dashboard); + 73 | await page.getByRole('searchbox').fill('User'); + 74 | + 75 | const dropdown = page.locator('[data-testid="search-dropdown"]'); + 76 | await expect(dropdown).toBeVisible({ timeout: 1000 }); + 77 | await dropdown.getByText('UserService').click(); + 78 | + 79 | // Should navigate to explorer with the selected node + 80 | await expect(page).toHaveURL(/\/explorer/); + 81 | }); + 82 | + 83 | test('pressing Escape clears search dropdown', async ({ page }) => { + 84 | await gotoRoute(page, ROUTES.dashboard); + 85 | await page.getByRole('searchbox').fill('User'); + 86 | + 87 | const dropdown = page.locator('[data-testid="search-dropdown"]'); + 88 | await expect(dropdown).toBeVisible({ timeout: 1000 }); + 89 | + 90 | await page.keyboard.press('Escape'); + 91 | await expect(dropdown).not.toBeVisible(); + 92 | }); + 93 | + 94 | test('clicking outside search closes the dropdown', async ({ page }) => { + 95 | await gotoRoute(page, ROUTES.dashboard); + 96 | await page.getByRole('searchbox').fill('User'); + 97 | + 98 | const dropdown = page.locator('[data-testid="search-dropdown"]'); + 99 | await expect(dropdown).toBeVisible({ timeout: 1000 }); + 100 | + 101 | // Click somewhere outside the search bar + 102 | await page.locator('main').click({ position: { x: 10, y: 10 }, force: true }); + 103 | await expect(dropdown).not.toBeVisible(); + 104 | }); + 105 | + 106 | test('keyboard navigation in search results (ArrowDown / Enter)', async ({ page }) => { + 107 | await gotoRoute(page, ROUTES.dashboard); + 108 | const searchBox = page.getByRole('searchbox'); + 109 | await searchBox.fill('User'); + 110 | + 111 | const dropdown = page.locator('[data-testid="search-dropdown"]'); + 112 | await expect(dropdown).toBeVisible({ timeout: 1000 }); + 113 | + 114 | // Navigate with ArrowDown and select with Enter + 115 | await page.keyboard.press('ArrowDown'); + 116 | await page.keyboard.press('Enter'); + 117 | + 118 | // Should have navigated +> 119 | await expect(page).toHaveURL(/\/explorer|\/graph/); + | ^ Error: expect(page).toHaveURL(expected) failed + 120 | }); + 121 | + 122 | test('loading indicator shows while search is in progress', async ({ page }) => { + 123 | // Slow down the search response to see the loading state + 124 | await page.route('**/api/search**', async route => { + 125 | await new Promise(resolve => setTimeout(resolve, 300)); + 126 | await route.fulfill({ + 127 | status: 200, + 128 | contentType: 'application/json', + 129 | body: JSON.stringify(MOCK_SEARCH_RESULTS), + 130 | }); + 131 | }); + 132 | + 133 | await gotoRoute(page, ROUTES.dashboard); + 134 | await page.getByRole('searchbox').fill('User'); + 135 | + 136 | // Loading indicator should appear briefly + 137 | const spinner = page.locator('[data-testid="search-spinner"]'); + 138 | await expect(spinner).toBeVisible({ timeout: 500 }); + 139 | }); + 140 | + 141 | test('empty search results shows "no results" message', async ({ page }) => { + 142 | await page.route('**/api/search**', route => + 143 | route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) + 144 | ); + 145 | + 146 | await gotoRoute(page, ROUTES.dashboard); + 147 | await page.getByRole('searchbox').fill('xyznonexistent'); + 148 | + 149 | const dropdown = page.locator('[data-testid="search-dropdown"]'); + 150 | await expect(dropdown).toBeVisible({ timeout: 1000 }); + 151 | await expect(dropdown).toContainText(/no results/i); + 152 | }); + 153 | }); + 154 | + 155 | // ── File tree filtering (Phase 2 Frontend) ──────────────────────────────────── + 156 | + 157 | test.describe('File tree search integration', () => { + 158 | test.beforeEach(async ({ page }) => { + 159 | await mockStats(page); + 160 | await page.route('**/api/file-tree**', route => + 161 | route.fulfill({ + 162 | status: 200, + 163 | contentType: 'application/json', + 164 | body: JSON.stringify({ + 165 | name: 'root', + 166 | children: [ + 167 | { name: 'src', children: [ + 168 | { name: 'main', children: [ + 169 | { name: 'java', children: [ + 170 | { name: 'UserService.java', nodeCount: 5 }, + 171 | { name: 'UserController.java', nodeCount: 3 }, + 172 | ]}, + 173 | ]}, + 174 | ]}, + 175 | ], + 176 | }), + 177 | }) + 178 | ); + 179 | }); + 180 | + 181 | test('typing in search filters the file tree', async ({ page }) => { + 182 | await gotoRoute(page, ROUTES.explorer); + 183 | await page.getByRole('searchbox').fill('UserService'); + 184 | + 185 | // File tree should filter to show only matching files + 186 | const tree = page.locator('[data-testid="file-tree"]'); + 187 | if (await tree.isVisible()) { + 188 | await expect(tree.getByText('UserService.java')).toBeVisible(); + 189 | // Non-matching file should be hidden + 190 | await expect(tree.getByText('UserController.java')).not.toBeVisible(); + 191 | } + 192 | }); + 193 | }); + 194 | +``` \ No newline at end of file diff --git a/src/main/frontend/playwright-report/data/b2072c8aa5cd97f91093ec8cad3e5f7b48b55c66.md b/src/main/frontend/playwright-report/data/b2072c8aa5cd97f91093ec8cad3e5f7b48b55c66.md new file mode 100644 index 00000000..eac5b650 --- /dev/null +++ b/src/main/frontend/playwright-report/data/b2072c8aa5cd97f91093ec8cad3e5f7b48b55c66.md @@ -0,0 +1,222 @@ +# Instructions + +- Following Playwright test failed. +- Explain why, be concise, respect Playwright best practices. +- Provide a snippet of code with the fix, if possible. + +# Test info + +- Name: search.spec.ts >> File tree search integration >> typing in search filters the file tree +- Location: tests/e2e/search.spec.ts:181:3 + +# Error details + +``` +Test timeout of 30000ms exceeded. +``` + +``` +TimeoutError: page.waitForSelector: Timeout 30000ms exceeded. +Call log: + - waiting for locator('main') to be visible + +``` + +# Page snapshot + +```yaml +- generic [ref=e3]: + - heading "Something went wrong" [level=1] [ref=e4] + - paragraph [ref=e5]: Cannot read properties of undefined (reading 'toLocaleString') + - button "Reload page" [ref=e6] [cursor=pointer] +``` + +# Test source + +```ts + 1 | /// + 2 | import { type Page, expect } from '@playwright/test'; + 3 | import { readFileSync, existsSync } from 'node:fs'; + 4 | import { resolve } from 'node:path'; + 5 | + 6 | // ── Route helpers ──────────────────────────────────────────────────────────── + 7 | + 8 | export const ROUTES = { + 9 | dashboard: '/', + 10 | graph: '/graph', + 11 | explorer: '/explorer', + 12 | console: '/console', + 13 | apiDocs: '/api-docs', + 14 | } as const; + 15 | + 16 | export type AppRoute = (typeof ROUTES)[keyof typeof ROUTES]; + 17 | + 18 | /** + 19 | * Intercept the HTML shell served by Spring Boot and replace it with the + 20 | * current on-disk version. The running JAR may contain a stale index.html + 21 | * (built before the last frontend rebuild), causing it to load an old + 22 | * JS bundle that crashes before React mounts. + 23 | * + 24 | * Bug: STALE_BUNDLE — tracked in RAN-80 (filed separately). + 25 | */ + 26 | export async function patchIndexHtml(page: Page) { + 27 | // process.cwd() is the frontend dir when running `npx playwright test` + 28 | const staticDir = resolve(process.cwd(), '../resources/static'); + 29 | const diskHtml = readFileSync(resolve(staticDir, 'index.html'), 'utf-8'); + 30 | + 31 | const CONTENT_TYPES: Record = { + 32 | '.js': 'application/javascript', + 33 | '.mjs': 'application/javascript', + 34 | '.css': 'text/css', + 35 | '.svg': 'image/svg+xml', + 36 | '.png': 'image/png', + 37 | '.ico': 'image/x-icon', + 38 | '.woff2': 'font/woff2', + 39 | '.woff': 'font/woff', + 40 | }; + 41 | + 42 | // Intercept the SPA shell route (all navigation routes return the same HTML) + 43 | await page.route('**/*', async (route) => { + 44 | const req = route.request(); + 45 | const url = req.url(); + 46 | + 47 | // Serve HTML shell from disk + 48 | if ( + 49 | req.resourceType() === 'document' && + 50 | !url.includes('/api/') && + 51 | !url.includes('/swagger') && + 52 | !url.includes('/v3/') + 53 | ) { + 54 | await route.fulfill({ status: 200, contentType: 'text/html', body: diskHtml }); + 55 | return; + 56 | } + 57 | + 58 | // Serve static assets from disk if available (fixes stale-JAR bundle mismatch) + 59 | const assetMatch = url.match(/\/assets\/([^?#]+)/); + 60 | if (assetMatch) { + 61 | const assetName = assetMatch[1]; + 62 | const diskPath = resolve(staticDir, 'assets', assetName); + 63 | const ext = assetName.includes('.') ? '.' + assetName.split('.').pop()! : ''; + 64 | if (existsSync(diskPath)) { + 65 | const body = readFileSync(diskPath); + 66 | await route.fulfill({ + 67 | status: 200, + 68 | contentType: CONTENT_TYPES[ext] ?? 'application/octet-stream', + 69 | body, + 70 | }); + 71 | return; + 72 | } + 73 | } + 74 | + 75 | await route.fallback(); + 76 | }); + 77 | } + 78 | + 79 | /** Navigate to a route and wait for the main content area to be visible. */ + 80 | export async function gotoRoute(page: Page, route: AppRoute) { + 81 | await patchIndexHtml(page); + 82 | await page.goto(route); + 83 | // Wait for React to hydrate (main rendered by Layout component) +> 84 | await page.waitForSelector('main', { state: 'visible', timeout: 30000 }); + | ^ TimeoutError: page.waitForSelector: Timeout 30000ms exceeded. + 85 | } + 86 | + 87 | // ── Theme helpers ──────────────────────────────────────────────────────────── + 88 | + 89 | /** Returns the current theme: 'dark' | 'light'. */ + 90 | export async function getTheme(page: Page): Promise<'dark' | 'light'> { + 91 | const cls = await page.locator('html').getAttribute('class') ?? ''; + 92 | return cls.includes('dark') ? 'dark' : 'light'; + 93 | } + 94 | + 95 | /** Click the theme toggle and wait for the class to flip. */ + 96 | export async function toggleTheme(page: Page) { + 97 | const before = await getTheme(page); + 98 | // Theme toggle button — uses aria-label or data-testid set by the component + 99 | await page.getByRole('button', { name: /toggle theme|switch theme|dark mode|light mode/i }).click(); + 100 | await expect(page.locator('html')).toHaveClass(before === 'dark' ? /light/ : /dark/, { timeout: 2000 }); + 101 | } + 102 | + 103 | // ── API mock helpers ───────────────────────────────────────────────────────── + 104 | + 105 | /** Seed the `/api/stats` mock for deterministic dashboard tests. */ + 106 | export async function mockStats(page: Page, nodeCount = 1234, edgeCount = 5678) { + 107 | await page.route('**/api/stats', route => + 108 | route.fulfill({ + 109 | status: 200, + 110 | contentType: 'application/json', + 111 | body: JSON.stringify({ + 112 | totalNodes: nodeCount, + 113 | totalEdges: edgeCount, + 114 | nodesByKind: { endpoint: 10, class: 20, method: 30 }, + 115 | edgesByKind: { calls: 100, depends_on: 50 }, + 116 | languages: { java: 500, typescript: 200 }, + 117 | frameworks: { spring_boot: 300 }, + 118 | layers: { backend: 600, frontend: 200, infra: 100, shared: 50, unknown: 284 }, + 119 | }), + 120 | }) + 121 | ); + 122 | } + 123 | + 124 | /** + 125 | * Generate a synthetic node list for performance/stress tests. + 126 | * Returns a NodesListResponse-shaped object. + 127 | */ + 128 | export function generateNodeList(count: number) { + 129 | const nodes = Array.from({ length: count }, (_, i) => ({ + 130 | id: `node:file${i % 100}.ts:class:Class${i}`, + 131 | kind: ['class', 'method', 'endpoint', 'entity', 'function'][i % 5], + 132 | name: `Symbol${i}`, + 133 | qualifiedName: `com.example.Symbol${i}`, + 134 | filePath: `src/file${i % 100}.ts`, + 135 | layer: 'backend', + 136 | framework: null, + 137 | properties: {}, + 138 | })); + 139 | return { nodes, total: count, offset: 0, limit: count }; + 140 | } + 141 | + 142 | /** Seed the `/api/kinds` + `/api/nodes` endpoints with synthetic data. */ + 143 | export async function mockGraphData(page: Page, nodeCount: number) { + 144 | const data = generateNodeList(nodeCount); + 145 | + 146 | await page.route('**/api/kinds', route => + 147 | route.fulfill({ + 148 | status: 200, + 149 | contentType: 'application/json', + 150 | body: JSON.stringify({ + 151 | kinds: [ + 152 | { kind: 'class', count: Math.floor(nodeCount * 0.3) }, + 153 | { kind: 'method', count: Math.floor(nodeCount * 0.3) }, + 154 | { kind: 'endpoint', count: Math.floor(nodeCount * 0.15) }, + 155 | { kind: 'entity', count: Math.floor(nodeCount * 0.15) }, + 156 | { kind: 'function', count: Math.floor(nodeCount * 0.1) }, + 157 | ], + 158 | }), + 159 | }) + 160 | ); + 161 | + 162 | await page.route('**/api/nodes**', route => + 163 | route.fulfill({ + 164 | status: 200, + 165 | contentType: 'application/json', + 166 | body: JSON.stringify(data), + 167 | }) + 168 | ); + 169 | + 170 | await page.route('**/api/topology', route => + 171 | route.fulfill({ + 172 | status: 200, + 173 | contentType: 'application/json', + 174 | body: JSON.stringify({ + 175 | services: [ + 176 | { name: 'api-service', nodeCount: Math.floor(nodeCount / 3), dependencies: ['db-service'] }, + 177 | { name: 'db-service', nodeCount: Math.floor(nodeCount / 3), dependencies: [] }, + 178 | { name: 'frontend-service', nodeCount: Math.floor(nodeCount / 3), dependencies: ['api-service'] }, + 179 | ], + 180 | }), + 181 | }) + 182 | ); + 183 | } + 184 | +``` \ No newline at end of file diff --git a/src/main/frontend/playwright-report/data/f1d00512fbfc05f51c50987f6e805c0156f31b2d.png b/src/main/frontend/playwright-report/data/f1d00512fbfc05f51c50987f6e805c0156f31b2d.png new file mode 100644 index 0000000000000000000000000000000000000000..2e81505d64fc7c0b441ffa32524daad0e728d1bf GIT binary patch literal 13387 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 AkpKVy literal 0 HcmV?d00001 diff --git a/src/main/frontend/playwright-report/data/f6eb10ff67df8fe6b39c93a7519699a0d4e67b55.webm b/src/main/frontend/playwright-report/data/f6eb10ff67df8fe6b39c93a7519699a0d4e67b55.webm new file mode 100644 index 0000000000000000000000000000000000000000..a092c5ee93de05d22c71780d75fef5b49a1f42d1 GIT binary patch literal 234633 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 QdHiDx(d9lQ0CVeq0HLh+9{>OV literal 0 HcmV?d00001 diff --git a/src/main/frontend/playwright-report/index.html b/src/main/frontend/playwright-report/index.html index 44aea3e0..d3c4cd5f 100644 --- a/src/main/frontend/playwright-report/index.html +++ b/src/main/frontend/playwright-report/index.html @@ -87,4 +87,4 @@

- \ No newline at end of file + \ No newline at end of file diff --git a/src/main/frontend/test-results/.last-run.json b/src/main/frontend/test-results/.last-run.json new file mode 100644 index 00000000..cbcc1fba --- /dev/null +++ b/src/main/frontend/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/src/main/frontend/test-results/.playwright-artifacts-16/page@333818398abd7970554a5643c2e3eeb2.webm b/src/main/frontend/test-results/.playwright-artifacts-16/page@333818398abd7970554a5643c2e3eeb2.webm deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/frontend/test-results/.playwright-artifacts-17/page@1643517e96d910eac709a898d06616e6.webm b/src/main/frontend/test-results/.playwright-artifacts-17/page@1643517e96d910eac709a898d06616e6.webm deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-0093f-tree-has-correct-ARIA-roles-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tre-0093f-tree-has-correct-ARIA-roles-chromium/error-context.md deleted file mode 100644 index d7cc8b9a..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tre-0093f-tree-has-correct-ARIA-roles-chromium/error-context.md +++ /dev/null @@ -1,134 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> accessibility: tree has correct ARIA roles -- Location: tests/e2e/file-tree.spec.ts:252:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { - 227 | await page.goto('/'); - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); - 234 | }); - 235 | - 236 | test('clearing file filter on graph view removes badge', async ({ page }) => { - 237 | await page.goto('/'); - 238 | await page.waitForSelector('[aria-label="Project file tree"]'); - 239 | - 240 | // Navigate via file click - 241 | await page.getByTestId('tree-node-pom.xml').click(); - 242 | await expect(page).toHaveURL(/\/graph/); - 243 | - 244 | const badge = page.getByTestId('file-filter-badge'); - 245 | await expect(badge).toBeVisible(); - 246 | - 247 | // Click X on the badge - 248 | await page.getByRole('button', { name: 'Clear file filter' }).click(); - 249 | await expect(badge).not.toBeVisible(); - 250 | }); - 251 | - 252 | test('accessibility: tree has correct ARIA roles', async ({ page }) => { -> 253 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 254 | await page.waitForSelector('[aria-label="Project file tree"]'); - 255 | - 256 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 257 | const items = page.locator('[role="treeitem"]'); - 258 | await expect(items).not.toHaveCount(0); - 259 | }); - 260 | }); - 261 | -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-0093f-tree-has-correct-ARIA-roles-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tre-0093f-tree-has-correct-ARIA-roles-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b98da0d74381bd9f7089dc0c99ac14baf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4254 zcmeAS@N?(olHy`uVBq!ia0y~yU#KiDjWttl9P!CsJDrMnSo)#sPJf*j3$WD+%Q@c zj24fhb;D@IINB;0Z4!+(6S23Efi38O(f0CadwI0IJlb9!bnWG?%4I8oO;~r(SOCQZ p_y?d#|NkF7qH+kxU;`P+%<#gUW5K!N4KhFx22WQ%mvv4FO#o+H0uulL diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-0093f-tree-has-correct-ARIA-roles-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tre-0093f-tree-has-correct-ARIA-roles-chromium/video.webm deleted file mode 100644 index 78cda28529546cd608d0add0ccea08a10313b29a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1990 zcmb1gy}x+AQ(GgW({~{L)X3uWxsk)EsiizMDc7mJk;$pGkx3%BA)S!{1lXqu`pz!d z<-5B(cy)`Y=gPF;HH`})Jh6~<*+AYk-`zbxIiZll>A`E77&ReWnc&?($tL!OHxP3e zBEiPdf&jT{gVyzp&HPRdz70J-iDhYKhI&9~U=Z$z!gcBFaG2l#b_2-q=hi{p=6b!6 zvG{1mwnL{^NqaJL%xPp$+B&DXG%uy2k)f?MEIus2qPdY#`Ehd#Q&JSegJ~O@m=$(4 zF&>)L$P~JvBXm2E+R?}a3V~n6#q*O7G^C%H>AJYx)z8`A#Wl#K9mNsF#aELLbSERM zOFyvyS@C3`2ahWtl?hQ$jT z89p#<1lljK0WQz5pza+IJa_o_HLtP(Xau7K#E1sC5||f$ZUi}j(E%bKgCYONi(&En zM#cIv5K6bxMF0?0_gx!BlX!0<7Qx0R>DPgcKMMQV>B%K?5NL zD+npLKuEz40t%QF2r2L&q@aM1f*FJq93Z6N1wI9!nsnms{>_aH+q-}@_Qxj16(1Uz E0njV2ApigX diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-0398c-on-graph-view-removes-badge-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tre-0398c-on-graph-view-removes-badge-chromium/error-context.md deleted file mode 100644 index e494b664..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tre-0398c-on-graph-view-removes-badge-chromium/error-context.md +++ /dev/null @@ -1,150 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> clearing file filter on graph view removes badge -- Location: tests/e2e/file-tree.spec.ts:236:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { - 227 | await page.goto('/'); - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); - 234 | }); - 235 | - 236 | test('clearing file filter on graph view removes badge', async ({ page }) => { -> 237 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 238 | await page.waitForSelector('[aria-label="Project file tree"]'); - 239 | - 240 | // Navigate via file click - 241 | await page.getByTestId('tree-node-pom.xml').click(); - 242 | await expect(page).toHaveURL(/\/graph/); - 243 | - 244 | const badge = page.getByTestId('file-filter-badge'); - 245 | await expect(badge).toBeVisible(); - 246 | - 247 | // Click X on the badge - 248 | await page.getByRole('button', { name: 'Clear file filter' }).click(); - 249 | await expect(badge).not.toBeVisible(); - 250 | }); - 251 | - 252 | test('accessibility: tree has correct ARIA roles', async ({ page }) => { - 253 | await page.goto('/'); - 254 | await page.waitForSelector('[aria-label="Project file tree"]'); - 255 | - 256 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 257 | const items = page.locator('[role="treeitem"]'); - 258 | await expect(items).not.toHaveCount(0); - 259 | }); - 260 | }); - 261 | -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-0398c-on-graph-view-removes-badge-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tre-0398c-on-graph-view-removes-badge-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b98da0d74381bd9f7089dc0c99ac14baf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4254 zcmeAS@N?(olHy`uVBq!ia0y~yU#KiDjWttl9P!CsJDrMnSo)#sPJf*j3$WD+%Q@c zj24fhb;D@IINB;0Z4!+(6S23Efi38O(f0CadwI0IJlb9!bnWG?%4I8oO;~r(SOCQZ p_y?d#|NkF7qH+kxU;`P+%<#gUW5K!N4KhFx22WQ%mvv4FO#o+H0uulL diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-0398c-on-graph-view-removes-badge-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tre-0398c-on-graph-view-removes-badge-chromium/video.webm deleted file mode 100644 index 776575409b75bd810b6e855c6754049e63af3c97..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1924 zcmb1gy}x+AQ(GgW({~{L)X3uWxsk)EsiizMDc7mJk;$pGkx3%BA)S!{1lU6aeP@^K z^4;AXyt+lyb7flan#P3?o><7bY#{HN@9rL;oKVQ&^x!p3jG7RxOz>`?WD~pH8;Cg! zkznI!L4aJfLF@X)W_~9J--aHa#IiIqLp>lgFbH=<;ktBoIP^iB0CN1fbx^mtUT8v90rz=9d3+8X1(f&S@^qOX+B2Xlo6N4-2qpZe&z`+}y&H6vgmh+J+`(gL8w-43L7G%|rg;8$_+{Nw`-=_h8oE^c@AbM|*}4RUEmaYS+P)#L-+$q4Jx zPb@%IJQ?VLWS|F(K^{=Z$xJFMs7ODt&~$&=3v7VPGc2fk2L#U@{(a4>YycX;C;>5|0j>n*g`XQiPGEF^$j4yF|M6m2Jin1K z10wH$A^*3m0IXmFM1c;5f`6S7U*@PmK?W(7hDJP0W$Af#XhJ_Vq9aN_R%&5aD(yMXoW$0o)V9~zkf<;Ilp diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-2bb63-ation-ArrowDown-moves-focus-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tre-2bb63-ation-ArrowDown-moves-focus-chromium/error-context.md deleted file mode 100644 index f1c3bf43..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tre-2bb63-ation-ArrowDown-moves-focus-chromium/error-context.md +++ /dev/null @@ -1,185 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> keyboard navigation: ArrowDown moves focus -- Location: tests/e2e/file-tree.spec.ts:201:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 102 | await mockMinimalApis(page); - 103 | }); - 104 | - 105 | test('renders file tree in sidebar', async ({ page }) => { - 106 | await page.goto('/'); - 107 | await page.waitForSelector('[aria-label="Project file tree"]'); - 108 | await expect(page.getByText('Project Files')).toBeVisible(); - 109 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 110 | }); - 111 | - 112 | test('shows root directory expanded by default', async ({ page }) => { - 113 | await page.goto('/'); - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { - 122 | await page.goto('/'); - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { -> 202 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { - 227 | await page.goto('/'); - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); - 234 | }); - 235 | - 236 | test('clearing file filter on graph view removes badge', async ({ page }) => { - 237 | await page.goto('/'); - 238 | await page.waitForSelector('[aria-label="Project file tree"]'); - 239 | - 240 | // Navigate via file click - 241 | await page.getByTestId('tree-node-pom.xml').click(); - 242 | await expect(page).toHaveURL(/\/graph/); - 243 | - 244 | const badge = page.getByTestId('file-filter-badge'); - 245 | await expect(badge).toBeVisible(); - 246 | - 247 | // Click X on the badge - 248 | await page.getByRole('button', { name: 'Clear file filter' }).click(); - 249 | await expect(badge).not.toBeVisible(); - 250 | }); - 251 | - 252 | test('accessibility: tree has correct ARIA roles', async ({ page }) => { - 253 | await page.goto('/'); - 254 | await page.waitForSelector('[aria-label="Project file tree"]'); - 255 | - 256 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 257 | const items = page.locator('[role="treeitem"]'); - 258 | await expect(items).not.toHaveCount(0); - 259 | }); - 260 | }); - 261 | -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-2bb63-ation-ArrowDown-moves-focus-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tre-2bb63-ation-ArrowDown-moves-focus-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b98da0d74381bd9f7089dc0c99ac14baf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4254 zcmeAS@N?(olHy`uVBq!ia0y~yU#KiDjWttl9P!CsJDrMnSo)#sPJf*j3$WD+%Q@c zj24fhb;D@IINB;0Z4!+(6S23Efi38O(f0CadwI0IJlb9!bnWG?%4I8oO;~r(SOCQZ p_y?d#|NkF7qH+kxU;`P+%<#gUW5K!N4KhFx22WQ%mvv4FO#o+H0uulL diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-2bb63-ation-ArrowDown-moves-focus-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tre-2bb63-ation-ArrowDown-moves-focus-chromium/video.webm deleted file mode 100644 index b1064e043baf3788d37e90fd15719a4f5b3b8da4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1924 zcmb1gy}x+AQ(GgW({~{L)X3uWxsk)EsiizMDc7mJk;$pGkx3%BA)S!{1lU6aeP@^K z^4;AXyt+lyb7flan#P3?o><7bY#{HN@9rL;oKVQ&^x!p3jG7RxOz>`?WD~pH8;Cg! zkznI!L4aJfLF@X)W_~9J--aHa#IiIqLp>lgFbH=<;ktBoIP^iB0CN1fbx^mtUTpieh*$Z9@~Y!mcL9 zL$ey0LN|1TZU<638ks;L@T<6Ze)55a^b<2(7q`3mIs3b~2D!AOIHI`tYVv{ZWQ29; zCl(+po(%LrGSCCYAP*?yWG0mrRHUC+=(@PWB{aw}#M9r;rG2u2ffX3(S(+I@0E4{q zwo=y#&l?#QJ56k4U}(7D&oGx!OM&?iBZE6fYiq~MmJX&S1~xI4?`(}J^RsWyR=21g zMWZ1=(-2TQ!SL^YO#{Q?rHu?441vrH+=2-V5)2agUwFiSv9WRgWoBUbdlv|9H2iy~ zz_55;BSQc~Arq1^4EYL(ya&`6B>5d+{R<%S0vPfiI2}Odp8(4XJi(CvDA2HYVI#u_ zhK)e`1vbFt85Y#N1A^xc|Gwr`HUN!alzB1uB>d?pA;mJRqQeiGz>=140TS2q|bFq+kUh v1s4b@_(4DcvjQOn9)uJW5K=G$p8`-lIB|FX=0=9?UBLSGV-w?w4~@(KqZg8S diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-36508-Enter-selects-and-navigates-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tre-36508-Enter-selects-and-navigates-chromium/error-context.md deleted file mode 100644 index 8f5f130a..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tre-36508-Enter-selects-and-navigates-chromium/error-context.md +++ /dev/null @@ -1,173 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> keyboard navigation: Enter selects and navigates -- Location: tests/e2e/file-tree.spec.ts:213:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { - 122 | await page.goto('/'); - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { -> 214 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { - 227 | await page.goto('/'); - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); - 234 | }); - 235 | - 236 | test('clearing file filter on graph view removes badge', async ({ page }) => { - 237 | await page.goto('/'); - 238 | await page.waitForSelector('[aria-label="Project file tree"]'); - 239 | - 240 | // Navigate via file click - 241 | await page.getByTestId('tree-node-pom.xml').click(); - 242 | await expect(page).toHaveURL(/\/graph/); - 243 | - 244 | const badge = page.getByTestId('file-filter-badge'); - 245 | await expect(badge).toBeVisible(); - 246 | - 247 | // Click X on the badge - 248 | await page.getByRole('button', { name: 'Clear file filter' }).click(); - 249 | await expect(badge).not.toBeVisible(); - 250 | }); - 251 | - 252 | test('accessibility: tree has correct ARIA roles', async ({ page }) => { - 253 | await page.goto('/'); - 254 | await page.waitForSelector('[aria-label="Project file tree"]'); - 255 | - 256 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 257 | const items = page.locator('[role="treeitem"]'); - 258 | await expect(items).not.toHaveCount(0); - 259 | }); - 260 | }); - 261 | -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-36508-Enter-selects-and-navigates-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tre-36508-Enter-selects-and-navigates-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b98da0d74381bd9f7089dc0c99ac14baf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4254 zcmeAS@N?(olHy`uVBq!ia0y~yU#KiDjWttl9P!CsJDrMnSo)#sPJf*j3$WD+%Q@c zj24fhb;D@IINB;0Z4!+(6S23Efi38O(f0CadwI0IJlb9!bnWG?%4I8oO;~r(SOCQZ p_y?d#|NkF7qH+kxU;`P+%<#gUW5K!N4KhFx22WQ%mvv4FO#o+H0uulL diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-36508-Enter-selects-and-navigates-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tre-36508-Enter-selects-and-navigates-chromium/video.webm deleted file mode 100644 index 75016b4b3d10ed2828d325020740f7f5a3987f26..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2056 zcmb1gy}x+AQ(GgW({~{L)X3uWxsk)EsiizMDc7mJk;$pGkx3%BA)S!{1lVr~`pz!d z<-5B(cy)`Y=gPF;HH`})Jh6~<*+AYk-`zbxIiZll>A`E77&ReWnc&?($tLzgZy@F{ zM1qZ@1p#u^2CeHGoB5p_d>eXv63f!e4E2D}z#!Zah3nGU;V`iQ>;{nI&#i;H&GmXC zWAV|B=g)dX^yf40ozuvmv~^B%XA7W&1=V)#1nAy_7)WpCh#`2x5F=c-C?b+%U z)uU)M1ZWxpY9|=}{jX_YSiH26L4zTXnSom{fkA>nBL54I_%Aj#?!U|o41eze!HtH0 z?-Upo&ue4|U?^llQidU40g?BB8iORi1FU}mL|y2>CO{PEU?}+4DFIfn1EN3$Q^DN|u!08!6fkiRQeZ$xK?ETM4TKb| zAf(^|Aq77OC}36~q`-raf&xMcW)M@)PXVYdp18Yzb0fp{E?}Mi Lv59fThel=q{sPMq diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-50ba7-e-when-sidebar-is-collapsed-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tre-50ba7-e-when-sidebar-is-collapsed-chromium/error-context.md deleted file mode 100644 index 1a22a3b9..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tre-50ba7-e-when-sidebar-is-collapsed-chromium/error-context.md +++ /dev/null @@ -1,160 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> hides file tree when sidebar is collapsed -- Location: tests/e2e/file-tree.spec.ts:226:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { -> 227 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); - 234 | }); - 235 | - 236 | test('clearing file filter on graph view removes badge', async ({ page }) => { - 237 | await page.goto('/'); - 238 | await page.waitForSelector('[aria-label="Project file tree"]'); - 239 | - 240 | // Navigate via file click - 241 | await page.getByTestId('tree-node-pom.xml').click(); - 242 | await expect(page).toHaveURL(/\/graph/); - 243 | - 244 | const badge = page.getByTestId('file-filter-badge'); - 245 | await expect(badge).toBeVisible(); - 246 | - 247 | // Click X on the badge - 248 | await page.getByRole('button', { name: 'Clear file filter' }).click(); - 249 | await expect(badge).not.toBeVisible(); - 250 | }); - 251 | - 252 | test('accessibility: tree has correct ARIA roles', async ({ page }) => { - 253 | await page.goto('/'); - 254 | await page.waitForSelector('[aria-label="Project file tree"]'); - 255 | - 256 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 257 | const items = page.locator('[role="treeitem"]'); - 258 | await expect(items).not.toHaveCount(0); - 259 | }); - 260 | }); - 261 | -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-50ba7-e-when-sidebar-is-collapsed-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tre-50ba7-e-when-sidebar-is-collapsed-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b98da0d74381bd9f7089dc0c99ac14baf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4254 zcmeAS@N?(olHy`uVBq!ia0y~yU#KiDjWttl9P!CsJDrMnSo)#sPJf*j3$WD+%Q@c zj24fhb;D@IINB;0Z4!+(6S23Efi38O(f0CadwI0IJlb9!bnWG?%4I8oO;~r(SOCQZ p_y?d#|NkF7qH+kxU;`P+%<#gUW5K!N4KhFx22WQ%mvv4FO#o+H0uulL diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-50ba7-e-when-sidebar-is-collapsed-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tre-50ba7-e-when-sidebar-is-collapsed-chromium/video.webm deleted file mode 100644 index b5268646f355e5b26f96bbb9743b9d062f60e457..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1990 zcmb1gy}x+AQ(GgW({~{L)X3uWxsk)EsiizMDc7mJk;$pGkx3%BA)S!{1lXqu`pz!d z<-5B(cy)`Y=gPF;HH`})Jh6~<*+AYk-`zbxIiZll>A`E77&ReWnc&?($tL!OHxP3e zBEiPdf&jT{gVyzp&HPRdz70J-iDhYKhI&9~U=Z$z!gcBFaG2l#b_2-q=hi{p=6b!6 zvG`~QgZ#dw4(}U!=QJ`XZJpCxnwQei$k5gr79SR1(cH+W{J6P=DJhEK!L$uc%nG}j z7!S>AWD4ET5xN~n?Pz2Ig}|@k;`zx38q!b9bY0x;>gVk5;u_@Aj^c>o;;YFAx|0#s zrJq=Utavie1Ia)S7=t{Zkdv8IR#1_CVxjBe4wuj%#}H3{KbQ8&1_o9jWTa#8|$wHKxqZzCByr zqIwjKh5$`NKzyCE242zdGGH5UaGBa=sCNM}aNaTOv5&y-;#{HL>f#L66Ah^-+ z@0|j};(3h>0StvqNXjteDenpfEXG=fnAVnhR63Cs&WH-enN=m3$A!I1ys#jtpO zBVz_c-T_1YZ&?9Y!32l`9SjBkIwimgc0d%UU@Ev<0aoyUfC45CLJABBDTpAXpn;Hr z6@(OAAf(_20R_wogcNuXQcyrh!3;tQ4iHlC0-pj)9?Onhc`(qR1iVuy< E0Iqtk> Project File Tree >> collapses expanded directory on second click -- Location: tests/e2e/file-tree.spec.ts:132:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 33 | type: 'file', - 34 | nodeCount: 3, - 35 | }, - 36 | ], - 37 | }, - 38 | { - 39 | name: 'components', - 40 | path: 'src/components', - 41 | type: 'directory', - 42 | nodeCount: 20, - 43 | children: [ - 44 | { - 45 | name: 'Button.tsx', - 46 | path: 'src/components/Button.tsx', - 47 | type: 'file', - 48 | nodeCount: 5, - 49 | }, - 50 | ], - 51 | }, - 52 | ], - 53 | }, - 54 | { - 55 | name: 'pom.xml', - 56 | path: 'pom.xml', - 57 | type: 'file', - 58 | nodeCount: 2, - 59 | }, - 60 | ], - 61 | }; - 62 | - 63 | async function mockFileTree(page: Page) { - 64 | await page.route('**/api/file-tree**', route => - 65 | route.fulfill({ - 66 | status: 200, - 67 | contentType: 'application/json', - 68 | body: JSON.stringify(MOCK_FILE_TREE), - 69 | }), - 70 | ); - 71 | } - 72 | - 73 | async function mockMinimalApis(page: Page) { - 74 | await page.route('**/api/stats**', route => - 75 | route.fulfill({ - 76 | status: 200, - 77 | contentType: 'application/json', - 78 | body: JSON.stringify({ node_count: 100, edge_count: 200, nodes_by_kind: {}, nodes_by_layer: {} }), - 79 | }), - 80 | ); - 81 | await page.route('**/api/kinds**', route => - 82 | route.fulfill({ - 83 | status: 200, - 84 | contentType: 'application/json', - 85 | body: JSON.stringify({ kinds: [{ kind: 'class', count: 50 }, { kind: 'method', count: 50 }], total: 100 }), - 86 | }), - 87 | ); - 88 | await page.route('**/api/nodes**', route => - 89 | route.fulfill({ - 90 | status: 200, - 91 | contentType: 'application/json', - 92 | body: JSON.stringify({ nodes: [], total: 0, offset: 0, limit: 50 }), - 93 | }), - 94 | ); - 95 | } - 96 | - 97 | /* ── Tests ────────────────────────────────────────────────────────── */ - 98 | - 99 | test.describe('Project File Tree', () => { - 100 | test.beforeEach(async ({ page }) => { - 101 | await mockFileTree(page); - 102 | await mockMinimalApis(page); - 103 | }); - 104 | - 105 | test('renders file tree in sidebar', async ({ page }) => { - 106 | await page.goto('/'); - 107 | await page.waitForSelector('[aria-label="Project file tree"]'); - 108 | await expect(page.getByText('Project Files')).toBeVisible(); - 109 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 110 | }); - 111 | - 112 | test('shows root directory expanded by default', async ({ page }) => { - 113 | await page.goto('/'); - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { - 122 | await page.goto('/'); - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { -> 133 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { - 227 | await page.goto('/'); - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-58a5d-d-directory-on-second-click-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tre-58a5d-d-directory-on-second-click-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b98da0d74381bd9f7089dc0c99ac14baf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4254 zcmeAS@N?(olHy`uVBq!ia0y~yU#KiDjWttl9P!CsJDrMnSo)#sPJf*j3$WD+%Q@c zj24fhb;D@IINB;0Z4!+(6S23Efi38O(f0CadwI0IJlb9!bnWG?%4I8oO;~r(SOCQZ p_y?d#|NkF7qH+kxU;`P+%<#gUW5K!N4KhFx22WQ%mvv4FO#o+H0uulL diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-58a5d-d-directory-on-second-click-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tre-58a5d-d-directory-on-second-click-chromium/video.webm deleted file mode 100644 index 58323b59e25cc06b8a18979d384fa86caa473223..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1924 zcmb1gy}x+AQ(GgW({~{L)X3uWxsk)EsiizMDc7mJk;$pGkx3%BA)S!{1lU6aeP@^K z^4;AXyt+lyb7flan#P3?o><7bY#{HN@9rL;oKVQ&^x!p3jG7RxOz>`?WD~pH8;Cg! zkznI!L4aJfLF@X)W_~9J--aHa#IiIqLp>lgFbH=<;ktBoIP^iB0CN1fbx^mtUTwxNkxVOJC5 zp;?Vgp&L3vw*#pijZB~r_*GmyKlwmI`iYsYi`!lOoc&!~gIwBC98p|+HTghyGQzs_ z6AO?PPX>A*8R!9HkOvfUGLy;*D$-9ZbY0xx5*p+f;_2__(mvV1zzU4?EX@ocfI;4Q zTdC`W=Zy@DohCLiFf?57XPC>VrNDfMk-?p#wY6hrO9xXE1DhDjceciq`PsK;t6Nl$ zqR|kbX$YvDVEFgHrh#Gc(nbaihCpTpZovcw2?mM$FFfME*x0!LGBYszy$b|48veag zU|2k_ks*MgkO@f{hI|D?-UDh3lKc*^{sjd z!$zR}0vq7+3=8Vs0l{;Je_!(|8-PYIN6Br#J@-Z0lf4mqL&u?VR zfXF*w$p0-X04taPQJ{mN;9sW%Siug60u@XJcPqdO9uQE##6d`b0U-qugcLLoQm}%M vf(wKc{2-u!S%HuO4?+qG2q~C>PXVYNoVdGxb0fp{E?|B8v59fThel=qQmBzh diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-9e7a2-rectory-expanded-by-default-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tre-9e7a2-rectory-expanded-by-default-chromium/error-context.md deleted file mode 100644 index 68d29979..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tre-9e7a2-rectory-expanded-by-default-chromium/error-context.md +++ /dev/null @@ -1,226 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> shows root directory expanded by default -- Location: tests/e2e/file-tree.spec.ts:112:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 13 | name: 'src', - 14 | path: 'src', - 15 | type: 'directory', - 16 | nodeCount: 80, - 17 | children: [ - 18 | { - 19 | name: 'main', - 20 | path: 'src/main', - 21 | type: 'directory', - 22 | nodeCount: 60, - 23 | children: [ - 24 | { - 25 | name: 'App.tsx', - 26 | path: 'src/main/App.tsx', - 27 | type: 'file', - 28 | nodeCount: 12, - 29 | }, - 30 | { - 31 | name: 'index.ts', - 32 | path: 'src/main/index.ts', - 33 | type: 'file', - 34 | nodeCount: 3, - 35 | }, - 36 | ], - 37 | }, - 38 | { - 39 | name: 'components', - 40 | path: 'src/components', - 41 | type: 'directory', - 42 | nodeCount: 20, - 43 | children: [ - 44 | { - 45 | name: 'Button.tsx', - 46 | path: 'src/components/Button.tsx', - 47 | type: 'file', - 48 | nodeCount: 5, - 49 | }, - 50 | ], - 51 | }, - 52 | ], - 53 | }, - 54 | { - 55 | name: 'pom.xml', - 56 | path: 'pom.xml', - 57 | type: 'file', - 58 | nodeCount: 2, - 59 | }, - 60 | ], - 61 | }; - 62 | - 63 | async function mockFileTree(page: Page) { - 64 | await page.route('**/api/file-tree**', route => - 65 | route.fulfill({ - 66 | status: 200, - 67 | contentType: 'application/json', - 68 | body: JSON.stringify(MOCK_FILE_TREE), - 69 | }), - 70 | ); - 71 | } - 72 | - 73 | async function mockMinimalApis(page: Page) { - 74 | await page.route('**/api/stats**', route => - 75 | route.fulfill({ - 76 | status: 200, - 77 | contentType: 'application/json', - 78 | body: JSON.stringify({ node_count: 100, edge_count: 200, nodes_by_kind: {}, nodes_by_layer: {} }), - 79 | }), - 80 | ); - 81 | await page.route('**/api/kinds**', route => - 82 | route.fulfill({ - 83 | status: 200, - 84 | contentType: 'application/json', - 85 | body: JSON.stringify({ kinds: [{ kind: 'class', count: 50 }, { kind: 'method', count: 50 }], total: 100 }), - 86 | }), - 87 | ); - 88 | await page.route('**/api/nodes**', route => - 89 | route.fulfill({ - 90 | status: 200, - 91 | contentType: 'application/json', - 92 | body: JSON.stringify({ nodes: [], total: 0, offset: 0, limit: 50 }), - 93 | }), - 94 | ); - 95 | } - 96 | - 97 | /* ── Tests ────────────────────────────────────────────────────────── */ - 98 | - 99 | test.describe('Project File Tree', () => { - 100 | test.beforeEach(async ({ page }) => { - 101 | await mockFileTree(page); - 102 | await mockMinimalApis(page); - 103 | }); - 104 | - 105 | test('renders file tree in sidebar', async ({ page }) => { - 106 | await page.goto('/'); - 107 | await page.waitForSelector('[aria-label="Project file tree"]'); - 108 | await expect(page.getByText('Project Files')).toBeVisible(); - 109 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 110 | }); - 111 | - 112 | test('shows root directory expanded by default', async ({ page }) => { -> 113 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { - 122 | await page.goto('/'); - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-9e7a2-rectory-expanded-by-default-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tre-9e7a2-rectory-expanded-by-default-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b98da0d74381bd9f7089dc0c99ac14baf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4254 zcmeAS@N?(olHy`uVBq!ia0y~yU#KiDjWttl9P!CsJDrMnSo)#sPJf*j3$WD+%Q@c zj24fhb;D@IINB;0Z4!+(6S23Efi38O(f0CadwI0IJlb9!bnWG?%4I8oO;~r(SOCQZ p_y?d#|NkF7qH+kxU;`P+%<#gUW5K!N4KhFx22WQ%mvv4FO#o+H0uulL diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-9e7a2-rectory-expanded-by-default-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tre-9e7a2-rectory-expanded-by-default-chromium/video.webm deleted file mode 100644 index 137d74f2e560e4d97d76084ca305a9874846faf9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1924 zcmb1gy}x+AQ(GgW({~{L)X3uWxsk)EsiizMDc7mJk;$pGkx3%BA)S!{1lU6aeP@^K z^4;AXyt+lyb7flan#P3?o><7bY#{HN@9rL;oKVQ&^x!p3jG7RxOz>`?WD~pH8;Cg! zkznI!L4aJfLF@X)W_~9J--aHa#IiIqLp>lgFbH=<;ktBoIP^iB0CN1fbx^mtUTL8w-43L7G%|rg;8$_+{Nw`-=_h8oE^c@AbM|*}4RUEmaYS+P)#L-+$q4Jx zPb@%IJQ?VLWS|F(K^{=Z$xJFMs7ODt&~$&=3v7VPGc2fk2L#U@{(a4>YycX;C;>5|0j>n*g`XQiPGEF^$j4yF|M6m2Jin1K z10wH$A^*3m0IXmFM1c;5f`6S7U*@PmK?W(7hDJP0W$Af#XhJ_Vq9aN_R%&5aD(yMXoW$0o)V9~zkf2}hJC diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-a07f8-message-for-unmatched-query-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tre-a07f8-message-for-unmatched-query-chromium/error-context.md deleted file mode 100644 index d08c3d34..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tre-a07f8-message-for-unmatched-query-chromium/error-context.md +++ /dev/null @@ -1,214 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> shows "no match" message for unmatched query -- Location: tests/e2e/file-tree.spec.ts:172:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 73 | async function mockMinimalApis(page: Page) { - 74 | await page.route('**/api/stats**', route => - 75 | route.fulfill({ - 76 | status: 200, - 77 | contentType: 'application/json', - 78 | body: JSON.stringify({ node_count: 100, edge_count: 200, nodes_by_kind: {}, nodes_by_layer: {} }), - 79 | }), - 80 | ); - 81 | await page.route('**/api/kinds**', route => - 82 | route.fulfill({ - 83 | status: 200, - 84 | contentType: 'application/json', - 85 | body: JSON.stringify({ kinds: [{ kind: 'class', count: 50 }, { kind: 'method', count: 50 }], total: 100 }), - 86 | }), - 87 | ); - 88 | await page.route('**/api/nodes**', route => - 89 | route.fulfill({ - 90 | status: 200, - 91 | contentType: 'application/json', - 92 | body: JSON.stringify({ nodes: [], total: 0, offset: 0, limit: 50 }), - 93 | }), - 94 | ); - 95 | } - 96 | - 97 | /* ── Tests ────────────────────────────────────────────────────────── */ - 98 | - 99 | test.describe('Project File Tree', () => { - 100 | test.beforeEach(async ({ page }) => { - 101 | await mockFileTree(page); - 102 | await mockMinimalApis(page); - 103 | }); - 104 | - 105 | test('renders file tree in sidebar', async ({ page }) => { - 106 | await page.goto('/'); - 107 | await page.waitForSelector('[aria-label="Project file tree"]'); - 108 | await expect(page.getByText('Project Files')).toBeVisible(); - 109 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 110 | }); - 111 | - 112 | test('shows root directory expanded by default', async ({ page }) => { - 113 | await page.goto('/'); - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { - 122 | await page.goto('/'); - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { -> 173 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { - 227 | await page.goto('/'); - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); - 234 | }); - 235 | - 236 | test('clearing file filter on graph view removes badge', async ({ page }) => { - 237 | await page.goto('/'); - 238 | await page.waitForSelector('[aria-label="Project file tree"]'); - 239 | - 240 | // Navigate via file click - 241 | await page.getByTestId('tree-node-pom.xml').click(); - 242 | await expect(page).toHaveURL(/\/graph/); - 243 | - 244 | const badge = page.getByTestId('file-filter-badge'); - 245 | await expect(badge).toBeVisible(); - 246 | - 247 | // Click X on the badge - 248 | await page.getByRole('button', { name: 'Clear file filter' }).click(); - 249 | await expect(badge).not.toBeVisible(); - 250 | }); - 251 | - 252 | test('accessibility: tree has correct ARIA roles', async ({ page }) => { - 253 | await page.goto('/'); - 254 | await page.waitForSelector('[aria-label="Project file tree"]'); - 255 | - 256 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 257 | const items = page.locator('[role="treeitem"]'); - 258 | await expect(items).not.toHaveCount(0); - 259 | }); - 260 | }); - 261 | -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-a07f8-message-for-unmatched-query-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tre-a07f8-message-for-unmatched-query-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b98da0d74381bd9f7089dc0c99ac14baf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4254 zcmeAS@N?(olHy`uVBq!ia0y~yU#KiDjWttl9P!CsJDrMnSo)#sPJf*j3$WD+%Q@c zj24fhb;D@IINB;0Z4!+(6S23Efi38O(f0CadwI0IJlb9!bnWG?%4I8oO;~r(SOCQZ p_y?d#|NkF7qH+kxU;`P+%<#gUW5K!N4KhFx22WQ%mvv4FO#o+H0uulL diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-a07f8-message-for-unmatched-query-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tre-a07f8-message-for-unmatched-query-chromium/video.webm deleted file mode 100644 index 32cb73236248658d0abf05badac1e3dddbd6d0bf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2089 zcmb1gy}x+AQ(GgW({~{L)X3uWxsk)EsiizMDc7mJk;$pGkx3%BA)S!{1lWHH`pz!d z<-5B(cy)`Y=gPF;HH`})Jh6~<*+AYk-`zbxIiZll>A`E77&ReWnc&?($tL#4Zy@F{ zM1qZ@1p#u^2CeHGoB5p_d>eXv63f!e4E2D}z#!Zah3nGU;V?-7>;{nI&#i;H&GmXC zWAV`rqnYb=-3anIIj509Y3rQk(!7+8MuxW5u=ub5i{?f~<;Tq}Oi57;52kHsVpiDI z#CT{{BU9*xj?nEuYDXgzCV(2#y&rt9K%S3hTe7uO({b`(bx7hg?2(4CC1 zF8#y;WW|$#9!Lgyz!>BKg`CW!vVw~A6AN7zcesQGIfi)p`?<7FHZZUPA)qJC3?P6( z-g#T8o9XjLhQ&@38yOfHF8DLdWzG&d6f-7BN!zhMl`^cz`XEtBghGi4iNbm4EaA^42$PC zGG;*J9WdnomKA^%On@lR!BFt8Qv$4D2SkAirh>Z_U> Project File Tree >> navigates to graph view on file click -- Location: tests/e2e/file-tree.spec.ts:189:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 90 | status: 200, - 91 | contentType: 'application/json', - 92 | body: JSON.stringify({ nodes: [], total: 0, offset: 0, limit: 50 }), - 93 | }), - 94 | ); - 95 | } - 96 | - 97 | /* ── Tests ────────────────────────────────────────────────────────── */ - 98 | - 99 | test.describe('Project File Tree', () => { - 100 | test.beforeEach(async ({ page }) => { - 101 | await mockFileTree(page); - 102 | await mockMinimalApis(page); - 103 | }); - 104 | - 105 | test('renders file tree in sidebar', async ({ page }) => { - 106 | await page.goto('/'); - 107 | await page.waitForSelector('[aria-label="Project file tree"]'); - 108 | await expect(page.getByText('Project Files')).toBeVisible(); - 109 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 110 | }); - 111 | - 112 | test('shows root directory expanded by default', async ({ page }) => { - 113 | await page.goto('/'); - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { - 122 | await page.goto('/'); - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { -> 190 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { - 227 | await page.goto('/'); - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); - 234 | }); - 235 | - 236 | test('clearing file filter on graph view removes badge', async ({ page }) => { - 237 | await page.goto('/'); - 238 | await page.waitForSelector('[aria-label="Project file tree"]'); - 239 | - 240 | // Navigate via file click - 241 | await page.getByTestId('tree-node-pom.xml').click(); - 242 | await expect(page).toHaveURL(/\/graph/); - 243 | - 244 | const badge = page.getByTestId('file-filter-badge'); - 245 | await expect(badge).toBeVisible(); - 246 | - 247 | // Click X on the badge - 248 | await page.getByRole('button', { name: 'Clear file filter' }).click(); - 249 | await expect(badge).not.toBeVisible(); - 250 | }); - 251 | - 252 | test('accessibility: tree has correct ARIA roles', async ({ page }) => { - 253 | await page.goto('/'); - 254 | await page.waitForSelector('[aria-label="Project file tree"]'); - 255 | - 256 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 257 | const items = page.locator('[role="treeitem"]'); - 258 | await expect(items).not.toHaveCount(0); - 259 | }); - 260 | }); - 261 | -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-b3d13-to-graph-view-on-file-click-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tre-b3d13-to-graph-view-on-file-click-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b98da0d74381bd9f7089dc0c99ac14baf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4254 zcmeAS@N?(olHy`uVBq!ia0y~yU#KiDjWttl9P!CsJDrMnSo)#sPJf*j3$WD+%Q@c zj24fhb;D@IINB;0Z4!+(6S23Efi38O(f0CadwI0IJlb9!bnWG?%4I8oO;~r(SOCQZ p_y?d#|NkF7qH+kxU;`P+%<#gUW5K!N4KhFx22WQ%mvv4FO#o+H0uulL diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-b3d13-to-graph-view-on-file-click-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tre-b3d13-to-graph-view-on-file-click-chromium/video.webm deleted file mode 100644 index 25687c76b04a35e65f2ce779d998579805a1dab1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1924 zcmb1gy}x+AQ(GgW({~{L)X3uWxsk)EsiizMDc7mJk;$pGkx3%BA)S!{1lU6aeP@^K z^4;AXyt+lyb7flan#P3?o><7bY#{HN@9rL;oKVQ&^x!p3jG7RxOz>`?WD~pH8;Cg! zkznI!L4aJfLF@X)W_~9J--aHa#IiIqLp>lgFbH=<;ktBoIP^iB0CN1fbx^mtUTpieh*$Z9@~Y!mcL9 zL$ey0LN|1TZU<638ks;L@T<6Ze)55a^b<2(7q`3mIs3b~2D!AOIHI`tYVv{ZWQ29; zCl(+po(%LrGSCCYAP*?yWG0mrRHUC+=(@PWB{aw}#M9r;rG2u2ffX3(S(+I@0E4{q zwo=y#&l?#QJ56k4U}(7D&oGx!OM&?iBZE6fYiq~MmJX&S1~xI4?`(}J^RsWyR=21g zMWZ1=(-2TQ!SL^YO#{Q?rHu?441vrH+=2-V5)2agUwFiSv9WRgWoBUbdlv|9H2iy~ zz_55;BSQc~Arq1^4EYL(ya&`6B>5d+{R<%S0vPfiI2}Odp8(4XJi(CvDA2HYVI#u_ zhK)e`1vbFt85Y#N1A^xc|Gwr`HUN!alzB1uB>d?pA;mJRqQeiGz>=140TS2q|bFq+kUh v1s4b@_(4DcvjQOn9)uJW5K=G$p8`-lIB|FX=0=9?UBLSGV-w?w4~@(K+&hxh diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-clears-search-with-X-button-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tree-clears-search-with-X-button-chromium/error-context.md deleted file mode 100644 index bd7f5f96..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tree-clears-search-with-X-button-chromium/error-context.md +++ /dev/null @@ -1,225 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> clears search with X button -- Location: tests/e2e/file-tree.spec.ts:161:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 62 | - 63 | async function mockFileTree(page: Page) { - 64 | await page.route('**/api/file-tree**', route => - 65 | route.fulfill({ - 66 | status: 200, - 67 | contentType: 'application/json', - 68 | body: JSON.stringify(MOCK_FILE_TREE), - 69 | }), - 70 | ); - 71 | } - 72 | - 73 | async function mockMinimalApis(page: Page) { - 74 | await page.route('**/api/stats**', route => - 75 | route.fulfill({ - 76 | status: 200, - 77 | contentType: 'application/json', - 78 | body: JSON.stringify({ node_count: 100, edge_count: 200, nodes_by_kind: {}, nodes_by_layer: {} }), - 79 | }), - 80 | ); - 81 | await page.route('**/api/kinds**', route => - 82 | route.fulfill({ - 83 | status: 200, - 84 | contentType: 'application/json', - 85 | body: JSON.stringify({ kinds: [{ kind: 'class', count: 50 }, { kind: 'method', count: 50 }], total: 100 }), - 86 | }), - 87 | ); - 88 | await page.route('**/api/nodes**', route => - 89 | route.fulfill({ - 90 | status: 200, - 91 | contentType: 'application/json', - 92 | body: JSON.stringify({ nodes: [], total: 0, offset: 0, limit: 50 }), - 93 | }), - 94 | ); - 95 | } - 96 | - 97 | /* ── Tests ────────────────────────────────────────────────────────── */ - 98 | - 99 | test.describe('Project File Tree', () => { - 100 | test.beforeEach(async ({ page }) => { - 101 | await mockFileTree(page); - 102 | await mockMinimalApis(page); - 103 | }); - 104 | - 105 | test('renders file tree in sidebar', async ({ page }) => { - 106 | await page.goto('/'); - 107 | await page.waitForSelector('[aria-label="Project file tree"]'); - 108 | await expect(page.getByText('Project Files')).toBeVisible(); - 109 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 110 | }); - 111 | - 112 | test('shows root directory expanded by default', async ({ page }) => { - 113 | await page.goto('/'); - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { - 122 | await page.goto('/'); - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { -> 162 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { - 227 | await page.goto('/'); - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); - 234 | }); - 235 | - 236 | test('clearing file filter on graph view removes badge', async ({ page }) => { - 237 | await page.goto('/'); - 238 | await page.waitForSelector('[aria-label="Project file tree"]'); - 239 | - 240 | // Navigate via file click - 241 | await page.getByTestId('tree-node-pom.xml').click(); - 242 | await expect(page).toHaveURL(/\/graph/); - 243 | - 244 | const badge = page.getByTestId('file-filter-badge'); - 245 | await expect(badge).toBeVisible(); - 246 | - 247 | // Click X on the badge - 248 | await page.getByRole('button', { name: 'Clear file filter' }).click(); - 249 | await expect(badge).not.toBeVisible(); - 250 | }); - 251 | - 252 | test('accessibility: tree has correct ARIA roles', async ({ page }) => { - 253 | await page.goto('/'); - 254 | await page.waitForSelector('[aria-label="Project file tree"]'); - 255 | - 256 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 257 | const items = page.locator('[role="treeitem"]'); - 258 | await expect(items).not.toHaveCount(0); - 259 | }); - 260 | }); - 261 | -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-clears-search-with-X-button-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tree-clears-search-with-X-button-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b98da0d74381bd9f7089dc0c99ac14baf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4254 zcmeAS@N?(olHy`uVBq!ia0y~yU#KiDjWttl9P!CsJDrMnSo)#sPJf*j3$WD+%Q@c zj24fhb;D@IINB;0Z4!+(6S23Efi38O(f0CadwI0IJlb9!bnWG?%4I8oO;~r(SOCQZ p_y?d#|NkF7qH+kxU;`P+%<#gUW5K!N4KhFx22WQ%mvv4FO#o+H0uulL diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-clears-search-with-X-button-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tree-clears-search-with-X-button-chromium/video.webm deleted file mode 100644 index 40b06050d896b17bcc12cdd2d8aca36d16e74aa7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2122 zcmb1gy}x+AQ(GgW({~{L)X3uWxsk)EsiizMDc7mJk;$pGkx3%BA)S!{1URGweP@^K z^4;AXyt+lyb7flan#P3?o><7bY#{HN@9rL;oKVQ&^x!p3jG7RxOz>`?WD^I=8;Cg! zkznI!L4aJfLF@X)W_~9J--aHa#IiIqLp>lgFbH=<;ktBoI7~VKb_2-q=hi{p=6b!6 zvG{1mjm24Zw=YhTo72dkv~^B%X3wz$ku-Iv0BLhRj1%HOQj9LoJhZq^$Ia*sgX0~)NH8HS>v3zH1Oqrj3d$zho z^(Yz*0h)$@+6jh#|7#i;7B6jN&|nB;X5bb~V31&t$p6A4{)>%``!6#C!{56=aHHYh zI|YWt^BNff7z&w?lwrtMK;%83#vsY>0P9}>kr%*_|G?=0GXDfvUf>Ca{6~R?#S0r5 zJ}_(q+ApvHF3+%_?i~<3clh@;ud)GX1fvARhz7V4m=}I-1UZ4x0U{rRA^*pVVe$M% z#tewO1BU$HvI4Mz2@nN37z+M%N`MvYfGAMGRB*Qftl$9w1xy@-6c`Xv5J5;m10e+~ z2r0NgNWl*R3YZlLDexeqpn#Bq8H5xZAf(_00R=1qgcMj1QjkDMK?gnspxS)m?*7e< S4BNYat%i?Hj4M7gG6MkVPUda^ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-expands-directory-on-click-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tree-expands-directory-on-click-chromium/error-context.md deleted file mode 100644 index 7ec52912..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tree-expands-directory-on-click-chromium/error-context.md +++ /dev/null @@ -1,226 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> expands directory on click -- Location: tests/e2e/file-tree.spec.ts:121:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 22 | nodeCount: 60, - 23 | children: [ - 24 | { - 25 | name: 'App.tsx', - 26 | path: 'src/main/App.tsx', - 27 | type: 'file', - 28 | nodeCount: 12, - 29 | }, - 30 | { - 31 | name: 'index.ts', - 32 | path: 'src/main/index.ts', - 33 | type: 'file', - 34 | nodeCount: 3, - 35 | }, - 36 | ], - 37 | }, - 38 | { - 39 | name: 'components', - 40 | path: 'src/components', - 41 | type: 'directory', - 42 | nodeCount: 20, - 43 | children: [ - 44 | { - 45 | name: 'Button.tsx', - 46 | path: 'src/components/Button.tsx', - 47 | type: 'file', - 48 | nodeCount: 5, - 49 | }, - 50 | ], - 51 | }, - 52 | ], - 53 | }, - 54 | { - 55 | name: 'pom.xml', - 56 | path: 'pom.xml', - 57 | type: 'file', - 58 | nodeCount: 2, - 59 | }, - 60 | ], - 61 | }; - 62 | - 63 | async function mockFileTree(page: Page) { - 64 | await page.route('**/api/file-tree**', route => - 65 | route.fulfill({ - 66 | status: 200, - 67 | contentType: 'application/json', - 68 | body: JSON.stringify(MOCK_FILE_TREE), - 69 | }), - 70 | ); - 71 | } - 72 | - 73 | async function mockMinimalApis(page: Page) { - 74 | await page.route('**/api/stats**', route => - 75 | route.fulfill({ - 76 | status: 200, - 77 | contentType: 'application/json', - 78 | body: JSON.stringify({ node_count: 100, edge_count: 200, nodes_by_kind: {}, nodes_by_layer: {} }), - 79 | }), - 80 | ); - 81 | await page.route('**/api/kinds**', route => - 82 | route.fulfill({ - 83 | status: 200, - 84 | contentType: 'application/json', - 85 | body: JSON.stringify({ kinds: [{ kind: 'class', count: 50 }, { kind: 'method', count: 50 }], total: 100 }), - 86 | }), - 87 | ); - 88 | await page.route('**/api/nodes**', route => - 89 | route.fulfill({ - 90 | status: 200, - 91 | contentType: 'application/json', - 92 | body: JSON.stringify({ nodes: [], total: 0, offset: 0, limit: 50 }), - 93 | }), - 94 | ); - 95 | } - 96 | - 97 | /* ── Tests ────────────────────────────────────────────────────────── */ - 98 | - 99 | test.describe('Project File Tree', () => { - 100 | test.beforeEach(async ({ page }) => { - 101 | await mockFileTree(page); - 102 | await mockMinimalApis(page); - 103 | }); - 104 | - 105 | test('renders file tree in sidebar', async ({ page }) => { - 106 | await page.goto('/'); - 107 | await page.waitForSelector('[aria-label="Project file tree"]'); - 108 | await expect(page.getByText('Project Files')).toBeVisible(); - 109 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 110 | }); - 111 | - 112 | test('shows root directory expanded by default', async ({ page }) => { - 113 | await page.goto('/'); - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { -> 122 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-expands-directory-on-click-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tree-expands-directory-on-click-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b98da0d74381bd9f7089dc0c99ac14baf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4254 zcmeAS@N?(olHy`uVBq!ia0y~yU#KiDjWttl9P!CsJDrMnSo)#sPJf*j3$WD+%Q@c zj24fhb;D@IINB;0Z4!+(6S23Efi38O(f0CadwI0IJlb9!bnWG?%4I8oO;~r(SOCQZ p_y?d#|NkF7qH+kxU;`P+%<#gUW5K!N4KhFx22WQ%mvv4FO#o+H0uulL diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-expands-directory-on-click-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tree-expands-directory-on-click-chromium/video.webm deleted file mode 100644 index 9be5193caecd03fbf1d19b8000773bd0b6816eef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1990 zcmb1gy}x+AQ(GgW({~{L)X3uWxsk)EsiizMDc7mJk;$pGkx3%BA)S!{1lXqu`pz!d z<-5B(cy)`Y=gPF;HH`})Jh6~<*+AYk-`zbxIiZll>A`E77&ReWnc&?($tL!OHxP3e zBEiPdf&jT{gVyzp&HPRdz70J-iDhYKhI&9~U=Z$z!gcBFaG2l#b_2-q=hi{p=6b!6 zvG{05CDUw?($+ZfIgJcTTjw;F=B0ErGPJdZ#fJr0G&eFTKW=VeN{V85Fl|E z#zV6jnL;;ogl-2?I~tilA@HlXcz*JMhV&CNT^F~z`Z@c%xCXhjqd20t_-gWj?qq~@ z=_eK-E1nGWKr+w+#vl(Ui!?|)4L!{Vil3>pl9%naOu2@Dbp68T?v#DB4|asOpzVEB6%2yQg| zd#Av#cwQqz07D@Yk}?eW3W&T1)EFfB9bo+nAo2nj@*g-IK<1wS%L_cgkpC#quy|o3 z!v}_qK>GzYz~vbh)V%|O=MMkA=2bQTjbN037|{S%0`tPpjUXp5IzZ%OFy#MuF)W_n z$e00kkOBij3L*$8Xdt9u z1tA3&2r2kMKmoG?Aq5_U6ci9rFoTeS1B4X3z^4FIlTO^-zqyfNdl#_A{@BF0;zJ`d E0QI1)n*aa+ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-filters-tree-with-search-input-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tree-filters-tree-with-search-input-chromium/error-context.md deleted file mode 100644 index 19b95889..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tree-filters-tree-with-search-input-chromium/error-context.md +++ /dev/null @@ -1,226 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> filters tree with search input -- Location: tests/e2e/file-tree.spec.ts:145:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 46 | path: 'src/components/Button.tsx', - 47 | type: 'file', - 48 | nodeCount: 5, - 49 | }, - 50 | ], - 51 | }, - 52 | ], - 53 | }, - 54 | { - 55 | name: 'pom.xml', - 56 | path: 'pom.xml', - 57 | type: 'file', - 58 | nodeCount: 2, - 59 | }, - 60 | ], - 61 | }; - 62 | - 63 | async function mockFileTree(page: Page) { - 64 | await page.route('**/api/file-tree**', route => - 65 | route.fulfill({ - 66 | status: 200, - 67 | contentType: 'application/json', - 68 | body: JSON.stringify(MOCK_FILE_TREE), - 69 | }), - 70 | ); - 71 | } - 72 | - 73 | async function mockMinimalApis(page: Page) { - 74 | await page.route('**/api/stats**', route => - 75 | route.fulfill({ - 76 | status: 200, - 77 | contentType: 'application/json', - 78 | body: JSON.stringify({ node_count: 100, edge_count: 200, nodes_by_kind: {}, nodes_by_layer: {} }), - 79 | }), - 80 | ); - 81 | await page.route('**/api/kinds**', route => - 82 | route.fulfill({ - 83 | status: 200, - 84 | contentType: 'application/json', - 85 | body: JSON.stringify({ kinds: [{ kind: 'class', count: 50 }, { kind: 'method', count: 50 }], total: 100 }), - 86 | }), - 87 | ); - 88 | await page.route('**/api/nodes**', route => - 89 | route.fulfill({ - 90 | status: 200, - 91 | contentType: 'application/json', - 92 | body: JSON.stringify({ nodes: [], total: 0, offset: 0, limit: 50 }), - 93 | }), - 94 | ); - 95 | } - 96 | - 97 | /* ── Tests ────────────────────────────────────────────────────────── */ - 98 | - 99 | test.describe('Project File Tree', () => { - 100 | test.beforeEach(async ({ page }) => { - 101 | await mockFileTree(page); - 102 | await mockMinimalApis(page); - 103 | }); - 104 | - 105 | test('renders file tree in sidebar', async ({ page }) => { - 106 | await page.goto('/'); - 107 | await page.waitForSelector('[aria-label="Project file tree"]'); - 108 | await expect(page.getByText('Project Files')).toBeVisible(); - 109 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 110 | }); - 111 | - 112 | test('shows root directory expanded by default', async ({ page }) => { - 113 | await page.goto('/'); - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { - 122 | await page.goto('/'); - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { -> 146 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { - 227 | await page.goto('/'); - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); - 234 | }); - 235 | - 236 | test('clearing file filter on graph view removes badge', async ({ page }) => { - 237 | await page.goto('/'); - 238 | await page.waitForSelector('[aria-label="Project file tree"]'); - 239 | - 240 | // Navigate via file click - 241 | await page.getByTestId('tree-node-pom.xml').click(); - 242 | await expect(page).toHaveURL(/\/graph/); - 243 | - 244 | const badge = page.getByTestId('file-filter-badge'); - 245 | await expect(badge).toBeVisible(); - 246 | -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-filters-tree-with-search-input-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tree-filters-tree-with-search-input-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b98da0d74381bd9f7089dc0c99ac14baf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4254 zcmeAS@N?(olHy`uVBq!ia0y~yU#KiDjWttl9P!CsJDrMnSo)#sPJf*j3$WD+%Q@c zj24fhb;D@IINB;0Z4!+(6S23Efi38O(f0CadwI0IJlb9!bnWG?%4I8oO;~r(SOCQZ p_y?d#|NkF7qH+kxU;`P+%<#gUW5K!N4KhFx22WQ%mvv4FO#o+H0uulL diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-filters-tree-with-search-input-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tree-filters-tree-with-search-input-chromium/video.webm deleted file mode 100644 index 0b489a3b54938917aca0343d7c20d153e11e085e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2188 zcmb1gy}x+AQ(GgW({~{L)X3uWxsk)EsiizMDc7mJk;$pGkx3%BA)S!{1UO;@eP@^K z^4;AXyt+lyb7flan#P3?o><7bY#{HN@9rL;oKVQ&^x!p3jG7RxOz>`?WD|$m8;Cg! zkznI!L4aJfLF@X)W_~9J--aHa#IiIqLp>lgFbH=<;ktBoI81>!0p$2|>!5CPz23-J ze6*vKr&VCi<)Za-8X1(f&S@^qOX+B2Xlo6N4-2qpZe&z`+}y&H6vgmh+J+`(gL8w-43L7G%|rg;8$_+{Nw`-=_h8oE^c@AbM|*}4RUEmaYS+P)#L-+$q4Jx zPb@%IJQ?VLWS|F(K^{=Z$xJFMs7ODt&~?C zJ&Hy{fTkg!c7oyG|C$Dd#Y-C*G#CPz8Mp-#7$g`Z^1twi|6*g~{>#k3@b@kd+-Ugs zPJv8APQ7472K@=D|kRa0TTxy1qOr^L=aNYKuEy~ zLJBSrQt*R-0%iq53OooYC?KR@1|bCp2q}0$Kmm&YAq5tM6eJK*&_PJS20{vM;8Or< Y4@}(MzqyfNdl#_X@v(_<#fL^_0Dw3E#{d8T diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-renders-file-tree-in-sidebar-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tree-renders-file-tree-in-sidebar-chromium/error-context.md deleted file mode 100644 index 70569476..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tree-renders-file-tree-in-sidebar-chromium/error-context.md +++ /dev/null @@ -1,226 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> renders file tree in sidebar -- Location: tests/e2e/file-tree.spec.ts:105:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 6 | const MOCK_FILE_TREE = { - 7 | name: 'root', - 8 | path: '', - 9 | type: 'directory', - 10 | nodeCount: 100, - 11 | children: [ - 12 | { - 13 | name: 'src', - 14 | path: 'src', - 15 | type: 'directory', - 16 | nodeCount: 80, - 17 | children: [ - 18 | { - 19 | name: 'main', - 20 | path: 'src/main', - 21 | type: 'directory', - 22 | nodeCount: 60, - 23 | children: [ - 24 | { - 25 | name: 'App.tsx', - 26 | path: 'src/main/App.tsx', - 27 | type: 'file', - 28 | nodeCount: 12, - 29 | }, - 30 | { - 31 | name: 'index.ts', - 32 | path: 'src/main/index.ts', - 33 | type: 'file', - 34 | nodeCount: 3, - 35 | }, - 36 | ], - 37 | }, - 38 | { - 39 | name: 'components', - 40 | path: 'src/components', - 41 | type: 'directory', - 42 | nodeCount: 20, - 43 | children: [ - 44 | { - 45 | name: 'Button.tsx', - 46 | path: 'src/components/Button.tsx', - 47 | type: 'file', - 48 | nodeCount: 5, - 49 | }, - 50 | ], - 51 | }, - 52 | ], - 53 | }, - 54 | { - 55 | name: 'pom.xml', - 56 | path: 'pom.xml', - 57 | type: 'file', - 58 | nodeCount: 2, - 59 | }, - 60 | ], - 61 | }; - 62 | - 63 | async function mockFileTree(page: Page) { - 64 | await page.route('**/api/file-tree**', route => - 65 | route.fulfill({ - 66 | status: 200, - 67 | contentType: 'application/json', - 68 | body: JSON.stringify(MOCK_FILE_TREE), - 69 | }), - 70 | ); - 71 | } - 72 | - 73 | async function mockMinimalApis(page: Page) { - 74 | await page.route('**/api/stats**', route => - 75 | route.fulfill({ - 76 | status: 200, - 77 | contentType: 'application/json', - 78 | body: JSON.stringify({ node_count: 100, edge_count: 200, nodes_by_kind: {}, nodes_by_layer: {} }), - 79 | }), - 80 | ); - 81 | await page.route('**/api/kinds**', route => - 82 | route.fulfill({ - 83 | status: 200, - 84 | contentType: 'application/json', - 85 | body: JSON.stringify({ kinds: [{ kind: 'class', count: 50 }, { kind: 'method', count: 50 }], total: 100 }), - 86 | }), - 87 | ); - 88 | await page.route('**/api/nodes**', route => - 89 | route.fulfill({ - 90 | status: 200, - 91 | contentType: 'application/json', - 92 | body: JSON.stringify({ nodes: [], total: 0, offset: 0, limit: 50 }), - 93 | }), - 94 | ); - 95 | } - 96 | - 97 | /* ── Tests ────────────────────────────────────────────────────────── */ - 98 | - 99 | test.describe('Project File Tree', () => { - 100 | test.beforeEach(async ({ page }) => { - 101 | await mockFileTree(page); - 102 | await mockMinimalApis(page); - 103 | }); - 104 | - 105 | test('renders file tree in sidebar', async ({ page }) => { -> 106 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 107 | await page.waitForSelector('[aria-label="Project file tree"]'); - 108 | await expect(page.getByText('Project Files')).toBeVisible(); - 109 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 110 | }); - 111 | - 112 | test('shows root directory expanded by default', async ({ page }) => { - 113 | await page.goto('/'); - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { - 122 | await page.goto('/'); - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-renders-file-tree-in-sidebar-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tree-renders-file-tree-in-sidebar-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b98da0d74381bd9f7089dc0c99ac14baf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4254 zcmeAS@N?(olHy`uVBq!ia0y~yU#KiDjWttl9P!CsJDrMnSo)#sPJf*j3$WD+%Q@c zj24fhb;D@IINB;0Z4!+(6S23Efi38O(f0CadwI0IJlb9!bnWG?%4I8oO;~r(SOCQZ p_y?d#|NkF7qH+kxU;`P+%<#gUW5K!N4KhFx22WQ%mvv4FO#o+H0uulL diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-renders-file-tree-in-sidebar-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tree-renders-file-tree-in-sidebar-chromium/video.webm deleted file mode 100644 index 189bb8f897d6686ab49f3e756af4b441c5a299a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1924 zcmb1gy}x+AQ(GgW({~{L)X3uWxsk)EsiizMDc7mJk;$pGkx3%BA)S!{1lU6aeP@^K z^4;AXyt+lyb7flan#P3?o><7bY#{HN@9rL;oKVQ&^x!p3jG7RxOz>`?WD~pH8;Cg! zkznI!L4aJfLF@X)W_~9J--aHa#IiIqLp>lgFbH=<;ktBoIP^iB0CN1fbx^mtUTUP?zJLtATDd{}@*b0eej63QE9`1w zJT$A3DRe_e=yo8rqmc;|0>6rj=O-U%NIx;tb#c3^pR>Pkb{Bm+HQ4Dx_NPG(YBK}GtBg|3S`Ttb5!Lp=TcT-qla7+8Uko~4-q1Te@u zZ!2}3@Vt>>vD3sx28MUtj}Vo?$`VJ0N)O@b7D0WdqO%MhS=!4R9qeFZ|pHass0RL_P*X{*M>K;`xn? z84!5~4Eeuh1z-geAPRIa6#VOy04vx5QJ{jU;BEz2!2<#cm^cV2Fd(ELf{=m+LJC$8 wQgDHgf*%AFFe?yJ;6X@10U-r5@F@V*gA;f6Z*FAR-UY00KQ=M0_|V7<0N*Z`uK)l5 diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-shows-node-count-badges-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tree-shows-node-count-badges-chromium/error-context.md deleted file mode 100644 index ca62cb23..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tree-shows-node-count-badges-chromium/error-context.md +++ /dev/null @@ -1,206 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> shows node count badges -- Location: tests/e2e/file-tree.spec.ts:180:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 81 | await page.route('**/api/kinds**', route => - 82 | route.fulfill({ - 83 | status: 200, - 84 | contentType: 'application/json', - 85 | body: JSON.stringify({ kinds: [{ kind: 'class', count: 50 }, { kind: 'method', count: 50 }], total: 100 }), - 86 | }), - 87 | ); - 88 | await page.route('**/api/nodes**', route => - 89 | route.fulfill({ - 90 | status: 200, - 91 | contentType: 'application/json', - 92 | body: JSON.stringify({ nodes: [], total: 0, offset: 0, limit: 50 }), - 93 | }), - 94 | ); - 95 | } - 96 | - 97 | /* ── Tests ────────────────────────────────────────────────────────── */ - 98 | - 99 | test.describe('Project File Tree', () => { - 100 | test.beforeEach(async ({ page }) => { - 101 | await mockFileTree(page); - 102 | await mockMinimalApis(page); - 103 | }); - 104 | - 105 | test('renders file tree in sidebar', async ({ page }) => { - 106 | await page.goto('/'); - 107 | await page.waitForSelector('[aria-label="Project file tree"]'); - 108 | await expect(page.getByText('Project Files')).toBeVisible(); - 109 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 110 | }); - 111 | - 112 | test('shows root directory expanded by default', async ({ page }) => { - 113 | await page.goto('/'); - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { - 122 | await page.goto('/'); - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { -> 181 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { - 227 | await page.goto('/'); - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); - 234 | }); - 235 | - 236 | test('clearing file filter on graph view removes badge', async ({ page }) => { - 237 | await page.goto('/'); - 238 | await page.waitForSelector('[aria-label="Project file tree"]'); - 239 | - 240 | // Navigate via file click - 241 | await page.getByTestId('tree-node-pom.xml').click(); - 242 | await expect(page).toHaveURL(/\/graph/); - 243 | - 244 | const badge = page.getByTestId('file-filter-badge'); - 245 | await expect(badge).toBeVisible(); - 246 | - 247 | // Click X on the badge - 248 | await page.getByRole('button', { name: 'Clear file filter' }).click(); - 249 | await expect(badge).not.toBeVisible(); - 250 | }); - 251 | - 252 | test('accessibility: tree has correct ARIA roles', async ({ page }) => { - 253 | await page.goto('/'); - 254 | await page.waitForSelector('[aria-label="Project file tree"]'); - 255 | - 256 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 257 | const items = page.locator('[role="treeitem"]'); - 258 | await expect(items).not.toHaveCount(0); - 259 | }); - 260 | }); - 261 | -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-shows-node-count-badges-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tree-shows-node-count-badges-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b98da0d74381bd9f7089dc0c99ac14baf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4254 zcmeAS@N?(olHy`uVBq!ia0y~yU#KiDjWttl9P!CsJDrMnSo)#sPJf*j3$WD+%Q@c zj24fhb;D@IINB;0Z4!+(6S23Efi38O(f0CadwI0IJlb9!bnWG?%4I8oO;~r(SOCQZ p_y?d#|NkF7qH+kxU;`P+%<#gUW5K!N4KhFx22WQ%mvv4FO#o+H0uulL diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-shows-node-count-badges-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tree-shows-node-count-badges-chromium/video.webm deleted file mode 100644 index 6a7273b95517140e5a2775540753dec94fc86a1f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1924 zcmb1gy}x+AQ(GgW({~{L)X3uWxsk)EsiizMDc7mJk;$pGkx3%BA)S!{1lU6aeP@^K z^4;AXyt+lyb7flan#P3?o><7bY#{HN@9rL;oKVQ&^x!p3jG7RxOz>`?WD~pH8;Cg! zkznI!L4aJfLF@X)W_~9J--aHa#IiIqLp>lgFbH=<;ktBoIP^iB0CN1fbx^mtUTpieh*$Z9@~Y!mcL9 zL$ey0LN|1TZU<638ks;L@T<6Ze)55a^b<2(7q`3mIs3b~2D!AOIHI`tYVv{ZWQ29; zCl(+po(%LrGSCCYAP*?yWG0mrRHUC+=(@PWB{aw}#M9r;rG2u2ffX3(S(+I@0E4{q zwo=y#&l?#QJ56k4U}(7D&oGx!OM&?iBZE6fYiq~MmJX&S1~xI4?`(}J^RsWyR=21g zMWZ1=(-2TQ!SL^YO#{Q?rHu?441vrH+=2-V5)2agUwFiSv9WRgWoBUbdlv|9H2iy~ zz_55;BSQc~Arq1^4EYL(ya&`6B>5d+{R<%S0vPfiI2}Odp8(4XJi(CvDA2HYVI#u_ zhK)e`1vbFt85Y#N1A^xc|Gwr`HUN!alzB1uB>d?pA;mJRqQeiGz>=140TS2q|bFq+kUh v1s4b@_(4DcvjQOn9)uJW5K=G$p8`-lIB|FX=0=9?UBLSGV-w?w4~@(KrM#0L diff --git "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-4a1c2-controls-toolbar-is-visible-chromium/error-context.md" "b/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-4a1c2-controls-toolbar-is-visible-chromium/error-context.md" deleted file mode 100644 index 7008b94d..00000000 --- "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-4a1c2-controls-toolbar-is-visible-chromium/error-context.md" +++ /dev/null @@ -1,189 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: graph.spec.ts >> Graph view — initial render >> controls toolbar is visible -- Location: tests/e2e/graph.spec.ts:30:3 - -# Error details - -``` -Test timeout of 30000ms exceeded while running "beforeEach" hook. -``` - -``` -TimeoutError: page.waitForSelector: Timeout 30000ms exceeded. -Call log: - - waiting for locator('main') to be visible - -``` - -# Test source - -```ts - 1 | /// - 2 | import { type Page, expect } from '@playwright/test'; - 3 | import { readFileSync } from 'node:fs'; - 4 | import { resolve } from 'node:path'; - 5 | - 6 | // ── Route helpers ──────────────────────────────────────────────────────────── - 7 | - 8 | export const ROUTES = { - 9 | dashboard: '/', - 10 | graph: '/graph', - 11 | explorer: '/explorer', - 12 | console: '/console', - 13 | apiDocs: '/api-docs', - 14 | } as const; - 15 | - 16 | export type AppRoute = (typeof ROUTES)[keyof typeof ROUTES]; - 17 | - 18 | /** - 19 | * Intercept the HTML shell served by Spring Boot and replace it with the - 20 | * current on-disk version. The running JAR may contain a stale index.html - 21 | * (built before the last frontend rebuild), causing it to load an old - 22 | * JS bundle that crashes before React mounts. - 23 | * - 24 | * Bug: STALE_BUNDLE — tracked in RAN-80 (filed separately). - 25 | */ - 26 | export async function patchIndexHtml(page: Page) { - 27 | // process.cwd() is the frontend dir when running `npx playwright test` - 28 | const diskHtml = readFileSync( - 29 | resolve(process.cwd(), '../resources/static/index.html'), - 30 | 'utf-8', - 31 | ); - 32 | // Intercept the SPA shell route (all navigation routes return the same HTML) - 33 | await page.route('**/*', async (route) => { - 34 | const req = route.request(); - 35 | const url = req.url(); - 36 | // Only intercept HTML document requests (the SPA shell), not API/asset calls - 37 | if ( - 38 | req.resourceType() === 'document' && - 39 | !url.includes('/api/') && - 40 | !url.includes('/assets/') && - 41 | !url.includes('/swagger') && - 42 | !url.includes('/v3/') - 43 | ) { - 44 | await route.fulfill({ - 45 | status: 200, - 46 | contentType: 'text/html', - 47 | body: diskHtml, - 48 | }); - 49 | } else { - 50 | await route.continue(); - 51 | } - 52 | }); - 53 | } - 54 | - 55 | /** Navigate to a route and wait for the main content area to be visible. */ - 56 | export async function gotoRoute(page: Page, route: AppRoute) { - 57 | await patchIndexHtml(page); - 58 | await page.goto(route); - 59 | // Wait for React to hydrate (main rendered by Layout component) -> 60 | await page.waitForSelector('main', { state: 'visible', timeout: 30000 }); - | ^ TimeoutError: page.waitForSelector: Timeout 30000ms exceeded. - 61 | } - 62 | - 63 | // ── Theme helpers ──────────────────────────────────────────────────────────── - 64 | - 65 | /** Returns the current theme: 'dark' | 'light'. */ - 66 | export async function getTheme(page: Page): Promise<'dark' | 'light'> { - 67 | const cls = await page.locator('html').getAttribute('class') ?? ''; - 68 | return cls.includes('dark') ? 'dark' : 'light'; - 69 | } - 70 | - 71 | /** Click the theme toggle and wait for the class to flip. */ - 72 | export async function toggleTheme(page: Page) { - 73 | const before = await getTheme(page); - 74 | // Theme toggle button — uses aria-label or data-testid set by the component - 75 | await page.getByRole('button', { name: /toggle theme|switch theme|dark mode|light mode/i }).click(); - 76 | await expect(page.locator('html')).toHaveClass(before === 'dark' ? /light/ : /dark/, { timeout: 2000 }); - 77 | } - 78 | - 79 | // ── API mock helpers ───────────────────────────────────────────────────────── - 80 | - 81 | /** Seed the `/api/stats` mock for deterministic dashboard tests. */ - 82 | export async function mockStats(page: Page, nodeCount = 1234, edgeCount = 5678) { - 83 | await page.route('**/api/stats', route => - 84 | route.fulfill({ - 85 | status: 200, - 86 | contentType: 'application/json', - 87 | body: JSON.stringify({ - 88 | totalNodes: nodeCount, - 89 | totalEdges: edgeCount, - 90 | nodesByKind: { endpoint: 10, class: 20, method: 30 }, - 91 | edgesByKind: { calls: 100, depends_on: 50 }, - 92 | languages: { java: 500, typescript: 200 }, - 93 | frameworks: { spring_boot: 300 }, - 94 | layers: { backend: 600, frontend: 200, infra: 100, shared: 50, unknown: 284 }, - 95 | }), - 96 | }) - 97 | ); - 98 | } - 99 | - 100 | /** - 101 | * Generate a synthetic node list for performance/stress tests. - 102 | * Returns a NodesListResponse-shaped object. - 103 | */ - 104 | export function generateNodeList(count: number) { - 105 | const nodes = Array.from({ length: count }, (_, i) => ({ - 106 | id: `node:file${i % 100}.ts:class:Class${i}`, - 107 | kind: ['class', 'method', 'endpoint', 'entity', 'function'][i % 5], - 108 | name: `Symbol${i}`, - 109 | qualifiedName: `com.example.Symbol${i}`, - 110 | filePath: `src/file${i % 100}.ts`, - 111 | layer: 'backend', - 112 | framework: null, - 113 | properties: {}, - 114 | })); - 115 | return { nodes, total: count, offset: 0, limit: count }; - 116 | } - 117 | - 118 | /** Seed the `/api/kinds` + `/api/nodes` endpoints with synthetic data. */ - 119 | export async function mockGraphData(page: Page, nodeCount: number) { - 120 | const data = generateNodeList(nodeCount); - 121 | - 122 | await page.route('**/api/kinds', route => - 123 | route.fulfill({ - 124 | status: 200, - 125 | contentType: 'application/json', - 126 | body: JSON.stringify({ - 127 | kinds: [ - 128 | { kind: 'class', count: Math.floor(nodeCount * 0.3) }, - 129 | { kind: 'method', count: Math.floor(nodeCount * 0.3) }, - 130 | { kind: 'endpoint', count: Math.floor(nodeCount * 0.15) }, - 131 | { kind: 'entity', count: Math.floor(nodeCount * 0.15) }, - 132 | { kind: 'function', count: Math.floor(nodeCount * 0.1) }, - 133 | ], - 134 | }), - 135 | }) - 136 | ); - 137 | - 138 | await page.route('**/api/nodes**', route => - 139 | route.fulfill({ - 140 | status: 200, - 141 | contentType: 'application/json', - 142 | body: JSON.stringify(data), - 143 | }) - 144 | ); - 145 | - 146 | await page.route('**/api/topology', route => - 147 | route.fulfill({ - 148 | status: 200, - 149 | contentType: 'application/json', - 150 | body: JSON.stringify({ - 151 | services: [ - 152 | { name: 'api-service', nodeCount: Math.floor(nodeCount / 3), dependencies: ['db-service'] }, - 153 | { name: 'db-service', nodeCount: Math.floor(nodeCount / 3), dependencies: [] }, - 154 | { name: 'frontend-service', nodeCount: Math.floor(nodeCount / 3), dependencies: ['api-service'] }, - 155 | ], - 156 | }), - 157 | }) - 158 | ); - 159 | } - 160 | -``` \ No newline at end of file diff --git "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-4a1c2-controls-toolbar-is-visible-chromium/test-failed-1.png" "b/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-4a1c2-controls-toolbar-is-visible-chromium/test-failed-1.png" deleted file mode 100644 index 3ddab50b98da0d74381bd9f7089dc0c99ac14baf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4254 zcmeAS@N?(olHy`uVBq!ia0y~yU#KiDjWttl9P!CsJDrMnSo)#sPJf*j3$WD+%Q@c zj24fhb;D@IINB;0Z4!+(6S23Efi38O(f0CadwI0IJlb9!bnWG?%4I8oO;~r(SOCQZ p_y?d#|NkF7qH+kxU;`P+%<#gUW5K!N4KhFx22WQ%mvv4FO#o+H0uulL diff --git "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-4a1c2-controls-toolbar-is-visible-chromium/video.webm" "b/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-4a1c2-controls-toolbar-is-visible-chromium/video.webm" deleted file mode 100644 index dac5f70b213d56c5fe065b7f1664c65ddac9cf0f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30516 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 diff --git "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-graph-container-is-visible-chromium/error-context.md" "b/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-graph-container-is-visible-chromium/error-context.md" deleted file mode 100644 index 524e2b3e..00000000 --- "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-graph-container-is-visible-chromium/error-context.md" +++ /dev/null @@ -1,189 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: graph.spec.ts >> Graph view — initial render >> graph container is visible -- Location: tests/e2e/graph.spec.ts:26:3 - -# Error details - -``` -Test timeout of 30000ms exceeded while running "beforeEach" hook. -``` - -``` -TimeoutError: page.waitForSelector: Timeout 30000ms exceeded. -Call log: - - waiting for locator('main') to be visible - -``` - -# Test source - -```ts - 1 | /// - 2 | import { type Page, expect } from '@playwright/test'; - 3 | import { readFileSync } from 'node:fs'; - 4 | import { resolve } from 'node:path'; - 5 | - 6 | // ── Route helpers ──────────────────────────────────────────────────────────── - 7 | - 8 | export const ROUTES = { - 9 | dashboard: '/', - 10 | graph: '/graph', - 11 | explorer: '/explorer', - 12 | console: '/console', - 13 | apiDocs: '/api-docs', - 14 | } as const; - 15 | - 16 | export type AppRoute = (typeof ROUTES)[keyof typeof ROUTES]; - 17 | - 18 | /** - 19 | * Intercept the HTML shell served by Spring Boot and replace it with the - 20 | * current on-disk version. The running JAR may contain a stale index.html - 21 | * (built before the last frontend rebuild), causing it to load an old - 22 | * JS bundle that crashes before React mounts. - 23 | * - 24 | * Bug: STALE_BUNDLE — tracked in RAN-80 (filed separately). - 25 | */ - 26 | export async function patchIndexHtml(page: Page) { - 27 | // process.cwd() is the frontend dir when running `npx playwright test` - 28 | const diskHtml = readFileSync( - 29 | resolve(process.cwd(), '../resources/static/index.html'), - 30 | 'utf-8', - 31 | ); - 32 | // Intercept the SPA shell route (all navigation routes return the same HTML) - 33 | await page.route('**/*', async (route) => { - 34 | const req = route.request(); - 35 | const url = req.url(); - 36 | // Only intercept HTML document requests (the SPA shell), not API/asset calls - 37 | if ( - 38 | req.resourceType() === 'document' && - 39 | !url.includes('/api/') && - 40 | !url.includes('/assets/') && - 41 | !url.includes('/swagger') && - 42 | !url.includes('/v3/') - 43 | ) { - 44 | await route.fulfill({ - 45 | status: 200, - 46 | contentType: 'text/html', - 47 | body: diskHtml, - 48 | }); - 49 | } else { - 50 | await route.continue(); - 51 | } - 52 | }); - 53 | } - 54 | - 55 | /** Navigate to a route and wait for the main content area to be visible. */ - 56 | export async function gotoRoute(page: Page, route: AppRoute) { - 57 | await patchIndexHtml(page); - 58 | await page.goto(route); - 59 | // Wait for React to hydrate (main rendered by Layout component) -> 60 | await page.waitForSelector('main', { state: 'visible', timeout: 30000 }); - | ^ TimeoutError: page.waitForSelector: Timeout 30000ms exceeded. - 61 | } - 62 | - 63 | // ── Theme helpers ──────────────────────────────────────────────────────────── - 64 | - 65 | /** Returns the current theme: 'dark' | 'light'. */ - 66 | export async function getTheme(page: Page): Promise<'dark' | 'light'> { - 67 | const cls = await page.locator('html').getAttribute('class') ?? ''; - 68 | return cls.includes('dark') ? 'dark' : 'light'; - 69 | } - 70 | - 71 | /** Click the theme toggle and wait for the class to flip. */ - 72 | export async function toggleTheme(page: Page) { - 73 | const before = await getTheme(page); - 74 | // Theme toggle button — uses aria-label or data-testid set by the component - 75 | await page.getByRole('button', { name: /toggle theme|switch theme|dark mode|light mode/i }).click(); - 76 | await expect(page.locator('html')).toHaveClass(before === 'dark' ? /light/ : /dark/, { timeout: 2000 }); - 77 | } - 78 | - 79 | // ── API mock helpers ───────────────────────────────────────────────────────── - 80 | - 81 | /** Seed the `/api/stats` mock for deterministic dashboard tests. */ - 82 | export async function mockStats(page: Page, nodeCount = 1234, edgeCount = 5678) { - 83 | await page.route('**/api/stats', route => - 84 | route.fulfill({ - 85 | status: 200, - 86 | contentType: 'application/json', - 87 | body: JSON.stringify({ - 88 | totalNodes: nodeCount, - 89 | totalEdges: edgeCount, - 90 | nodesByKind: { endpoint: 10, class: 20, method: 30 }, - 91 | edgesByKind: { calls: 100, depends_on: 50 }, - 92 | languages: { java: 500, typescript: 200 }, - 93 | frameworks: { spring_boot: 300 }, - 94 | layers: { backend: 600, frontend: 200, infra: 100, shared: 50, unknown: 284 }, - 95 | }), - 96 | }) - 97 | ); - 98 | } - 99 | - 100 | /** - 101 | * Generate a synthetic node list for performance/stress tests. - 102 | * Returns a NodesListResponse-shaped object. - 103 | */ - 104 | export function generateNodeList(count: number) { - 105 | const nodes = Array.from({ length: count }, (_, i) => ({ - 106 | id: `node:file${i % 100}.ts:class:Class${i}`, - 107 | kind: ['class', 'method', 'endpoint', 'entity', 'function'][i % 5], - 108 | name: `Symbol${i}`, - 109 | qualifiedName: `com.example.Symbol${i}`, - 110 | filePath: `src/file${i % 100}.ts`, - 111 | layer: 'backend', - 112 | framework: null, - 113 | properties: {}, - 114 | })); - 115 | return { nodes, total: count, offset: 0, limit: count }; - 116 | } - 117 | - 118 | /** Seed the `/api/kinds` + `/api/nodes` endpoints with synthetic data. */ - 119 | export async function mockGraphData(page: Page, nodeCount: number) { - 120 | const data = generateNodeList(nodeCount); - 121 | - 122 | await page.route('**/api/kinds', route => - 123 | route.fulfill({ - 124 | status: 200, - 125 | contentType: 'application/json', - 126 | body: JSON.stringify({ - 127 | kinds: [ - 128 | { kind: 'class', count: Math.floor(nodeCount * 0.3) }, - 129 | { kind: 'method', count: Math.floor(nodeCount * 0.3) }, - 130 | { kind: 'endpoint', count: Math.floor(nodeCount * 0.15) }, - 131 | { kind: 'entity', count: Math.floor(nodeCount * 0.15) }, - 132 | { kind: 'function', count: Math.floor(nodeCount * 0.1) }, - 133 | ], - 134 | }), - 135 | }) - 136 | ); - 137 | - 138 | await page.route('**/api/nodes**', route => - 139 | route.fulfill({ - 140 | status: 200, - 141 | contentType: 'application/json', - 142 | body: JSON.stringify(data), - 143 | }) - 144 | ); - 145 | - 146 | await page.route('**/api/topology', route => - 147 | route.fulfill({ - 148 | status: 200, - 149 | contentType: 'application/json', - 150 | body: JSON.stringify({ - 151 | services: [ - 152 | { name: 'api-service', nodeCount: Math.floor(nodeCount / 3), dependencies: ['db-service'] }, - 153 | { name: 'db-service', nodeCount: Math.floor(nodeCount / 3), dependencies: [] }, - 154 | { name: 'frontend-service', nodeCount: Math.floor(nodeCount / 3), dependencies: ['api-service'] }, - 155 | ], - 156 | }), - 157 | }) - 158 | ); - 159 | } - 160 | -``` \ No newline at end of file diff --git "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-graph-container-is-visible-chromium/test-failed-1.png" "b/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-graph-container-is-visible-chromium/test-failed-1.png" deleted file mode 100644 index 3ddab50b98da0d74381bd9f7089dc0c99ac14baf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4254 zcmeAS@N?(olHy`uVBq!ia0y~yU#KiDjWttl9P!CsJDrMnSo)#sPJf*j3$WD+%Q@c zj24fhb;D@IINB;0Z4!+(6S23Efi38O(f0CadwI0IJlb9!bnWG?%4I8oO;~r(SOCQZ p_y?d#|NkF7qH+kxU;`P+%<#gUW5K!N4KhFx22WQ%mvv4FO#o+H0uulL diff --git "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-graph-container-is-visible-chromium/video.webm" "b/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-graph-container-is-visible-chromium/video.webm" deleted file mode 100644 index 0a6f5b0622623871f6fa82636731de61671b5806..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29697 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 QD17Kp=b`;a+7Ipg56{SY-v9sr diff --git "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-minimap-is-visible-chromium/test-failed-1.png" "b/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-minimap-is-visible-chromium/test-failed-1.png" deleted file mode 100644 index 3ddab50b98da0d74381bd9f7089dc0c99ac14baf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4254 zcmeAS@N?(olHy`uVBq!ia0y~yU#KiDjWttl9P!CsJDrMnSo)#sPJf*j3$WD+%Q@c zj24fhb;D@IINB;0Z4!+(6S23Efi38O(f0CadwI0IJlb9!bnWG?%4I8oO;~r(SOCQZ p_y?d#|NkF7qH+kxU;`P+%<#gUW5K!N4KhFx22WQ%mvv4FO#o+H0uulL From c9b8d66d0dde72fb68cb987780a1d5d7e5140ce4 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 3 Apr 2026 16:33:37 +0000 Subject: [PATCH 02/16] fix(security): add npm override for lodash >= 4.17.24 to fix HIGH CVEs Adds lodash >= 4.17.24 override in package.json to resolve two CVEs (HIGH code injection via _.template, MODERATE prototype pollution via _.unset/_.omit) in transitive dependencies swagger-ui-react and @antv/g6. All lodash instances now resolve to 4.18.1. npm audit reports 0 vulnerabilities. Co-Authored-By: Paperclip --- src/main/frontend/package-lock.json | 6 +++--- src/main/frontend/package.json | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/frontend/package-lock.json b/src/main/frontend/package-lock.json index 45e9c009..a4bc3e6e 100644 --- a/src/main/frontend/package-lock.json +++ b/src/main/frontend/package-lock.json @@ -5139,9 +5139,9 @@ "license": "MIT" }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.debounce": { diff --git a/src/main/frontend/package.json b/src/main/frontend/package.json index 5438d088..857c8aaf 100644 --- a/src/main/frontend/package.json +++ b/src/main/frontend/package.json @@ -33,7 +33,8 @@ "tailwindcss-animate": "^1.0.7" }, "overrides": { - "dompurify": "^3.3.3" + "dompurify": "^3.3.3", + "lodash": ">=4.17.24" }, "devDependencies": { "@axe-core/playwright": "^4.10.1", From a912c6a0a328892a5c44c2a142cb1e70bc5453bd Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 3 Apr 2026 17:45:18 +0000 Subject: [PATCH 03/16] feat(intelligence): implement capability matrix and deterministic query planner (RAN-148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Phase 3 of the Repository Intelligence system: - CapabilityMatrix: static per-language × per-dimension capability registry (EXACT/PARTIAL/LEXICAL_ONLY/UNSUPPORTED) for Java, TypeScript, JavaScript, Python, Go, C#, Rust, and lexical-only languages. - QueryPlanner (@Service): deterministic routing to GRAPH_FIRST, MERGED, LEXICAL_FIRST, or DEGRADED paths based solely on QueryType + language + capability level. No LLM, no probabilistic logic. - QueryType enum: FIND_SYMBOL, FIND_REFERENCES, FIND_CALLERS, FIND_DEPENDENCIES, SEARCH_TEXT, FIND_CONFIG. - CapabilityDimension enum: 9 analysis dimensions. - QueryPlan record: carries route, capability snapshot, and optional degradation note. - GET /api/capabilities endpoint (optional ?language= filter). - get_capabilities MCP tool (32nd tool). - 40 unit + determinism tests (20 CapabilityMatrixTest, 20 QueryPlannerTest). Co-Authored-By: Paperclip --- .../iq/api/GraphController.java | 19 ++ .../query/CapabilityDimension.java | 26 ++ .../intelligence/query/CapabilityMatrix.java | 270 ++++++++++++++++++ .../iq/intelligence/query/QueryPlan.java | 43 +++ .../iq/intelligence/query/QueryPlanner.java | 140 +++++++++ .../iq/intelligence/query/QueryRoute.java | 28 ++ .../iq/intelligence/query/QueryType.java | 20 ++ .../randomcodespace/iq/mcp/McpTools.java | 21 ++ .../query/CapabilityMatrixTest.java | 167 +++++++++++ .../intelligence/query/QueryPlannerTest.java | 212 ++++++++++++++ 10 files changed, 946 insertions(+) create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityDimension.java create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrix.java create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryPlan.java create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryPlanner.java create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryRoute.java create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryType.java create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrixTest.java create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/query/QueryPlannerTest.java diff --git a/src/main/java/io/github/randomcodespace/iq/api/GraphController.java b/src/main/java/io/github/randomcodespace/iq/api/GraphController.java index 4647541b..824450db 100644 --- a/src/main/java/io/github/randomcodespace/iq/api/GraphController.java +++ b/src/main/java/io/github/randomcodespace/iq/api/GraphController.java @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; +import io.github.randomcodespace.iq.intelligence.query.CapabilityMatrix; import io.github.randomcodespace.iq.model.NodeKind; import java.io.IOException; @@ -209,6 +210,24 @@ public Map getFileTree( return queryService.getFileTree(cappedDepth); } + @GetMapping("/capabilities") + public Map getCapabilities( + @RequestParam(required = false) String language) { + Map result = new java.util.LinkedHashMap<>(); + if (language != null && !language.isBlank()) { + result.put("language", language.strip().toLowerCase()); + result.put("capabilities", CapabilityMatrix.forLanguage(language).entrySet().stream() + .collect(java.util.stream.Collectors.toMap( + e -> e.getKey().name().toLowerCase(), + e -> e.getValue().name(), + (a, b) -> a, + java.util.TreeMap::new))); + } else { + result.put("matrix", CapabilityMatrix.asSerializableMap()); + } + return result; + } + private void validateNodeKind(String kind) { try { NodeKind.fromValue(kind); diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityDimension.java b/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityDimension.java new file mode 100644 index 00000000..5455444d --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityDimension.java @@ -0,0 +1,26 @@ +package io.github.randomcodespace.iq.intelligence.query; + +/** + * Semantic dimensions of language intelligence used in the capability matrix. + * Each dimension corresponds to a distinct analysis concern. + */ +public enum CapabilityDimension { + /** Detection of symbol definitions (classes, functions, methods, variables). */ + SYMBOL_DEFINITIONS, + /** Detection of symbol references and usages across files. */ + SYMBOL_REFERENCES, + /** Resolution of import/require/use directives to target symbols. */ + IMPORT_RESOLUTION, + /** Type information extraction (static types, inferred types, generics). */ + TYPE_INFO, + /** Class hierarchy and interface/mixin relationship detection. */ + CLASS_HIERARCHY, + /** Framework-specific semantics (annotations, decorators, conventions). */ + FRAMEWORK_SEMANTICS, + /** ORM entity and relationship mapping detection. */ + ORM_ENTITY_MAPPING, + /** Authentication and authorization pattern detection. */ + AUTH_SECURITY, + /** Async, event-driven, and messaging pattern detection. */ + ASYNC_PATTERNS +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrix.java b/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrix.java new file mode 100644 index 00000000..8c02bbfa --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrix.java @@ -0,0 +1,270 @@ +package io.github.randomcodespace.iq.intelligence.query; + +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/** + * Static, deterministic capability matrix that declares per-language analysis + * capability levels for each {@link CapabilityDimension}. + * + *

Levels reflect what the current detector suite actually provides: + *

    + *
  • Java — 27 detectors with JavaParser AST → {@code EXACT} for most dimensions.
  • + *
  • TypeScript / JavaScript — ANTLR grammar-based → {@code PARTIAL}.
  • + *
  • Python — ANTLR grammar-based → {@code PARTIAL}.
  • + *
  • Go — ANTLR grammar-based → {@code PARTIAL}.
  • + *
  • C# / Rust — ANTLR grammar-based → {@code PARTIAL}.
  • + *
  • Kotlin / Scala / Ruby / PHP / Shell / Markdown — regex only → {@code LEXICAL_ONLY}.
  • + *
  • Everything else — {@code UNSUPPORTED}.
  • + *
+ * + *

This class is intentionally non-instantiable. Use the static {@link #get} methods. + */ +public final class CapabilityMatrix { + + // ------------------------------------------------------------------ + // Language normalisation + // ------------------------------------------------------------------ + + /** Languages with ANTLR or JavaParser AST-level support. */ + private static final Set ANTLR_LANGUAGES = + Set.of("typescript", "javascript", "python", "go", "csharp", "rust", "cpp"); + + /** Languages with regex/text-only detection (no grammar). */ + private static final Set LEXICAL_ONLY_LANGUAGES = + Set.of("kotlin", "scala", "ruby", "php", "shell", "bash", "powershell", + "markdown", "proto", "hcl", "terraform", "dockerfile", "yaml", + "json", "toml", "ini", "properties", "xml", "sql"); + + // ------------------------------------------------------------------ + // Per-language dimension tables + // ------------------------------------------------------------------ + + /** Java: full AST analysis via JavaParser — highest fidelity. */ + private static final Map JAVA_CAPS; + + /** TypeScript: ANTLR grammar — good structural coverage. */ + private static final Map TYPESCRIPT_CAPS; + + /** JavaScript: same grammar as TypeScript, no static types. */ + private static final Map JAVASCRIPT_CAPS; + + /** Python: ANTLR grammar — class/function/import aware. */ + private static final Map PYTHON_CAPS; + + /** Go: ANTLR grammar — struct/interface/import aware. */ + private static final Map GO_CAPS; + + /** C#: ANTLR grammar — partial coverage. */ + private static final Map CSHARP_CAPS; + + /** Rust: ANTLR grammar — partial structural coverage. */ + private static final Map RUST_CAPS; + + /** Fallback for regex-only languages. */ + private static final Map LEXICAL_ONLY_CAPS; + + /** Fallback for completely unsupported languages. */ + private static final Map UNSUPPORTED_CAPS; + + static { + JAVA_CAPS = immutableDimMap( + CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.EXACT, + CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.EXACT, + CapabilityDimension.IMPORT_RESOLUTION, CapabilityLevel.EXACT, + CapabilityDimension.TYPE_INFO, CapabilityLevel.EXACT, + CapabilityDimension.CLASS_HIERARCHY, CapabilityLevel.EXACT, + CapabilityDimension.FRAMEWORK_SEMANTICS, CapabilityLevel.EXACT, + CapabilityDimension.ORM_ENTITY_MAPPING, CapabilityLevel.EXACT, + CapabilityDimension.AUTH_SECURITY, CapabilityLevel.EXACT, + CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.PARTIAL + ); + + TYPESCRIPT_CAPS = immutableDimMap( + CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.PARTIAL, + CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.PARTIAL, + CapabilityDimension.IMPORT_RESOLUTION, CapabilityLevel.PARTIAL, + CapabilityDimension.TYPE_INFO, CapabilityLevel.PARTIAL, + CapabilityDimension.CLASS_HIERARCHY, CapabilityLevel.PARTIAL, + CapabilityDimension.FRAMEWORK_SEMANTICS, CapabilityLevel.PARTIAL, + CapabilityDimension.ORM_ENTITY_MAPPING, CapabilityLevel.PARTIAL, + CapabilityDimension.AUTH_SECURITY, CapabilityLevel.PARTIAL, + CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.PARTIAL + ); + + JAVASCRIPT_CAPS = immutableDimMap( + CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.PARTIAL, + CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.PARTIAL, + CapabilityDimension.IMPORT_RESOLUTION, CapabilityLevel.PARTIAL, + CapabilityDimension.TYPE_INFO, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.CLASS_HIERARCHY, CapabilityLevel.PARTIAL, + CapabilityDimension.FRAMEWORK_SEMANTICS, CapabilityLevel.PARTIAL, + CapabilityDimension.ORM_ENTITY_MAPPING, CapabilityLevel.PARTIAL, + CapabilityDimension.AUTH_SECURITY, CapabilityLevel.PARTIAL, + CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.PARTIAL + ); + + PYTHON_CAPS = immutableDimMap( + CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.PARTIAL, + CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.PARTIAL, + CapabilityDimension.IMPORT_RESOLUTION, CapabilityLevel.PARTIAL, + CapabilityDimension.TYPE_INFO, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.CLASS_HIERARCHY, CapabilityLevel.PARTIAL, + CapabilityDimension.FRAMEWORK_SEMANTICS, CapabilityLevel.PARTIAL, + CapabilityDimension.ORM_ENTITY_MAPPING, CapabilityLevel.PARTIAL, + CapabilityDimension.AUTH_SECURITY, CapabilityLevel.PARTIAL, + CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.PARTIAL + ); + + GO_CAPS = immutableDimMap( + CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.PARTIAL, + CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.PARTIAL, + CapabilityDimension.IMPORT_RESOLUTION, CapabilityLevel.PARTIAL, + CapabilityDimension.TYPE_INFO, CapabilityLevel.PARTIAL, + CapabilityDimension.CLASS_HIERARCHY, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.FRAMEWORK_SEMANTICS, CapabilityLevel.PARTIAL, + CapabilityDimension.ORM_ENTITY_MAPPING, CapabilityLevel.PARTIAL, + CapabilityDimension.AUTH_SECURITY, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.PARTIAL + ); + + CSHARP_CAPS = immutableDimMap( + CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.PARTIAL, + CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.PARTIAL, + CapabilityDimension.IMPORT_RESOLUTION, CapabilityLevel.PARTIAL, + CapabilityDimension.TYPE_INFO, CapabilityLevel.PARTIAL, + CapabilityDimension.CLASS_HIERARCHY, CapabilityLevel.PARTIAL, + CapabilityDimension.FRAMEWORK_SEMANTICS, CapabilityLevel.PARTIAL, + CapabilityDimension.ORM_ENTITY_MAPPING, CapabilityLevel.PARTIAL, + CapabilityDimension.AUTH_SECURITY, CapabilityLevel.PARTIAL, + CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.PARTIAL + ); + + RUST_CAPS = immutableDimMap( + CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.PARTIAL, + CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.PARTIAL, + CapabilityDimension.IMPORT_RESOLUTION, CapabilityLevel.PARTIAL, + CapabilityDimension.TYPE_INFO, CapabilityLevel.PARTIAL, + CapabilityDimension.CLASS_HIERARCHY, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.FRAMEWORK_SEMANTICS, CapabilityLevel.PARTIAL, + CapabilityDimension.ORM_ENTITY_MAPPING, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.AUTH_SECURITY, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.PARTIAL + ); + + LEXICAL_ONLY_CAPS = immutableDimMap( + CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.IMPORT_RESOLUTION, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.TYPE_INFO, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.CLASS_HIERARCHY, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.FRAMEWORK_SEMANTICS, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.ORM_ENTITY_MAPPING, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.AUTH_SECURITY, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.LEXICAL_ONLY + ); + + UNSUPPORTED_CAPS = immutableDimMap( + CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.IMPORT_RESOLUTION, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.TYPE_INFO, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.CLASS_HIERARCHY, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.FRAMEWORK_SEMANTICS, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.ORM_ENTITY_MAPPING, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.AUTH_SECURITY, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.UNSUPPORTED + ); + } + + private CapabilityMatrix() {} + + // ------------------------------------------------------------------ + // Public API + // ------------------------------------------------------------------ + + /** + * Returns the full dimension-to-level map for {@code language}. + * The map is sorted by {@link CapabilityDimension} ordinal order (deterministic). + * + * @param language normalised lowercase language name + * @return immutable capability map; never {@code null} + */ + public static Map forLanguage(String language) { + return tableFor(normalise(language)); + } + + /** + * Returns the capability level for a single {@code language} / {@code dimension} pair. + * + * @param language normalised lowercase language name + * @param dimension the capability dimension to query + * @return the capability level; never {@code null} + */ + public static CapabilityLevel get(String language, CapabilityDimension dimension) { + return tableFor(normalise(language)).getOrDefault(dimension, CapabilityLevel.UNSUPPORTED); + } + + /** + * Returns the full matrix as a serialisation-friendly nested map. + * Outer keys are language names (sorted), inner keys are dimension names, values are level names. + * Deterministic by construction. + */ + public static Map> asSerializableMap() { + Map> result = new TreeMap<>(); + for (String lang : new String[]{ + "java", "typescript", "javascript", "python", "go", "csharp", "rust", + "kotlin", "scala", "ruby", "php", "shell"}) { + Map caps = tableFor(lang); + Map row = new LinkedHashMap<>(); + for (CapabilityDimension dim : CapabilityDimension.values()) { + row.put(dim.name().toLowerCase(), caps.getOrDefault(dim, CapabilityLevel.UNSUPPORTED).name()); + } + result.put(lang, row); + } + return Collections.unmodifiableMap(result); + } + + // ------------------------------------------------------------------ + // Internals + // ------------------------------------------------------------------ + + private static String normalise(String language) { + if (language == null) return ""; + return language.strip().toLowerCase(); + } + + private static Map tableFor(String lang) { + return switch (lang) { + case "java" -> JAVA_CAPS; + case "typescript" -> TYPESCRIPT_CAPS; + case "javascript" -> JAVASCRIPT_CAPS; + case "python" -> PYTHON_CAPS; + case "go" -> GO_CAPS; + case "csharp", "c#" -> CSHARP_CAPS; + case "rust" -> RUST_CAPS; + default -> { + if (LEXICAL_ONLY_LANGUAGES.contains(lang)) yield LEXICAL_ONLY_CAPS; + if (ANTLR_LANGUAGES.contains(lang)) yield CSHARP_CAPS; // cpp etc → PARTIAL + yield UNSUPPORTED_CAPS; + } + }; + } + + /** + * Varargs helper: alternating {@code (dimension, level)} pairs → immutable EnumMap. + */ + private static Map immutableDimMap(Object... pairs) { + EnumMap map = new EnumMap<>(CapabilityDimension.class); + for (int i = 0; i < pairs.length - 1; i += 2) { + map.put((CapabilityDimension) pairs[i], (CapabilityLevel) pairs[i + 1]); + } + return Collections.unmodifiableMap(map); + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryPlan.java b/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryPlan.java new file mode 100644 index 00000000..4ac5384d --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryPlan.java @@ -0,0 +1,43 @@ +package io.github.randomcodespace.iq.intelligence.query; + +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; + +import java.util.Map; + +/** + * Immutable result produced by the {@link QueryPlanner}. + * Describes how a given query intent should be executed for a specific language. + * + * @param queryType The type of query being planned. + * @param language Normalised lowercase language name (e.g. {@code "java"}, {@code "python"}). + * @param route The selected retrieval path. + * @param capabilities Snapshot of the capability levels for dimensions relevant to this query. + * @param degradationNote Human-readable explanation when {@code route} is + * {@link QueryRoute#LEXICAL_FIRST} or {@link QueryRoute#DEGRADED}; + * {@code null} for {@link QueryRoute#GRAPH_FIRST} and {@link QueryRoute#MERGED}. + */ +public record QueryPlan( + QueryType queryType, + String language, + QueryRoute route, + Map capabilities, + String degradationNote +) { + /** + * Convenience factory for a fully-capable plan (no degradation note). + */ + public static QueryPlan of(QueryType queryType, String language, QueryRoute route, + Map capabilities) { + return new QueryPlan(queryType, language, route, capabilities, null); + } + + /** Returns {@code true} if this plan involves any graph traversal. */ + public boolean usesGraph() { + return route == QueryRoute.GRAPH_FIRST || route == QueryRoute.MERGED; + } + + /** Returns {@code true} if this plan involves lexical/text search. */ + public boolean usesLexical() { + return route == QueryRoute.LEXICAL_FIRST || route == QueryRoute.MERGED; + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryPlanner.java b/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryPlanner.java new file mode 100644 index 00000000..f837b60c --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryPlanner.java @@ -0,0 +1,140 @@ +package io.github.randomcodespace.iq.intelligence.query; + +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import org.springframework.stereotype.Service; + +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Deterministic query planner that routes a query intent to the correct retrieval path + * based on explicit rules derived from the {@link CapabilityMatrix}. + * + *

Routing rules (no LLM, no probabilistic logic): + *

    + *
  1. {@link QueryRoute#GRAPH_FIRST} — all relevant dimensions are {@code EXACT}.
  2. + *
  3. {@link QueryRoute#MERGED} — at least one relevant dimension is {@code PARTIAL} + * (graph results plus lexical search for coverage).
  4. + *
  5. {@link QueryRoute#LEXICAL_FIRST} — all relevant dimensions are {@code LEXICAL_ONLY}.
  6. + *
  7. {@link QueryRoute#DEGRADED} — any relevant dimension is {@code UNSUPPORTED}.
  8. + *
+ * + *

{@link QueryType#SEARCH_TEXT} always routes to {@link QueryRoute#LEXICAL_FIRST} + * regardless of language, because text search operates on raw source content, not the graph. + */ +@Service +public class QueryPlanner { + + // ------------------------------------------------------------------ + // Query-type → relevant dimensions mapping (deterministic, static) + // ------------------------------------------------------------------ + + private static final Map> QUERY_DIMENSIONS = Map.of( + QueryType.FIND_SYMBOL, List.of(CapabilityDimension.SYMBOL_DEFINITIONS), + QueryType.FIND_REFERENCES, List.of(CapabilityDimension.SYMBOL_REFERENCES), + QueryType.FIND_CALLERS, List.of(CapabilityDimension.SYMBOL_REFERENCES), + QueryType.FIND_DEPENDENCIES, List.of(CapabilityDimension.IMPORT_RESOLUTION), + QueryType.SEARCH_TEXT, List.of(), // special-cased below + QueryType.FIND_CONFIG, List.of(CapabilityDimension.FRAMEWORK_SEMANTICS) + ); + + // ------------------------------------------------------------------ + // Public API + // ------------------------------------------------------------------ + + /** + * Produce a {@link QueryPlan} for the given {@code queryType} and {@code language}. + * The result is fully deterministic for the same input. + * + * @param queryType the type of query being planned + * @param language normalised lowercase language name (e.g. {@code "java"}, {@code "python"}) + * @return a non-null {@link QueryPlan} + */ + public QueryPlan plan(QueryType queryType, String language) { + Map caps = CapabilityMatrix.forLanguage(language); + + // SEARCH_TEXT is always lexical — the graph does not index raw text content + if (queryType == QueryType.SEARCH_TEXT) { + return new QueryPlan(queryType, language, QueryRoute.LEXICAL_FIRST, caps, null); + } + + List relevant = QUERY_DIMENSIONS.getOrDefault(queryType, List.of()); + + if (relevant.isEmpty()) { + // Unknown query type with no dimension mapping → treat as degraded + return new QueryPlan(queryType, language, QueryRoute.DEGRADED, caps, + "No capability dimensions are mapped for query type " + queryType + + ". This query type may not be supported yet."); + } + + Set levels = EnumSet.noneOf(CapabilityLevel.class); + for (CapabilityDimension dim : relevant) { + levels.add(caps.getOrDefault(dim, CapabilityLevel.UNSUPPORTED)); + } + + QueryRoute route = selectRoute(levels, queryType, language); + String degradationNote = buildDegradationNote(route, levels, queryType, language, relevant); + + return new QueryPlan(queryType, language, route, caps, degradationNote); + } + + // ------------------------------------------------------------------ + // Internals + // ------------------------------------------------------------------ + + /** + * Select the route given the set of capability levels for the relevant dimensions. + * Priority: DEGRADED > LEXICAL_FIRST > MERGED > GRAPH_FIRST. + */ + private QueryRoute selectRoute(Set levels, + QueryType queryType, String language) { + if (levels.contains(CapabilityLevel.UNSUPPORTED)) { + return QueryRoute.DEGRADED; + } + if (levels.contains(CapabilityLevel.LEXICAL_ONLY) && levels.contains(CapabilityLevel.EXACT)) { + // Some dimensions exact, others lexical-only → merge for best coverage + return QueryRoute.MERGED; + } + if (levels.contains(CapabilityLevel.PARTIAL)) { + return QueryRoute.MERGED; + } + if (levels.contains(CapabilityLevel.LEXICAL_ONLY)) { + return QueryRoute.LEXICAL_FIRST; + } + // All dimensions are EXACT + return QueryRoute.GRAPH_FIRST; + } + + /** + * Build a human-readable degradation note for LEXICAL_FIRST and DEGRADED routes. + * Returns {@code null} for GRAPH_FIRST and MERGED (no explanation needed). + */ + private String buildDegradationNote(QueryRoute route, + Set levels, + QueryType queryType, + String language, + List relevant) { + if (route == QueryRoute.GRAPH_FIRST) return null; + if (route == QueryRoute.MERGED) return null; + + String lang = language == null || language.isBlank() ? "this language" : "'" + language + "'"; + String dims = relevant.stream() + .map(d -> d.name().toLowerCase().replace('_', ' ')) + .reduce((a, b) -> a + ", " + b) + .orElse("the requested dimensions"); + + if (route == QueryRoute.DEGRADED) { + return "Query type " + queryType + " is not supported for " + lang + ". " + + "The current extractor suite has no structural analysis for " + dims + ". " + + "Consider running the analysis on a supported language (java, typescript, " + + "javascript, python, go, csharp, rust) or use SEARCH_TEXT for lexical fallback."; + } + + // LEXICAL_FIRST + return "Query type " + queryType + " for " + lang + " uses lexical search only. " + + "Structural graph analysis is unavailable for " + dims + " in " + lang + ". " + + "Results may be less precise than for fully-supported languages."; + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryRoute.java b/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryRoute.java new file mode 100644 index 00000000..773693f9 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryRoute.java @@ -0,0 +1,28 @@ +package io.github.randomcodespace.iq.intelligence.query; + +/** + * The retrieval path chosen by the {@link QueryPlanner} for a given query intent and language. + */ +public enum QueryRoute { + /** + * Primary path: query the structural graph (Neo4j). + * Used when capability is {@code EXACT} — AST-level analysis is available. + */ + GRAPH_FIRST, + /** + * Fallback path: lexical/text search only. + * Used when capability is {@code LEXICAL_ONLY} — no structural analysis is available. + */ + LEXICAL_FIRST, + /** + * Combined path: graph results augmented with lexical search. + * Used when capability is {@code PARTIAL} — structural analysis is incomplete + * and lexical search fills the gaps. + */ + MERGED, + /** + * Degraded path: the feature is unsupported for this language. + * A {@code degradationNote} is included in the {@link QueryPlan} to explain what is missing. + */ + DEGRADED +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryType.java b/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryType.java new file mode 100644 index 00000000..1416dc54 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryType.java @@ -0,0 +1,20 @@ +package io.github.randomcodespace.iq.intelligence.query; + +/** + * Enumeration of query intents the query planner can route. + * Each type maps to one or more {@link CapabilityDimension}s for routing decisions. + */ +public enum QueryType { + /** Locate symbol definitions (classes, functions, methods, variables). */ + FIND_SYMBOL, + /** Find all usages/references of a symbol across the codebase. */ + FIND_REFERENCES, + /** Find callers of a function or method. */ + FIND_CALLERS, + /** Find modules or packages that a given module depends on. */ + FIND_DEPENDENCIES, + /** Full-text / lexical search across source files. */ + SEARCH_TEXT, + /** Locate configuration files and structured config values. */ + FIND_CONFIG +} diff --git a/src/main/java/io/github/randomcodespace/iq/mcp/McpTools.java b/src/main/java/io/github/randomcodespace/iq/mcp/McpTools.java index 9bfb4388..1dbc285a 100644 --- a/src/main/java/io/github/randomcodespace/iq/mcp/McpTools.java +++ b/src/main/java/io/github/randomcodespace/iq/mcp/McpTools.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.github.randomcodespace.iq.config.CodeIqConfig; import io.github.randomcodespace.iq.flow.FlowEngine; +import io.github.randomcodespace.iq.intelligence.query.CapabilityMatrix; // Note: No Analyzer import — MCP server is read-only. Analysis is done via CLI only. import io.github.randomcodespace.iq.flow.FlowModels.FlowDiagram; import io.github.randomcodespace.iq.graph.GraphStore; @@ -337,6 +338,26 @@ public String searchGraph( } } + @McpTool(name = "get_capabilities", description = "Return the capability matrix declaring per-language analysis fidelity levels (EXACT/PARTIAL/LEXICAL_ONLY/UNSUPPORTED) for each intelligence dimension. Optionally filter by a single language.") + public String getCapabilities( + @McpToolParam(description = "Language to filter (e.g. java, python). Omit for the full matrix.", required = false) String language) { + try { + Map result = new LinkedHashMap<>(); + if (language != null && !language.isBlank()) { + result.put("language", language.strip().toLowerCase()); + Map caps = new java.util.TreeMap<>(); + CapabilityMatrix.forLanguage(language) + .forEach((dim, lvl) -> caps.put(dim.name().toLowerCase(), lvl.name())); + result.put("capabilities", caps); + } else { + result.put("matrix", CapabilityMatrix.asSerializableMap()); + } + return toJson(result); + } catch (Exception e) { + return toJson(Map.of("error", e.getMessage())); + } + } + @McpTool(name = "read_file", description = "Read a source file from the codebase, optionally a specific line range") public String readFile( @McpToolParam(description = "File path relative to codebase root") String filePath, diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrixTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrixTest.java new file mode 100644 index 00000000..74051e76 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrixTest.java @@ -0,0 +1,167 @@ +package io.github.randomcodespace.iq.intelligence.query; + +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link CapabilityMatrix}. + * Validates per-language capability lookups and matrix completeness. + */ +class CapabilityMatrixTest { + + // ------------------------------------------------------------------ + // Java — highest fidelity + // ------------------------------------------------------------------ + + @Test + void java_symbolDefinitions_isExact() { + assertThat(CapabilityMatrix.get("java", CapabilityDimension.SYMBOL_DEFINITIONS)) + .isEqualTo(CapabilityLevel.EXACT); + } + + @Test + void java_frameworkSemantics_isExact() { + assertThat(CapabilityMatrix.get("java", CapabilityDimension.FRAMEWORK_SEMANTICS)) + .isEqualTo(CapabilityLevel.EXACT); + } + + @Test + void java_ormEntityMapping_isExact() { + assertThat(CapabilityMatrix.get("java", CapabilityDimension.ORM_ENTITY_MAPPING)) + .isEqualTo(CapabilityLevel.EXACT); + } + + @Test + void java_allDimensionsPopulated() { + Map caps = CapabilityMatrix.forLanguage("java"); + assertThat(caps).containsKeys(CapabilityDimension.values()); + } + + // ------------------------------------------------------------------ + // TypeScript — PARTIAL across most dimensions + // ------------------------------------------------------------------ + + @Test + void typescript_symbolDefinitions_isPartial() { + assertThat(CapabilityMatrix.get("typescript", CapabilityDimension.SYMBOL_DEFINITIONS)) + .isEqualTo(CapabilityLevel.PARTIAL); + } + + @Test + void typescript_allDimensionsPopulated() { + Map caps = CapabilityMatrix.forLanguage("typescript"); + assertThat(caps).containsKeys(CapabilityDimension.values()); + } + + // ------------------------------------------------------------------ + // Python — PARTIAL structural, LEXICAL_ONLY for type info + // ------------------------------------------------------------------ + + @Test + void python_symbolDefinitions_isPartial() { + assertThat(CapabilityMatrix.get("python", CapabilityDimension.SYMBOL_DEFINITIONS)) + .isEqualTo(CapabilityLevel.PARTIAL); + } + + @Test + void python_typeInfo_isLexicalOnly() { + assertThat(CapabilityMatrix.get("python", CapabilityDimension.TYPE_INFO)) + .isEqualTo(CapabilityLevel.LEXICAL_ONLY); + } + + // ------------------------------------------------------------------ + // Lexical-only languages + // ------------------------------------------------------------------ + + @Test + void kotlin_symbolDefinitions_isLexicalOnly() { + assertThat(CapabilityMatrix.get("kotlin", CapabilityDimension.SYMBOL_DEFINITIONS)) + .isEqualTo(CapabilityLevel.LEXICAL_ONLY); + } + + @Test + void kotlin_typeInfo_isUnsupported() { + assertThat(CapabilityMatrix.get("kotlin", CapabilityDimension.TYPE_INFO)) + .isEqualTo(CapabilityLevel.UNSUPPORTED); + } + + @Test + void shell_ormEntityMapping_isUnsupported() { + assertThat(CapabilityMatrix.get("shell", CapabilityDimension.ORM_ENTITY_MAPPING)) + .isEqualTo(CapabilityLevel.UNSUPPORTED); + } + + // ------------------------------------------------------------------ + // Unknown language → UNSUPPORTED + // ------------------------------------------------------------------ + + @Test + void unknownLanguage_allUnsupported() { + Map caps = CapabilityMatrix.forLanguage("brainfuck"); + assertThat(caps.values()).allMatch(l -> l == CapabilityLevel.UNSUPPORTED); + } + + @Test + void nullLanguage_allUnsupported() { + Map caps = CapabilityMatrix.forLanguage(null); + assertThat(caps.values()).allMatch(l -> l == CapabilityLevel.UNSUPPORTED); + } + + @Test + void caseNormalisation_java_upper() { + assertThat(CapabilityMatrix.get("JAVA", CapabilityDimension.SYMBOL_DEFINITIONS)) + .isEqualTo(CapabilityLevel.EXACT); + } + + @Test + void caseNormalisation_java_mixed() { + assertThat(CapabilityMatrix.get("Java", CapabilityDimension.FRAMEWORK_SEMANTICS)) + .isEqualTo(CapabilityLevel.EXACT); + } + + // ------------------------------------------------------------------ + // Rust — ORM is UNSUPPORTED + // ------------------------------------------------------------------ + + @Test + void rust_ormEntityMapping_isUnsupported() { + assertThat(CapabilityMatrix.get("rust", CapabilityDimension.ORM_ENTITY_MAPPING)) + .isEqualTo(CapabilityLevel.UNSUPPORTED); + } + + @Test + void rust_symbolDefinitions_isPartial() { + assertThat(CapabilityMatrix.get("rust", CapabilityDimension.SYMBOL_DEFINITIONS)) + .isEqualTo(CapabilityLevel.PARTIAL); + } + + // ------------------------------------------------------------------ + // asSerializableMap — determinism + // ------------------------------------------------------------------ + + @Test + void serializableMap_isDeterministic() { + Map> first = CapabilityMatrix.asSerializableMap(); + Map> second = CapabilityMatrix.asSerializableMap(); + assertThat(first).isEqualTo(second); + } + + @Test + void serializableMap_containsExpectedLanguages() { + Map> matrix = CapabilityMatrix.asSerializableMap(); + assertThat(matrix).containsKeys("java", "typescript", "javascript", "python", "go", "csharp", "rust"); + } + + @Test + void serializableMap_allDimensionsCovered() { + Map> matrix = CapabilityMatrix.asSerializableMap(); + for (Map.Entry> entry : matrix.entrySet()) { + assertThat(entry.getValue()).as("language=%s", entry.getKey()) + .hasSize(CapabilityDimension.values().length); + } + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/query/QueryPlannerTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/query/QueryPlannerTest.java new file mode 100644 index 00000000..a6ae9119 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/query/QueryPlannerTest.java @@ -0,0 +1,212 @@ +package io.github.randomcodespace.iq.intelligence.query; + +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit and integration tests for {@link QueryPlanner}. + * Covers each routing path and the determinism contract. + */ +class QueryPlannerTest { + + private QueryPlanner planner; + + @BeforeEach + void setUp() { + planner = new QueryPlanner(); + } + + // ------------------------------------------------------------------ + // GRAPH_FIRST path — Java has EXACT capability + // ------------------------------------------------------------------ + + @Test + void java_findSymbol_routesGraphFirst() { + QueryPlan plan = planner.plan(QueryType.FIND_SYMBOL, "java"); + assertThat(plan.route()).isEqualTo(QueryRoute.GRAPH_FIRST); + assertThat(plan.usesGraph()).isTrue(); + assertThat(plan.usesLexical()).isFalse(); + assertThat(plan.degradationNote()).isNull(); + } + + @Test + void java_findCallers_routesGraphFirst() { + QueryPlan plan = planner.plan(QueryType.FIND_CALLERS, "java"); + assertThat(plan.route()).isEqualTo(QueryRoute.GRAPH_FIRST); + assertThat(plan.degradationNote()).isNull(); + } + + @Test + void java_findDependencies_routesGraphFirst() { + QueryPlan plan = planner.plan(QueryType.FIND_DEPENDENCIES, "java"); + assertThat(plan.route()).isEqualTo(QueryRoute.GRAPH_FIRST); + assertThat(plan.degradationNote()).isNull(); + } + + @Test + void java_findConfig_routesGraphFirst() { + QueryPlan plan = planner.plan(QueryType.FIND_CONFIG, "java"); + assertThat(plan.route()).isEqualTo(QueryRoute.GRAPH_FIRST); + assertThat(plan.degradationNote()).isNull(); + } + + // ------------------------------------------------------------------ + // MERGED path — TypeScript/Python have PARTIAL capability + // ------------------------------------------------------------------ + + @Test + void typescript_findSymbol_routesMerged() { + QueryPlan plan = planner.plan(QueryType.FIND_SYMBOL, "typescript"); + assertThat(plan.route()).isEqualTo(QueryRoute.MERGED); + assertThat(plan.usesGraph()).isTrue(); + assertThat(plan.usesLexical()).isTrue(); + assertThat(plan.degradationNote()).isNull(); + } + + @Test + void python_findCallers_routesMerged() { + QueryPlan plan = planner.plan(QueryType.FIND_CALLERS, "python"); + assertThat(plan.route()).isEqualTo(QueryRoute.MERGED); + assertThat(plan.degradationNote()).isNull(); + } + + @Test + void go_findDependencies_routesMerged() { + QueryPlan plan = planner.plan(QueryType.FIND_DEPENDENCIES, "go"); + assertThat(plan.route()).isEqualTo(QueryRoute.MERGED); + assertThat(plan.degradationNote()).isNull(); + } + + // ------------------------------------------------------------------ + // LEXICAL_FIRST path — lexical-only languages + // ------------------------------------------------------------------ + + @Test + void kotlin_findSymbol_routesLexicalFirst() { + QueryPlan plan = planner.plan(QueryType.FIND_SYMBOL, "kotlin"); + assertThat(plan.route()).isEqualTo(QueryRoute.LEXICAL_FIRST); + assertThat(plan.usesGraph()).isFalse(); + assertThat(plan.usesLexical()).isTrue(); + assertThat(plan.degradationNote()).isNotBlank(); + } + + @Test + void shell_findCallers_routesLexicalFirst() { + QueryPlan plan = planner.plan(QueryType.FIND_CALLERS, "shell"); + assertThat(plan.route()).isEqualTo(QueryRoute.LEXICAL_FIRST); + assertThat(plan.degradationNote()).isNotBlank(); + } + + // ------------------------------------------------------------------ + // DEGRADED path — unsupported language or dimension + // ------------------------------------------------------------------ + + @Test + void unknownLanguage_findSymbol_routesDegraded() { + QueryPlan plan = planner.plan(QueryType.FIND_SYMBOL, "brainfuck"); + assertThat(plan.route()).isEqualTo(QueryRoute.DEGRADED); + assertThat(plan.usesGraph()).isFalse(); + assertThat(plan.usesLexical()).isFalse(); + assertThat(plan.degradationNote()).isNotBlank(); + } + + @Test + void rust_findSymbol_routesMerged_notDegraded() { + // Rust SYMBOL_DEFINITIONS is PARTIAL — should be MERGED, not DEGRADED + QueryPlan plan = planner.plan(QueryType.FIND_SYMBOL, "rust"); + assertThat(plan.route()).isEqualTo(QueryRoute.MERGED); + } + + @Test + void degradedPlan_hasExplanatoryNote() { + QueryPlan plan = planner.plan(QueryType.FIND_SYMBOL, "brainfuck"); + assertThat(plan.degradationNote()) + .contains("FIND_SYMBOL") + .contains("brainfuck"); + } + + // ------------------------------------------------------------------ + // LEXICAL_FIRST always — SEARCH_TEXT + // ------------------------------------------------------------------ + + @Test + void java_searchText_routesLexicalFirst() { + QueryPlan plan = planner.plan(QueryType.SEARCH_TEXT, "java"); + assertThat(plan.route()).isEqualTo(QueryRoute.LEXICAL_FIRST); + assertThat(plan.usesLexical()).isTrue(); + // No degradation note — SEARCH_TEXT lexical routing is normal behaviour + assertThat(plan.degradationNote()).isNull(); + } + + @Test + void unknownLanguage_searchText_routesLexicalFirst() { + QueryPlan plan = planner.plan(QueryType.SEARCH_TEXT, "brainfuck"); + assertThat(plan.route()).isEqualTo(QueryRoute.LEXICAL_FIRST); + } + + // ------------------------------------------------------------------ + // Query plan fields + // ------------------------------------------------------------------ + + @Test + void plan_populatesQueryTypeAndLanguage() { + QueryPlan plan = planner.plan(QueryType.FIND_REFERENCES, "java"); + assertThat(plan.queryType()).isEqualTo(QueryType.FIND_REFERENCES); + assertThat(plan.language()).isEqualTo("java"); + } + + @Test + void plan_capabilitiesContainAllDimensions() { + QueryPlan plan = planner.plan(QueryType.FIND_SYMBOL, "java"); + assertThat(plan.capabilities()).containsKeys(CapabilityDimension.values()); + } + + @Test + void plan_capabilitiesMatchMatrix() { + QueryPlan plan = planner.plan(QueryType.FIND_SYMBOL, "java"); + assertThat(plan.capabilities().get(CapabilityDimension.SYMBOL_DEFINITIONS)) + .isEqualTo(CapabilityLevel.EXACT); + } + + // ------------------------------------------------------------------ + // Determinism test — same input always produces same output + // ------------------------------------------------------------------ + + @Test + void determinism_sameInputProducesSameOutput() { + for (QueryType qt : QueryType.values()) { + for (String lang : new String[]{"java", "typescript", "python", "go", "kotlin", "brainfuck"}) { + QueryPlan first = planner.plan(qt, lang); + QueryPlan second = planner.plan(qt, lang); + assertThat(first.route()) + .as("route for %s/%s must be deterministic", qt, lang) + .isEqualTo(second.route()); + assertThat(first.degradationNote()) + .as("degradationNote for %s/%s must be deterministic", qt, lang) + .isEqualTo(second.degradationNote()); + assertThat(first.capabilities()) + .as("capabilities for %s/%s must be deterministic", qt, lang) + .isEqualTo(second.capabilities()); + } + } + } + + // ------------------------------------------------------------------ + // Degradation note quality + // ------------------------------------------------------------------ + + @Test + void lexicalOnlyNote_mentionsLanguage() { + QueryPlan plan = planner.plan(QueryType.FIND_SYMBOL, "kotlin"); + assertThat(plan.degradationNote()).contains("kotlin"); + } + + @Test + void lexicalOnlyNote_mentionsQueryType() { + QueryPlan plan = planner.plan(QueryType.FIND_REFERENCES, "kotlin"); + assertThat(plan.degradationNote()).containsIgnoringCase("FIND_REFERENCES"); + } +} From b33baa951fc2703581655e69deefa07627eada3c Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 3 Apr 2026 17:57:21 +0000 Subject: [PATCH 04/16] feat(intelligence): Phase 1 provenance model, repository identity, file inventory (RAN-146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the foundational contracts for the Repository Intelligence layer: - intelligence/ package: Provenance, RepositoryIdentity, FileInventory, FileEntry, FileClassification, CapabilityLevel, ArtifactManifest records - Provenance stored via prov_* keys in CodeNode.properties (round-trips through Neo4j) - RepositoryIdentity resolves git URL, commit SHA, branch from git CLI at analysis time - FileInventory builds a deterministic sorted list of all discovered files with classification heuristics (source/config/doc/test/generated) - GraphBuilder now accepts Provenance as constructor parameter (not a mutable setter) - Analyzer and EnrichCommand stamp provenance on all nodes during pipeline - BundleCommand upgraded to use ArtifactManifest record (repo identity, inventory summary) - Tests: ProvenanceTest (6), FileInventoryTest (8), ArtifactManifestTest (5), ProvenanceIntegrationTest (2) — all nodes carry provenance + determinism verified Addresses PE architecture review blocking constraints from RAN-150: - BLOCKING 1: Provenance uses properties map (prov_* prefix), not direct CodeNode fields - BLOCKING 2: Provenance is a GraphBuilder constructor parameter, not a setter - BLOCKING 3: FileEntry added to intelligence/ without modifying DiscoveredFile Co-Authored-By: Paperclip --- .../randomcodespace/iq/analyzer/Analyzer.java | 35 +++++- .../iq/analyzer/GraphBuilder.java | 22 +++- .../randomcodespace/iq/cli/BundleCommand.java | 58 ++++------ .../randomcodespace/iq/cli/EnrichCommand.java | 14 ++- .../iq/cli/VersionCommand.java | 2 +- .../iq/intelligence/ArtifactManifest.java | 80 +++++++++++++ .../iq/intelligence/CapabilityLevel.java | 16 +++ .../iq/intelligence/FileClassification.java | 18 +++ .../iq/intelligence/FileEntry.java | 61 ++++++++++ .../iq/intelligence/FileInventory.java | 64 ++++++++++ .../iq/intelligence/Provenance.java | 63 ++++++++++ .../iq/intelligence/RepositoryIdentity.java | 50 ++++++++ .../randomcodespace/iq/model/CodeNode.java | 22 ++++ .../iq/intelligence/ArtifactManifestTest.java | 64 ++++++++++ .../iq/intelligence/FileInventoryTest.java | 109 ++++++++++++++++++ .../ProvenanceIntegrationTest.java | 100 ++++++++++++++++ .../iq/intelligence/ProvenanceTest.java | 81 +++++++++++++ 17 files changed, 820 insertions(+), 39 deletions(-) create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/ArtifactManifest.java create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/CapabilityLevel.java create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/FileClassification.java create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/FileEntry.java create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/FileInventory.java create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/Provenance.java create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentity.java create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/ArtifactManifestTest.java create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/FileInventoryTest.java create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceIntegrationTest.java create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceTest.java diff --git a/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java b/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java index 21b84c0c..0aa298b6 100644 --- a/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java +++ b/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java @@ -3,6 +3,7 @@ import io.github.randomcodespace.iq.analyzer.linker.Linker; import io.github.randomcodespace.iq.cache.AnalysisCache; import io.github.randomcodespace.iq.cache.FileHasher; +import io.github.randomcodespace.iq.cli.VersionCommand; import io.github.randomcodespace.iq.config.CodeIqConfig; import io.github.randomcodespace.iq.config.ProjectConfig; import io.github.randomcodespace.iq.config.ProjectConfigLoader; @@ -12,6 +13,12 @@ import io.github.randomcodespace.iq.detector.DetectorResult; import io.github.randomcodespace.iq.detector.DetectorUtils; import io.github.randomcodespace.iq.grammar.AntlrParserFactory; +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import io.github.randomcodespace.iq.intelligence.FileClassification; +import io.github.randomcodespace.iq.intelligence.FileEntry; +import io.github.randomcodespace.iq.intelligence.FileInventory; +import io.github.randomcodespace.iq.intelligence.Provenance; +import io.github.randomcodespace.iq.intelligence.RepositoryIdentity; import io.github.randomcodespace.iq.model.CodeEdge; import io.github.randomcodespace.iq.model.CodeNode; import io.github.randomcodespace.iq.model.NodeKind; @@ -227,6 +234,17 @@ private AnalysisResult runWithCache(Path root, Integer parallelism, AnalysisCach int totalFiles = files.size(); report.accept("Found " + totalFiles + " files"); + // 1b. Resolve repository identity and build file inventory + RepositoryIdentity repoIdentity = RepositoryIdentity.resolve(root); + FileInventory fileInventory = buildFileInventory(files); + Provenance provenance = new Provenance( + repoIdentity.repoUrl(), + repoIdentity.commitSha(), + VersionCommand.VERSION, + Provenance.CURRENT_SCHEMA_VERSION, + CapabilityLevel.PARTIAL + ); + // Compute language breakdown Map languageBreakdown = new HashMap<>(); for (DiscoveredFile f : files) { @@ -300,7 +318,7 @@ private AnalysisResult runWithCache(Path root, Integer parallelism, AnalysisCach // 3. Build graph (batched) report.accept("Building graph..."); - var builder = new GraphBuilder(); + var builder = new GraphBuilder(provenance); int filesAnalyzed = 0; for (int i = 0; i < resultSlots.length; i++) { DetectorResult result = resultSlots[i]; @@ -329,6 +347,7 @@ private AnalysisResult runWithCache(Path root, Integer parallelism, AnalysisCach String projectDirName = root.getFileName() != null ? root.getFileName().toString() : "root"; var serviceResult = serviceDetector.detect(allNodes, builder.getEdges(), projectDirName, root); if (!serviceResult.serviceNodes().isEmpty()) { + serviceResult.serviceNodes().forEach(n -> n.setProvenance(provenance)); builder.addNodes(serviceResult.serviceNodes()); builder.addEdges(serviceResult.serviceEdges()); allNodes = builder.getNodes(); // refresh reference after adding service nodes @@ -1257,6 +1276,20 @@ DetectorResult analyzeFile(DiscoveredFile file, Path repoPath, DetectorRegistry return DetectorResult.of(allNodes, allEdges); } + /** + * Build a deterministic FileInventory from the list of discovered files. + * Content hashes are not computed here (too expensive at discovery time); they remain null. + */ + private static FileInventory buildFileInventory(List files) { + List entries = new ArrayList<>(files.size()); + for (DiscoveredFile f : files) { + String relPath = f.path().toString().replace('\\', '/'); + FileClassification cls = FileEntry.classify(relPath, f.language()); + entries.add(new FileEntry(relPath, f.language(), f.sizeBytes(), null, cls)); + } + return new FileInventory(entries); + } + /** * Get the current git HEAD commit SHA, or null if not a git repo. */ diff --git a/src/main/java/io/github/randomcodespace/iq/analyzer/GraphBuilder.java b/src/main/java/io/github/randomcodespace/iq/analyzer/GraphBuilder.java index cd357831..f4d6b4f6 100644 --- a/src/main/java/io/github/randomcodespace/iq/analyzer/GraphBuilder.java +++ b/src/main/java/io/github/randomcodespace/iq/analyzer/GraphBuilder.java @@ -3,6 +3,7 @@ import io.github.randomcodespace.iq.analyzer.linker.LinkResult; import io.github.randomcodespace.iq.analyzer.linker.Linker; import io.github.randomcodespace.iq.detector.DetectorResult; +import io.github.randomcodespace.iq.intelligence.Provenance; import io.github.randomcodespace.iq.model.CodeEdge; import io.github.randomcodespace.iq.model.CodeNode; import org.slf4j.Logger; @@ -34,13 +35,23 @@ public class GraphBuilder { private final List deferredEdges = new ArrayList<>(); private int droppedEdgeCount = 0; private final int batchSize; + private final Provenance provenance; public GraphBuilder() { - this(1000); + this(1000, null); } public GraphBuilder(int batchSize) { + this(batchSize, null); + } + + public GraphBuilder(Provenance provenance) { + this(1000, provenance); + } + + public GraphBuilder(int batchSize, Provenance provenance) { this.batchSize = Math.max(1, batchSize); + this.provenance = provenance; } /** @@ -67,11 +78,18 @@ public void addEdges(List edges) { /** * Flush all buffered data: insert nodes first, then edges. - * Edges whose source or target node doesn't exist are deferred. + * Applies provenance to every node, then partitions edges into valid/deferred. * * @return a snapshot of all valid nodes and edges */ public FlushResult flush() { + // Stamp provenance on every node + if (provenance != null) { + for (CodeNode node : allNodes) { + node.setProvenance(provenance); + } + } + // Build the set of all node IDs Set nodeIds = new HashSet<>(); for (CodeNode node : allNodes) { diff --git a/src/main/java/io/github/randomcodespace/iq/cli/BundleCommand.java b/src/main/java/io/github/randomcodespace/iq/cli/BundleCommand.java index 7ff5e3d0..4cabc4fa 100644 --- a/src/main/java/io/github/randomcodespace/iq/cli/BundleCommand.java +++ b/src/main/java/io/github/randomcodespace/iq/cli/BundleCommand.java @@ -6,6 +6,9 @@ import io.github.randomcodespace.iq.config.CodeIqConfig; import io.github.randomcodespace.iq.flow.FlowEngine; import io.github.randomcodespace.iq.graph.GraphStore; +import io.github.randomcodespace.iq.intelligence.ArtifactManifest; +import io.github.randomcodespace.iq.intelligence.FileInventory; +import io.github.randomcodespace.iq.intelligence.RepositoryIdentity; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import picocli.CommandLine.Command; @@ -120,7 +123,6 @@ public Integer call() { // Get node/edge counts from H2 cache long nodeCount = 0, edgeCount = 0; - int filesAnalyzed = 0; if (Files.isDirectory(h2Dir)) { try (var cache = new AnalysisCache(h2Dir.resolve("analysis-cache.db"))) { nodeCount = cache.getNodeCount(); @@ -136,8 +138,9 @@ public Integer call() { // 1. manifest.json CliOutput.info(" Writing manifest.json"); - String manifest = createManifest(projectName, bundleTag, version, - nodeCount, edgeCount, filesAnalyzed, !noSource, includeJar); + RepositoryIdentity repoIdentity = RepositoryIdentity.resolve(root); + String manifest = createManifest(projectName, bundleTag, version, repoIdentity, + nodeCount, edgeCount, !noSource, includeJar); writeEntry(zos, "manifest.json", manifest); // 2. serve.sh @@ -217,27 +220,27 @@ public Integer call() { // --- Manifest --- private String createManifest(String projectName, String bundleTag, String version, - long nodeCount, long edgeCount, int filesAnalyzed, + RepositoryIdentity repoIdentity, + long nodeCount, long edgeCount, boolean includesSource, boolean includesJar) { - Map m = new LinkedHashMap<>(); - m.put("bundle_format", 2); - m.put("tag", bundleTag); - m.put("project", projectName); - m.put("osscodeiq_version", version); - m.put("created_at", Instant.now().toString()); - m.put("backend", "neo4j"); - m.put("node_count", nodeCount); - m.put("edge_count", edgeCount); - m.put("files_analyzed", filesAnalyzed); - m.put("includes_source", includesSource); - m.put("includes_jar", includesJar); - - String gitSha = getGitSha(); - if (gitSha != null) m.put("git_sha", gitSha); - + var manifest = new ArtifactManifest( + ArtifactManifest.BUNDLE_FORMAT_VERSION, + bundleTag, + projectName, + version, + io.github.randomcodespace.iq.intelligence.Provenance.CURRENT_SCHEMA_VERSION, + Instant.now().toString(), + repoIdentity, + FileInventory.EMPTY.toSummary(), + nodeCount, + edgeCount, + includesSource, + includesJar, + null + ); try { return new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT) - .writeValueAsString(m); + .writeValueAsString(manifest.toMap()); } catch (Exception e) { return "{}"; } @@ -464,17 +467,4 @@ private void writeEntry(ZipOutputStream zos, String name, String content, String writeEntry(zos, name, content); } - private String getGitSha() { - try { - ProcessBuilder pb = new ProcessBuilder("git", "rev-parse", "HEAD") - .directory(path.toAbsolutePath().normalize().toFile()) - .redirectErrorStream(true); - Process proc = pb.start(); - String sha = new String(proc.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); - int exitCode = proc.waitFor(); - return (exitCode == 0 && sha.length() >= 7) ? sha : null; - } catch (Exception ignored) { - return null; - } - } } diff --git a/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java b/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java index 5c88e108..17d8219c 100644 --- a/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java +++ b/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java @@ -5,6 +5,9 @@ import io.github.randomcodespace.iq.analyzer.linker.Linker; import io.github.randomcodespace.iq.cache.AnalysisCache; import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import io.github.randomcodespace.iq.intelligence.Provenance; +import io.github.randomcodespace.iq.intelligence.RepositoryIdentity; import io.github.randomcodespace.iq.model.CodeEdge; import io.github.randomcodespace.iq.model.CodeNode; import io.github.randomcodespace.iq.model.EdgeKind; @@ -116,7 +119,15 @@ private int enrichFromCache(AnalysisCache cache, Path root, NumberFormat nf, Ins // 2. Run linkers (these work on in-memory node/edge lists) CliOutput.step("\uD83D\uDD17", "Running cross-file linkers..."); - var builder = new GraphBuilder(); + RepositoryIdentity repoIdentity = RepositoryIdentity.resolve(root); + Provenance provenance = new Provenance( + repoIdentity.repoUrl(), + repoIdentity.commitSha(), + VersionCommand.VERSION, + Provenance.CURRENT_SCHEMA_VERSION, + CapabilityLevel.PARTIAL + ); + var builder = new GraphBuilder(provenance); for (CodeNode node : allNodes) { builder.addNodes(List.of(node)); } @@ -147,6 +158,7 @@ private int enrichFromCache(AnalysisCache cache, Path root, NumberFormat nf, Ins String projectName = root.getFileName().toString(); var serviceResult = serviceDetector.detect(enrichedNodes, enrichedEdges, projectName, root); if (!serviceResult.serviceNodes().isEmpty()) { + serviceResult.serviceNodes().forEach(n -> n.setProvenance(provenance)); // Add service nodes and edges to the builder builder.addNodes(serviceResult.serviceNodes()); builder.addEdges(serviceResult.serviceEdges()); diff --git a/src/main/java/io/github/randomcodespace/iq/cli/VersionCommand.java b/src/main/java/io/github/randomcodespace/iq/cli/VersionCommand.java index 57e69829..e773c677 100644 --- a/src/main/java/io/github/randomcodespace/iq/cli/VersionCommand.java +++ b/src/main/java/io/github/randomcodespace/iq/cli/VersionCommand.java @@ -16,7 +16,7 @@ description = "Show version info") public class VersionCommand implements Callable { - static final String VERSION = "0.1.0-SNAPSHOT"; + public static final String VERSION = "0.1.0-SNAPSHOT"; private final DetectorRegistry registry; diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/ArtifactManifest.java b/src/main/java/io/github/randomcodespace/iq/intelligence/ArtifactManifest.java new file mode 100644 index 00000000..77f00f89 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/ArtifactManifest.java @@ -0,0 +1,80 @@ +package io.github.randomcodespace.iq.intelligence; + +import java.util.Map; + +/** + * Artifact manifest — extends the bundle manifest with repository identity, + * schema version, extractor version, file inventory summary, and integrity checksums. + * + * @param bundleFormat Always {@code 2} for this record. + * @param tag User-supplied bundle tag (may be null). + * @param project Project name. + * @param extractorVersion Version of the code-iq extractor that built this bundle. + * @param schemaVersion Graph schema version at bundle time. + * @param createdAt ISO-8601 timestamp. + * @param repositoryIdentity Git/VCS identity of the analysed repo. + * @param fileInventorySummary Summary from {@link FileInventory#toSummary()}. + * @param nodeCount Total graph nodes. + * @param edgeCount Total graph edges. + * @param includesSource Whether source files are bundled. + * @param includesJar Whether the CLI JAR is bundled. + * @param checksums SHA-256 digests of key bundle entries (entry → hex digest). + */ +public record ArtifactManifest( + int bundleFormat, + String tag, + String project, + String extractorVersion, + int schemaVersion, + String createdAt, + RepositoryIdentity repositoryIdentity, + Map fileInventorySummary, + long nodeCount, + long edgeCount, + boolean includesSource, + boolean includesJar, + Map checksums +) { + public static final int BUNDLE_FORMAT_VERSION = 2; + + /** + * Serialise to a JSON-friendly {@link Map} (preserves insertion order). + * Null/empty fields are omitted for a clean manifest. + */ + public Map toMap() { + var m = new java.util.LinkedHashMap(); + m.put("bundle_format", bundleFormat); + if (tag != null) m.put("tag", tag); + m.put("project", project); + m.put("extractor_version", extractorVersion); + m.put("schema_version", schemaVersion); + m.put("created_at", createdAt); + + // Repository identity + if (repositoryIdentity != null) { + var ri = new java.util.LinkedHashMap(); + if (repositoryIdentity.repoUrl() != null) ri.put("repo_url", repositoryIdentity.repoUrl()); + if (repositoryIdentity.commitSha() != null) ri.put("commit_sha", repositoryIdentity.commitSha()); + if (repositoryIdentity.branch() != null) ri.put("branch", repositoryIdentity.branch()); + if (repositoryIdentity.buildTimestamp() != null) + ri.put("build_timestamp", repositoryIdentity.buildTimestamp().toString()); + if (!ri.isEmpty()) m.put("repository", ri); + } + + // File inventory summary + if (fileInventorySummary != null && !fileInventorySummary.isEmpty()) { + m.put("file_inventory", fileInventorySummary); + } + + m.put("backend", "neo4j"); + m.put("node_count", nodeCount); + m.put("edge_count", edgeCount); + m.put("includes_source", includesSource); + m.put("includes_jar", includesJar); + + if (checksums != null && !checksums.isEmpty()) { + m.put("checksums", checksums); + } + return m; + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/CapabilityLevel.java b/src/main/java/io/github/randomcodespace/iq/intelligence/CapabilityLevel.java new file mode 100644 index 00000000..9ee25550 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/CapabilityLevel.java @@ -0,0 +1,16 @@ +package io.github.randomcodespace.iq.intelligence; + +/** + * Confidence level for intelligence capabilities on a given language or feature. + * Used in provenance records and capability matrix entries. + */ +public enum CapabilityLevel { + /** Full semantic understanding — AST-level, cross-file, high confidence. */ + EXACT, + /** Partial coverage — some constructs detected, others may be missed. */ + PARTIAL, + /** Lexical/text search only — no structural analysis. */ + LEXICAL_ONLY, + /** Language or feature is not supported by current extractors. */ + UNSUPPORTED +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/FileClassification.java b/src/main/java/io/github/randomcodespace/iq/intelligence/FileClassification.java new file mode 100644 index 00000000..814cd0c5 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/FileClassification.java @@ -0,0 +1,18 @@ +package io.github.randomcodespace.iq.intelligence; + +/** + * Heuristic classification of a file's role in the repository. + * Determined by file extension and path conventions. + */ +public enum FileClassification { + /** Production source code. */ + SOURCE, + /** Configuration files (YAML, JSON, TOML, properties, etc.). */ + CONFIG, + /** Documentation (Markdown, AsciiDoc, plain text, etc.). */ + DOC, + /** Test code (paths containing test/, spec/, __tests__, etc.). */ + TEST, + /** Generated code (paths containing generated/, build/, target/, etc.). */ + GENERATED +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/FileEntry.java b/src/main/java/io/github/randomcodespace/iq/intelligence/FileEntry.java new file mode 100644 index 00000000..f2edc475 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/FileEntry.java @@ -0,0 +1,61 @@ +package io.github.randomcodespace.iq.intelligence; + +/** + * A single file record in the {@link FileInventory}. + * + * @param path Repository-relative path (forward-slash normalised). + * @param language Detected language (lower-case, e.g. "java", "typescript"). + * @param sizeBytes File size in bytes at discovery time. + * @param contentHash SHA-256 hex digest of the file content (may be null for large/skipped files). + * @param classification Heuristic role of the file. + */ +public record FileEntry( + String path, + String language, + long sizeBytes, + String contentHash, + FileClassification classification +) implements Comparable { + + @Override + public int compareTo(FileEntry other) { + return this.path.compareTo(other.path); + } + + /** + * Classify a file by its path and language. + * Rules applied in order — first match wins. + */ + public static FileClassification classify(String relPath, String language) { + String lower = relPath.replace('\\', '/').toLowerCase(); + + // Generated paths + if (lower.contains("/generated/") || lower.contains("/target/") + || lower.contains("/build/") || lower.contains("/dist/") + || lower.contains("/out/") || lower.contains("/.gradle/")) { + return FileClassification.GENERATED; + } + // Test paths + if (lower.contains("/test/") || lower.contains("/tests/") + || lower.contains("/spec/") || lower.contains("/__tests__/") + || lower.endsWith("test.java") || lower.endsWith("tests.java") + || lower.endsWith(".test.ts") || lower.endsWith(".spec.ts") + || lower.endsWith(".test.js") || lower.endsWith(".spec.js") + || lower.endsWith("_test.go") || lower.endsWith("_test.py")) { + return FileClassification.TEST; + } + // Documentation + if (lower.contains("/docs/") || lower.contains("/doc/") + || lower.endsWith(".md") || lower.endsWith(".adoc") + || lower.endsWith(".rst") || lower.endsWith(".txt")) { + return FileClassification.DOC; + } + // Config by language + if ("yaml".equals(language) || "json".equals(language) || "toml".equals(language) + || "ini".equals(language) || "properties".equals(language) + || "xml".equals(language) || lower.endsWith(".env")) { + return FileClassification.CONFIG; + } + return FileClassification.SOURCE; + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/FileInventory.java b/src/main/java/io/github/randomcodespace/iq/intelligence/FileInventory.java new file mode 100644 index 00000000..4eefef58 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/FileInventory.java @@ -0,0 +1,64 @@ +package io.github.randomcodespace.iq.intelligence; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Deterministic inventory of all files discovered in a repository. + * Entries are always sorted by path for reproducibility. + * + * @param entries Sorted, immutable list of file entries. + */ +public record FileInventory(List entries) { + + /** Canonical constructor — sorts and makes the list immutable. */ + public FileInventory(List entries) { + var sorted = entries.stream().sorted().toList(); + this.entries = Collections.unmodifiableList(sorted); + } + + /** Total number of files. */ + public int totalFiles() { + return entries.size(); + } + + /** Count of files per {@link FileClassification}. */ + public Map countsByClassification() { + return entries.stream() + .collect(Collectors.groupingBy(FileEntry::classification, Collectors.counting())); + } + + /** Count of files per language. */ + public Map countsByLanguage() { + return entries.stream() + .collect(Collectors.groupingBy(FileEntry::language, Collectors.counting())); + } + + /** Sum of all file sizes in bytes. */ + public long totalBytes() { + return entries.stream().mapToLong(FileEntry::sizeBytes).sum(); + } + + /** + * Build a compact summary map suitable for inclusion in the v3 manifest. + */ + public Map toSummary() { + var summary = new java.util.LinkedHashMap(); + summary.put("total_files", totalFiles()); + summary.put("total_bytes", totalBytes()); + var byCls = countsByClassification().entrySet().stream() + .collect(Collectors.toMap(e -> e.getKey().name().toLowerCase(), Map.Entry::getValue)); + summary.put("by_classification", byCls); + var byLang = countsByLanguage().entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, + (a, b) -> a, java.util.LinkedHashMap::new)); + summary.put("by_language", byLang); + return summary; + } + + /** Empty inventory constant. */ + public static final FileInventory EMPTY = new FileInventory(List.of()); +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/Provenance.java b/src/main/java/io/github/randomcodespace/iq/intelligence/Provenance.java new file mode 100644 index 00000000..85c6280b --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/Provenance.java @@ -0,0 +1,63 @@ +package io.github.randomcodespace.iq.intelligence; + +/** + * Provenance metadata attached to every CodeNode in the intelligence graph. + * Stored in the node's {@code properties} map using {@code prov_*} keys. + * + * @param repositoryUrl Remote URL of the repository (may be null for local-only analysis). + * @param commitSha Full SHA-1 of the HEAD commit at analysis time (may be null). + * @param extractorVersion Version of the code-iq extractor that produced this node. + * @param schemaVersion Graph schema version (integer, incremented on breaking changes). + * @param confidence Capability level for the language/feature that produced this node. + */ +public record Provenance( + String repositoryUrl, + String commitSha, + String extractorVersion, + int schemaVersion, + CapabilityLevel confidence +) { + /** Current graph schema version. Increment on any breaking schema change. */ + public static final int CURRENT_SCHEMA_VERSION = 1; + + // --- Property map keys (prov_* prefix for Neo4j round-trip) --- + public static final String KEY_REPO_URL = "prov_repo_url"; + public static final String KEY_COMMIT_SHA = "prov_commit_sha"; + public static final String KEY_EXTRACTOR_VER = "prov_extractor_version"; + public static final String KEY_SCHEMA_VER = "prov_schema_version"; + public static final String KEY_CONFIDENCE = "prov_confidence"; + + /** + * Write this provenance into a node's properties map. + * Null-valued fields are skipped to avoid polluting the map. + */ + public java.util.Map toProperties() { + var map = new java.util.LinkedHashMap(); + if (repositoryUrl != null) map.put(KEY_REPO_URL, repositoryUrl); + if (commitSha != null) map.put(KEY_COMMIT_SHA, commitSha); + map.put(KEY_EXTRACTOR_VER, extractorVersion); + map.put(KEY_SCHEMA_VER, schemaVersion); + map.put(KEY_CONFIDENCE, confidence.name()); + return map; + } + + /** + * Reconstruct a Provenance from a node's properties map. + * Returns null if provenance keys are absent. + */ + public static Provenance fromProperties(java.util.Map props) { + if (props == null || !props.containsKey(KEY_EXTRACTOR_VER)) return null; + String repoUrl = (String) props.get(KEY_REPO_URL); + String sha = (String) props.get(KEY_COMMIT_SHA); + String extVer = (String) props.getOrDefault(KEY_EXTRACTOR_VER, "unknown"); + int schemaVer = ((Number) props.getOrDefault(KEY_SCHEMA_VER, CURRENT_SCHEMA_VERSION)).intValue(); + String confStr = (String) props.getOrDefault(KEY_CONFIDENCE, CapabilityLevel.PARTIAL.name()); + CapabilityLevel confidence; + try { + confidence = CapabilityLevel.valueOf(confStr); + } catch (IllegalArgumentException e) { + confidence = CapabilityLevel.PARTIAL; + } + return new Provenance(repoUrl, sha, extVer, schemaVer, confidence); + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentity.java b/src/main/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentity.java new file mode 100644 index 00000000..71192b2c --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentity.java @@ -0,0 +1,50 @@ +package io.github.randomcodespace.iq.intelligence; + +import java.time.Instant; + +/** + * Identity snapshot of a repository at analysis time. + * Populated from git metadata during the {@code index} command. + * + * @param repoUrl Remote origin URL (null for local-only repos). + * @param commitSha Full SHA-1 of HEAD (null if not a git repo). + * @param branch Current branch name (null if detached HEAD or not git). + * @param buildTimestamp When the analysis run started. + */ +public record RepositoryIdentity( + String repoUrl, + String commitSha, + String branch, + Instant buildTimestamp +) { + /** + * Resolve repository identity from a local path using git commands. + * Fields that cannot be determined are set to null gracefully. + */ + public static RepositoryIdentity resolve(java.nio.file.Path repoPath) { + String repoUrl = runGit(repoPath, "remote", "get-url", "origin"); + String commitSha = runGit(repoPath, "rev-parse", "HEAD"); + String branch = runGit(repoPath, "rev-parse", "--abbrev-ref", "HEAD"); + // Detached HEAD produces "HEAD" rather than a branch name — normalise to null + if ("HEAD".equals(branch)) branch = null; + return new RepositoryIdentity(repoUrl, commitSha, branch, Instant.now()); + } + + /** Returns null on any error. */ + private static String runGit(java.nio.file.Path repoPath, String... args) { + try { + var cmd = new java.util.ArrayList(); + cmd.add("git"); + cmd.addAll(java.util.Arrays.asList(args)); + var pb = new ProcessBuilder(cmd) + .directory(repoPath.toFile()) + .redirectErrorStream(true); + var proc = pb.start(); + String out = new String(proc.getInputStream().readAllBytes()).trim(); + int exit = proc.waitFor(); + return (exit == 0 && !out.isBlank()) ? out : null; + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/model/CodeNode.java b/src/main/java/io/github/randomcodespace/iq/model/CodeNode.java index 1e6dff41..13eca106 100644 --- a/src/main/java/io/github/randomcodespace/iq/model/CodeNode.java +++ b/src/main/java/io/github/randomcodespace/iq/model/CodeNode.java @@ -7,6 +7,8 @@ import org.springframework.data.neo4j.core.schema.Relationship; import org.springframework.data.neo4j.core.schema.Relationship.Direction; +import io.github.randomcodespace.iq.intelligence.Provenance; + import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -169,6 +171,26 @@ public int hashCode() { return Objects.hash(id); } + // --- Provenance helpers --- + + /** + * Write provenance into this node's properties map using {@code prov_*} keys. + * Overwrites any existing provenance. + */ + public void setProvenance(Provenance provenance) { + if (provenance != null) { + properties.putAll(provenance.toProperties()); + } + } + + /** + * Read provenance from this node's properties map. + * Returns null if no provenance keys are present. + */ + public Provenance getProvenance() { + return Provenance.fromProperties(properties); + } + @Override public String toString() { return "CodeNode{id='%s', kind=%s, label='%s'}".formatted(id, kind, label); diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/ArtifactManifestTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/ArtifactManifestTest.java new file mode 100644 index 00000000..5961236d --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/ArtifactManifestTest.java @@ -0,0 +1,64 @@ +package io.github.randomcodespace.iq.intelligence; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class ArtifactManifestTest { + + @Test + void toMap_bundleFormatIsTwo() { + var manifest = minimalManifest(); + assertThat(manifest.toMap()).containsEntry("bundle_format", ArtifactManifest.BUNDLE_FORMAT_VERSION); + assertThat(ArtifactManifest.BUNDLE_FORMAT_VERSION).isEqualTo(2); + } + + @Test + void toMap_repositoryIdentityIncluded() { + var identity = new RepositoryIdentity( + "https://github.com/example/repo", "abc123def", "main", Instant.now()); + var manifest = new ArtifactManifest(ArtifactManifest.BUNDLE_FORMAT_VERSION, "v1", "myproject", "0.1.0", 1, + Instant.now().toString(), identity, Map.of(), 100L, 200L, true, false, null); + + var map = manifest.toMap(); + assertThat(map).containsKey("repository"); + @SuppressWarnings("unchecked") + var repo = (Map) map.get("repository"); + assertThat(repo).containsEntry("repo_url", "https://github.com/example/repo"); + assertThat(repo).containsEntry("commit_sha", "abc123def"); + assertThat(repo).containsEntry("branch", "main"); + } + + @Test + void toMap_nullRepositoryIdentityOmitted() { + var manifest = new ArtifactManifest(3, null, "proj", "0.1.0", 1, + Instant.now().toString(), null, Map.of(), 0L, 0L, false, false, null); + + assertThat(manifest.toMap()).doesNotContainKey("repository"); + } + + @Test + void toMap_nullTagOmitted() { + var manifest = new ArtifactManifest(3, null, "proj", "0.1.0", 1, + Instant.now().toString(), null, Map.of(), 0L, 0L, false, false, null); + + assertThat(manifest.toMap()).doesNotContainKey("tag"); + } + + @Test + void toMap_checksumsPresentWhenProvided() { + var manifest = new ArtifactManifest(3, "t", "p", "0.1.0", 1, + Instant.now().toString(), null, Map.of(), 10L, 20L, true, false, + Map.of("graph.db.zip", "sha256abc")); + + assertThat(manifest.toMap()).containsKey("checksums"); + } + + private ArtifactManifest minimalManifest() { + return new ArtifactManifest(ArtifactManifest.BUNDLE_FORMAT_VERSION, "latest", "testproject", "0.1.0", 1, + Instant.now().toString(), null, Map.of(), 42L, 84L, true, false, null); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/FileInventoryTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/FileInventoryTest.java new file mode 100644 index 00000000..cc3ec1cd --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/FileInventoryTest.java @@ -0,0 +1,109 @@ +package io.github.randomcodespace.iq.intelligence; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class FileInventoryTest { + + @Test + void entries_areSortedByPath() { + var entries = List.of( + new FileEntry("z/File.java", "java", 100, null, FileClassification.SOURCE), + new FileEntry("a/Main.java", "java", 200, null, FileClassification.SOURCE), + new FileEntry("m/Config.yml", "yaml", 50, null, FileClassification.CONFIG) + ); + var inventory = new FileInventory(entries); + + var paths = inventory.entries().stream().map(FileEntry::path).toList(); + assertThat(paths).containsExactly("a/Main.java", "m/Config.yml", "z/File.java"); + } + + @Test + void entries_areImmutable() { + var inventory = new FileInventory(List.of( + new FileEntry("src/Foo.java", "java", 10, null, FileClassification.SOURCE) + )); + assertThat(inventory.entries()).hasSize(1); + // Attempting to modify should throw + org.junit.jupiter.api.Assertions.assertThrows(UnsupportedOperationException.class, + () -> inventory.entries().add(new FileEntry("src/Bar.java", "java", 10, null, FileClassification.SOURCE))); + } + + @Test + void countsByClassification_correctCounts() { + var inventory = new FileInventory(List.of( + new FileEntry("src/A.java", "java", 10, null, FileClassification.SOURCE), + new FileEntry("src/B.java", "java", 10, null, FileClassification.SOURCE), + new FileEntry("src/test/ATest.java", "java", 10, null, FileClassification.TEST), + new FileEntry("config.yml", "yaml", 10, null, FileClassification.CONFIG) + )); + + Map counts = inventory.countsByClassification(); + assertThat(counts.get(FileClassification.SOURCE)).isEqualTo(2L); + assertThat(counts.get(FileClassification.TEST)).isEqualTo(1L); + assertThat(counts.get(FileClassification.CONFIG)).isEqualTo(1L); + } + + @Test + void totalBytes_sumsAllFiles() { + var inventory = new FileInventory(List.of( + new FileEntry("a.java", "java", 100, null, FileClassification.SOURCE), + new FileEntry("b.java", "java", 200, null, FileClassification.SOURCE) + )); + assertThat(inventory.totalBytes()).isEqualTo(300L); + } + + @Test + void empty_inventoryConstant() { + assertThat(FileInventory.EMPTY.entries()).isEmpty(); + assertThat(FileInventory.EMPTY.totalFiles()).isZero(); + } + + @Test + void toSummary_containsExpectedKeys() { + var inventory = new FileInventory(List.of( + new FileEntry("src/Main.java", "java", 500, null, FileClassification.SOURCE), + new FileEntry("README.md", "markdown", 100, null, FileClassification.DOC) + )); + var summary = inventory.toSummary(); + + assertThat(summary).containsKey("total_files"); + assertThat(summary).containsKey("total_bytes"); + assertThat(summary).containsKey("by_classification"); + assertThat(summary).containsKey("by_language"); + assertThat(summary.get("total_files")).isEqualTo(2); + assertThat(summary.get("total_bytes")).isEqualTo(600L); + } + + @Test + void fileEntry_classify_testPaths() { + assertThat(FileEntry.classify("src/test/java/FooTest.java", "java")).isEqualTo(FileClassification.TEST); + assertThat(FileEntry.classify("src/main/Foo.java", "java")).isEqualTo(FileClassification.SOURCE); + assertThat(FileEntry.classify("application.yml", "yaml")).isEqualTo(FileClassification.CONFIG); + assertThat(FileEntry.classify("README.md", "markdown")).isEqualTo(FileClassification.DOC); + assertThat(FileEntry.classify("target/generated/Foo.java", "java")).isEqualTo(FileClassification.GENERATED); + } + + @Test + void determinism_sameInputProducesSameOutput() { + var entries1 = List.of( + new FileEntry("c.java", "java", 30, null, FileClassification.SOURCE), + new FileEntry("a.java", "java", 10, null, FileClassification.SOURCE), + new FileEntry("b.java", "java", 20, null, FileClassification.SOURCE) + ); + var entries2 = List.of( + new FileEntry("b.java", "java", 20, null, FileClassification.SOURCE), + new FileEntry("c.java", "java", 30, null, FileClassification.SOURCE), + new FileEntry("a.java", "java", 10, null, FileClassification.SOURCE) + ); + + var inv1 = new FileInventory(entries1); + var inv2 = new FileInventory(entries2); + + assertThat(inv1.entries()).isEqualTo(inv2.entries()); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceIntegrationTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceIntegrationTest.java new file mode 100644 index 00000000..ebeed5b3 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceIntegrationTest.java @@ -0,0 +1,100 @@ +package io.github.randomcodespace.iq.intelligence; + +import io.github.randomcodespace.iq.analyzer.Analyzer; +import io.github.randomcodespace.iq.analyzer.AnalysisResult; +import io.github.randomcodespace.iq.model.CodeNode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that every node produced by the analysis pipeline carries provenance. + * Also validates determinism: running twice on the same input produces identical provenance. + */ +@SpringBootTest +@ActiveProfiles("indexing") +class ProvenanceIntegrationTest { + + @Autowired + Analyzer analyzer; + + @Test + void everyNodeHasProvenance(@TempDir Path tempDir) throws Exception { + // Write a minimal Java source file + Path src = tempDir.resolve("src/main/java/com/example/HelloController.java"); + Files.createDirectories(src.getParent()); + Files.writeString(src, """ + package com.example; + import org.springframework.web.bind.annotation.RestController; + import org.springframework.web.bind.annotation.GetMapping; + @RestController + public class HelloController { + @GetMapping("/hello") + public String hello() { return "hello"; } + } + """); + + AnalysisResult result = analyzer.run(tempDir, msg -> {}); + + List nodes = result.nodes(); + assertThat(nodes).isNotNull(); + assertThat(nodes).isNotEmpty(); + + for (CodeNode node : nodes) { + Provenance prov = node.getProvenance(); + assertThat(prov) + .as("Node %s should have provenance", node.getId()) + .isNotNull(); + assertThat(prov.extractorVersion()) + .as("Node %s should have extractorVersion", node.getId()) + .isNotBlank(); + assertThat(prov.confidence()) + .as("Node %s should have confidence", node.getId()) + .isNotNull(); + assertThat(prov.schemaVersion()) + .as("Node %s should have schemaVersion >= 1", node.getId()) + .isGreaterThanOrEqualTo(1); + } + } + + @Test + void provenance_isDeterministic(@TempDir Path tempDir) throws Exception { + Path src = tempDir.resolve("src/Foo.java"); + Files.createDirectories(src.getParent()); + Files.writeString(src, """ + package com; + public class Foo { + public void bar() {} + } + """); + + // Run twice + AnalysisResult r1 = analyzer.run(tempDir, msg -> {}); + AnalysisResult r2 = analyzer.run(tempDir, msg -> {}); + + assertThat(r1.nodes()).isNotNull(); + assertThat(r2.nodes()).isNotNull(); + + // Same node count + assertThat(r1.nodes()).hasSameSizeAs(r2.nodes()); + + // Provenance fields must be identical (same extractor version, same schema version) + for (int i = 0; i < r1.nodes().size(); i++) { + Provenance p1 = r1.nodes().get(i).getProvenance(); + Provenance p2 = r2.nodes().get(i).getProvenance(); + assertThat(p1).isNotNull(); + assertThat(p2).isNotNull(); + assertThat(p1.extractorVersion()).isEqualTo(p2.extractorVersion()); + assertThat(p1.schemaVersion()).isEqualTo(p2.schemaVersion()); + assertThat(p1.confidence()).isEqualTo(p2.confidence()); + } + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceTest.java new file mode 100644 index 00000000..0e0bb6d8 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceTest.java @@ -0,0 +1,81 @@ +package io.github.randomcodespace.iq.intelligence; + +import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.NodeKind; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ProvenanceTest { + + @Test + void toProperties_populatesAllKeys() { + var p = new Provenance("https://github.com/example/repo", "abc123", "0.1.0", 1, CapabilityLevel.PARTIAL); + var props = p.toProperties(); + + assertThat(props).containsEntry(Provenance.KEY_REPO_URL, "https://github.com/example/repo"); + assertThat(props).containsEntry(Provenance.KEY_COMMIT_SHA, "abc123"); + assertThat(props).containsEntry(Provenance.KEY_EXTRACTOR_VER, "0.1.0"); + assertThat(props).containsEntry(Provenance.KEY_SCHEMA_VER, 1); + assertThat(props).containsEntry(Provenance.KEY_CONFIDENCE, "PARTIAL"); + } + + @Test + void toProperties_skipsNullRepoUrl() { + var p = new Provenance(null, null, "0.1.0", 1, CapabilityLevel.EXACT); + var props = p.toProperties(); + + assertThat(props).doesNotContainKey(Provenance.KEY_REPO_URL); + assertThat(props).doesNotContainKey(Provenance.KEY_COMMIT_SHA); + assertThat(props).containsKey(Provenance.KEY_EXTRACTOR_VER); + } + + @Test + void fromProperties_roundTrip() { + var original = new Provenance("https://github.com/x/y", "sha999", "1.0", 1, CapabilityLevel.EXACT); + var props = original.toProperties(); + var restored = Provenance.fromProperties(props); + + assertThat(restored).isNotNull(); + assertThat(restored.repositoryUrl()).isEqualTo("https://github.com/x/y"); + assertThat(restored.commitSha()).isEqualTo("sha999"); + assertThat(restored.extractorVersion()).isEqualTo("1.0"); + assertThat(restored.schemaVersion()).isEqualTo(1); + assertThat(restored.confidence()).isEqualTo(CapabilityLevel.EXACT); + } + + @Test + void fromProperties_returnsNullForEmptyMap() { + assertThat(Provenance.fromProperties(java.util.Map.of())).isNull(); + assertThat(Provenance.fromProperties(null)).isNull(); + } + + @Test + void codeNode_setAndGetProvenance() { + var node = new CodeNode("id1", NodeKind.ENDPOINT, "MyEndpoint"); + var p = new Provenance("https://github.com/a/b", "deadbeef", "0.1.0", 1, CapabilityLevel.PARTIAL); + + node.setProvenance(p); + + assertThat(node.getProperties()).containsKey(Provenance.KEY_EXTRACTOR_VER); + assertThat(node.getProperties()).containsEntry(Provenance.KEY_COMMIT_SHA, "deadbeef"); + + Provenance restored = node.getProvenance(); + assertThat(restored).isNotNull(); + assertThat(restored.commitSha()).isEqualTo("deadbeef"); + assertThat(restored.confidence()).isEqualTo(CapabilityLevel.PARTIAL); + } + + @Test + void codeNode_setProvenance_isIdempotent() { + var node = new CodeNode("id2", NodeKind.ENDPOINT, "EP"); + var p1 = new Provenance(null, "sha1", "0.1.0", 1, CapabilityLevel.PARTIAL); + var p2 = new Provenance(null, "sha2", "0.1.0", 1, CapabilityLevel.EXACT); + + node.setProvenance(p1); + node.setProvenance(p2); + + assertThat(node.getProvenance().commitSha()).isEqualTo("sha2"); + assertThat(node.getProvenance().confidence()).isEqualTo(CapabilityLevel.EXACT); + } +} From 59f2e4efacbdda8fd19bb29fb2f5e23c9d59d0b1 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 3 Apr 2026 18:06:44 +0000 Subject: [PATCH 05/16] =?UTF-8?q?fix(intelligence):=20address=20PE=20archi?= =?UTF-8?q?tecture=20review=20=E2=80=94=20RepositoryIdentity=20constructor?= =?UTF-8?q?=20+=20AnalysisCache=20hash=20reuse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GraphBuilder now accepts RepositoryIdentity + extractorVersion as constructor params; Provenance is derived internally (never constructed externally by callers) - Analyzer and EnrichCommand updated to pass RepositoryIdentity directly to GraphBuilder - AnalysisCache.getHashForPath() added for reverse path→hash lookup - buildFileInventory() now populates FileEntry.contentHash from cache (no file re-reads) Addresses BLOCKING 2 and BLOCKING 3 from PE review on RAN-150. Co-Authored-By: Paperclip --- .../randomcodespace/iq/analyzer/Analyzer.java | 23 +++++--------- .../iq/analyzer/GraphBuilder.java | 31 +++++++++++++++++-- .../iq/cache/AnalysisCache.java | 17 ++++++++++ .../randomcodespace/iq/cli/EnrichCommand.java | 13 ++------ 4 files changed, 55 insertions(+), 29 deletions(-) diff --git a/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java b/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java index 0aa298b6..93037261 100644 --- a/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java +++ b/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java @@ -13,11 +13,9 @@ import io.github.randomcodespace.iq.detector.DetectorResult; import io.github.randomcodespace.iq.detector.DetectorUtils; import io.github.randomcodespace.iq.grammar.AntlrParserFactory; -import io.github.randomcodespace.iq.intelligence.CapabilityLevel; import io.github.randomcodespace.iq.intelligence.FileClassification; import io.github.randomcodespace.iq.intelligence.FileEntry; import io.github.randomcodespace.iq.intelligence.FileInventory; -import io.github.randomcodespace.iq.intelligence.Provenance; import io.github.randomcodespace.iq.intelligence.RepositoryIdentity; import io.github.randomcodespace.iq.model.CodeEdge; import io.github.randomcodespace.iq.model.CodeNode; @@ -236,14 +234,7 @@ private AnalysisResult runWithCache(Path root, Integer parallelism, AnalysisCach // 1b. Resolve repository identity and build file inventory RepositoryIdentity repoIdentity = RepositoryIdentity.resolve(root); - FileInventory fileInventory = buildFileInventory(files); - Provenance provenance = new Provenance( - repoIdentity.repoUrl(), - repoIdentity.commitSha(), - VersionCommand.VERSION, - Provenance.CURRENT_SCHEMA_VERSION, - CapabilityLevel.PARTIAL - ); + FileInventory fileInventory = buildFileInventory(files, cache); // Compute language breakdown Map languageBreakdown = new HashMap<>(); @@ -318,7 +309,7 @@ private AnalysisResult runWithCache(Path root, Integer parallelism, AnalysisCach // 3. Build graph (batched) report.accept("Building graph..."); - var builder = new GraphBuilder(provenance); + var builder = new GraphBuilder(repoIdentity, VersionCommand.VERSION); int filesAnalyzed = 0; for (int i = 0; i < resultSlots.length; i++) { DetectorResult result = resultSlots[i]; @@ -347,7 +338,7 @@ private AnalysisResult runWithCache(Path root, Integer parallelism, AnalysisCach String projectDirName = root.getFileName() != null ? root.getFileName().toString() : "root"; var serviceResult = serviceDetector.detect(allNodes, builder.getEdges(), projectDirName, root); if (!serviceResult.serviceNodes().isEmpty()) { - serviceResult.serviceNodes().forEach(n -> n.setProvenance(provenance)); + serviceResult.serviceNodes().forEach(n -> n.setProvenance(builder.getProvenance())); builder.addNodes(serviceResult.serviceNodes()); builder.addEdges(serviceResult.serviceEdges()); allNodes = builder.getNodes(); // refresh reference after adding service nodes @@ -1278,14 +1269,16 @@ DetectorResult analyzeFile(DiscoveredFile file, Path repoPath, DetectorRegistry /** * Build a deterministic FileInventory from the list of discovered files. - * Content hashes are not computed here (too expensive at discovery time); they remain null. + * Content hashes are reused from {@code cache} when available (no re-read of files). + * Hashes remain null for files not yet present in the cache. */ - private static FileInventory buildFileInventory(List files) { + private static FileInventory buildFileInventory(List files, AnalysisCache cache) { List entries = new ArrayList<>(files.size()); for (DiscoveredFile f : files) { String relPath = f.path().toString().replace('\\', '/'); FileClassification cls = FileEntry.classify(relPath, f.language()); - entries.add(new FileEntry(relPath, f.language(), f.sizeBytes(), null, cls)); + String contentHash = cache != null ? cache.getHashForPath(relPath) : null; + entries.add(new FileEntry(relPath, f.language(), f.sizeBytes(), contentHash, cls)); } return new FileInventory(entries); } diff --git a/src/main/java/io/github/randomcodespace/iq/analyzer/GraphBuilder.java b/src/main/java/io/github/randomcodespace/iq/analyzer/GraphBuilder.java index f4d6b4f6..a81632f8 100644 --- a/src/main/java/io/github/randomcodespace/iq/analyzer/GraphBuilder.java +++ b/src/main/java/io/github/randomcodespace/iq/analyzer/GraphBuilder.java @@ -3,7 +3,9 @@ import io.github.randomcodespace.iq.analyzer.linker.LinkResult; import io.github.randomcodespace.iq.analyzer.linker.Linker; import io.github.randomcodespace.iq.detector.DetectorResult; +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; import io.github.randomcodespace.iq.intelligence.Provenance; +import io.github.randomcodespace.iq.intelligence.RepositoryIdentity; import io.github.randomcodespace.iq.model.CodeEdge; import io.github.randomcodespace.iq.model.CodeNode; import org.slf4j.Logger; @@ -45,15 +47,38 @@ public GraphBuilder(int batchSize) { this(batchSize, null); } - public GraphBuilder(Provenance provenance) { - this(1000, provenance); + /** + * Construct with repository identity and extractor version. + * Provenance is derived internally from the identity. + */ + public GraphBuilder(RepositoryIdentity identity, String extractorVersion) { + this(1000, identity, extractorVersion); + } + + /** + * Construct with batch size, repository identity, and extractor version. + * Provenance is derived internally from the identity. + */ + public GraphBuilder(int batchSize, RepositoryIdentity identity, String extractorVersion) { + this(batchSize, identity == null ? null : new Provenance( + identity.repoUrl(), + identity.commitSha(), + extractorVersion, + Provenance.CURRENT_SCHEMA_VERSION, + CapabilityLevel.PARTIAL + )); } - public GraphBuilder(int batchSize, Provenance provenance) { + private GraphBuilder(int batchSize, Provenance provenance) { this.batchSize = Math.max(1, batchSize); this.provenance = provenance; } + /** Returns the provenance stamped on every node, or null if none was configured. */ + public Provenance getProvenance() { + return provenance; + } + /** * Add all nodes and edges from a detector result. */ diff --git a/src/main/java/io/github/randomcodespace/iq/cache/AnalysisCache.java b/src/main/java/io/github/randomcodespace/iq/cache/AnalysisCache.java index b953a661..7590ea04 100644 --- a/src/main/java/io/github/randomcodespace/iq/cache/AnalysisCache.java +++ b/src/main/java/io/github/randomcodespace/iq/cache/AnalysisCache.java @@ -153,6 +153,23 @@ public synchronized boolean isCached(String contentHash) { } } + /** + * Look up the content hash stored for a given file path. + * Returns null if the path has not been cached yet. + */ + public synchronized String getHashForPath(String filePath) { + try (var stmt = conn.prepareStatement( + "SELECT content_hash FROM files WHERE path = ? LIMIT 1")) { + stmt.setString(1, filePath); + try (ResultSet rs = stmt.executeQuery()) { + return rs.next() ? rs.getString(1) : null; + } + } catch (SQLException e) { + log.debug("Hash lookup by path failed", e); + return null; + } + } + // --- Store results --- /** diff --git a/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java b/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java index 17d8219c..df0df4b8 100644 --- a/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java +++ b/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java @@ -5,8 +5,6 @@ import io.github.randomcodespace.iq.analyzer.linker.Linker; import io.github.randomcodespace.iq.cache.AnalysisCache; import io.github.randomcodespace.iq.config.CodeIqConfig; -import io.github.randomcodespace.iq.intelligence.CapabilityLevel; -import io.github.randomcodespace.iq.intelligence.Provenance; import io.github.randomcodespace.iq.intelligence.RepositoryIdentity; import io.github.randomcodespace.iq.model.CodeEdge; import io.github.randomcodespace.iq.model.CodeNode; @@ -120,14 +118,7 @@ private int enrichFromCache(AnalysisCache cache, Path root, NumberFormat nf, Ins // 2. Run linkers (these work on in-memory node/edge lists) CliOutput.step("\uD83D\uDD17", "Running cross-file linkers..."); RepositoryIdentity repoIdentity = RepositoryIdentity.resolve(root); - Provenance provenance = new Provenance( - repoIdentity.repoUrl(), - repoIdentity.commitSha(), - VersionCommand.VERSION, - Provenance.CURRENT_SCHEMA_VERSION, - CapabilityLevel.PARTIAL - ); - var builder = new GraphBuilder(provenance); + var builder = new GraphBuilder(repoIdentity, VersionCommand.VERSION); for (CodeNode node : allNodes) { builder.addNodes(List.of(node)); } @@ -158,7 +149,7 @@ private int enrichFromCache(AnalysisCache cache, Path root, NumberFormat nf, Ins String projectName = root.getFileName().toString(); var serviceResult = serviceDetector.detect(enrichedNodes, enrichedEdges, projectName, root); if (!serviceResult.serviceNodes().isEmpty()) { - serviceResult.serviceNodes().forEach(n -> n.setProvenance(provenance)); + serviceResult.serviceNodes().forEach(n -> n.setProvenance(builder.getProvenance())); // Add service nodes and edges to the builder builder.addNodes(serviceResult.serviceNodes()); builder.addEdges(serviceResult.serviceEdges()); From 624fdd31055776c4c205b3eec0fd5d148a245012 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 3 Apr 2026 18:13:26 +0000 Subject: [PATCH 06/16] =?UTF-8?q?fix(intelligence):=20address=20CTO=20revi?= =?UTF-8?q?ew=20=E2=80=94=20TreeMap=20determinism=20+=20Neo4j=20provenance?= =?UTF-8?q?=20round-trip=20(RAN-146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FileInventory.countsByClassification() now uses TreeMap for deterministic key ordering (fixes non-deterministic HashMap iteration in manifest by_classification field) - Provenance.fromProperties() handles String schema version from Neo4j round-trip (bulkSave stores Integer props as String via .toString(); parseInt handles both types) - Add ProvenanceNeo4jRoundTripTest: two mock-based tests verifying prov_* -> prop_prov_* -> prov_* round-trip including schemaVersion Integer/String coercion and null fields Co-Authored-By: Paperclip --- .../iq/intelligence/FileInventory.java | 6 +- .../iq/intelligence/Provenance.java | 4 +- .../ProvenanceNeo4jRoundTripTest.java | 155 ++++++++++++++++++ 3 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceNeo4jRoundTripTest.java diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/FileInventory.java b/src/main/java/io/github/randomcodespace/iq/intelligence/FileInventory.java index 4eefef58..474a330f 100644 --- a/src/main/java/io/github/randomcodespace/iq/intelligence/FileInventory.java +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/FileInventory.java @@ -3,6 +3,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.TreeMap; import java.util.stream.Collectors; /** @@ -24,10 +25,11 @@ public int totalFiles() { return entries.size(); } - /** Count of files per {@link FileClassification}. */ + /** Count of files per {@link FileClassification}, sorted by name for determinism. */ public Map countsByClassification() { return entries.stream() - .collect(Collectors.groupingBy(FileEntry::classification, Collectors.counting())); + .collect(Collectors.groupingBy(FileEntry::classification, + TreeMap::new, Collectors.counting())); } /** Count of files per language. */ diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/Provenance.java b/src/main/java/io/github/randomcodespace/iq/intelligence/Provenance.java index 85c6280b..a4731bcb 100644 --- a/src/main/java/io/github/randomcodespace/iq/intelligence/Provenance.java +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/Provenance.java @@ -50,7 +50,9 @@ public static Provenance fromProperties(java.util.Map props) { String repoUrl = (String) props.get(KEY_REPO_URL); String sha = (String) props.get(KEY_COMMIT_SHA); String extVer = (String) props.getOrDefault(KEY_EXTRACTOR_VER, "unknown"); - int schemaVer = ((Number) props.getOrDefault(KEY_SCHEMA_VER, CURRENT_SCHEMA_VERSION)).intValue(); + Object schemaVerObj = props.getOrDefault(KEY_SCHEMA_VER, CURRENT_SCHEMA_VERSION); + int schemaVer = schemaVerObj instanceof Number n ? n.intValue() + : Integer.parseInt(schemaVerObj.toString()); String confStr = (String) props.getOrDefault(KEY_CONFIDENCE, CapabilityLevel.PARTIAL.name()); CapabilityLevel confidence; try { diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceNeo4jRoundTripTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceNeo4jRoundTripTest.java new file mode 100644 index 00000000..748133ca --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceNeo4jRoundTripTest.java @@ -0,0 +1,155 @@ +package io.github.randomcodespace.iq.intelligence; + +import io.github.randomcodespace.iq.graph.GraphRepository; +import io.github.randomcodespace.iq.graph.GraphStore; +import io.github.randomcodespace.iq.model.CodeNode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.neo4j.graphdb.GraphDatabaseService; +import org.neo4j.graphdb.Result; +import org.neo4j.graphdb.Transaction; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Verifies the {@code prov_*} → {@code prop_prov_*} → {@code prov_*} Neo4j round-trip. + *

+ * {@link io.github.randomcodespace.iq.graph.GraphStore#bulkSave} stores node properties + * as {@code prop_} in Neo4j (all values coerced to String via {@code toString()}). + * {@code nodeFromNeo4j()} restores them by stripping the {@code prop_} prefix. + * This test verifies that provenance fields survive that transformation, including + * the {@code schemaVersion} Integer→String→int coercion. + */ +@ExtendWith(MockitoExtension.class) +class ProvenanceNeo4jRoundTripTest { + + @Mock + private GraphRepository repository; + + @Mock + private GraphDatabaseService graphDb; + + private GraphStore store; + + @BeforeEach + void setUp() { + store = new GraphStore(repository, graphDb); + } + + @Test + void provenance_survivesNeo4jRoundTrip() { + // Arrange: mock a Neo4j node with prop_prov_* keys (what bulkSave writes). + // bulkSave stores all properties as Strings via .toString(), so schemaVersion + // is stored as "1" not 1. + var neo4jNode = mock(org.neo4j.graphdb.Node.class); + when(neo4jNode.getProperty("id", null)).thenReturn("prov:roundtrip:test"); + when(neo4jNode.getProperty("kind", null)).thenReturn("endpoint"); + when(neo4jNode.getProperty("label", "")).thenReturn("TestEndpoint"); + when(neo4jNode.getProperty("fqn", null)).thenReturn(null); + when(neo4jNode.getProperty("module", null)).thenReturn(null); + when(neo4jNode.getProperty("filePath", null)).thenReturn(null); + when(neo4jNode.getProperty("layer", null)).thenReturn(null); + when(neo4jNode.getProperty("lineStart", null)).thenReturn(null); + when(neo4jNode.getProperty("lineEnd", null)).thenReturn(null); + when(neo4jNode.getProperty("annotations", null)).thenReturn(null); + + // Property keys as stored by bulkSave (prop_ prefix, values as String) + when(neo4jNode.getPropertyKeys()).thenReturn(List.of( + "prop_prov_repo_url", + "prop_prov_commit_sha", + "prop_prov_extractor_version", + "prop_prov_schema_version", + "prop_prov_confidence" + )); + when(neo4jNode.getProperty("prop_prov_repo_url")).thenReturn("https://github.com/example/repo"); + when(neo4jNode.getProperty("prop_prov_commit_sha")).thenReturn("abc123def456"); + when(neo4jNode.getProperty("prop_prov_extractor_version")).thenReturn("0.1.0-SNAPSHOT"); + when(neo4jNode.getProperty("prop_prov_schema_version")).thenReturn("1"); // String after bulkSave + when(neo4jNode.getProperty("prop_prov_confidence")).thenReturn("PARTIAL"); + + // Mock Transaction: first execute() returns the node, second (edges) returns empty + var tx = mock(Transaction.class); + when(graphDb.beginTx()).thenReturn(tx); + + var nodeResult = mock(Result.class); + when(nodeResult.hasNext()).thenReturn(true, false); + when(nodeResult.next()).thenReturn(Map.of("n", neo4jNode)); + + var edgeResult = mock(Result.class); + when(edgeResult.hasNext()).thenReturn(false); + + when(tx.execute(anyString(), anyMap())).thenReturn(nodeResult, edgeResult); + + // Act: findById invokes nodeFromNeo4j() internally + Optional result = store.findById("prov:roundtrip:test"); + + // Assert: node is present and provenance is fully restored + assertThat(result).isPresent(); + CodeNode node = result.get(); + assertThat(node.getId()).isEqualTo("prov:roundtrip:test"); + + Provenance prov = node.getProvenance(); + assertThat(prov).as("Provenance must be restored after Neo4j round-trip").isNotNull(); + assertThat(prov.repositoryUrl()).isEqualTo("https://github.com/example/repo"); + assertThat(prov.commitSha()).isEqualTo("abc123def456"); + assertThat(prov.extractorVersion()).isEqualTo("0.1.0-SNAPSHOT"); + assertThat(prov.schemaVersion()).isEqualTo(1); + assertThat(prov.confidence()).isEqualTo(CapabilityLevel.PARTIAL); + } + + @Test + void provenance_survivesNeo4jRoundTrip_withNullRepoUrl() { + // Verifies that absent optional fields (repo_url, commit_sha) round-trip as null + var neo4jNode = mock(org.neo4j.graphdb.Node.class); + when(neo4jNode.getProperty("id", null)).thenReturn("prov:roundtrip:nullfields"); + when(neo4jNode.getProperty("kind", null)).thenReturn("class"); + when(neo4jNode.getProperty("label", "")).thenReturn("SomeClass"); + when(neo4jNode.getProperty("fqn", null)).thenReturn(null); + when(neo4jNode.getProperty("module", null)).thenReturn(null); + when(neo4jNode.getProperty("filePath", null)).thenReturn(null); + when(neo4jNode.getProperty("layer", null)).thenReturn(null); + when(neo4jNode.getProperty("lineStart", null)).thenReturn(null); + when(neo4jNode.getProperty("lineEnd", null)).thenReturn(null); + when(neo4jNode.getProperty("annotations", null)).thenReturn(null); + + // Only required provenance keys (no repo_url, no commit_sha) + when(neo4jNode.getPropertyKeys()).thenReturn(List.of( + "prop_prov_extractor_version", + "prop_prov_schema_version", + "prop_prov_confidence" + )); + when(neo4jNode.getProperty("prop_prov_extractor_version")).thenReturn("0.1.0-SNAPSHOT"); + when(neo4jNode.getProperty("prop_prov_schema_version")).thenReturn("1"); + when(neo4jNode.getProperty("prop_prov_confidence")).thenReturn("EXACT"); + + var tx = mock(Transaction.class); + when(graphDb.beginTx()).thenReturn(tx); + var nodeResult = mock(Result.class); + when(nodeResult.hasNext()).thenReturn(true, false); + when(nodeResult.next()).thenReturn(Map.of("n", neo4jNode)); + var edgeResult = mock(Result.class); + when(edgeResult.hasNext()).thenReturn(false); + when(tx.execute(anyString(), anyMap())).thenReturn(nodeResult, edgeResult); + + Optional result = store.findById("prov:roundtrip:nullfields"); + + assertThat(result).isPresent(); + Provenance prov = result.get().getProvenance(); + assertThat(prov).isNotNull(); + assertThat(prov.repositoryUrl()).isNull(); + assertThat(prov.commitSha()).isNull(); + assertThat(prov.confidence()).isEqualTo(CapabilityLevel.EXACT); + assertThat(prov.schemaVersion()).isEqualTo(1); + } +} From d3e462c4bae4e78c62b7028b2f12234e4c7b8ecb Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 3 Apr 2026 18:38:16 +0000 Subject: [PATCH 07/16] fix(intelligence): fix HashMap determinism in FileInventory + gitignore entries (RAN-154) - countsByLanguage(): use TreeMap::new for deterministic alphabetical key ordering - toSummary() byLang: add thenComparing secondary sort to break ties alphabetically - toSummary() byCls: use LinkedHashMap::new to preserve TreeMap insertion order - .gitignore: add playwright-report/ and test-results/ frontend build artifacts Co-Authored-By: Paperclip --- .gitignore | 2 ++ .../iq/intelligence/FileInventory.java | 14 ++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index ed53e658..ad3520db 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,8 @@ src/main/frontend/node_modules/ src/main/frontend/node/ src/main/frontend/dist/ src/main/frontend/tsconfig.tsbuildinfo +playwright-report/ +test-results/ # Generated explorer CSS (rebuild via: cd src/main/frontend && npm run build:explorer-css) src/main/resources/static/css/explorer.css diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/FileInventory.java b/src/main/java/io/github/randomcodespace/iq/intelligence/FileInventory.java index 474a330f..3a3b0f31 100644 --- a/src/main/java/io/github/randomcodespace/iq/intelligence/FileInventory.java +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/FileInventory.java @@ -32,10 +32,11 @@ public Map countsByClassification() { TreeMap::new, Collectors.counting())); } - /** Count of files per language. */ + /** Count of files per language, sorted alphabetically for determinism. */ public Map countsByLanguage() { return entries.stream() - .collect(Collectors.groupingBy(FileEntry::language, Collectors.counting())); + .collect(Collectors.groupingBy(FileEntry::language, + TreeMap::new, Collectors.counting())); } /** Sum of all file sizes in bytes. */ @@ -51,10 +52,15 @@ public Map toSummary() { summary.put("total_files", totalFiles()); summary.put("total_bytes", totalBytes()); var byCls = countsByClassification().entrySet().stream() - .collect(Collectors.toMap(e -> e.getKey().name().toLowerCase(), Map.Entry::getValue)); + .collect(Collectors.toMap( + e -> e.getKey().name().toLowerCase(), + Map.Entry::getValue, + (a, b) -> a, + java.util.LinkedHashMap::new)); // preserve TreeMap insertion order summary.put("by_classification", byCls); var byLang = countsByLanguage().entrySet().stream() - .sorted(Map.Entry.comparingByValue().reversed()) + .sorted(Map.Entry.comparingByValue().reversed() + .thenComparing(Map.Entry.comparingByKey())) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a, java.util.LinkedHashMap::new)); summary.put("by_language", byLang); From 7b30a333cfcaab33863154c124234248caa68678 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 3 Apr 2026 18:42:51 +0000 Subject: [PATCH 08/16] =?UTF-8?q?fix(intelligence):=20address=20PE=20revie?= =?UTF-8?q?w=20=E2=80=94=20cpp=20capability=20table=20+=20QueryPlanner=20p?= =?UTF-8?q?rofile=20guard=20(RAN-155)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CPP_CAPS table (distinct from C# — no ORM, lexical-only auth) - Add explicit case "cpp","c++" to CapabilityMatrix.tableFor() - Add "cpp" to asSerializableMap() hardcoded language list - Remove incorrect CSHARP_CAPS fallback for cpp in ANTLR_LANGUAGES branch - Add @Profile("serving") to QueryPlanner so it is not instantiated during indexing CLI runs Co-Authored-By: Paperclip --- .../iq/intelligence/query/CapabilityMatrix.java | 15 +++++++++++++++ .../iq/intelligence/query/QueryPlanner.java | 2 ++ 2 files changed, 17 insertions(+) diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrix.java b/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrix.java index 8c02bbfa..da413602 100644 --- a/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrix.java +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrix.java @@ -67,6 +67,9 @@ public final class CapabilityMatrix { /** Rust: ANTLR grammar — partial structural coverage. */ private static final Map RUST_CAPS; + /** C++: ANTLR grammar — partial structural coverage, no ORM convention. */ + private static final Map CPP_CAPS; + /** Fallback for regex-only languages. */ private static final Map LEXICAL_ONLY_CAPS; @@ -158,6 +161,18 @@ public final class CapabilityMatrix { CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.PARTIAL ); + CPP_CAPS = immutableDimMap( + CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.PARTIAL, + CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.PARTIAL, + CapabilityDimension.IMPORT_RESOLUTION, CapabilityLevel.PARTIAL, + CapabilityDimension.TYPE_INFO, CapabilityLevel.PARTIAL, + CapabilityDimension.CLASS_HIERARCHY, CapabilityLevel.PARTIAL, + CapabilityDimension.FRAMEWORK_SEMANTICS, CapabilityLevel.PARTIAL, + CapabilityDimension.ORM_ENTITY_MAPPING, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.AUTH_SECURITY, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.PARTIAL + ); + LEXICAL_ONLY_CAPS = immutableDimMap( CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.LEXICAL_ONLY, CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.LEXICAL_ONLY, diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryPlanner.java b/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryPlanner.java index f837b60c..ffae298f 100644 --- a/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryPlanner.java +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryPlanner.java @@ -1,6 +1,7 @@ package io.github.randomcodespace.iq.intelligence.query; import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import java.util.EnumSet; @@ -25,6 +26,7 @@ * regardless of language, because text search operates on raw source content, not the graph. */ @Service +@Profile("serving") public class QueryPlanner { // ------------------------------------------------------------------ From d901b3bab7e0f21b74d5d59e8ba61800317603c0 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 3 Apr 2026 18:48:44 +0000 Subject: [PATCH 09/16] fix(intelligence): fix Process resource leak in RepositoryIdentity.runGit() Process does not implement AutoCloseable in Java 25, so try-with-resources is not applicable. Use try-finally with proc.destroy() to ensure OS process handles are always released, resolving SonarQube C-Reliability finding. Closes RAN-156 Co-Authored-By: Paperclip --- .../iq/intelligence/RepositoryIdentity.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentity.java b/src/main/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentity.java index 71192b2c..8a1bd200 100644 --- a/src/main/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentity.java +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentity.java @@ -40,9 +40,13 @@ private static String runGit(java.nio.file.Path repoPath, String... args) { .directory(repoPath.toFile()) .redirectErrorStream(true); var proc = pb.start(); - String out = new String(proc.getInputStream().readAllBytes()).trim(); - int exit = proc.waitFor(); - return (exit == 0 && !out.isBlank()) ? out : null; + try { + String out = new String(proc.getInputStream().readAllBytes()).trim(); + int exit = proc.waitFor(); + return (exit == 0 && !out.isBlank()) ? out : null; + } finally { + proc.destroy(); + } } catch (Exception e) { return null; } From eef9dbc511c17bcd9ebd2c7ac9e20a934e0ab43b Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 3 Apr 2026 18:59:22 +0000 Subject: [PATCH 10/16] =?UTF-8?q?fix(intelligence):=20close=20InputStream?= =?UTF-8?q?=20explicitly=20in=20runGit()=20=E2=80=94=20SonarQube=20S2095?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap proc.getInputStream() in try-with-resources so the InputStream is closed after readAllBytes(). proc.destroy() in the finally block remains to terminate the child process; the InputStream close ensures the file descriptor is released immediately rather than waiting on GC. Co-Authored-By: Paperclip --- .../randomcodespace/iq/intelligence/RepositoryIdentity.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentity.java b/src/main/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentity.java index 8a1bd200..f5a32440 100644 --- a/src/main/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentity.java +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentity.java @@ -40,8 +40,8 @@ private static String runGit(java.nio.file.Path repoPath, String... args) { .directory(repoPath.toFile()) .redirectErrorStream(true); var proc = pb.start(); - try { - String out = new String(proc.getInputStream().readAllBytes()).trim(); + try (var is = proc.getInputStream()) { + String out = new String(is.readAllBytes()).trim(); int exit = proc.waitFor(); return (exit == 0 && !out.isBlank()) ? out : null; } finally { From cc08698ac431a8ce4e2031a81927915a367c4a71 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 3 Apr 2026 19:05:02 +0000 Subject: [PATCH 11/16] =?UTF-8?q?feat(intelligence):=20Phase=202=20lexical?= =?UTF-8?q?=20intelligence=20=E2=80=94=20doc=20comment=20index=20+=20snipp?= =?UTF-8?q?et=20store=20(RAN-147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New package: intelligence/lexical - CodeSnippet: bounded source snippet record (path, line range, language, provenance) - LexicalResult: query result record (node, score, matchedField, snippet, provenance) - DocCommentExtractor: extracts Javadoc/JSDoc, Go/Rust line comments, Python docstrings - SnippetStore: extracts bounded code snippets (max 50 lines) from source files - LexicalEnricher: populates lex_comment and lex_config_keys properties before Neo4j load - LexicalQueryService: findByIdentifier, findByDocComment, findByConfigKey (serving profile) Infrastructure changes: - GraphStore: add searchLexical() + lexical_index (standard analyzer on prop_lex_* fields) - EnrichCommand: inject LexicalEnricher, add enrichment step before Neo4j bulk load - lexical_index created in both GraphStore.bulkSave() and EnrichCommand Tests: 24 new tests across DocCommentExtractor, SnippetStore, LexicalEnricher All 1591 tests passing. Co-Authored-By: Paperclip --- .../randomcodespace/iq/cli/EnrichCommand.java | 15 +- .../randomcodespace/iq/graph/GraphStore.java | 15 ++ .../iq/intelligence/lexical/CodeSnippet.java | 22 +++ .../lexical/DocCommentExtractor.java | 179 ++++++++++++++++++ .../intelligence/lexical/LexicalEnricher.java | 101 ++++++++++ .../lexical/LexicalQueryService.java | 107 +++++++++++ .../intelligence/lexical/LexicalResult.java | 27 +++ .../iq/intelligence/lexical/SnippetStore.java | 104 ++++++++++ .../iq/cli/EnrichCommandTest.java | 7 +- .../lexical/DocCommentExtractorTest.java | 125 ++++++++++++ .../lexical/LexicalEnricherTest.java | 110 +++++++++++ .../lexical/SnippetStoreTest.java | 118 ++++++++++++ 12 files changed, 925 insertions(+), 5 deletions(-) create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/lexical/CodeSnippet.java create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/lexical/DocCommentExtractor.java create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalEnricher.java create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalQueryService.java create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalResult.java create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/lexical/SnippetStore.java create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/lexical/DocCommentExtractorTest.java create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalEnricherTest.java create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/lexical/SnippetStoreTest.java diff --git a/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java b/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java index df0df4b8..0a179140 100644 --- a/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java +++ b/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java @@ -6,6 +6,7 @@ import io.github.randomcodespace.iq.cache.AnalysisCache; import io.github.randomcodespace.iq.config.CodeIqConfig; import io.github.randomcodespace.iq.intelligence.RepositoryIdentity; +import io.github.randomcodespace.iq.intelligence.lexical.LexicalEnricher; import io.github.randomcodespace.iq.model.CodeEdge; import io.github.randomcodespace.iq.model.CodeNode; import io.github.randomcodespace.iq.model.EdgeKind; @@ -59,11 +60,14 @@ public class EnrichCommand implements Callable { private final CodeIqConfig config; private final LayerClassifier layerClassifier; private final List linkers; + private final LexicalEnricher lexicalEnricher; - public EnrichCommand(CodeIqConfig config, LayerClassifier layerClassifier, List linkers) { + public EnrichCommand(CodeIqConfig config, LayerClassifier layerClassifier, + List linkers, LexicalEnricher lexicalEnricher) { this.config = config; this.layerClassifier = layerClassifier; this.linkers = linkers; + this.lexicalEnricher = lexicalEnricher; } @Override @@ -143,7 +147,11 @@ private int enrichFromCache(AnalysisCache cache, Path root, NumberFormat nf, Ins CliOutput.step("\uD83C\uDFF7\uFE0F", "Classifying layers..."); layerClassifier.classify(enrichedNodes); - // 3b. Detect services + // 3b. Enrich lexical metadata (doc comments, config keys) for fulltext search + CliOutput.step("\uD83D\uDD0D", "Enriching lexical metadata..."); + lexicalEnricher.enrich(enrichedNodes, root); + + // 3c. Detect services CliOutput.step("\uD83C\uDFD7\uFE0F", "Detecting service boundaries..."); var serviceDetector = new io.github.randomcodespace.iq.analyzer.ServiceDetector(); String projectName = root.getFileName().toString(); @@ -315,6 +323,9 @@ private int enrichFromCache(AnalysisCache cache, Path root, NumberFormat nf, Ins tx.execute("CREATE FULLTEXT INDEX search_index IF NOT EXISTS " + "FOR (n:CodeNode) ON EACH [n.label_lower, n.fqn_lower] " + "OPTIONS {indexConfig: {`fulltext.analyzer`: 'keyword'}}"); + tx.execute("CREATE FULLTEXT INDEX lexical_index IF NOT EXISTS " + + "FOR (n:CodeNode) ON EACH [n.prop_lex_comment, n.prop_lex_config_keys] " + + "OPTIONS {indexConfig: {`fulltext.analyzer`: 'standard'}}"); tx.commit(); } // Wait for all indexes (including fulltext) to finish building diff --git a/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java b/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java index acfa1521..6b0a8bb8 100644 --- a/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java +++ b/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java @@ -92,6 +92,9 @@ public void bulkSave(List nodes) { tx.execute("CREATE FULLTEXT INDEX search_index IF NOT EXISTS " + "FOR (n:CodeNode) ON EACH [n.label_lower, n.fqn_lower] " + "OPTIONS {indexConfig: {`fulltext.analyzer`: 'keyword'}}"); + tx.execute("CREATE FULLTEXT INDEX lexical_index IF NOT EXISTS " + + "FOR (n:CodeNode) ON EACH [n.prop_lex_comment, n.prop_lex_config_keys] " + + "OPTIONS {indexConfig: {`fulltext.analyzer`: 'standard'}}"); tx.commit(); } @@ -264,6 +267,18 @@ public List search(String text, int limit) { Map.of("text", toLuceneQuery(text), "limit", limit)); } + /** + * Search the lexical index ({@code prop_lex_comment} and {@code prop_lex_config_keys}) + * for nodes matching {@code text}. Used by {@code LexicalQueryService} for doc comment + * and config key retrieval. + */ + public List searchLexical(String text, int limit) { + return queryNodes( + "CALL db.index.fulltext.queryNodes('lexical_index', $text) " + + "YIELD node RETURN node AS n LIMIT $limit", + Map.of("text", toLuceneQuery(text), "limit", limit)); + } + /** * Wraps a search term in Lucene wildcard syntax for substring matching against * the fulltext index (which stores lowercased property values via keyword analyzer). diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/CodeSnippet.java b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/CodeSnippet.java new file mode 100644 index 00000000..9b77e151 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/CodeSnippet.java @@ -0,0 +1,22 @@ +package io.github.randomcodespace.iq.intelligence.lexical; + +import io.github.randomcodespace.iq.intelligence.Provenance; + +/** + * A bounded code snippet extracted from a source file, produced by {@link SnippetStore}. + * + * @param sourceText Raw source text, bounded to at most {@link SnippetStore#MAX_LINES} lines. + * @param filePath Repo-relative path of the source file. + * @param lineStart 1-based start line of the extracted snippet. + * @param lineEnd 1-based end line of the extracted snippet (inclusive). + * @param language Lowercase language identifier (e.g. "java", "typescript"). + * @param provenance Provenance of the parent CodeNode; may be null. + */ +public record CodeSnippet( + String sourceText, + String filePath, + int lineStart, + int lineEnd, + String language, + Provenance provenance +) {} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/DocCommentExtractor.java b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/DocCommentExtractor.java new file mode 100644 index 00000000..bece90e1 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/DocCommentExtractor.java @@ -0,0 +1,179 @@ +package io.github.randomcodespace.iq.intelligence.lexical; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +/** + * Extracts doc comments from source files by scanning lines before (or just inside) + * a given declaration. + * + *

Supported styles: + *

    + *
  • Javadoc / JSDoc / C++ Doxygen — {@code /** ... * /} block immediately before the declaration.
  • + *
  • Python triple-quoted docstrings — first string literal inside the function/class body.
  • + *
  • Go / Rust / TypeScript line comments — contiguous {@code //} lines ending at the declaration.
  • + *
+ * + *

All methods are static — this class has no state. + */ +public final class DocCommentExtractor { + + private DocCommentExtractor() {} + + /** + * Extract the doc comment for the symbol declared at {@code lineStart} in {@code file}. + * + * @param file Absolute path to the source file. + * @param language Lowercase language identifier (e.g. "java", "typescript", "python"). + * @param lineStart 1-based line number of the symbol declaration. + * @return Cleaned comment text, or null if none found or file unreadable. + */ + public static String extract(Path file, String language, int lineStart) { + if (file == null || language == null || lineStart <= 0) return null; + try { + List lines = Files.readAllLines(file, StandardCharsets.UTF_8); + if (lineStart > lines.size()) return null; + return switch (language) { + case "python" -> extractPythonDocstring(lines, lineStart); + case "go", "rust" -> extractLineComments(lines, lineStart); + default -> extractBlockComment(lines, lineStart); + }; + } catch (Exception e) { + return null; + } + } + + /** + * Extracts a {@code /** ... * /} block comment ending just before the declaration + * (skipping blank lines and annotation lines). + */ + private static String extractBlockComment(List lines, int lineStart) { + // Walk backwards from the declaration line, skip blanks and annotations + int scanIdx = lineStart - 2; // convert to 0-based, start one line before declaration + while (scanIdx >= 0) { + String trimmed = lines.get(scanIdx).trim(); + if (trimmed.isBlank() || trimmed.startsWith("@")) { + scanIdx--; + continue; + } + break; + } + if (scanIdx < 0) return null; + + String endLine = lines.get(scanIdx).trim(); + if (!endLine.endsWith("*/")) return null; + + // Find the matching opening /* or /** + int openIdx = scanIdx; + while (openIdx >= 0 && !lines.get(openIdx).trim().startsWith("/*")) { + openIdx--; + } + if (openIdx < 0) return null; + + // Collect and clean the comment block + var sb = new StringBuilder(); + for (int i = openIdx; i <= scanIdx; i++) { + String cleaned = lines.get(i).trim() + .replaceAll("^/\\*+\\s*", "") + .replaceAll("\\s*\\*/$", "") + .replaceAll("^\\*\\s?", "") + .trim(); + if (!cleaned.isBlank()) { + if (!sb.isEmpty()) sb.append(' '); + sb.append(cleaned); + } + } + return sb.isEmpty() ? null : sb.toString(); + } + + /** + * Extracts contiguous {@code //} line comments immediately before the declaration. + * Used for Go and Rust doc comment styles. + */ + private static String extractLineComments(List lines, int lineStart) { + int scanIdx = lineStart - 2; // 0-based index of line before declaration + // Skip blank lines + while (scanIdx >= 0 && lines.get(scanIdx).trim().isBlank()) scanIdx--; + + if (scanIdx < 0) return null; + + // Collect contiguous // lines going upward + int endIdx = scanIdx; + while (scanIdx >= 0 && lines.get(scanIdx).trim().startsWith("//")) { + scanIdx--; + } + int startIdx = scanIdx + 1; + if (startIdx > endIdx) return null; + + var sb = new StringBuilder(); + for (int i = startIdx; i <= endIdx; i++) { + String cleaned = lines.get(i).trim() + .replaceAll("^//[!/]?\\s*", "") + .trim(); + if (!cleaned.isBlank()) { + if (!sb.isEmpty()) sb.append(' '); + sb.append(cleaned); + } + } + return sb.isEmpty() ? null : sb.toString(); + } + + /** + * Extracts a Python triple-quoted docstring from the first string literal + * inside the function/class body (the line immediately after the declaration). + */ + private static String extractPythonDocstring(List lines, int lineStart) { + // Python docstring starts at lineStart (0-based: lineStart is the def/class line) + // The body starts at lineStart (1-based lineStart + 1 = 0-based lineStart) + StringBuilder accumulated = null; + String openQuote = null; + + for (int i = lineStart; i < Math.min(lineStart + 15, lines.size()); i++) { + String line = lines.get(i).trim(); + if (accumulated == null) { + // Look for opening triple-quote + int idxDouble = line.indexOf("\"\"\""); + int idxSingle = line.indexOf("'''"); + int tripleIdx; + String quote; + if (idxDouble >= 0 && (idxSingle < 0 || idxDouble <= idxSingle)) { + tripleIdx = idxDouble; + quote = "\"\"\""; + } else if (idxSingle >= 0) { + tripleIdx = idxSingle; + quote = "'''"; + } else { + // No triple quote on this line — not a docstring line, stop + break; + } + openQuote = quote; + String after = line.substring(tripleIdx + 3); + int closingIdx = after.indexOf(quote); + if (closingIdx >= 0) { + // Single-line docstring + String content = after.substring(0, closingIdx).trim(); + return content.isBlank() ? null : content; + } + accumulated = new StringBuilder(after.trim()); + } else { + int closingIdx = line.indexOf(openQuote); + if (closingIdx >= 0) { + String before = line.substring(0, closingIdx).trim(); + if (!before.isBlank()) { + if (!accumulated.isEmpty()) accumulated.append(' '); + accumulated.append(before); + } + String result = accumulated.toString().trim(); + return result.isBlank() ? null : result; + } + if (!line.isBlank()) { + if (!accumulated.isEmpty()) accumulated.append(' '); + accumulated.append(line); + } + } + } + return null; + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalEnricher.java b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalEnricher.java new file mode 100644 index 00000000..5d00d884 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalEnricher.java @@ -0,0 +1,101 @@ +package io.github.randomcodespace.iq.intelligence.lexical; + +import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.NodeKind; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.nio.file.Path; +import java.util.List; + +/** + * Enriches {@link CodeNode} instances with lexical metadata before Neo4j bulk-load. + * + *

Populates two properties in the node's {@code properties} map: + *

    + *
  • {@value #KEY_LEX_COMMENT} — extracted doc comment / docstring for the symbol.
  • + *
  • {@value #KEY_LEX_CONFIG_KEYS} — config key path for config-typed nodes.
  • + *
+ * + *

These are stored as {@code prop_lex_comment} and {@code prop_lex_config_keys} in Neo4j + * (via the {@code prop_*} round-trip convention) and indexed by {@code lexical_index}. + */ +@Component +public class LexicalEnricher { + + private static final Logger log = LoggerFactory.getLogger(LexicalEnricher.class); + + /** Property key for doc comment text stored in CodeNode.properties. */ + public static final String KEY_LEX_COMMENT = "lex_comment"; + + /** Property key for config key path stored in CodeNode.properties. */ + public static final String KEY_LEX_CONFIG_KEYS = "lex_config_keys"; + + /** + * Enrich all nodes with lexical metadata extracted from source files. + * + * @param nodes All enriched nodes (post-linker, post-classifier). + * @param rootPath Absolute root path of the analysed repository. + */ + public void enrich(List nodes, Path rootPath) { + int commented = 0; + int configKeyed = 0; + for (CodeNode node : nodes) { + if (enrichDocComment(node, rootPath)) commented++; + if (enrichConfigKeys(node)) configKeyed++; + } + log.info("Lexical enrichment: {} doc comments, {} config key entries indexed", + commented, configKeyed); + } + + /** + * Extract and store the doc comment for the given node. + * + * @return true if a comment was found and stored. + */ + private boolean enrichDocComment(CodeNode node, Path rootPath) { + if (node.getFilePath() == null || node.getLineStart() == null) return false; + if (!isDocCommentCandidate(node.getKind())) return false; + + String language = SnippetStore.inferLanguage(node.getFilePath()); + Path file = rootPath.resolve(node.getFilePath()).normalize(); + if (!file.startsWith(rootPath)) return false; // path traversal guard + + String comment = DocCommentExtractor.extract(file, language, node.getLineStart()); + if (comment != null && !comment.isBlank()) { + node.getProperties().put(KEY_LEX_COMMENT, comment); + return true; + } + return false; + } + + /** + * For config-typed nodes, store the label/fqn as the config key path. + * + * @return true if the node was a config node and the key was stored. + */ + private static boolean enrichConfigKeys(CodeNode node) { + if (node.getKind() != NodeKind.CONFIG_KEY + && node.getKind() != NodeKind.CONFIG_FILE + && node.getKind() != NodeKind.CONFIG_DEFINITION) { + return false; + } + String keyPath = node.getFqn() != null ? node.getFqn() : node.getLabel(); + if (keyPath != null && !keyPath.isBlank()) { + node.getProperties().put(KEY_LEX_CONFIG_KEYS, keyPath); + return true; + } + return false; + } + + /** True for node kinds that typically carry doc comments. */ + private static boolean isDocCommentCandidate(NodeKind kind) { + return switch (kind) { + case CLASS, ABSTRACT_CLASS, INTERFACE, ENUM, ANNOTATION_TYPE, + METHOD, ENDPOINT, ENTITY, SERVICE, REPOSITORY, + COMPONENT, GUARD, MIDDLEWARE -> true; + default -> false; + }; + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalQueryService.java b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalQueryService.java new file mode 100644 index 00000000..567280f0 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalQueryService.java @@ -0,0 +1,107 @@ +package io.github.randomcodespace.iq.intelligence.lexical; + +import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.graph.GraphStore; +import io.github.randomcodespace.iq.intelligence.Provenance; +import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.NodeKind; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +/** + * Lexical query service for identifier, doc comment, and config key retrieval. + * + *

Only active in the {@code serving} profile — lexical queries require a Neo4j + * graph produced by the {@code enrich} command. + */ +@Service +@Profile("serving") +public class LexicalQueryService { + + private static final int DEFAULT_LIMIT = 50; + private static final int MAX_LIMIT = 200; + + private final GraphStore graphStore; + private final SnippetStore snippetStore; + private final CodeIqConfig config; + + public LexicalQueryService(GraphStore graphStore, SnippetStore snippetStore, + CodeIqConfig config) { + this.graphStore = graphStore; + this.snippetStore = snippetStore; + this.config = config; + } + + /** + * Find nodes whose name (label or fully-qualified name) matches an identifier. + * Results are ranked by fulltext relevance. + * + * @param identifier Identifier name to look up (e.g. "UserService", "handleLogin"). + * @param limit Maximum number of results. + * @return Ranked list of lexical results carrying provenance. + */ + public List findByIdentifier(String identifier, int limit) { + List nodes = graphStore.search(identifier, Math.min(limit, MAX_LIMIT)); + return nodes.stream() + .map(n -> LexicalResult.of(n, 0f, "identifier")) + .toList(); + } + + /** Convenience overload using the default limit. */ + public List findByIdentifier(String identifier) { + return findByIdentifier(identifier, DEFAULT_LIMIT); + } + + /** + * Find nodes whose doc comment / docstring text matches the given query. + * Searches the {@code lexical_index} over {@code prop_lex_comment}. + * + * @param docQuery Natural-language or keyword query against doc comment text. + * @param limit Maximum number of results. + * @return Ranked list of lexical results with optional bounded snippets. + */ + public List findByDocComment(String docQuery, int limit) { + Path rootPath = Path.of(config.getRootPath()); + List nodes = graphStore.searchLexical(docQuery, Math.min(limit, MAX_LIMIT)); + return nodes.stream() + .map(n -> { + Optional snippet = snippetStore.extract(n, rootPath); + Provenance prov = Provenance.fromProperties(n.getProperties()); + return new LexicalResult(n, 0f, LexicalEnricher.KEY_LEX_COMMENT, + snippet.orElse(null), prov); + }) + .toList(); + } + + /** Convenience overload using the default limit. */ + public List findByDocComment(String docQuery) { + return findByDocComment(docQuery, DEFAULT_LIMIT); + } + + /** + * Find config nodes whose key path matches the given query. + * Results are filtered to config-typed NodeKinds only. + * + * @param keyQuery Partial or full config key path (e.g. "spring.datasource"). + * @param limit Maximum number of results. + * @return Ranked list of lexical results. + */ + public List findByConfigKey(String keyQuery, int limit) { + List nodes = graphStore.searchLexical(keyQuery, Math.min(limit, MAX_LIMIT)); + return nodes.stream() + .filter(n -> n.getKind() == NodeKind.CONFIG_KEY + || n.getKind() == NodeKind.CONFIG_FILE + || n.getKind() == NodeKind.CONFIG_DEFINITION) + .map(n -> LexicalResult.of(n, 0f, LexicalEnricher.KEY_LEX_CONFIG_KEYS)) + .toList(); + } + + /** Convenience overload using the default limit. */ + public List findByConfigKey(String keyQuery) { + return findByConfigKey(keyQuery, DEFAULT_LIMIT); + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalResult.java b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalResult.java new file mode 100644 index 00000000..e3c6549d --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalResult.java @@ -0,0 +1,27 @@ +package io.github.randomcodespace.iq.intelligence.lexical; + +import io.github.randomcodespace.iq.intelligence.Provenance; +import io.github.randomcodespace.iq.model.CodeNode; + +/** + * A single result from a lexical query, carrying relevance metadata and optional snippet. + * + * @param node The matched CodeNode. + * @param score Lucene relevance score (higher = more relevant); 0.0 for non-scored queries. + * @param matchedField The indexed field that matched: "identifier", "lex_comment", or "lex_config_keys". + * @param snippet Optional bounded code snippet for this node; null if not extracted. + * @param provenance Provenance extracted from the node's properties; may be null. + */ +public record LexicalResult( + CodeNode node, + float score, + String matchedField, + CodeSnippet snippet, + Provenance provenance +) { + /** Convenience factory — no snippet, provenance read from node properties. */ + public static LexicalResult of(CodeNode node, float score, String matchedField) { + return new LexicalResult(node, score, matchedField, null, + Provenance.fromProperties(node.getProperties())); + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/SnippetStore.java b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/SnippetStore.java new file mode 100644 index 00000000..7d147122 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/SnippetStore.java @@ -0,0 +1,104 @@ +package io.github.randomcodespace.iq.intelligence.lexical; + +import io.github.randomcodespace.iq.intelligence.Provenance; +import io.github.randomcodespace.iq.model.CodeNode; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +/** + * Extracts bounded code snippets from source files for a given {@link CodeNode}. + * + *

Snippets are bounded to at most {@value #MAX_LINES} lines to keep evidence + * packs compact. + */ +@Component +public class SnippetStore { + + /** Maximum lines in any extracted snippet. */ + public static final int MAX_LINES = 50; + + private static final int DEFAULT_CONTEXT_LINES = 3; + + /** + * Extract a code snippet for the given node using default context (±{@value #DEFAULT_CONTEXT_LINES} lines). + * + * @param node Source node; must have {@code filePath} and {@code lineStart}. + * @param rootPath Absolute root path of the repository being analysed. + * @return Snippet, or empty if the node has no location or the file cannot be read. + */ + public Optional extract(CodeNode node, Path rootPath) { + return extract(node, rootPath, DEFAULT_CONTEXT_LINES); + } + + /** + * Extract a code snippet for the given node with custom context lines. + * + * @param node Source node. + * @param rootPath Absolute repository root. + * @param contextLines Lines of context to add above and below the symbol range. + * @return Snippet, or empty if node has no location or the file cannot be read. + */ + public Optional extract(CodeNode node, Path rootPath, int contextLines) { + if (node.getFilePath() == null || node.getLineStart() == null) return Optional.empty(); + try { + Path file = rootPath.resolve(node.getFilePath()).normalize(); + if (!file.startsWith(rootPath)) return Optional.empty(); // path traversal guard + if (!Files.isRegularFile(file)) return Optional.empty(); + + String content = Files.readString(file, StandardCharsets.UTF_8); + String[] lines = content.split("\n", -1); + int totalLines = lines.length; + + int symStart = node.getLineStart(); + int symEnd = node.getLineEnd() != null ? node.getLineEnd() : symStart; + + // Compute extraction window with context, bounded by file length + int extractStart = Math.max(1, symStart - contextLines); + int extractEnd = Math.min(totalLines, symEnd + contextLines); + + // Enforce MAX_LINES cap — centre on the symbol definition + if (extractEnd - extractStart + 1 > MAX_LINES) { + int centre = (symStart + symEnd) / 2; + extractStart = Math.max(1, centre - MAX_LINES / 2); + extractEnd = Math.min(totalLines, extractStart + MAX_LINES - 1); + } + + // Build source text (lines are 1-based, array is 0-based) + var sb = new StringBuilder(); + for (int i = extractStart - 1; i < extractEnd; i++) { + sb.append(lines[i]).append('\n'); + } + + String language = inferLanguage(node.getFilePath()); + Provenance provenance = Provenance.fromProperties(node.getProperties()); + return Optional.of(new CodeSnippet(sb.toString(), node.getFilePath(), + extractStart, extractEnd, language, provenance)); + } catch (Exception e) { + return Optional.empty(); + } + } + + static String inferLanguage(String filePath) { + if (filePath == null) return "unknown"; + int dot = filePath.lastIndexOf('.'); + if (dot < 0) return "unknown"; + return switch (filePath.substring(dot + 1).toLowerCase()) { + case "java" -> "java"; + case "ts", "tsx" -> "typescript"; + case "js", "jsx" -> "javascript"; + case "py" -> "python"; + case "go" -> "go"; + case "rs" -> "rust"; + case "cs" -> "csharp"; + case "cpp", "cc", "cxx", + "h", "hpp" -> "cpp"; + case "kt" -> "kotlin"; + case "scala", "sc" -> "scala"; + default -> "unknown"; + }; + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/cli/EnrichCommandTest.java b/src/test/java/io/github/randomcodespace/iq/cli/EnrichCommandTest.java index 426bda90..bbfd4447 100644 --- a/src/test/java/io/github/randomcodespace/iq/cli/EnrichCommandTest.java +++ b/src/test/java/io/github/randomcodespace/iq/cli/EnrichCommandTest.java @@ -4,6 +4,7 @@ import io.github.randomcodespace.iq.analyzer.linker.Linker; import io.github.randomcodespace.iq.cache.AnalysisCache; import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.intelligence.lexical.LexicalEnricher; import io.github.randomcodespace.iq.model.CodeEdge; import io.github.randomcodespace.iq.model.CodeNode; import io.github.randomcodespace.iq.model.EdgeKind; @@ -50,7 +51,7 @@ void enrichFailsWhenNoIndexExists(@TempDir Path tempDir) { var layerClassifier = new LayerClassifier(); List linkers = List.of(); - var cmd = new EnrichCommand(config, layerClassifier, linkers); + var cmd = new EnrichCommand(config, layerClassifier, linkers, new LexicalEnricher()); var cmdLine = new picocli.CommandLine(cmd); int exitCode = cmdLine.execute(tempDir.toString()); @@ -86,7 +87,7 @@ void enrichWithIndexedData(@TempDir Path tempDir) throws Exception { var layerClassifier = new LayerClassifier(); List linkers = List.of(); - var cmd = new EnrichCommand(config, layerClassifier, linkers); + var cmd = new EnrichCommand(config, layerClassifier, linkers, new LexicalEnricher()); var cmdLine = new picocli.CommandLine(cmd); int exitCode = cmdLine.execute(tempDir.toString()); @@ -121,7 +122,7 @@ void enrichClassifiesLayers(@TempDir Path tempDir) throws Exception { var layerClassifier = new LayerClassifier(); List linkers = List.of(); - var cmd = new EnrichCommand(config, layerClassifier, linkers); + var cmd = new EnrichCommand(config, layerClassifier, linkers, new LexicalEnricher()); var cmdLine = new picocli.CommandLine(cmd); int exitCode = cmdLine.execute(tempDir.toString()); diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/DocCommentExtractorTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/DocCommentExtractorTest.java new file mode 100644 index 00000000..de57ed3f --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/DocCommentExtractorTest.java @@ -0,0 +1,125 @@ +package io.github.randomcodespace.iq.intelligence.lexical; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class DocCommentExtractorTest { + + @TempDir + Path tmp; + + // --- Javadoc block comments --- + + @Test + void extractsJavadocBeforeClass() throws Exception { + Path file = tmp.resolve("Foo.java"); + Files.writeString(file, """ + /** + * This is the Foo class. + * It does foo things. + */ + public class Foo {} + """); + String result = DocCommentExtractor.extract(file, "java", 5); + assertThat(result).contains("Foo class").contains("foo things"); + } + + @Test + void extractsJavadocSkippingAnnotations() throws Exception { + Path file = tmp.resolve("Bar.java"); + Files.writeString(file, """ + /** + * Bar service implementation. + */ + @Service + @Transactional + public class Bar {} + """); + String result = DocCommentExtractor.extract(file, "java", 6); + assertThat(result).contains("Bar service implementation"); + } + + @Test + void returnsNullWhenNoDocComment() throws Exception { + Path file = tmp.resolve("Plain.java"); + Files.writeString(file, """ + public class Plain { + void method() {} + } + """); + String result = DocCommentExtractor.extract(file, "java", 1); + assertThat(result).isNull(); + } + + @Test + void returnsNullForNullArgs() { + assertThat(DocCommentExtractor.extract(null, "java", 1)).isNull(); + assertThat(DocCommentExtractor.extract(tmp.resolve("x.java"), null, 1)).isNull(); + assertThat(DocCommentExtractor.extract(tmp.resolve("x.java"), "java", 0)).isNull(); + } + + // --- Python docstrings --- + + @Test + void extractsPythonDoubleQuoteDocstring() throws Exception { + Path file = tmp.resolve("service.py"); + Files.writeString(file, """ + def handle_request(req): + \"\"\"Handle an incoming HTTP request.\"\"\" + pass + """); + String result = DocCommentExtractor.extract(file, "python", 1); + assertThat(result).contains("Handle an incoming HTTP request"); + } + + @Test + void extractsPythonMultilineDocstring() throws Exception { + Path file = tmp.resolve("multi.py"); + Files.writeString(file, """ + class UserService: + \"\"\" + Service for managing users. + Supports CRUD operations. + \"\"\" + pass + """); + String result = DocCommentExtractor.extract(file, "python", 1); + assertThat(result).contains("Service for managing users"); + } + + // --- Go line comments --- + + @Test + void extractsGoLineComments() throws Exception { + Path file = tmp.resolve("handler.go"); + Files.writeString(file, """ + // HandleLogin processes a user login request. + // Returns 401 on failure. + func HandleLogin(w http.ResponseWriter, r *http.Request) { + } + """); + String result = DocCommentExtractor.extract(file, "go", 3); + assertThat(result).contains("HandleLogin").contains("401"); + } + + // --- Determinism --- + + @Test + void extractionIsDeterministic() throws Exception { + Path file = tmp.resolve("Det.java"); + Files.writeString(file, """ + /** + * Deterministic class. + */ + public class Det {} + """); + String r1 = DocCommentExtractor.extract(file, "java", 4); + String r2 = DocCommentExtractor.extract(file, "java", 4); + assertThat(r1).isEqualTo(r2); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalEnricherTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalEnricherTest.java new file mode 100644 index 00000000..3f0d1bba --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalEnricherTest.java @@ -0,0 +1,110 @@ +package io.github.randomcodespace.iq.intelligence.lexical; + +import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.NodeKind; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class LexicalEnricherTest { + + @TempDir + Path root; + + private final LexicalEnricher enricher = new LexicalEnricher(); + + @Test + void enrichesDocCommentForClass() throws Exception { + Path file = root.resolve("MyService.java"); + Files.writeString(file, """ + /** + * Handles user authentication. + */ + public class MyService {} + """); + + CodeNode node = new CodeNode("id1", NodeKind.CLASS, "MyService"); + node.setFilePath("MyService.java"); + node.setLineStart(4); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).containsKey(LexicalEnricher.KEY_LEX_COMMENT); + assertThat(node.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT).toString()) + .contains("user authentication"); + } + + @Test + void enrichesConfigKeyForConfigNode() { + CodeNode node = new CodeNode("id2", NodeKind.CONFIG_KEY, "spring.datasource.url"); + node.setFqn("spring.datasource.url"); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).containsKey(LexicalEnricher.KEY_LEX_CONFIG_KEYS); + assertThat(node.getProperties().get(LexicalEnricher.KEY_LEX_CONFIG_KEYS).toString()) + .isEqualTo("spring.datasource.url"); + } + + @Test + void enrichesConfigFileNode() { + CodeNode node = new CodeNode("id3", NodeKind.CONFIG_FILE, "application.yml"); + node.setLabel("application.yml"); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).containsKey(LexicalEnricher.KEY_LEX_CONFIG_KEYS); + } + + @Test + void skipsNodesWithoutFilePath() { + CodeNode node = new CodeNode("id4", NodeKind.CLASS, "Bare"); + // no filePath, no lineStart + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).doesNotContainKey(LexicalEnricher.KEY_LEX_COMMENT); + } + + @Test + void doesNotEnrichModuleOrTopicNodes() throws Exception { + Path file = root.resolve("module.java"); + Files.writeString(file, "/** doc */\nmodule foo {}"); + + CodeNode node = new CodeNode("id5", NodeKind.MODULE, "foo"); + node.setFilePath("module.java"); + node.setLineStart(2); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).doesNotContainKey(LexicalEnricher.KEY_LEX_COMMENT); + } + + @Test + void enrichmentIsDeterministic() throws Exception { + Path file = root.resolve("Svc.java"); + Files.writeString(file, """ + /** Service docs. */ + public class Svc {} + """); + + CodeNode n1 = new CodeNode("id6", NodeKind.CLASS, "Svc"); + n1.setFilePath("Svc.java"); + n1.setLineStart(2); + + CodeNode n2 = new CodeNode("id7", NodeKind.CLASS, "Svc"); + n2.setFilePath("Svc.java"); + n2.setLineStart(2); + + enricher.enrich(List.of(n1), root); + enricher.enrich(List.of(n2), root); + + assertThat(n1.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT)) + .isEqualTo(n2.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT)); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/SnippetStoreTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/SnippetStoreTest.java new file mode 100644 index 00000000..12f5fdf7 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/SnippetStoreTest.java @@ -0,0 +1,118 @@ +package io.github.randomcodespace.iq.intelligence.lexical; + +import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.NodeKind; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class SnippetStoreTest { + + @TempDir + Path root; + + private final SnippetStore snippetStore = new SnippetStore(); + + @Test + void extractsSnippetWithContext() throws Exception { + Path file = root.resolve("MyClass.java"); + Files.writeString(file, """ + line1 + line2 + line3 + line4 + line5 + line6 + line7 + line8 + line9 + line10 + """); + + CodeNode node = new CodeNode("id1", NodeKind.CLASS, "MyClass"); + node.setFilePath("MyClass.java"); + node.setLineStart(5); + node.setLineEnd(5); + + Optional result = snippetStore.extract(node, root, 2); + assertThat(result).isPresent(); + CodeSnippet snippet = result.get(); + assertThat(snippet.lineStart()).isEqualTo(3); + assertThat(snippet.lineEnd()).isEqualTo(7); + assertThat(snippet.sourceText()).contains("line3").contains("line5").contains("line7"); + assertThat(snippet.filePath()).isEqualTo("MyClass.java"); + assertThat(snippet.language()).isEqualTo("java"); + } + + @Test + void returnsEmptyForMissingFilePath() { + CodeNode node = new CodeNode("id2", NodeKind.CLASS, "NoPath"); + // no filePath set + assertThat(snippetStore.extract(node, root)).isEmpty(); + } + + @Test + void returnsEmptyForMissingLineStart() { + CodeNode node = new CodeNode("id3", NodeKind.CLASS, "NoLine"); + node.setFilePath("SomeFile.java"); + // no lineStart set + assertThat(snippetStore.extract(node, root)).isEmpty(); + } + + @Test + void enforcesMaxLinesLimit() throws Exception { + // Write a 200-line file + var sb = new StringBuilder(); + for (int i = 1; i <= 200; i++) sb.append("line").append(i).append('\n'); + Path file = root.resolve("Big.java"); + Files.writeString(file, sb.toString()); + + CodeNode node = new CodeNode("id4", NodeKind.CLASS, "Big"); + node.setFilePath("Big.java"); + node.setLineStart(100); + node.setLineEnd(100); + + Optional result = snippetStore.extract(node, root, 100); + assertThat(result).isPresent(); + int lineCount = result.get().lineEnd() - result.get().lineStart() + 1; + assertThat(lineCount).isLessThanOrEqualTo(SnippetStore.MAX_LINES); + } + + @Test + void preventsPathTraversal() { + CodeNode node = new CodeNode("id5", NodeKind.CLASS, "Traversal"); + node.setFilePath("../../etc/passwd"); + node.setLineStart(1); + assertThat(snippetStore.extract(node, root)).isEmpty(); + } + + @Test + void inferredLanguageFromExtension() { + assertThat(SnippetStore.inferLanguage("Foo.java")).isEqualTo("java"); + assertThat(SnippetStore.inferLanguage("bar.ts")).isEqualTo("typescript"); + assertThat(SnippetStore.inferLanguage("baz.py")).isEqualTo("python"); + assertThat(SnippetStore.inferLanguage("main.go")).isEqualTo("go"); + assertThat(SnippetStore.inferLanguage("lib.rs")).isEqualTo("rust"); + assertThat(SnippetStore.inferLanguage("noext")).isEqualTo("unknown"); + } + + @Test + void extractionIsDeterministic() throws Exception { + Path file = root.resolve("Det.java"); + Files.writeString(file, "class Det {\n void go() {}\n}\n"); + + CodeNode node = new CodeNode("id6", NodeKind.CLASS, "Det"); + node.setFilePath("Det.java"); + node.setLineStart(1); + node.setLineEnd(3); + + Optional r1 = snippetStore.extract(node, root); + Optional r2 = snippetStore.extract(node, root); + assertThat(r1.map(CodeSnippet::sourceText)).isEqualTo(r2.map(CodeSnippet::sourceText)); + } +} From c8550190bc45104f5fee766bd7f783d1a0ee7cc2 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 3 Apr 2026 19:24:06 +0000 Subject: [PATCH 12/16] =?UTF-8?q?test(intelligence):=20Phase=201-3=20test?= =?UTF-8?q?=20execution=20=E2=80=94=20edge=20cases,=20cross-language,=20Re?= =?UTF-8?q?positoryIdentity=20(RAN-159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RepositoryIdentityTest (8 tests): non-git dir graceful null, commit SHA on git repo, detached HEAD branch normalised to null, record equality/null safety - ProvenanceEdgeCasesTest (6 tests): empty dir, single-file, unsupported-language-only, mixed-language (Java/TS/Python/Go), no-git-history null provenance fields, mixed-language determinism - LexicalCrossLanguageTest (11 tests): TypeScript/JavaScript block comments, Python triple-quoted docstrings (single-line and multiline), Go line comments, cross-language determinism, DocCommentExtractor direct calls All 1616 tests pass (0 failures, 0 errors, 31 skipped). Co-Authored-By: Paperclip --- .../intelligence/ProvenanceEdgeCasesTest.java | 210 ++++++++++++++ .../intelligence/RepositoryIdentityTest.java | 139 +++++++++ .../lexical/LexicalCrossLanguageTest.java | 272 ++++++++++++++++++ 3 files changed, 621 insertions(+) create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceEdgeCasesTest.java create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentityTest.java create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalCrossLanguageTest.java diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceEdgeCasesTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceEdgeCasesTest.java new file mode 100644 index 00000000..3c940431 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceEdgeCasesTest.java @@ -0,0 +1,210 @@ +package io.github.randomcodespace.iq.intelligence; + +import io.github.randomcodespace.iq.analyzer.Analyzer; +import io.github.randomcodespace.iq.analyzer.AnalysisResult; +import io.github.randomcodespace.iq.model.CodeNode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Edge-case tests for provenance pipeline. + * Covers: empty repos, single-file repos, unsupported-language-only repos, + * mixed-language repos, and repos with no git history. + */ +@SpringBootTest +@ActiveProfiles("indexing") +class ProvenanceEdgeCasesTest { + + @Autowired + Analyzer analyzer; + + // ------------------------------------------------------------------ + // Empty repo — no source files, pipeline should not throw + // ------------------------------------------------------------------ + + @Test + void emptyDirectory_producesNoNodes_noException(@TempDir Path dir) { + assertThatCode(() -> { + AnalysisResult result = analyzer.run(dir, msg -> {}); + assertThat(result).isNotNull(); + // Empty dir may produce zero nodes — that is acceptable + }).doesNotThrowAnyException(); + } + + // ------------------------------------------------------------------ + // Single-file repo — exactly one Java file + // ------------------------------------------------------------------ + + @Test + void singleJavaFile_allNodesHaveProvenance(@TempDir Path dir) throws Exception { + Path src = dir.resolve("Greeter.java"); + Files.writeString(src, """ + public class Greeter { + public String greet(String name) { + return "Hello, " + name; + } + } + """); + + AnalysisResult result = analyzer.run(dir, msg -> {}); + assertThat(result.nodes()).isNotEmpty(); + + for (CodeNode node : result.nodes()) { + assertThat(node.getProvenance()) + .as("Node %s must carry provenance", node.getId()) + .isNotNull(); + assertThat(node.getProvenance().extractorVersion()).isNotBlank(); + assertThat(node.getProvenance().confidence()).isNotNull(); + } + } + + // ------------------------------------------------------------------ + // Unsupported-language-only repo — e.g., only .rb files + // Pipeline should complete gracefully; any nodes produced must have provenance. + // ------------------------------------------------------------------ + + @Test + void unsupportedLanguageOnly_completesGracefully(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("script.rb"), "puts 'hello world'"); + Files.writeString(dir.resolve("helper.brainfuck"), "++++++++[>++++["); + + assertThatCode(() -> { + AnalysisResult result = analyzer.run(dir, msg -> {}); + assertThat(result).isNotNull(); + // Any nodes emitted for unsupported languages must still carry provenance + for (CodeNode node : result.nodes()) { + assertThat(node.getProvenance()) + .as("Node %s from unsupported-language file must have provenance", node.getId()) + .isNotNull(); + } + }).doesNotThrowAnyException(); + } + + // ------------------------------------------------------------------ + // Mixed-language repo — Java + TypeScript + Python + Go + // Every node must carry provenance regardless of language. + // ------------------------------------------------------------------ + + @Test + void mixedLanguageRepo_allNodesHaveProvenance(@TempDir Path dir) throws Exception { + // Java + Path javaDir = dir.resolve("src/main/java/com/example"); + Files.createDirectories(javaDir); + Files.writeString(javaDir.resolve("UserService.java"), """ + package com.example; + public class UserService { + public String findUser(String id) { return id; } + } + """); + + // TypeScript + Path tsDir = dir.resolve("frontend/src"); + Files.createDirectories(tsDir); + Files.writeString(tsDir.resolve("api.ts"), """ + export interface User { id: string; name: string; } + export function fetchUser(id: string): Promise { + return fetch(`/api/users/${id}`).then(r => r.json()); + } + """); + + // Python + Path pyDir = dir.resolve("scripts"); + Files.createDirectories(pyDir); + Files.writeString(pyDir.resolve("process.py"), """ + def process_data(items: list) -> list: + return [item for item in items if item] + """); + + // Go + Path goDir = dir.resolve("cmd"); + Files.createDirectories(goDir); + Files.writeString(goDir.resolve("main.go"), """ + package main + import "fmt" + func main() { + fmt.Println("hello") + } + """); + + AnalysisResult result = analyzer.run(dir, msg -> {}); + List nodes = result.nodes(); + assertThat(nodes).isNotEmpty(); + + for (CodeNode node : nodes) { + assertThat(node.getProvenance()) + .as("Node %s (%s) must carry provenance", node.getId(), node.getFilePath()) + .isNotNull(); + assertThat(node.getProvenance().schemaVersion()) + .as("Node %s must have schemaVersion >= 1", node.getId()) + .isGreaterThanOrEqualTo(1); + } + } + + // ------------------------------------------------------------------ + // No git history — plain directory, not initialised as a git repo. + // commitSha and repoUrl in provenance should be null, not throw. + // ------------------------------------------------------------------ + + @Test + void noGitHistory_provenanceHasNullGitFields(@TempDir Path dir) throws Exception { + Path src = dir.resolve("App.java"); + Files.writeString(src, """ + public class App { + public static void main(String[] args) {} + } + """); + + AnalysisResult result = analyzer.run(dir, msg -> {}); + assertThat(result.nodes()).isNotEmpty(); + + for (CodeNode node : result.nodes()) { + Provenance prov = node.getProvenance(); + assertThat(prov).isNotNull(); + // No git repo → commitSha and repoUrl must be null + assertThat(prov.commitSha()) + .as("No git repo — commitSha should be null for node %s", node.getId()) + .isNull(); + assertThat(prov.repositoryUrl()) + .as("No git repo — repositoryUrl should be null for node %s", node.getId()) + .isNull(); + // extractorVersion and schemaVersion must still be populated + assertThat(prov.extractorVersion()).isNotBlank(); + assertThat(prov.schemaVersion()).isGreaterThanOrEqualTo(1); + } + } + + // ------------------------------------------------------------------ + // Mixed-language determinism — same mixed-language repo analysed twice + // ------------------------------------------------------------------ + + @Test + void mixedLanguageRepo_deterministicProvenance(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("Service.java"), "public class Service {}"); + Files.writeString(dir.resolve("index.ts"), "export const x = 1;"); + Files.writeString(dir.resolve("util.py"), "def helper(): pass"); + + AnalysisResult r1 = analyzer.run(dir, msg -> {}); + AnalysisResult r2 = analyzer.run(dir, msg -> {}); + + assertThat(r1.nodes()).hasSameSizeAs(r2.nodes()); + for (int i = 0; i < r1.nodes().size(); i++) { + Provenance p1 = r1.nodes().get(i).getProvenance(); + Provenance p2 = r2.nodes().get(i).getProvenance(); + assertThat(p1).isNotNull(); + assertThat(p2).isNotNull(); + assertThat(p1.extractorVersion()).isEqualTo(p2.extractorVersion()); + assertThat(p1.schemaVersion()).isEqualTo(p2.schemaVersion()); + assertThat(p1.confidence()).isEqualTo(p2.confidence()); + } + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentityTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentityTest.java new file mode 100644 index 00000000..1718a4dd --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentityTest.java @@ -0,0 +1,139 @@ +package io.github.randomcodespace.iq.intelligence; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Unit tests for {@link RepositoryIdentity}. + * Validates graceful degradation when git metadata is unavailable. + */ +class RepositoryIdentityTest { + + // ------------------------------------------------------------------ + // Non-git directory — all git fields null, no exception + // ------------------------------------------------------------------ + + @Test + void resolve_nonGitDirectory_allNullNoException(@TempDir Path dir) { + assertThatCode(() -> RepositoryIdentity.resolve(dir)).doesNotThrowAnyException(); + + RepositoryIdentity id = RepositoryIdentity.resolve(dir); + assertThat(id.repoUrl()).isNull(); + assertThat(id.commitSha()).isNull(); + assertThat(id.branch()).isNull(); + } + + @Test + void resolve_nonGitDirectory_buildTimestampAlwaysPresent(@TempDir Path dir) { + RepositoryIdentity id = RepositoryIdentity.resolve(dir); + assertThat(id.buildTimestamp()).isNotNull(); + } + + // ------------------------------------------------------------------ + // Git repo with no remote — commitSha present, repoUrl null + // ------------------------------------------------------------------ + + @Test + void resolve_gitRepoNoRemote_commitShaPopulated(@TempDir Path dir) throws Exception { + initGitRepo(dir); + + RepositoryIdentity id = RepositoryIdentity.resolve(dir); + // commit SHA may be null if no commits yet, but should not throw + assertThat(id.repoUrl()).isNull(); + assertThat(id.buildTimestamp()).isNotNull(); + } + + @Test + void resolve_gitRepoWithCommit_commitShaPresent(@TempDir Path dir) throws Exception { + initGitRepo(dir); + makeInitialCommit(dir); + + RepositoryIdentity id = RepositoryIdentity.resolve(dir); + assertThat(id.commitSha()).isNotNull().isNotBlank(); + assertThat(id.repoUrl()).isNull(); + } + + // ------------------------------------------------------------------ + // Detached HEAD — branch normalised to null + // ------------------------------------------------------------------ + + @Test + void resolve_detachedHead_branchIsNull(@TempDir Path dir) throws Exception { + initGitRepo(dir); + makeInitialCommit(dir); + + // Detach HEAD by checking out the commit SHA directly + String sha = runGit(dir, "rev-parse", "HEAD"); + runGit(dir, "checkout", "--detach", sha); + + RepositoryIdentity id = RepositoryIdentity.resolve(dir); + assertThat(id.branch()).isNull(); + assertThat(id.commitSha()).isNotNull(); + } + + // ------------------------------------------------------------------ + // Record semantics + // ------------------------------------------------------------------ + + @Test + void record_constructorAndAccessors() { + var ts = java.time.Instant.now(); + var id = new RepositoryIdentity("https://github.com/x/y", "abc123", "main", ts); + + assertThat(id.repoUrl()).isEqualTo("https://github.com/x/y"); + assertThat(id.commitSha()).isEqualTo("abc123"); + assertThat(id.branch()).isEqualTo("main"); + assertThat(id.buildTimestamp()).isEqualTo(ts); + } + + @Test + void record_equalityOnSameValues() { + var ts = java.time.Instant.parse("2026-01-01T00:00:00Z"); + var id1 = new RepositoryIdentity("url", "sha", "main", ts); + var id2 = new RepositoryIdentity("url", "sha", "main", ts); + assertThat(id1).isEqualTo(id2); + } + + @Test + void record_nullFieldsAllowed() { + assertThatCode(() -> new RepositoryIdentity(null, null, null, java.time.Instant.now())) + .doesNotThrowAnyException(); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private static void initGitRepo(Path dir) throws Exception { + run(dir, "git", "init"); + run(dir, "git", "config", "user.email", "test@test.com"); + run(dir, "git", "config", "user.name", "Test"); + } + + private static void makeInitialCommit(Path dir) throws Exception { + Path readme = dir.resolve("README.md"); + java.nio.file.Files.writeString(readme, "# Test"); + run(dir, "git", "add", "."); + run(dir, "git", "commit", "-m", "init"); + } + + private static String runGit(Path dir, String... args) throws Exception { + var cmd = new java.util.ArrayList(); + cmd.add("git"); + cmd.addAll(java.util.Arrays.asList(args)); + var proc = new ProcessBuilder(cmd).directory(dir.toFile()).start(); + String out = new String(proc.getInputStream().readAllBytes()).trim(); + proc.waitFor(); + return out; + } + + private static void run(Path dir, String... cmd) throws Exception { + new ProcessBuilder(cmd).directory(dir.toFile()) + .redirectErrorStream(true).start().waitFor(); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalCrossLanguageTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalCrossLanguageTest.java new file mode 100644 index 00000000..c9588252 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalCrossLanguageTest.java @@ -0,0 +1,272 @@ +package io.github.randomcodespace.iq.intelligence.lexical; + +import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.NodeKind; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Cross-language lexical enrichment tests. + * Validates DocCommentExtractor and LexicalEnricher for TypeScript, Python, Go, and JavaScript. + * Complements {@link LexicalEnricherTest} which only covers Java. + */ +class LexicalCrossLanguageTest { + + @TempDir + Path root; + + private final LexicalEnricher enricher = new LexicalEnricher(); + + // ------------------------------------------------------------------ + // TypeScript — block comment (/** ... */) + // ------------------------------------------------------------------ + + @Test + void typescript_blockComment_extracted() throws Exception { + Path file = root.resolve("UserService.ts"); + Files.writeString(file, """ + /** + * Fetches a user by their unique identifier. + */ + export class UserService { + fetchUser(id: string) { return id; } + } + """); + + CodeNode node = new CodeNode("ts:id1", NodeKind.CLASS, "UserService"); + node.setFilePath("UserService.ts"); + node.setLineStart(4); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).containsKey(LexicalEnricher.KEY_LEX_COMMENT); + assertThat(node.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT).toString()) + .contains("unique identifier"); + } + + @Test + void typescript_noComment_noLexKey() throws Exception { + Path file = root.resolve("Bare.ts"); + Files.writeString(file, """ + export function bare() { return 42; } + """); + + CodeNode node = new CodeNode("ts:id2", NodeKind.METHOD, "bare"); + node.setFilePath("Bare.ts"); + node.setLineStart(1); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).doesNotContainKey(LexicalEnricher.KEY_LEX_COMMENT); + } + + // ------------------------------------------------------------------ + // JavaScript — block comment (/** ... */) + // ------------------------------------------------------------------ + + @Test + void javascript_jsDocComment_extracted() throws Exception { + Path file = root.resolve("helper.js"); + Files.writeString(file, """ + /** + * Computes the sum of two numbers. + * @param {number} a + * @param {number} b + */ + function add(a, b) { + return a + b; + } + """); + + CodeNode node = new CodeNode("js:id1", NodeKind.METHOD, "add"); + node.setFilePath("helper.js"); + node.setLineStart(6); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).containsKey(LexicalEnricher.KEY_LEX_COMMENT); + assertThat(node.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT).toString()) + .contains("sum"); + } + + // ------------------------------------------------------------------ + // Python — triple-quoted docstring + // ------------------------------------------------------------------ + + @Test + void python_tripleDoubleQuotedDocstring_extracted() throws Exception { + Path file = root.resolve("processor.py"); + Files.writeString(file, """ + class DataProcessor: + \"\"\"Processes raw data into structured records.\"\"\" + def run(self): pass + """); + + CodeNode node = new CodeNode("py:id1", NodeKind.CLASS, "DataProcessor"); + node.setFilePath("processor.py"); + node.setLineStart(1); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).containsKey(LexicalEnricher.KEY_LEX_COMMENT); + assertThat(node.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT).toString()) + .contains("structured records"); + } + + @Test + void python_multilineDocstring_extractedTrimmed() throws Exception { + Path file = root.resolve("service.py"); + Files.writeString(file, """ + def fetch_user(user_id: str): + \"\"\" + Fetch a user from the database. + Returns None if not found. + \"\"\" + return None + """); + + CodeNode node = new CodeNode("py:id2", NodeKind.METHOD, "fetch_user"); + node.setFilePath("service.py"); + node.setLineStart(1); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).containsKey(LexicalEnricher.KEY_LEX_COMMENT); + String comment = node.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT).toString(); + assertThat(comment).contains("database"); + assertThat(comment).contains("None if not found"); + } + + // ------------------------------------------------------------------ + // Go — line comments (//) + // ------------------------------------------------------------------ + + @Test + void go_lineComments_extracted() throws Exception { + Path file = root.resolve("handler.go"); + Files.writeString(file, """ + package handler + + // ServeHTTP handles incoming HTTP requests. + // It validates the token and returns 401 if invalid. + func ServeHTTP(w http.ResponseWriter, r *http.Request) { + } + """); + + CodeNode node = new CodeNode("go:id1", NodeKind.METHOD, "ServeHTTP"); + node.setFilePath("handler.go"); + node.setLineStart(5); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).containsKey(LexicalEnricher.KEY_LEX_COMMENT); + assertThat(node.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT).toString()) + .contains("HTTP requests"); + } + + @Test + void go_noComment_noLexKey() throws Exception { + Path file = root.resolve("bare.go"); + Files.writeString(file, """ + package bare + func noop() {} + """); + + CodeNode node = new CodeNode("go:id2", NodeKind.METHOD, "noop"); + node.setFilePath("bare.go"); + node.setLineStart(2); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).doesNotContainKey(LexicalEnricher.KEY_LEX_COMMENT); + } + + // ------------------------------------------------------------------ + // Determinism — same cross-language nodes enriched twice yield same result + // ------------------------------------------------------------------ + + @Test + void crossLanguage_deterministicEnrichment() throws Exception { + Path tsFile = root.resolve("api.ts"); + Files.writeString(tsFile, """ + /** Returns the current user session. */ + export function getSession() {} + """); + + Path pyFile = root.resolve("models.py"); + Files.writeString(pyFile, """ + class Order: + \"\"\"Represents a customer order.\"\"\" + pass + """); + + CodeNode ts1 = new CodeNode("ts:d1", NodeKind.METHOD, "getSession"); + ts1.setFilePath("api.ts"); ts1.setLineStart(2); + + CodeNode ts2 = new CodeNode("ts:d2", NodeKind.METHOD, "getSession"); + ts2.setFilePath("api.ts"); ts2.setLineStart(2); + + CodeNode py1 = new CodeNode("py:d1", NodeKind.CLASS, "Order"); + py1.setFilePath("models.py"); py1.setLineStart(1); + + CodeNode py2 = new CodeNode("py:d2", NodeKind.CLASS, "Order"); + py2.setFilePath("models.py"); py2.setLineStart(1); + + enricher.enrich(List.of(ts1, py1), root); + enricher.enrich(List.of(ts2, py2), root); + + assertThat(ts1.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT)) + .isEqualTo(ts2.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT)); + assertThat(py1.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT)) + .isEqualTo(py2.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT)); + } + + // ------------------------------------------------------------------ + // DocCommentExtractor direct — Go and Python extraction + // ------------------------------------------------------------------ + + @Test + void docCommentExtractor_python_singleLineTripledQuote() throws Exception { + Path file = root.resolve("util.py"); + Files.writeString(file, """ + def compute(): + \"\"\"Computes the result.\"\"\" + return 42 + """); + + String comment = DocCommentExtractor.extract(file, "python", 1); + assertThat(comment).contains("Computes the result"); + } + + @Test + void docCommentExtractor_go_lineComment() throws Exception { + Path file = root.resolve("repo.go"); + Files.writeString(file, """ + // FindAll retrieves all records from the store. + func FindAll() {} + """); + + String comment = DocCommentExtractor.extract(file, "go", 2); + assertThat(comment).contains("retrieves all records"); + } + + @Test + void docCommentExtractor_typescript_blockComment() throws Exception { + Path file = root.resolve("client.ts"); + Files.writeString(file, """ + /** + * HTTP client for the backend API. + */ + export class ApiClient {} + """); + + String comment = DocCommentExtractor.extract(file, "typescript", 4); + assertThat(comment).contains("backend API"); + } +} From 03eca642aeca613899bfdba44a5c3610c7e1c323 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 3 Apr 2026 19:39:19 +0000 Subject: [PATCH 13/16] =?UTF-8?q?fix(intelligence):=20explicit=20UTF-8=20i?= =?UTF-8?q?n=20RepositoryIdentity.runGit()=20=E2=80=94=20DM=5FDEFAULT=5FEN?= =?UTF-8?q?CODING=20(RAN-160)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace new String(is.readAllBytes()) with new String(is.readAllBytes(), StandardCharsets.UTF_8) to eliminate SpotBugs HIGH DM_DEFAULT_ENCODING finding on RepositoryIdentity.java:44. This was the sole blocker gating all Phase 1-3 PRs from merge. Co-Authored-By: Paperclip --- .../randomcodespace/iq/intelligence/RepositoryIdentity.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentity.java b/src/main/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentity.java index f5a32440..5c6880f8 100644 --- a/src/main/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentity.java +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentity.java @@ -1,5 +1,6 @@ package io.github.randomcodespace.iq.intelligence; +import java.nio.charset.StandardCharsets; import java.time.Instant; /** @@ -41,7 +42,7 @@ private static String runGit(java.nio.file.Path repoPath, String... args) { .redirectErrorStream(true); var proc = pb.start(); try (var is = proc.getInputStream()) { - String out = new String(is.readAllBytes()).trim(); + String out = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim(); int exit = proc.waitFor(); return (exit == 0 && !out.isBlank()) ? out : null; } finally { From af203537322b445f3071b81a4be81633f96431c2 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 3 Apr 2026 20:25:08 +0000 Subject: [PATCH 14/16] =?UTF-8?q?feat(intelligence):=20Phase=204=20evidenc?= =?UTF-8?q?e=20packs=20+=20runtime=20API=20=E2=80=94=20RAN-161?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EvidencePack record + EvidencePackRequest + EvidencePackAssembler (@Service) - ArtifactMetadata record with SHA-256 integrity hash computation - IntelligenceController: GET /api/intelligence/{evidence,manifest,capabilities} - McpTools: get_evidence_pack + get_artifact_metadata @McpTool methods - CodeIqConfig: maxSnippetLines property (default 50, configurable) - Tests: EvidencePackAssemblerTest, IntelligenceControllerTest, McpToolsEvidenceTest (16 tests) - Fix existing McpToolsTest + TopologyEndpointTest for updated constructor Co-Authored-By: Paperclip --- .../iq/api/IntelligenceController.java | 127 ++++++++++ .../iq/config/CodeIqConfig.java | 11 + .../intelligence/evidence/EvidencePack.java | 41 +++ .../evidence/EvidencePackAssembler.java | 238 ++++++++++++++++++ .../evidence/EvidencePackRequest.java | 21 ++ .../provenance/ArtifactMetadata.java | 53 ++++ .../randomcodespace/iq/mcp/McpTools.java | 42 +++- .../iq/api/IntelligenceControllerTest.java | 92 +++++++ .../iq/api/TopologyEndpointTest.java | 3 +- .../evidence/EvidencePackAssemblerTest.java | 123 +++++++++ .../iq/mcp/McpToolsEvidenceTest.java | 110 ++++++++ .../randomcodespace/iq/mcp/McpToolsTest.java | 2 +- 12 files changed, 860 insertions(+), 3 deletions(-) create mode 100644 src/main/java/io/github/randomcodespace/iq/api/IntelligenceController.java create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePack.java create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssembler.java create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackRequest.java create mode 100644 src/main/java/io/github/randomcodespace/iq/intelligence/provenance/ArtifactMetadata.java create mode 100644 src/test/java/io/github/randomcodespace/iq/api/IntelligenceControllerTest.java create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssemblerTest.java create mode 100644 src/test/java/io/github/randomcodespace/iq/mcp/McpToolsEvidenceTest.java diff --git a/src/main/java/io/github/randomcodespace/iq/api/IntelligenceController.java b/src/main/java/io/github/randomcodespace/iq/api/IntelligenceController.java new file mode 100644 index 00000000..1a607115 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/api/IntelligenceController.java @@ -0,0 +1,127 @@ +package io.github.randomcodespace.iq.api; + +import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePack; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePackAssembler; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePackRequest; +import io.github.randomcodespace.iq.intelligence.provenance.ArtifactMetadata; +import io.github.randomcodespace.iq.intelligence.query.CapabilityMatrix; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Map; + +/** + * Intelligence REST API — evidence packs, artifact metadata, and capability matrix. + * Read-only. Active only in the {@code serving} profile. + */ +@RestController +@RequestMapping("/api/intelligence") +@Profile("serving") +public class IntelligenceController { + + private final EvidencePackAssembler assembler; + private final ArtifactMetadata artifactMetadata; + private final CodeIqConfig config; + + public IntelligenceController( + @org.springframework.beans.factory.annotation.Autowired(required = false) + EvidencePackAssembler assembler, + @org.springframework.beans.factory.annotation.Autowired(required = false) + ArtifactMetadata artifactMetadata, + CodeIqConfig config) { + this.assembler = assembler; + this.artifactMetadata = artifactMetadata; + this.config = config; + } + + /** + * Assemble an evidence pack for a symbol or file path. + * + *

At least one of {@code symbol} or {@code file} must be provided. + * The {@code file} parameter is path-traversal guarded. + * + * @param symbol symbol name to look up + * @param file file path relative to repo root (path traversal guarded) + * @param maxSnippetLines max lines per snippet (optional, capped at config limit) + * @param includeRefs whether to include cross-reference nodes (default false) + * @return assembled evidence pack + */ + @GetMapping("/evidence") + public EvidencePack getEvidence( + @RequestParam(required = false) String symbol, + @RequestParam(required = false) String file, + @RequestParam(required = false) Integer maxSnippetLines, + @RequestParam(defaultValue = "false") boolean includeRefs) { + + requireAssembler(); + + // 400 when both are absent + if ((symbol == null || symbol.isBlank()) && (file == null || file.isBlank())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "At least one of 'symbol' or 'file' must be provided."); + } + + // Path traversal guard on file param + if (file != null && !file.isBlank()) { + java.nio.file.Path root = java.nio.file.Path.of(config.getRootPath()) + .toAbsolutePath().normalize(); + java.nio.file.Path resolved = root.resolve(file).normalize(); + if (!resolved.startsWith(root)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Invalid file path: path traversal detected."); + } + } + + EvidencePackRequest request = new EvidencePackRequest(symbol, file, maxSnippetLines, includeRefs); + return assembler.assemble(request, artifactMetadata); + } + + /** + * Returns the artifact metadata loaded at serve startup. + */ + @GetMapping("/manifest") + public ArtifactMetadata getManifest() { + if (artifactMetadata == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Artifact metadata unavailable. Run 'enrich' first."); + } + return artifactMetadata; + } + + /** + * Returns the full capability matrix as a JSON object. + * Optionally filter by language. + * + * @param language optional language filter (e.g. "java", "python") + */ + @GetMapping("/capabilities") + public Map getCapabilities( + @RequestParam(required = false) String language) { + Map result = new java.util.LinkedHashMap<>(); + if (language != null && !language.isBlank()) { + result.put("language", language.strip().toLowerCase()); + result.put("capabilities", CapabilityMatrix.forLanguage(language).entrySet().stream() + .collect(java.util.stream.Collectors.toMap( + e -> e.getKey().name().toLowerCase(), + e -> e.getValue().name(), + (a, b) -> a, + java.util.TreeMap::new))); + } else { + result.put("matrix", CapabilityMatrix.asSerializableMap()); + } + return result; + } + + private void requireAssembler() { + if (assembler == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Intelligence service unavailable. Run 'enrich' first."); + } + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java b/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java index 9e14a7eb..bc79fda6 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java +++ b/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java @@ -34,6 +34,9 @@ public class CodeIqConfig { /** Whether to serve the React web UI. Set to false via --no-ui flag. */ private boolean uiEnabled = true; + /** Maximum lines per snippet returned in evidence packs (default 50). */ + private int maxSnippetLines = 50; + public static class Graph { private String path = ".osscodeiq/graph.db"; @@ -117,4 +120,12 @@ public boolean isUiEnabled() { public void setUiEnabled(boolean uiEnabled) { this.uiEnabled = uiEnabled; } + + public int getMaxSnippetLines() { + return maxSnippetLines; + } + + public void setMaxSnippetLines(int maxSnippetLines) { + this.maxSnippetLines = Math.max(1, maxSnippetLines); + } } diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePack.java b/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePack.java new file mode 100644 index 00000000..1b389f89 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePack.java @@ -0,0 +1,41 @@ +package io.github.randomcodespace.iq.intelligence.evidence; + +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import io.github.randomcodespace.iq.intelligence.lexical.CodeSnippet; +import io.github.randomcodespace.iq.intelligence.provenance.ArtifactMetadata; +import io.github.randomcodespace.iq.model.CodeNode; + +import java.util.List; + +/** + * Runtime-facing evidence pack: everything the caller needs to understand a symbol or file. + * + * @param matchedSymbols Nodes whose name matched the requested symbol or file. + * @param relatedFiles File paths of related nodes discovered via cross-references. + * @param references Related nodes discovered via cross-reference traversal (non-empty when + * {@link EvidencePackRequest#includeReferences()} is true). + * @param snippets Bounded source snippets extracted for matched symbols. + * @param provenance Provenance maps (one per matched node; may be null entries). + * @param degradationNotes Human-readable notes explaining capability gaps; empty list when fully capable. + * @param artifactMetadata Runtime projection of the artifact manifest. + * @param capabilityLevel Overall capability level for the primary language of the matched symbols. + */ +public record EvidencePack( + List matchedSymbols, + List relatedFiles, + List references, + List snippets, + List> provenance, + List degradationNotes, + ArtifactMetadata artifactMetadata, + CapabilityLevel capabilityLevel +) { + /** Returns an empty evidence pack — used when no symbols are found. */ + public static EvidencePack empty(ArtifactMetadata artifactMetadata, String degradationNote) { + List notes = degradationNote != null ? List.of(degradationNote) : List.of(); + return new EvidencePack( + List.of(), List.of(), List.of(), List.of(), List.of(), + notes, artifactMetadata, CapabilityLevel.UNSUPPORTED + ); + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssembler.java b/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssembler.java new file mode 100644 index 00000000..2c1086fa --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssembler.java @@ -0,0 +1,238 @@ +package io.github.randomcodespace.iq.intelligence.evidence; + +import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import io.github.randomcodespace.iq.intelligence.lexical.CodeSnippet; +import io.github.randomcodespace.iq.intelligence.lexical.LexicalQueryService; +import io.github.randomcodespace.iq.intelligence.lexical.LexicalResult; +import io.github.randomcodespace.iq.intelligence.lexical.SnippetStore; +import io.github.randomcodespace.iq.intelligence.provenance.ArtifactMetadata; +import io.github.randomcodespace.iq.intelligence.query.QueryPlan; +import io.github.randomcodespace.iq.intelligence.query.QueryPlanner; +import io.github.randomcodespace.iq.intelligence.query.QueryRoute; +import io.github.randomcodespace.iq.intelligence.query.QueryType; +import io.github.randomcodespace.iq.model.CodeNode; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Assembles {@link EvidencePack} instances from query plans and lexical results. + * + *

Stateless and thread-safe — all state is method-local. Active only in the + * {@code serving} profile. + */ +@Service +@Profile("serving") +public class EvidencePackAssembler { + + private final LexicalQueryService lexicalQueryService; + private final SnippetStore snippetStore; + private final QueryPlanner queryPlanner; + private final CodeIqConfig config; + + public EvidencePackAssembler(LexicalQueryService lexicalQueryService, + SnippetStore snippetStore, + QueryPlanner queryPlanner, + CodeIqConfig config) { + this.lexicalQueryService = lexicalQueryService; + this.snippetStore = snippetStore; + this.queryPlanner = queryPlanner; + this.config = config; + } + + /** + * Assemble an evidence pack for the given request. + * + *

When no symbols are found, returns an empty pack with a degradation note + * rather than throwing an exception. + * + * @param request the evidence pack request + * @param artifactMetadata provenance metadata loaded at serve startup + * @return a fully-assembled (possibly empty) evidence pack; never {@code null} + */ + public EvidencePack assemble(EvidencePackRequest request, ArtifactMetadata artifactMetadata) { + int maxLines = resolveMaxLines(request.maxSnippetLines()); + Path rootPath = Path.of(config.getRootPath()).toAbsolutePath().normalize(); + + // Resolve query subject — prefer symbol, fall back to filePath + String subject = request.symbol() != null && !request.symbol().isBlank() + ? request.symbol().strip() + : (request.filePath() != null ? request.filePath().strip() : null); + + if (subject == null) { + return EvidencePack.empty(artifactMetadata, "No symbol or file path provided."); + } + + // Determine language from filePath when available (for query planner) + String language = request.filePath() != null + ? inferLanguage(request.filePath()) + : "unknown"; + + // Plan the query + QueryPlan plan = queryPlanner.plan(QueryType.FIND_SYMBOL, language); + + // Execute lexical lookup + List lexResults = lexicalQueryService.findByIdentifier(subject); + + if (lexResults.isEmpty()) { + String degradationNote = buildEmptyNote(subject, plan); + return EvidencePack.empty(artifactMetadata, degradationNote); + } + + // Collect matched symbols (deterministic order) + List matchedSymbols = lexResults.stream() + .map(LexicalResult::node) + .toList(); + + // Extract snippets bounded by maxLines + List snippets = new ArrayList<>(); + for (LexicalResult lr : lexResults) { + CodeNode node = lr.node(); + Optional snippet = snippetStore.extract(node, rootPath); + snippet.map(s -> boundSnippet(s, maxLines)).ifPresent(snippets::add); + } + + // Collect related files (sorted for determinism) + Set relatedFilesSet = new LinkedHashSet<>(); + for (CodeNode node : matchedSymbols) { + if (node.getFilePath() != null) relatedFilesSet.add(node.getFilePath()); + } + List relatedFiles = new ArrayList<>(relatedFilesSet); + relatedFiles.sort(String::compareTo); + + // References: fetch related symbols from the same files when requested + List references = List.of(); + if (request.includeReferences()) { + references = fetchReferences(matchedSymbols, subject); + } + + // Build provenance list (parallel to matchedSymbols) + List> provenanceList = matchedSymbols.stream() + .map(n -> { + Map m = new LinkedHashMap<>(); + if (n.getFilePath() != null) m.put("filePath", n.getFilePath()); + if (n.getLineStart() != null) m.put("lineStart", n.getLineStart()); + if (n.getLineEnd() != null) m.put("lineEnd", n.getLineEnd()); + m.put("kind", n.getKind() != null ? n.getKind().getValue() : "unknown"); + if (n.getProperties() != null) { + n.getProperties().forEach((k, v) -> { + if (k.startsWith("prov_") && v != null) m.put(k, v); + }); + } + return (Map) m; + }) + .toList(); + + // Degradation notes + List degradationNotes = buildDegradationNotes(plan); + + // Overall capability level = worst across matched symbols + CapabilityLevel capLevel = deriveCapabilityLevel(plan); + + return new EvidencePack( + matchedSymbols, + relatedFiles, + references, + snippets, + provenanceList, + degradationNotes, + artifactMetadata, + capLevel + ); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private int resolveMaxLines(Integer requested) { + int configured = config.getMaxSnippetLines(); + if (requested == null) return configured; + return Math.min(Math.max(1, requested), configured); + } + + /** + * Truncate a snippet to {@code maxLines} lines, centred on the symbol start. + * Returns the original snippet when already within bounds. + */ + private CodeSnippet boundSnippet(CodeSnippet snippet, int maxLines) { + String[] lines = snippet.sourceText().split("\n", -1); + if (lines.length <= maxLines) return snippet; + + // Take first maxLines lines + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < maxLines; i++) { + sb.append(lines[i]).append('\n'); + } + return new CodeSnippet( + sb.toString(), + snippet.filePath(), + snippet.lineStart(), + snippet.lineStart() + maxLines - 1, + snippet.language(), + snippet.provenance() + ); + } + + private List fetchReferences(List matchedSymbols, String subject) { + // Lexical search for related symbols — exclude exact matches already in matchedSymbols + Set matchedIds = new LinkedHashSet<>(); + for (CodeNode n : matchedSymbols) { + if (n.getId() != null) matchedIds.add(n.getId()); + } + List refResults = lexicalQueryService.findByDocComment(subject); + return refResults.stream() + .map(LexicalResult::node) + .filter(n -> !matchedIds.contains(n.getId())) + .toList(); + } + + private String buildEmptyNote(String subject, QueryPlan plan) { + if (plan.route() == QueryRoute.DEGRADED) { + return plan.degradationNote() != null ? plan.degradationNote() + : "Symbol '" + subject + "' not found. Language is not fully supported."; + } + return "Symbol '" + subject + "' was not found in the indexed graph."; + } + + private List buildDegradationNotes(QueryPlan plan) { + if (plan.degradationNote() != null) { + return List.of(plan.degradationNote()); + } + return List.of(); + } + + private CapabilityLevel deriveCapabilityLevel(QueryPlan plan) { + return switch (plan.route()) { + case GRAPH_FIRST -> CapabilityLevel.EXACT; + case MERGED -> CapabilityLevel.PARTIAL; + case LEXICAL_FIRST -> CapabilityLevel.LEXICAL_ONLY; + case DEGRADED -> CapabilityLevel.UNSUPPORTED; + }; + } + + private static String inferLanguage(String filePath) { + if (filePath == null) return "unknown"; + int dot = filePath.lastIndexOf('.'); + if (dot < 0) return "unknown"; + return switch (filePath.substring(dot + 1).toLowerCase()) { + case "java" -> "java"; + case "ts", "tsx" -> "typescript"; + case "js", "jsx" -> "javascript"; + case "py" -> "python"; + case "go" -> "go"; + case "rs" -> "rust"; + case "cs" -> "csharp"; + default -> "unknown"; + }; + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackRequest.java b/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackRequest.java new file mode 100644 index 00000000..8c60438a --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackRequest.java @@ -0,0 +1,21 @@ +package io.github.randomcodespace.iq.intelligence.evidence; + +/** + * Request parameters for assembling an evidence pack. + * + * @param symbol Symbol name to look up (e.g. "UserService", "handleLogin"). May be null if filePath provided. + * @param filePath Source file path relative to repo root. May be null if symbol provided. + * @param maxSnippetLines Maximum lines per snippet; null → use config default. + * @param includeReferences Whether to include cross-reference nodes in the pack. + */ +public record EvidencePackRequest( + String symbol, + String filePath, + Integer maxSnippetLines, + boolean includeReferences +) { + /** Returns true when neither symbol nor filePath are provided. */ + public boolean isEmpty() { + return (symbol == null || symbol.isBlank()) && (filePath == null || filePath.isBlank()); + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/provenance/ArtifactMetadata.java b/src/main/java/io/github/randomcodespace/iq/intelligence/provenance/ArtifactMetadata.java new file mode 100644 index 00000000..c166bff5 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/provenance/ArtifactMetadata.java @@ -0,0 +1,53 @@ +package io.github.randomcodespace.iq.intelligence.provenance; + +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Map; + +/** + * Runtime-facing projection of {@code ArtifactManifest}. + * Loaded once at {@code serve} startup; immutable and thread-safe. + * + *

The {@code integrityHash} is a SHA-256 digest of + * {@code nodeCount + "|" + edgeCount + "|" + commitSha}. + * + * @param repositoryIdentity Remote URL or local path of the analysed repository. + * @param commitSha Full SHA-1 of HEAD at analysis time (may be null). + * @param buildTimestamp When the {@code enrich} run completed. + * @param schemaVersion Graph schema version. + * @param artifactFormatVersion Bundle format version string. + * @param extractorVersions Map of extractor component name → version string. + * @param languageCapabilities Per-language capability matrix snapshot. + * @param integrityHash SHA-256 integrity hash (hex). + */ +public record ArtifactMetadata( + String repositoryIdentity, + String commitSha, + Instant buildTimestamp, + String schemaVersion, + String artifactFormatVersion, + Map extractorVersions, + Map> languageCapabilities, + String integrityHash +) { + /** + * Compute the integrity hash from graph counts and commit SHA. + * Returns a hex-encoded SHA-256 digest. + */ + public static String computeIntegrityHash(long nodeCount, long edgeCount, String commitSha) { + String canonical = nodeCount + "|" + edgeCount + "|" + (commitSha != null ? commitSha : ""); + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(canonical.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is guaranteed by the JDK + throw new IllegalStateException("SHA-256 unavailable", e); + } + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/mcp/McpTools.java b/src/main/java/io/github/randomcodespace/iq/mcp/McpTools.java index 1dbc285a..ad40fefe 100644 --- a/src/main/java/io/github/randomcodespace/iq/mcp/McpTools.java +++ b/src/main/java/io/github/randomcodespace/iq/mcp/McpTools.java @@ -3,6 +3,9 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePackAssembler; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePackRequest; +import io.github.randomcodespace.iq.intelligence.provenance.ArtifactMetadata; import io.github.randomcodespace.iq.flow.FlowEngine; import io.github.randomcodespace.iq.intelligence.query.CapabilityMatrix; // Note: No Analyzer import — MCP server is read-only. Analysis is done via CLI only. @@ -45,12 +48,16 @@ public class McpTools { private final StatsService statsService; private final TopologyService topologyService; private final GraphStore graphStore; + private final EvidencePackAssembler evidencePackAssembler; + private final ArtifactMetadata artifactMetadata; public McpTools(QueryService queryService, CodeIqConfig config, ObjectMapper objectMapper, Optional flowEngine, GraphDatabaseService graphDb, StatsService statsService, TopologyService topologyService, - GraphStore graphStore) { + GraphStore graphStore, + Optional evidencePackAssembler, + Optional artifactMetadata) { this.queryService = queryService; this.config = config; this.objectMapper = objectMapper; @@ -59,6 +66,8 @@ public McpTools(QueryService queryService, this.statsService = statsService; this.topologyService = topologyService; this.graphStore = graphStore; + this.evidencePackAssembler = evidencePackAssembler.orElse(null); + this.artifactMetadata = artifactMetadata.orElse(null); } /** @@ -499,6 +508,37 @@ public String findNode( } } + @McpTool(name = "get_evidence_pack", description = "Assemble an evidence pack for a symbol or file. Returns matched nodes, snippets, provenance, and degradation notes. Provide symbol name and/or file path.") + public String getEvidencePack( + @McpToolParam(description = "Symbol name to look up (e.g. UserService, handleLogin)", required = false) String symbol, + @McpToolParam(description = "File path relative to repo root", required = false) String filePath, + @McpToolParam(description = "Max lines per snippet (default: config value)", required = false) Integer maxSnippetLines, + @McpToolParam(description = "Include cross-reference nodes (default: false)", required = false) Boolean includeReferences) { + if (evidencePackAssembler == null) { + return toJson(Map.of("error", "Evidence pack service unavailable. Run 'enrich' first.")); + } + try { + EvidencePackRequest request = new EvidencePackRequest( + symbol, filePath, maxSnippetLines, + Boolean.TRUE.equals(includeReferences)); + return toJson(evidencePackAssembler.assemble(request, artifactMetadata)); + } catch (Exception e) { + return toJson(Map.of("error", e.getMessage())); + } + } + + @McpTool(name = "get_artifact_metadata", description = "Return artifact metadata: repo identity, commit SHA, build timestamp, extractor versions, capability matrix snapshot, and integrity hash.") + public String getArtifactMetadata() { + if (artifactMetadata == null) { + return toJson(Map.of("error", "Artifact metadata unavailable. Run 'enrich' first.")); + } + try { + return toJson(artifactMetadata); + } catch (Exception e) { + return toJson(Map.of("error", e.getMessage())); + } + } + /** * Resolve FlowEngine: use injected instance if available, otherwise create from H2 cache. */ diff --git a/src/test/java/io/github/randomcodespace/iq/api/IntelligenceControllerTest.java b/src/test/java/io/github/randomcodespace/iq/api/IntelligenceControllerTest.java new file mode 100644 index 00000000..6278fb67 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/api/IntelligenceControllerTest.java @@ -0,0 +1,92 @@ +package io.github.randomcodespace.iq.api; + +import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePack; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePackAssembler; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePackRequest; +import io.github.randomcodespace.iq.intelligence.provenance.ArtifactMetadata; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class IntelligenceControllerTest { + + private MockMvc mockMvc; + private EvidencePackAssembler assembler; + private ArtifactMetadata metadata; + + @BeforeEach + void setUp() { + assembler = Mockito.mock(EvidencePackAssembler.class); + metadata = new ArtifactMetadata( + "https://github.com/example/repo", "abc123", Instant.now(), + "1", "2", Map.of("code-iq", "1.0"), + Map.of(), "deadbeef"); + + CodeIqConfig config = new CodeIqConfig(); + config.setRootPath(System.getProperty("java.io.tmpdir")); + + IntelligenceController controller = new IntelligenceController(assembler, metadata, config); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + void evidenceEndpointReturns200ForValidSymbol() throws Exception { + EvidencePack pack = new EvidencePack( + List.of(), List.of(), List.of(), List.of(), List.of(), + List.of(), metadata, CapabilityLevel.EXACT); + when(assembler.assemble(any(EvidencePackRequest.class), any())).thenReturn(pack); + + mockMvc.perform(get("/api/intelligence/evidence").param("symbol", "UserService")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.capabilityLevel").value("EXACT")); + } + + @Test + void evidenceEndpointReturns400WhenNeitherSymbolNorFileProvided() throws Exception { + mockMvc.perform(get("/api/intelligence/evidence")) + .andExpect(status().isBadRequest()); + } + + @Test + void evidenceEndpointReturns400ForPathTraversal() throws Exception { + mockMvc.perform(get("/api/intelligence/evidence") + .param("file", "../../etc/passwd")) + .andExpect(status().isBadRequest()); + } + + @Test + void manifestEndpointReturns200() throws Exception { + mockMvc.perform(get("/api/intelligence/manifest")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.commitSha").value("abc123")); + } + + @Test + void capabilitiesEndpointReturnsMatrix() throws Exception { + mockMvc.perform(get("/api/intelligence/capabilities")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.matrix").isMap()); + } + + @Test + void capabilitiesEndpointFiltersbyLanguage() throws Exception { + mockMvc.perform(get("/api/intelligence/capabilities").param("language", "java")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.language").value("java")) + .andExpect(jsonPath("$.capabilities").isMap()); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/api/TopologyEndpointTest.java b/src/test/java/io/github/randomcodespace/iq/api/TopologyEndpointTest.java index 14f51a97..d3af11d1 100644 --- a/src/test/java/io/github/randomcodespace/iq/api/TopologyEndpointTest.java +++ b/src/test/java/io/github/randomcodespace/iq/api/TopologyEndpointTest.java @@ -76,7 +76,8 @@ void setUp() { mcpTools = new McpTools(queryService, config, objectMapper, Optional.empty(), graphDb, new StatsService(), - new TopologyService(), graphStore); + new TopologyService(), graphStore, + Optional.empty(), Optional.empty()); } private Map buildTopologyResponse() { diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssemblerTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssemblerTest.java new file mode 100644 index 00000000..eb6b8337 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssemblerTest.java @@ -0,0 +1,123 @@ +package io.github.randomcodespace.iq.intelligence.evidence; + +import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import io.github.randomcodespace.iq.intelligence.lexical.LexicalQueryService; +import io.github.randomcodespace.iq.intelligence.lexical.LexicalResult; +import io.github.randomcodespace.iq.intelligence.lexical.SnippetStore; +import io.github.randomcodespace.iq.intelligence.provenance.ArtifactMetadata; +import io.github.randomcodespace.iq.intelligence.query.QueryPlanner; +import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.NodeKind; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class EvidencePackAssemblerTest { + + @Mock + private LexicalQueryService lexicalQueryService; + @Mock + private SnippetStore snippetStore; + + private QueryPlanner queryPlanner; + private CodeIqConfig config; + private EvidencePackAssembler assembler; + private ArtifactMetadata metadata; + + @BeforeEach + void setUp() { + queryPlanner = new QueryPlanner(); + config = new CodeIqConfig(); + config.setRootPath(System.getProperty("java.io.tmpdir")); + config.setMaxSnippetLines(50); + assembler = new EvidencePackAssembler(lexicalQueryService, snippetStore, queryPlanner, config); + metadata = new ArtifactMetadata( + "https://github.com/example/repo", "abc123", Instant.now(), + "1", "2", Map.of("code-iq", "1.0"), + Map.of(), "deadbeef"); + } + + @Test + void assemblesPackForKnownSymbol() { + CodeNode node = new CodeNode("java:Foo.java:class:Foo", NodeKind.CLASS, "Foo"); + node.setFilePath("src/Foo.java"); + node.setLineStart(1); + node.setLineEnd(10); + + when(lexicalQueryService.findByIdentifier("Foo")).thenReturn( + List.of(LexicalResult.of(node, 1.0f, "identifier"))); + when(snippetStore.extract(any(CodeNode.class), any())).thenReturn(java.util.Optional.empty()); + + // Provide filePath so language resolves to "java" → GRAPH_FIRST route → no degradation note + EvidencePackRequest req = new EvidencePackRequest("Foo", "src/Foo.java", null, false); + EvidencePack pack = assembler.assemble(req, metadata); + + assertThat(pack.matchedSymbols()).hasSize(1); + assertThat(pack.matchedSymbols().get(0).getLabel()).isEqualTo("Foo"); + assertThat(pack.relatedFiles()).contains("src/Foo.java"); + assertThat(pack.degradationNotes()).isEmpty(); + assertThat(pack.capabilityLevel()).isNotNull(); + } + + @Test + void returnsEmptyPackWithDegradationNoteForMissingSymbol() { + when(lexicalQueryService.findByIdentifier(anyString())).thenReturn(List.of()); + + EvidencePackRequest req = new EvidencePackRequest("NonExistent", null, null, false); + EvidencePack pack = assembler.assemble(req, metadata); + + assertThat(pack.matchedSymbols()).isEmpty(); + assertThat(pack.snippets()).isEmpty(); + assertThat(pack.degradationNotes()).isNotEmpty(); + assertThat(pack.capabilityLevel()).isEqualTo(CapabilityLevel.UNSUPPORTED); + } + + @Test + void returnsEmptyPackWhenNeitherSymbolNorFileProvided() { + EvidencePackRequest req = new EvidencePackRequest(null, null, null, false); + EvidencePack pack = assembler.assemble(req, metadata); + + assertThat(pack.matchedSymbols()).isEmpty(); + assertThat(pack.degradationNotes()).isNotEmpty(); + } + + @Test + void isDeterministic() { + CodeNode node = new CodeNode("java:Bar.java:class:Bar", NodeKind.CLASS, "Bar"); + node.setFilePath("src/Bar.java"); + node.setLineStart(1); + node.setLineEnd(5); + + when(lexicalQueryService.findByIdentifier("Bar")).thenReturn( + List.of(LexicalResult.of(node, 1.0f, "identifier"))); + when(snippetStore.extract(any(CodeNode.class), any())).thenReturn(java.util.Optional.empty()); + + EvidencePackRequest req = new EvidencePackRequest("Bar", null, null, false); + EvidencePack pack1 = assembler.assemble(req, metadata); + EvidencePack pack2 = assembler.assemble(req, metadata); + + assertThat(pack1.matchedSymbols().stream().map(CodeNode::getId).toList()) + .isEqualTo(pack2.matchedSymbols().stream().map(CodeNode::getId).toList()); + assertThat(pack1.relatedFiles()).isEqualTo(pack2.relatedFiles()); + assertThat(pack1.capabilityLevel()).isEqualTo(pack2.capabilityLevel()); + } + + @Test + void respectsMaxSnippetLinesFromConfig() { + config.setMaxSnippetLines(10); + assertThat(config.getMaxSnippetLines()).isEqualTo(10); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsEvidenceTest.java b/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsEvidenceTest.java new file mode 100644 index 00000000..342196b6 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsEvidenceTest.java @@ -0,0 +1,110 @@ +package io.github.randomcodespace.iq.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePack; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePackAssembler; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePackRequest; +import io.github.randomcodespace.iq.intelligence.provenance.ArtifactMetadata; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class McpToolsEvidenceTest { + + @Mock private io.github.randomcodespace.iq.query.QueryService queryService; + @Mock private io.github.randomcodespace.iq.graph.GraphStore graphStore; + @Mock private org.neo4j.graphdb.GraphDatabaseService graphDb; + @Mock private io.github.randomcodespace.iq.query.StatsService statsService; + @Mock private io.github.randomcodespace.iq.query.TopologyService topologyService; + @Mock private EvidencePackAssembler assembler; + + private McpTools mcpTools; + private ArtifactMetadata metadata; + private final ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()) + .disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + @BeforeEach + void setUp() { + CodeIqConfig config = new CodeIqConfig(); + metadata = new ArtifactMetadata( + "https://github.com/example/repo", "sha456", Instant.now(), + "1", "2", Map.of("code-iq", "1.0"), + Map.of(), "cafebabe"); + + mcpTools = new McpTools( + queryService, config, objectMapper, + Optional.empty(), graphDb, + statsService, topologyService, graphStore, + Optional.of(assembler), Optional.of(metadata)); + } + + @Test + void getEvidencePackReturnsPackJson() throws Exception { + EvidencePack pack = new EvidencePack( + List.of(), List.of(), List.of(), List.of(), List.of(), + List.of(), metadata, CapabilityLevel.PARTIAL); + when(assembler.assemble(any(EvidencePackRequest.class), any())).thenReturn(pack); + + String result = mcpTools.getEvidencePack("UserService", null, null, null); + assertThat(result).contains("capabilityLevel"); + assertThat(result).contains("PARTIAL"); + } + + @Test + void getEvidencePackReturnsErrorWhenAssemblerAbsent() { + McpTools noAssembler = new McpTools( + queryService, new CodeIqConfig(), objectMapper, + Optional.empty(), graphDb, + statsService, topologyService, graphStore, + Optional.empty(), Optional.empty()); + + String result = noAssembler.getEvidencePack("Foo", null, null, null); + assertThat(result).contains("error"); + } + + @Test + void getArtifactMetadataReturnsMetadataJson() { + String result = mcpTools.getArtifactMetadata(); + assertThat(result).contains("sha456"); + assertThat(result).contains("cafebabe"); + } + + @Test + void getArtifactMetadataReturnsErrorWhenAbsent() { + McpTools noMeta = new McpTools( + queryService, new CodeIqConfig(), objectMapper, + Optional.empty(), graphDb, + statsService, topologyService, graphStore, + Optional.empty(), Optional.empty()); + + String result = noMeta.getArtifactMetadata(); + assertThat(result).contains("error"); + } + + @Test + void getEvidencePackIsDeterministic() throws Exception { + EvidencePack pack = new EvidencePack( + List.of(), List.of(), List.of(), List.of(), List.of(), + List.of(), metadata, CapabilityLevel.EXACT); + when(assembler.assemble(any(EvidencePackRequest.class), any())).thenReturn(pack); + + String r1 = mcpTools.getEvidencePack("Svc", null, null, false); + String r2 = mcpTools.getEvidencePack("Svc", null, null, false); + assertThat(r1).isEqualTo(r2); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsTest.java b/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsTest.java index 59b8b697..94430244 100644 --- a/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsTest.java +++ b/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsTest.java @@ -61,7 +61,7 @@ void setUp() { config = new CodeIqConfig(); config.setRootPath("."); objectMapper = new ObjectMapper(); - mcpTools = new McpTools(queryService, config, objectMapper, java.util.Optional.ofNullable(flowEngine), graphDb, statsService, new io.github.randomcodespace.iq.query.TopologyService(), graphStore); + mcpTools = new McpTools(queryService, config, objectMapper, java.util.Optional.ofNullable(flowEngine), graphDb, statsService, new io.github.randomcodespace.iq.query.TopologyService(), graphStore, java.util.Optional.empty(), java.util.Optional.empty()); } private Map parseJson(String json) throws IOException { From 5ceb0e1b9585ffe929b701e58d958db34e2182d6 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 3 Apr 2026 21:00:00 +0000 Subject: [PATCH 15/16] =?UTF-8?q?fix(intelligence):=20Phase=204=20critical?= =?UTF-8?q?=20bugs=20=E2=80=94=20C++=20dead=20code,=20fetchReferences=20gr?= =?UTF-8?q?aph=20traversal,=20ArtifactMetadata=20@Bean=20(RAN-171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CapabilityMatrix: add missing `case "cpp", "c++"` so C++ queries return CPP_CAPS instead of silently falling through to CSHARP_CAPS - EvidencePackAssembler: replace doc-comment text search in fetchReferences() with CALLS/DEPENDS_ON graph edge traversal via GraphStore - CodeIqConfig: add @Bean @Profile("serving") provider for ArtifactMetadata so /manifest endpoint and get_artifact_metadata MCP tool are no longer permanently null - EvidencePackAssemblerTest: pass GraphStore mock to updated constructor Co-Authored-By: Paperclip --- .../iq/config/CodeIqConfig.java | 63 +++++++++++++++++++ .../evidence/EvidencePackAssembler.java | 29 ++++++--- .../intelligence/query/CapabilityMatrix.java | 1 + .../evidence/EvidencePackAssemblerTest.java | 5 +- 4 files changed, 90 insertions(+), 8 deletions(-) diff --git a/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java b/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java index bc79fda6..d7f2d50b 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java +++ b/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java @@ -1,7 +1,22 @@ package io.github.randomcodespace.iq.config; +import io.github.randomcodespace.iq.intelligence.RepositoryIdentity; +import io.github.randomcodespace.iq.graph.GraphStore; +import io.github.randomcodespace.iq.intelligence.ArtifactManifest; +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import io.github.randomcodespace.iq.intelligence.Provenance; +import io.github.randomcodespace.iq.intelligence.provenance.ArtifactMetadata; +import io.github.randomcodespace.iq.intelligence.query.CapabilityMatrix; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; /** * Configuration properties for OSSCodeIQ, bound to the "codeiq" prefix. @@ -128,4 +143,52 @@ public int getMaxSnippetLines() { public void setMaxSnippetLines(int maxSnippetLines) { this.maxSnippetLines = Math.max(1, maxSnippetLines); } + + /** + * Provides {@link ArtifactMetadata} as a Spring bean in the {@code serving} profile. + * + *

Metadata is derived at serve-startup from the analysed repository and the + * populated Neo4j graph. {@code graphStore} is optional so serve can start even + * when the graph has not been populated yet (the manifest endpoint returns 503 in + * that case, handled by {@code IntelligenceController}). + */ + @Bean + @Profile("serving") + public ArtifactMetadata artifactMetadata( + @Autowired(required = false) GraphStore graphStore) { + Path root = Path.of(rootPath).toAbsolutePath().normalize(); + RepositoryIdentity identity = RepositoryIdentity.resolve(root); + + long nodeCount = 0L; + long edgeCount = 0L; + if (graphStore != null) { + try { + nodeCount = graphStore.count(); + edgeCount = graphStore.countEdges(); + } catch (Exception ignored) { + // Graph not yet populated — counts stay zero + } + } + + String integrityHash = ArtifactMetadata.computeIntegrityHash( + nodeCount, edgeCount, identity.commitSha()); + + Map> langCaps = new LinkedHashMap<>(); + CapabilityMatrix.asSerializableMap().forEach((lang, dims) -> { + Map dimMap = new LinkedHashMap<>(); + dims.forEach((dim, level) -> dimMap.put(dim, CapabilityLevel.valueOf(level))); + langCaps.put(lang, Collections.unmodifiableMap(dimMap)); + }); + + return new ArtifactMetadata( + identity.repoUrl() != null ? identity.repoUrl() : root.toString(), + identity.commitSha(), + identity.buildTimestamp(), + String.valueOf(Provenance.CURRENT_SCHEMA_VERSION), + String.valueOf(ArtifactManifest.BUNDLE_FORMAT_VERSION), + Map.of("code-iq", "phase-4"), + Collections.unmodifiableMap(langCaps), + integrityHash + ); + } } diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssembler.java b/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssembler.java index 2c1086fa..876837be 100644 --- a/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssembler.java +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssembler.java @@ -1,6 +1,7 @@ package io.github.randomcodespace.iq.intelligence.evidence; import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.graph.GraphStore; import io.github.randomcodespace.iq.intelligence.CapabilityLevel; import io.github.randomcodespace.iq.intelligence.lexical.CodeSnippet; import io.github.randomcodespace.iq.intelligence.lexical.LexicalQueryService; @@ -38,15 +39,18 @@ public class EvidencePackAssembler { private final SnippetStore snippetStore; private final QueryPlanner queryPlanner; private final CodeIqConfig config; + private final GraphStore graphStore; public EvidencePackAssembler(LexicalQueryService lexicalQueryService, SnippetStore snippetStore, QueryPlanner queryPlanner, - CodeIqConfig config) { + CodeIqConfig config, + GraphStore graphStore) { this.lexicalQueryService = lexicalQueryService; this.snippetStore = snippetStore; this.queryPlanner = queryPlanner; this.config = config; + this.graphStore = graphStore; } /** @@ -184,16 +188,27 @@ private CodeSnippet boundSnippet(CodeSnippet snippet, int maxLines) { } private List fetchReferences(List matchedSymbols, String subject) { - // Lexical search for related symbols — exclude exact matches already in matchedSymbols + // Traverse CALLS and DEPENDS_ON edges to find nodes that actually reference these symbols Set matchedIds = new LinkedHashSet<>(); for (CodeNode n : matchedSymbols) { if (n.getId() != null) matchedIds.add(n.getId()); } - List refResults = lexicalQueryService.findByDocComment(subject); - return refResults.stream() - .map(LexicalResult::node) - .filter(n -> !matchedIds.contains(n.getId())) - .toList(); + Set seen = new LinkedHashSet<>(matchedIds); + List references = new ArrayList<>(); + for (CodeNode n : matchedSymbols) { + if (n.getId() == null) continue; + for (CodeNode caller : graphStore.findCallers(n.getId())) { + if (caller.getId() != null && seen.add(caller.getId())) { + references.add(caller); + } + } + for (CodeNode dependent : graphStore.findDependents(n.getId())) { + if (dependent.getId() != null && seen.add(dependent.getId())) { + references.add(dependent); + } + } + } + return references; } private String buildEmptyNote(String subject, QueryPlan plan) { diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrix.java b/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrix.java index da413602..fbc88c8b 100644 --- a/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrix.java +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrix.java @@ -263,6 +263,7 @@ private static Map tableFor(String lang) { case "python" -> PYTHON_CAPS; case "go" -> GO_CAPS; case "csharp", "c#" -> CSHARP_CAPS; + case "cpp", "c++" -> CPP_CAPS; case "rust" -> RUST_CAPS; default -> { if (LEXICAL_ONLY_LANGUAGES.contains(lang)) yield LEXICAL_ONLY_CAPS; diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssemblerTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssemblerTest.java index eb6b8337..fa314acb 100644 --- a/src/test/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssemblerTest.java +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssemblerTest.java @@ -1,6 +1,7 @@ package io.github.randomcodespace.iq.intelligence.evidence; import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.graph.GraphStore; import io.github.randomcodespace.iq.intelligence.CapabilityLevel; import io.github.randomcodespace.iq.intelligence.lexical.LexicalQueryService; import io.github.randomcodespace.iq.intelligence.lexical.LexicalResult; @@ -31,6 +32,8 @@ class EvidencePackAssemblerTest { private LexicalQueryService lexicalQueryService; @Mock private SnippetStore snippetStore; + @Mock + private GraphStore graphStore; private QueryPlanner queryPlanner; private CodeIqConfig config; @@ -43,7 +46,7 @@ void setUp() { config = new CodeIqConfig(); config.setRootPath(System.getProperty("java.io.tmpdir")); config.setMaxSnippetLines(50); - assembler = new EvidencePackAssembler(lexicalQueryService, snippetStore, queryPlanner, config); + assembler = new EvidencePackAssembler(lexicalQueryService, snippetStore, queryPlanner, config, graphStore); metadata = new ArtifactMetadata( "https://github.com/example/repo", "abc123", Instant.now(), "1", "2", Map.of("code-iq", "1.0"), From 0d2be0f97ff8592d3d6595cdf70e3976ab58e802 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 3 Apr 2026 21:14:36 +0000 Subject: [PATCH 16/16] fix(intelligence): add cpp to CapabilityMatrix.asSerializableMap() (RAN-172) CPP_CAPS was fully defined and tableFor("cpp") routed correctly, but asSerializableMap() omitted "cpp" from the language array, silently dropping C++ capabilities from the /manifest endpoint and get_artifact_metadata MCP tool response. Co-Authored-By: Paperclip --- .../randomcodespace/iq/intelligence/query/CapabilityMatrix.java | 2 +- .../iq/intelligence/query/CapabilityMatrixTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrix.java b/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrix.java index fbc88c8b..6779478d 100644 --- a/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrix.java +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrix.java @@ -234,7 +234,7 @@ public static CapabilityLevel get(String language, CapabilityDimension dimension public static Map> asSerializableMap() { Map> result = new TreeMap<>(); for (String lang : new String[]{ - "java", "typescript", "javascript", "python", "go", "csharp", "rust", + "java", "typescript", "javascript", "python", "go", "csharp", "rust", "cpp", "kotlin", "scala", "ruby", "php", "shell"}) { Map caps = tableFor(lang); Map row = new LinkedHashMap<>(); diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrixTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrixTest.java index 74051e76..2c6468b5 100644 --- a/src/test/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrixTest.java +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrixTest.java @@ -153,7 +153,7 @@ void serializableMap_isDeterministic() { @Test void serializableMap_containsExpectedLanguages() { Map> matrix = CapabilityMatrix.asSerializableMap(); - assertThat(matrix).containsKeys("java", "typescript", "javascript", "python", "go", "csharp", "rust"); + assertThat(matrix).containsKeys("java", "typescript", "javascript", "python", "go", "csharp", "rust", "cpp"); } @Test