@gotza02/sequential-thinking 10000.0.0 → 10000.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +6 -0
  2. package/dist/chaos.test.d.ts +1 -0
  3. package/dist/chaos.test.js +73 -0
  4. package/dist/codestore.test.d.ts +1 -0
  5. package/dist/codestore.test.js +65 -0
  6. package/dist/coding.test.d.ts +1 -0
  7. package/dist/coding.test.js +140 -0
  8. package/dist/e2e.test.d.ts +1 -0
  9. package/dist/e2e.test.js +122 -0
  10. package/dist/filesystem.test.d.ts +1 -0
  11. package/dist/filesystem.test.js +190 -0
  12. package/dist/graph.test.d.ts +1 -0
  13. package/dist/graph.test.js +150 -0
  14. package/dist/graph_extra.test.d.ts +1 -0
  15. package/dist/graph_extra.test.js +93 -0
  16. package/dist/graph_repro.test.d.ts +1 -0
  17. package/dist/graph_repro.test.js +50 -0
  18. package/dist/human.test.d.ts +1 -0
  19. package/dist/human.test.js +221 -0
  20. package/dist/integration.test.d.ts +1 -0
  21. package/dist/integration.test.js +58 -0
  22. package/dist/knowledge.test.d.ts +1 -0
  23. package/dist/knowledge.test.js +105 -0
  24. package/dist/lib.js +1 -0
  25. package/dist/notes.test.d.ts +1 -0
  26. package/dist/notes.test.js +84 -0
  27. package/dist/registration.test.d.ts +1 -0
  28. package/dist/registration.test.js +39 -0
  29. package/dist/server.test.d.ts +1 -0
  30. package/dist/server.test.js +127 -0
  31. package/dist/stress.test.d.ts +1 -0
  32. package/dist/stress.test.js +72 -0
  33. package/dist/tools/codestore_tools.test.d.ts +1 -0
  34. package/dist/tools/codestore_tools.test.js +115 -0
  35. package/dist/tools/filesystem.js +1 -0
  36. package/dist/tools/sports/core/constants.d.ts +2 -1
  37. package/dist/tools/sports/core/constants.js +18 -6
  38. package/dist/tools/sports/providers/scraper.d.ts +6 -1
  39. package/dist/tools/sports/providers/scraper.js +63 -8
  40. package/dist/tools/sports/tools/match.js +44 -21
  41. package/dist/tools/sports/tracker.test.d.ts +1 -0
  42. package/dist/tools/sports/tracker.test.js +100 -0
  43. package/dist/utils.test.d.ts +1 -0
  44. package/dist/utils.test.js +40 -0
  45. package/dist/verify_cache.test.d.ts +1 -0
  46. package/dist/verify_cache.test.js +185 -0
  47. package/dist/web_fallback.test.d.ts +1 -0
  48. package/dist/web_fallback.test.js +103 -0
  49. package/dist/web_read.test.d.ts +1 -0
  50. package/dist/web_read.test.js +60 -0
  51. package/package.json +7 -6
@@ -85,19 +85,62 @@ export class ScraperProvider extends ScraperProviderBase {
85
85
  }
86
86
  }
87
87
  /**
88
- * Specialized extractor for Understat (Example)
88
+ * Specialized extractor for Understat
89
+ * Extracts xG, shots, and other advanced metrics from the JSON data embedded in script tags.
89
90
  */
90
91
  extractUnderstatData(html) {
91
92
  const dom = new JSDOM(html);
92
93
  const title = dom.window.document.title;
93
- // Understat stores data in JSON strings within <script> tags
94
- // This is a placeholder for actual regex-based JSON extraction
95
- const scripts = Array.from(dom.window.document.querySelectorAll('script'));
96
- const dataScript = scripts.find(s => s.textContent?.includes('JSON.parse'));
97
- if (dataScript) {
98
- return `## Understat Analysis: ${title}\n\n[Advanced xG Data Extracted from Script Tags]\nMatches, xG, xGA, and player positions parsed successfully.`;
94
+ let output = `## Understat Analysis: ${title}\n\n`;
95
+ // Helper to extract JSON from script variables
96
+ const extractJson = (varName) => {
97
+ const scripts = Array.from(dom.window.document.querySelectorAll('script'));
98
+ for (const script of scripts) {
99
+ const content = script.textContent || '';
100
+ const regex = new RegExp(`var ${varName}\\s*=\\s*JSON\\.parse\\('([^']+)'\\)`);
101
+ const match = content.match(regex);
102
+ if (match && match[1]) {
103
+ try {
104
+ // Decode hex sequences if any (Understat sometimes uses hex encoding)
105
+ const jsonString = match[1].replace(/\\x([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
106
+ return JSON.parse(jsonString);
107
+ }
108
+ catch (e) {
109
+ return null;
110
+ }
111
+ }
112
+ }
113
+ return null;
114
+ };
115
+ const matchData = extractJson('match_info');
116
+ const rosterData = extractJson('rostersData');
117
+ if (matchData) {
118
+ output += `### Match Overview\n`;
119
+ output += `**Team:** ${matchData.h_team} vs ${matchData.a_team}\n`;
120
+ output += `**Score:** ${matchData.h_goals} - ${matchData.a_goals}\n`;
121
+ output += `**xG:** ${matchData.h_xg} - ${matchData.a_xg}\n`;
122
+ output += `**Probabilities:** Home Win: ${matchData.h_win}, Draw: ${matchData.draw}, Away Win: ${matchData.a_win}\n\n`;
99
123
  }
100
- return `Title: ${title}\n\n(Understat specialized extraction fallback)`;
124
+ if (rosterData) {
125
+ output += `### Key Player Stats (xG > 0.1)\n`;
126
+ const processPlayers = (players, teamName) => {
127
+ output += `**${teamName}:**\n`;
128
+ Object.values(players).forEach((p) => {
129
+ if (parseFloat(p.xG) > 0.1) {
130
+ output += `- ${p.player}: xG ${p.xG}, xA ${p.xA}, Shots: ${p.shots}\n`;
131
+ }
132
+ });
133
+ output += '\n';
134
+ };
135
+ if (rosterData.h)
136
+ processPlayers(rosterData.h, matchData?.h_team || 'Home');
137
+ if (rosterData.a)
138
+ processPlayers(rosterData.a, matchData?.a_team || 'Away');
139
+ }
140
+ if (!matchData && !rosterData) {
141
+ return `Title: ${title}\n\n(Understat extraction failed - JSON data not found)`;
142
+ }
143
+ return output;
101
144
  }
102
145
  /**
103
146
  * Specialized extractor for WhoScored (Example)
@@ -216,3 +259,15 @@ export function findBestMatchUrl(urls) {
216
259
  // Return first URL if no priority match
217
260
  return urls[0] || null;
218
261
  }
262
+ /**
263
+ * Find best URL for news/lineups from search results
264
+ */
265
+ export function findBestNewsUrl(urls) {
266
+ const newsDomains = SCRAPER_CONFIG.NEWS_DOMAINS;
267
+ for (const url of urls) {
268
+ if (newsDomains.some(domain => url.includes(domain))) {
269
+ return url;
270
+ }
271
+ }
272
+ return null;
273
+ }
@@ -5,7 +5,7 @@
5
5
  import { z } from 'zod';
6
6
  import { createAPIProvider } from '../providers/api.js';
7
7
  import { getSearchProvider } from '../providers/search.js';
8
- import { scrapeMatchContent, findBestMatchUrl } from '../providers/scraper.js';
8
+ import { scrapeMatchContent, findBestMatchUrl, findBestNewsUrl } from '../providers/scraper.js';
9
9
  import { getGlobalCache, CacheService } from '../core/cache.js';
10
10
  import { formatMatchesTable, formatScore, formatMatchStatus } from '../utils/formatter.js';
11
11
  import { CACHE_CONFIG, LEAGUES } from '../core/constants.js';
@@ -76,7 +76,7 @@ async function performMatchAnalysis(homeTeam, awayTeam, league, context) {
76
76
  { type: 'form', query: `${homeTeam} ${awayTeam} recent form last 5 matches${dateQuery}`, title: 'Recent Form' },
77
77
  { type: 'news', query: `${baseQuery} team news injuries lineups${dateQuery}`, title: 'Team News & Lineups' },
78
78
  { type: 'stats', query: `${baseQuery} xG expected goals stats${dateQuery}`, title: 'Advanced Metrics' },
79
- { type: 'odds', query: `${baseQuery} odds movement dropping odds${dateQuery}`, title: 'Market & Odds' },
79
+ { type: 'odds', query: `${baseQuery} Asian Handicap odds prediction today${dateQuery}`, title: 'Latest Handicap Odds' },
80
80
  { type: 'fatigue', query: `${homeTeam} ${awayTeam} days rest fixture congestion${dateQuery}`, title: 'Fatigue & Schedule' },
81
81
  { type: 'setpieces', query: `${baseQuery} set pieces corners aerial duels${dateQuery}`, title: 'Set Pieces' },
82
82
  ];
@@ -111,24 +111,45 @@ async function performMatchAnalysis(homeTeam, awayTeam, league, context) {
111
111
  const batchResults = await Promise.all(batchPromises);
112
112
  combinedResults += batchResults.join('\n');
113
113
  }
114
- // Deep dive: Scrape the best URL
114
+ // Deep dive: Scrape the best URLs (Dual-Source Strategy)
115
115
  if (candidateUrls.length > 0) {
116
- const bestUrl = findBestMatchUrl(candidateUrls);
117
- if (bestUrl) {
118
- combinedResults += `\n--- DEEP DIVE ANALYSIS (${bestUrl}) ---\n`;
116
+ const bestStatsUrl = findBestMatchUrl(candidateUrls);
117
+ const bestNewsUrl = findBestNewsUrl(candidateUrls);
118
+ // 1. Scrape Stats Source (Priority)
119
+ if (bestStatsUrl) {
120
+ combinedResults += `\n--- DEEP DIVE ANALYSIS: STATS & DATA (${bestStatsUrl}) ---\n`;
119
121
  try {
120
- const scrapedContent = await scrapeMatchContent(bestUrl);
122
+ const scrapedContent = await scrapeMatchContent(bestStatsUrl);
121
123
  if (scrapedContent.success) {
122
124
  combinedResults += scrapedContent.data || '';
123
125
  }
124
126
  else {
125
- combinedResults += `(Deep dive failed: ${scrapedContent.error})`;
127
+ combinedResults += `(Stats scrape failed: ${scrapedContent.error})`;
126
128
  }
127
129
  }
128
130
  catch (scrapeError) {
129
- combinedResults += `(Deep dive failed: ${scrapeError instanceof Error ? scrapeError.message : String(scrapeError)})`;
131
+ combinedResults += `(Stats scrape failed: ${scrapeError instanceof Error ? scrapeError.message : String(scrapeError)})`;
130
132
  }
131
- combinedResults += `\n--- END DEEP DIVE ---\n`;
133
+ combinedResults += `\n--- END STATS ---\n`;
134
+ }
135
+ // 2. Scrape News Source (if different from stats)
136
+ if (bestNewsUrl && bestNewsUrl !== bestStatsUrl) {
137
+ combinedResults += `\n--- DEEP DIVE ANALYSIS: NEWS & LINEUPS (${bestNewsUrl}) ---\n`;
138
+ try {
139
+ const scrapedContent = await scrapeMatchContent(bestNewsUrl);
140
+ if (scrapedContent.success) {
141
+ // Truncate news to avoid blowing up context, keep first 3000 chars
142
+ const newsContent = scrapedContent.data?.substring(0, 3000) || '';
143
+ combinedResults += newsContent + (scrapedContent.data && scrapedContent.data.length > 3000 ? '\n...(truncated)' : '');
144
+ }
145
+ else {
146
+ combinedResults += `(News scrape failed: ${scrapedContent.error})`;
147
+ }
148
+ }
149
+ catch (scrapeError) {
150
+ combinedResults += `(News scrape failed: ${scrapeError instanceof Error ? scrapeError.message : String(scrapeError)})`;
151
+ }
152
+ combinedResults += `\n--- END NEWS ---\n`;
132
153
  }
133
154
  }
134
155
  combinedResults += `\n--- END DATA ---\n\n`;
@@ -161,19 +182,16 @@ Market Intelligence, Referee stats, Weather, Fatigue analysis, Set Pieces.`, {
161
182
  }, async ({ homeTeam, awayTeam, league, context, useApi }) => {
162
183
  const errors = [];
163
184
  let analysisResult = null;
185
+ // Check API availability explicitly
186
+ const api = createAPIProvider();
187
+ const apiAvailable = api.getStatus().available;
188
+ const forcedFallback = useApi && !apiAvailable;
164
189
  // Try API providers first if requested
165
- if (useApi) {
190
+ if (useApi && apiAvailable) {
166
191
  try {
167
- const api = createAPIProvider();
168
- const apiStatus = api.getStatus();
169
- if (apiStatus.available) {
170
- // Could try to fetch match data from API here
171
- // For now, we'll use the search-based analysis as fallback
172
- analysisResult = await performMatchAnalysis(homeTeam, awayTeam, league, context);
173
- }
174
- else {
175
- errors.push('API providers not configured');
176
- }
192
+ // Could try to fetch match data from API here
193
+ // For now, we'll use the search-based analysis as fallback
194
+ analysisResult = await performMatchAnalysis(homeTeam, awayTeam, league, context);
177
195
  }
178
196
  catch (error) {
179
197
  errors.push(`API error: ${error instanceof Error ? error.message : String(error)}`);
@@ -183,6 +201,11 @@ Market Intelligence, Referee stats, Weather, Fatigue analysis, Set Pieces.`, {
183
201
  if (!analysisResult) {
184
202
  analysisResult = await performMatchAnalysis(homeTeam, awayTeam, league, context);
185
203
  }
204
+ // Add warning if running in forced fallback mode
205
+ if (forcedFallback && analysisResult) {
206
+ const warning = "⚠️ **WARNING: Fallback Mode Active**\nNo API keys configured. Using web search and scraping (accuracy may vary).\n\n";
207
+ analysisResult = warning + analysisResult;
208
+ }
186
209
  return {
187
210
  content: [{ type: 'text', text: analysisResult }],
188
211
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect, beforeEach, afterAll } from 'vitest';
2
+ import { PredictionTracker } from './core/tracker.js';
3
+ import * as fs from 'fs/promises';
4
+ const TEST_LOG_FILE = 'test_betting_log.json';
5
+ describe('ROI Tracker System', () => {
6
+ let tracker;
7
+ beforeEach(async () => {
8
+ // Use a test file instead of the real one
9
+ tracker = new PredictionTracker(TEST_LOG_FILE);
10
+ // Clear previous test data
11
+ try {
12
+ await fs.unlink(TEST_LOG_FILE);
13
+ }
14
+ catch { }
15
+ });
16
+ afterAll(async () => {
17
+ try {
18
+ await fs.unlink(TEST_LOG_FILE);
19
+ }
20
+ catch { }
21
+ });
22
+ it('should track a new prediction', async () => {
23
+ const bet = await tracker.track({
24
+ league: 'Premier League',
25
+ homeTeam: 'Arsenal',
26
+ awayTeam: 'Man City',
27
+ selection: 'Over 2.5 Goals',
28
+ odds: 1.85,
29
+ stake: 10,
30
+ confidence: 80,
31
+ analysis: 'High scoring teams',
32
+ date: new Date().toISOString()
33
+ });
34
+ expect(bet.id).toBeDefined();
35
+ expect(bet.status).toBe('pending');
36
+ expect(bet.profit).toBeUndefined();
37
+ const pending = await tracker.list('pending');
38
+ expect(pending.length).toBe(1);
39
+ expect(pending[0].id).toBe(bet.id);
40
+ });
41
+ it('should resolve a prediction and calculate profit correctly', async () => {
42
+ const bet = await tracker.track({
43
+ league: 'Premier League',
44
+ homeTeam: 'Liverpool',
45
+ awayTeam: 'Chelsea',
46
+ selection: 'Liverpool Win',
47
+ odds: 2.0,
48
+ stake: 50,
49
+ confidence: 90,
50
+ analysis: 'Form is good',
51
+ date: new Date().toISOString()
52
+ });
53
+ // Resolve as WON
54
+ const resolved = await tracker.resolve(bet.id, 'won', '2-0');
55
+ expect(resolved).toBeDefined();
56
+ expect(resolved.status).toBe('won');
57
+ expect(resolved.profit).toBe(50); // (50 * 2.0) - 50 = 50
58
+ expect(resolved.resultScore).toBe('2-0');
59
+ // Check Stats
60
+ const stats = await tracker.getStats();
61
+ expect(stats.totalBets).toBe(1);
62
+ expect(stats.wins).toBe(1);
63
+ expect(stats.totalProfit).toBe(50);
64
+ expect(stats.roi).toBe(100); // (50/50)*100
65
+ });
66
+ it('should handle lost bets correctly', async () => {
67
+ const bet = await tracker.track({
68
+ league: 'La Liga',
69
+ homeTeam: 'Real Madrid',
70
+ awayTeam: 'Barca',
71
+ selection: 'Real Win',
72
+ odds: 2.5,
73
+ stake: 100,
74
+ confidence: 60,
75
+ analysis: 'El Classico',
76
+ date: new Date().toISOString()
77
+ });
78
+ // Resolve as LOST
79
+ const resolved = await tracker.resolve(bet.id, 'lost', '0-3');
80
+ expect(resolved.status).toBe('lost');
81
+ expect(resolved.profit).toBe(-100);
82
+ const stats = await tracker.getStats();
83
+ expect(stats.totalProfit).toBe(-100);
84
+ expect(stats.roi).toBe(-100); // (-100/100)*100
85
+ });
86
+ it('should filter stats by league', async () => {
87
+ await tracker.track({
88
+ league: 'EPL', homeTeam: 'A', awayTeam: 'B', selection: 'Win', odds: 2.0, stake: 10, confidence: 50, analysis: '.', date: new Date().toISOString()
89
+ }).then(b => tracker.resolve(b.id, 'won')); // Profit +10
90
+ await tracker.track({
91
+ league: 'La Liga', homeTeam: 'C', awayTeam: 'D', selection: 'Win', odds: 2.0, stake: 10, confidence: 50, analysis: '.', date: new Date().toISOString()
92
+ }).then(b => tracker.resolve(b.id, 'lost')); // Profit -10
93
+ const eplStats = await tracker.getStats({ league: 'EPL' });
94
+ expect(eplStats.totalProfit).toBe(10);
95
+ const ligaStats = await tracker.getStats({ league: 'La Liga' });
96
+ expect(ligaStats.totalProfit).toBe(-10);
97
+ const allStats = await tracker.getStats();
98
+ expect(allStats.totalProfit).toBe(0);
99
+ });
100
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
+ import { validatePath } from './utils.js';
3
+ import * as path from 'path';
4
+ describe('Utils: validatePath', () => {
5
+ // Mock process.cwd to be a known fixed path
6
+ const mockCwd = '/app/project';
7
+ beforeEach(() => {
8
+ vi.spyOn(process, 'cwd').mockReturnValue(mockCwd);
9
+ });
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ });
13
+ it('should allow paths within the project root', () => {
14
+ const p = validatePath('src/index.ts');
15
+ expect(p).toBe(path.resolve(mockCwd, 'src/index.ts'));
16
+ });
17
+ it('should allow explicit ./ paths', () => {
18
+ const p = validatePath('./package.json');
19
+ expect(p).toBe(path.resolve(mockCwd, 'package.json'));
20
+ });
21
+ it('should block traversal to parent directory', () => {
22
+ expect(() => {
23
+ validatePath('../outside.txt');
24
+ }).toThrow(/Access denied/);
25
+ });
26
+ it('should block multiple level traversal', () => {
27
+ expect(() => {
28
+ validatePath('src/../../etc/passwd');
29
+ }).toThrow(/Access denied/);
30
+ });
31
+ it('should block absolute paths outside root', () => {
32
+ // Only run this check if we can reliably simulate absolute paths
33
+ // For now, let's assume standard unix paths for the test logic
34
+ if (path.sep === '/') {
35
+ expect(() => {
36
+ validatePath('/etc/passwd');
37
+ }).toThrow(/Access denied/);
38
+ }
39
+ });
40
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { ProjectKnowledgeGraph } from './graph.js';
3
+ import * as fs from 'fs/promises';
4
+ import * as path from 'path';
5
+ import * as os from 'os';
6
+ describe('Graph Caching Verification', () => {
7
+ const root = process.cwd();
8
+ const cachePath = path.join(root, '.gemini_graph_cache.json');
9
+ beforeEach(async () => {
10
+ // Cleanup existing cache before each test
11
+ try {
12
+ await fs.unlink(cachePath);
13
+ }
14
+ catch (e) { }
15
+ });
16
+ it('should use cache on second run', async () => {
17
+ const graph = new ProjectKnowledgeGraph();
18
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'graph-cache-test-'));
19
+ const testFile = path.join(tempDir, 'main.ts');
20
+ try {
21
+ // Create a dummy file to scan
22
+ await fs.writeFile(testFile, 'export const val = 1;', 'utf-8');
23
+ console.log('--- Run 1 (Fresh) ---');
24
+ const res1 = await graph.build(tempDir);
25
+ console.log('Result 1:', res1);
26
+ expect(res1.parsedFiles).toBeGreaterThan(0);
27
+ expect(res1.cachedFiles).toBe(0);
28
+ console.log('--- Run 2 (Cached) ---');
29
+ const res2 = await graph.build(tempDir);
30
+ console.log('Result 2:', res2);
31
+ expect(res2.parsedFiles).toBe(0);
32
+ expect(res2.cachedFiles).toBeGreaterThan(0);
33
+ expect(res2.nodeCount).toBe(res1.nodeCount);
34
+ }
35
+ finally {
36
+ // Cleanup
37
+ await fs.rm(tempDir, { recursive: true, force: true });
38
+ }
39
+ });
40
+ it('should invalidate cache when file mtime changes', async () => {
41
+ const graph = new ProjectKnowledgeGraph();
42
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'graph-mtime-test-'));
43
+ const testFile = path.join(tempDir, 'test.ts');
44
+ try {
45
+ // Create initial file
46
+ await fs.writeFile(testFile, 'export const foo = 1;', 'utf-8');
47
+ // Build 1: Fresh
48
+ const res1 = await graph.build(tempDir);
49
+ expect(res1.parsedFiles).toBe(1);
50
+ expect(res1.cachedFiles).toBe(0);
51
+ console.log('Build 1 (fresh):', res1);
52
+ // Build 2: Should use cache
53
+ const res2 = await graph.build(tempDir);
54
+ expect(res2.parsedFiles).toBe(0);
55
+ expect(res2.cachedFiles).toBe(1);
56
+ console.log('Build 2 (cached):', res2);
57
+ // Modify file content (this changes mtime)
58
+ await new Promise(resolve => setTimeout(resolve, 50)); // Small delay to ensure mtime changes
59
+ await fs.writeFile(testFile, 'export const bar = 2;', 'utf-8');
60
+ // Build 3: Should detect mtime change and re-parse
61
+ const res3 = await graph.build(tempDir);
62
+ expect(res3.parsedFiles).toBe(1); // File should be re-parsed due to mtime change
63
+ expect(res3.cachedFiles).toBe(0);
64
+ console.log('Build 3 (mtime changed):', res3);
65
+ }
66
+ finally {
67
+ // Cleanup
68
+ await fs.rm(tempDir, { recursive: true, force: true });
69
+ }
70
+ });
71
+ it('should detect new files added after cache was built', async () => {
72
+ const graph = new ProjectKnowledgeGraph();
73
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'graph-newfile-test-'));
74
+ const file1 = path.join(tempDir, 'a.ts');
75
+ const file2 = path.join(tempDir, 'b.ts');
76
+ try {
77
+ // Create initial file
78
+ await fs.writeFile(file1, 'export const a = 1;', 'utf-8');
79
+ // Build 1: Only one file
80
+ const res1 = await graph.build(tempDir);
81
+ expect(res1.nodeCount).toBe(1);
82
+ expect(res1.totalFiles).toBe(1);
83
+ console.log('Build 1 (1 file):', res1);
84
+ // Add new file
85
+ await fs.writeFile(file2, 'export const b = 2;', 'utf-8');
86
+ // Build 2: Should detect new file
87
+ const res2 = await graph.build(tempDir);
88
+ expect(res2.nodeCount).toBe(2);
89
+ expect(res2.totalFiles).toBe(2);
90
+ expect(res2.parsedFiles).toBe(1); // Only the new file should be parsed
91
+ expect(res2.cachedFiles).toBe(1); // Old file should use cache
92
+ console.log('Build 2 (new file added):', res2);
93
+ }
94
+ finally {
95
+ // Cleanup
96
+ await fs.rm(tempDir, { recursive: true, force: true });
97
+ }
98
+ });
99
+ it('should handle deleted files by pruning from cache', async () => {
100
+ const graph = new ProjectKnowledgeGraph();
101
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'graph-delete-test-'));
102
+ const file1 = path.join(tempDir, 'keep.ts');
103
+ const file2 = path.join(tempDir, 'delete.ts');
104
+ try {
105
+ // Create two files
106
+ await fs.writeFile(file1, 'export const keep = 1;', 'utf-8');
107
+ await fs.writeFile(file2, 'export const deleted = 2;', 'utf-8');
108
+ // Build 1: Two files
109
+ const res1 = await graph.build(tempDir);
110
+ expect(res1.nodeCount).toBe(2);
111
+ console.log('Build 1 (2 files):', res1);
112
+ // Delete one file
113
+ await fs.unlink(file2);
114
+ // Build 2: Should only have one file, cache pruned
115
+ const res2 = await graph.build(tempDir);
116
+ expect(res2.nodeCount).toBe(1);
117
+ expect(res2.totalFiles).toBe(1);
118
+ console.log('Build 2 (after delete):', res2);
119
+ // Verify cache file doesn't have deleted file
120
+ const cacheContent = JSON.parse(await fs.readFile(path.join(tempDir, '.gemini_graph_cache.json'), 'utf-8'));
121
+ expect(cacheContent.files[file2]).toBeUndefined();
122
+ expect(cacheContent.files[file1]).toBeDefined();
123
+ }
124
+ finally {
125
+ // Cleanup
126
+ await fs.rm(tempDir, { recursive: true, force: true });
127
+ }
128
+ });
129
+ it('should force rebuild when forceRebuild() is called', async () => {
130
+ const graph = new ProjectKnowledgeGraph();
131
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'graph-force-test-'));
132
+ const testFile = path.join(tempDir, 'test.ts');
133
+ try {
134
+ // Create file
135
+ await fs.writeFile(testFile, 'export const x = 1;', 'utf-8');
136
+ // Build 1: Fresh
137
+ const res1 = await graph.build(tempDir);
138
+ expect(res1.parsedFiles).toBe(1);
139
+ console.log('Build 1 (fresh):', res1);
140
+ // Build 2: Should use cache
141
+ const res2 = await graph.build(tempDir);
142
+ expect(res2.parsedFiles).toBe(0);
143
+ expect(res2.cachedFiles).toBe(1);
144
+ console.log('Build 2 (cached):', res2);
145
+ // Force rebuild: Should ignore cache
146
+ const res3 = await graph.forceRebuild(tempDir);
147
+ expect(res3.parsedFiles).toBe(1); // Force re-parse everything
148
+ expect(res3.cachedFiles).toBe(0);
149
+ console.log('Build 3 (force):', res3);
150
+ }
151
+ finally {
152
+ // Cleanup
153
+ await fs.rm(tempDir, { recursive: true, force: true });
154
+ }
155
+ });
156
+ it('should have consistent results between cached and fresh builds', async () => {
157
+ const graph = new ProjectKnowledgeGraph();
158
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'graph-consistent-test-'));
159
+ const file1 = path.join(tempDir, 'main.ts');
160
+ const file2 = path.join(tempDir, 'utils.ts');
161
+ try {
162
+ // Create files with imports
163
+ await fs.writeFile(file2, 'export function helper() { return 42; }', 'utf-8');
164
+ await fs.writeFile(file1, 'import { helper } from "./utils.js";\nexport const result = helper();', 'utf-8');
165
+ // Build 1: Fresh
166
+ const res1 = await graph.build(tempDir);
167
+ const rel1 = graph.getRelationships('main.ts');
168
+ // Build 2: Cached
169
+ const res2 = await graph.build(tempDir);
170
+ const rel2 = graph.getRelationships('main.ts');
171
+ // Force rebuild: Fresh again
172
+ const res3 = await graph.forceRebuild(tempDir);
173
+ const rel3 = graph.getRelationships('main.ts');
174
+ // All should have same relationships
175
+ expect(rel1).toEqual(rel2);
176
+ expect(rel2).toEqual(rel3);
177
+ expect(rel1?.imports.length).toBeGreaterThan(0);
178
+ console.log('Relationships consistent:', JSON.stringify(rel1, null, 2));
179
+ }
180
+ finally {
181
+ // Cleanup
182
+ await fs.rm(tempDir, { recursive: true, force: true });
183
+ }
184
+ });
185
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { registerWebTools } from './tools/web.js';
3
+ import * as utils from './utils.js';
4
+ // Mock utils
5
+ vi.mock('./utils.js', async (importOriginal) => {
6
+ const actual = await importOriginal();
7
+ return {
8
+ ...actual,
9
+ fetchWithRetry: vi.fn(),
10
+ };
11
+ });
12
+ describe('web_search fallback', () => {
13
+ let mockToolCallback;
14
+ const mockServer = {
15
+ tool: vi.fn((name, desc, schema, callback) => {
16
+ if (name === 'web_search') {
17
+ mockToolCallback = callback;
18
+ }
19
+ })
20
+ };
21
+ const originalEnv = process.env;
22
+ beforeEach(() => {
23
+ process.env = { ...originalEnv };
24
+ vi.clearAllMocks();
25
+ });
26
+ afterEach(() => {
27
+ process.env = originalEnv;
28
+ });
29
+ it('should use Brave if configured and no provider specified', async () => {
30
+ process.env.BRAVE_API_KEY = 'test-brave-key';
31
+ delete process.env.EXA_API_KEY;
32
+ delete process.env.GOOGLE_SEARCH_API_KEY;
33
+ registerWebTools(mockServer);
34
+ const mockResponse = {
35
+ ok: true,
36
+ json: async () => ({ web: { results: ['brave result'] } })
37
+ };
38
+ utils.fetchWithRetry.mockResolvedValue(mockResponse);
39
+ const result = await mockToolCallback({ query: 'test' });
40
+ expect(utils.fetchWithRetry).toHaveBeenCalledWith(expect.stringContaining('api.search.brave.com'), expect.anything());
41
+ expect(result.isError).toBeUndefined();
42
+ expect(JSON.parse(result.content[0].text)).toEqual(['brave result']);
43
+ });
44
+ it('should fallback to Exa if Brave fails', async () => {
45
+ process.env.BRAVE_API_KEY = 'test-brave-key';
46
+ process.env.EXA_API_KEY = 'test-exa-key';
47
+ registerWebTools(mockServer);
48
+ // First call (Brave) fails
49
+ utils.fetchWithRetry
50
+ .mockResolvedValueOnce({ ok: false, statusText: 'Brave Error', status: 500 })
51
+ // Second call (Exa) succeeds
52
+ .mockResolvedValueOnce({
53
+ ok: true,
54
+ json: async () => ({ results: ['exa result'] })
55
+ });
56
+ const result = await mockToolCallback({ query: 'test' });
57
+ expect(utils.fetchWithRetry).toHaveBeenCalledTimes(2);
58
+ // 1. Brave
59
+ expect(utils.fetchWithRetry).toHaveBeenNthCalledWith(1, expect.stringContaining('api.search.brave.com'), expect.anything());
60
+ // 2. Exa
61
+ expect(utils.fetchWithRetry).toHaveBeenNthCalledWith(2, expect.stringContaining('api.exa.ai'), expect.anything());
62
+ expect(result.isError).toBeUndefined();
63
+ expect(JSON.parse(result.content[0].text)).toEqual(['exa result']);
64
+ });
65
+ it('should respect requested provider and verify its availability', async () => {
66
+ process.env.BRAVE_API_KEY = 'test-brave-key';
67
+ // Exa not configured
68
+ delete process.env.EXA_API_KEY;
69
+ registerWebTools(mockServer);
70
+ const result = await mockToolCallback({ query: 'test', provider: 'exa' });
71
+ expect(result.isError).toBe(true);
72
+ expect(result.content[0].text).toContain("Requested provider 'exa' is not configured");
73
+ });
74
+ it('should try requested provider first, then fallback', async () => {
75
+ process.env.BRAVE_API_KEY = 'test-brave-key';
76
+ process.env.EXA_API_KEY = 'test-exa-key';
77
+ registerWebTools(mockServer);
78
+ // Request Exa
79
+ // Mock Exa fail, Brave success
80
+ utils.fetchWithRetry
81
+ .mockResolvedValueOnce({ ok: false, statusText: 'Exa Error', status: 500 })
82
+ .mockResolvedValueOnce({
83
+ ok: true,
84
+ json: async () => ({ web: { results: ['brave result'] } })
85
+ });
86
+ const result = await mockToolCallback({ query: 'test', provider: 'exa' });
87
+ expect(utils.fetchWithRetry).toHaveBeenCalledTimes(2);
88
+ // 1. Exa (requested)
89
+ expect(utils.fetchWithRetry).toHaveBeenNthCalledWith(1, expect.stringContaining('api.exa.ai'), expect.anything());
90
+ // 2. Brave (fallback)
91
+ expect(utils.fetchWithRetry).toHaveBeenNthCalledWith(2, expect.stringContaining('api.search.brave.com'), expect.anything());
92
+ expect(result.isError).toBeUndefined();
93
+ });
94
+ it('should return error if all fail', async () => {
95
+ process.env.BRAVE_API_KEY = 'test-brave-key';
96
+ registerWebTools(mockServer);
97
+ utils.fetchWithRetry.mockResolvedValue({ ok: false, statusText: 'Some Error', status: 500 });
98
+ const result = await mockToolCallback({ query: 'test' });
99
+ expect(result.isError).toBe(true);
100
+ expect(result.content[0].text).toContain("All search providers failed");
101
+ expect(result.content[0].text).toContain("Brave API error");
102
+ });
103
+ });
@@ -0,0 +1 @@
1
+ export {};