@skyramp/mcp 0.1.0 → 0.1.1

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.
@@ -2,6 +2,7 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { simpleGit } from "simple-git";
4
4
  import { logger } from "../utils/logger.js";
5
+ import { TestSource } from "../types/TestAnalysis.js";
5
6
  import fg from "fast-glob";
6
7
  export class TestDiscoveryService {
7
8
  EXCLUDED_DIRS = [
@@ -19,18 +20,54 @@ export class TestDiscoveryService {
19
20
  "target",
20
21
  ];
21
22
  SKYRAMP_MARKER = "Generated by Skyramp";
22
- // Supported test file extensions — languages Skyramp can generate tests for
23
- SUPPORTED_EXTENSIONS = [".py", ".js", ".ts", ".java"];
23
+ // Supported test file extensions — covers all common languages/frameworks
24
+ SUPPORTED_EXTENSIONS = [
25
+ ".py", ".js", ".ts", ".tsx", ".jsx", ".java",
26
+ ".go", ".rb", ".kt", ".kts", ".cs", ".rs",
27
+ ".php", ".scala", ".swift", ".m",
28
+ ];
24
29
  // Concurrency control for parallel operations
25
30
  MAX_CONCURRENT_OPERATIONS = 10;
31
+ // Max external files to fully extract in full-repo mode (no diff context).
32
+ // In PR mode this hard limit is replaced by relevance-based partitioning which
33
+ // naturally bounds the read set to files relevant to the changed endpoints.
34
+ MAX_EXTERNAL_FULL_REPO = 100;
35
+ // Test file naming patterns — match against basename.
36
+ // Includes Skyramp-generated suffixes so that files like orders_smoke.py placed
37
+ // outside a recognized test directory are still discovered.
38
+ TEST_FILE_PATTERNS = [
39
+ /^test_.*\.(py|js|ts|rb|go|php)$/, // test_*.py, test_*.rb, test_*.go
40
+ /.*_test\.(py|ts|js|go|rs)$/, // *_test.py, *_test.go, *_test.rs
41
+ /.*\.test\.(ts|js|tsx|jsx)$/, // *.test.ts, *.test.js, *.test.tsx
42
+ /.*\.spec\.(ts|js|tsx|jsx|rb)$/, // *.spec.ts, *.spec.js, *.spec.rb
43
+ /.*Test\.(java|kt|kts|cs|scala|swift|m)$/, // *Test.java, *Test.kt, *Test.m (ObjC)
44
+ /.*Tests\.(cs|swift|m)$/, // *Tests.cs, *Tests.swift, *Tests.m (ObjC)
45
+ /.*_spec\.rb$/, // *_spec.rb (RSpec)
46
+ /.*\.test\.php$/, // *.test.php
47
+ /.*Test\.php$/, // *Test.php (PHPUnit)
48
+ // Skyramp-generated test suffixes — must be matched even outside test dirs
49
+ /.*_(?:smoke|contract|fuzz|integration|load|e2e|ui)\.(py|ts|js|java|rb|go|cs|kt|kts|scala|swift|php|rs)$/i,
50
+ ];
51
+ // Directory patterns that signal test directories — works with both / and \ separators
52
+ TEST_DIR_PATTERNS = [
53
+ /[\\/]tests?[\\/]/,
54
+ /[\\/]__tests__[\\/]/,
55
+ /[\\/]spec[\\/]/,
56
+ ];
26
57
  // Cache git client and repo status per repository
27
58
  gitClientCache = new Map();
28
59
  isGitRepoCache = new Map();
29
60
  /**
30
- * Discover all Skyramp tests in a repository
31
- * Uses fast-glob for cross-platform file scanning
61
+ * Discover all tests in a repository — both Skyramp-generated and external (user-written).
62
+ * Uses fast-glob for cross-platform file scanning, then classifies discovered files
63
+ * as Skyramp-generated tests, external tests, or not-a-test during processing.
64
+ *
65
+ * When `options.changedResources` is provided (PR mode), external files are partitioned
66
+ * by relevance: files whose path/name overlaps with the changed resource names get full
67
+ * endpoint extraction; low-relevance files are returned as name-only entries (no reads).
68
+ * This eliminates the old hard cap while keeping state file size bounded.
32
69
  */
33
- async discoverTests(repositoryPath) {
70
+ async discoverTests(repositoryPath, options = {}) {
34
71
  logger.info(`Starting test discovery in: ${repositoryPath}`);
35
72
  if (!fs.existsSync(repositoryPath)) {
36
73
  throw new Error(`Repository path does not exist: ${repositoryPath}`);
@@ -41,19 +78,99 @@ export class TestDiscoveryService {
41
78
  }
42
79
  // Initialize git client cache for this repository
43
80
  await this.initializeGitClient(repositoryPath);
44
- // Use cross-platform file search to find files containing Skyramp marker
45
- const testFiles = this.findSkyrampTestsWithGrep(repositoryPath);
46
- logger.info(`Found ${testFiles.length} Skyramp test files`);
47
- // Process files in parallel with concurrency control
48
- const skyrampTests = await this.processFilesInBatches(testFiles, repositoryPath);
49
- logger.info(`Discovered ${skyrampTests.length} Skyramp tests`);
50
- // Clean up cache to free memory
81
+ // File classification: skyramp vs external vs not-a-test (carries content forward)
82
+ const classified = this.classifyTestFiles(repositoryPath);
83
+ logger.info(`Found ${classified.skyramp.length} Skyramp test files, ${classified.external.length} external test files`);
84
+ // Process Skyramp tests (content already cached from classification)
85
+ const skyrampTests = await this.processFilesInBatches(classified.skyramp, false, classified.contentCache);
86
+ skyrampTests.forEach(t => { t.source = TestSource.Skyramp; });
87
+ // Partition external tests into relevant (full extraction) and low-relevance (name-only).
88
+ //
89
+ // PR mode (changedResources provided):
90
+ // Files whose path/name token-overlaps with the changed resource names are "relevant".
91
+ // Only they get full endpoint extraction. Low-relevance files get name-only entries.
92
+ // No hard cap — the relevance filter naturally bounds the read set to PR scope.
93
+ //
94
+ // Full-repo mode (no changedResources):
95
+ // No diff context — all external files treated as potentially relevant.
96
+ // Cap at MAX_EXTERNAL_FULL_REPO to avoid reading hundreds of files.
97
+ const { changedResources } = options;
98
+ let relevantExternal;
99
+ let otherExternal;
100
+ if (changedResources?.length) {
101
+ ({ relevant: relevantExternal, other: otherExternal } =
102
+ this.partitionByRelevance(classified.external, changedResources));
103
+ }
104
+ else {
105
+ // Full-repo mode: cap full-extraction set, remaining become name-only
106
+ relevantExternal = classified.external.slice(0, this.MAX_EXTERNAL_FULL_REPO);
107
+ otherExternal = classified.external.slice(this.MAX_EXTERNAL_FULL_REPO);
108
+ }
109
+ logger.info(`External tests: ${relevantExternal.length} relevant (full extraction), ${otherExternal.length} low-relevance (name-only)`);
110
+ // Free cached content for files we will not extract — their content is not needed.
111
+ for (const f of otherExternal) {
112
+ classified.contentCache.delete(f);
113
+ }
114
+ // Full metadata + endpoint extraction only for relevant external files
115
+ const relevantExternalTests = await this.processFilesInBatches(relevantExternal, true, classified.contentCache);
116
+ // Minimal entries for low-relevance files — no content reads, no endpoint extraction
117
+ const otherExternalTests = otherExternal.map(filePath => ({
118
+ testFile: filePath,
119
+ testType: this.detectExternalTestType(filePath, ""),
120
+ language: this.detectLanguage(filePath),
121
+ framework: "",
122
+ apiEndpoint: "",
123
+ source: TestSource.External,
124
+ }));
125
+ const externalTests = [...relevantExternalTests, ...otherExternalTests];
126
+ logger.info(`Discovered ${skyrampTests.length} Skyramp tests, ${externalTests.length} external tests`);
127
+ // Clean up caches to free memory
51
128
  this.gitClientCache.clear();
52
129
  this.isGitRepoCache.clear();
53
130
  return {
54
- tests: skyrampTests,
131
+ tests: [...skyrampTests, ...externalTests],
132
+ // Expose the relevant file paths so callers can build read instructions for the LLM.
133
+ // These are the files the agent should read in Step 0 for definitive coverage
134
+ // verification — includes files where regex extraction may have failed (e.g. helper
135
+ // abstractions) since those most need semantic inspection.
136
+ relevantExternalTestPaths: relevantExternal,
55
137
  };
56
138
  }
139
+ /**
140
+ * Score an external test file's relevance to a set of changed resource names.
141
+ * Tokenises the last two segments of the file path (split by /, \, -, _, .)
142
+ * and counts overlapping tokens with the changed resources.
143
+ * Example: "test_orders_api.py" vs ["orders"] → score 1.
144
+ */
145
+ scoreRelevance(filePath, changedResources) {
146
+ const normalized = filePath.toLowerCase().replace(/\\/g, "/");
147
+ // Use the last two path segments to avoid false matches from deeply-nested dirs
148
+ const segments = normalized.split("/").slice(-2).join("/");
149
+ const tokens = new Set(segments.split(/[/\-_.]+/).filter(Boolean));
150
+ return changedResources.filter(r => {
151
+ const rLower = r.toLowerCase();
152
+ // Direct single-token match (e.g. "orders" in file path tokens)
153
+ if (tokens.has(rLower))
154
+ return true;
155
+ // Compound resource names (e.g. "order-items" from /api/order-items) — check
156
+ // that ALL hyphen/underscore-separated parts appear as individual tokens.
157
+ // This handles test_order_items.py correctly matching resource "order-items".
158
+ const parts = rLower.split(/[-_]/);
159
+ return parts.length > 1 && parts.every(p => p.length >= 3 && tokens.has(p));
160
+ }).length;
161
+ }
162
+ /**
163
+ * Partition external test files into relevant (score > 0) and low-relevance (score = 0)
164
+ * based on file path/name overlap with the changed resource names from the PR diff.
165
+ */
166
+ partitionByRelevance(files, changedResources) {
167
+ const relevant = [];
168
+ const other = [];
169
+ for (const f of files) {
170
+ (this.scoreRelevance(f, changedResources) > 0 ? relevant : other).push(f);
171
+ }
172
+ return { relevant, other };
173
+ }
57
174
  /**
58
175
  * Initialize git client and check if repository is a git repo
59
176
  */
@@ -77,15 +194,20 @@ export class TestDiscoveryService {
77
194
  }
78
195
  /**
79
196
  * Process test files in parallel batches with concurrency control
197
+ * @param isExternal When true, uses external test metadata extraction
198
+ * @param contentCache Optional pre-read file contents from classification pass
80
199
  */
81
- async processFilesInBatches(testFiles, repositoryPath) {
200
+ async processFilesInBatches(testFiles, isExternal = false, contentCache) {
82
201
  const results = [];
83
202
  // Process files in batches to control concurrency
84
203
  for (let i = 0; i < testFiles.length; i += this.MAX_CONCURRENT_OPERATIONS) {
85
204
  const batch = testFiles.slice(i, i + this.MAX_CONCURRENT_OPERATIONS);
86
205
  const batchResults = await Promise.all(batch.map(async (testFile) => {
87
206
  try {
88
- return await this.extractTestMetadata(testFile);
207
+ const cachedContent = contentCache?.get(testFile);
208
+ return isExternal
209
+ ? await this.extractExternalTestMetadata(testFile, cachedContent)
210
+ : await this.extractTestMetadata(testFile, cachedContent);
89
211
  }
90
212
  catch (error) {
91
213
  logger.error(`Error processing test file ${testFile}: ${error}`);
@@ -98,61 +220,72 @@ export class TestDiscoveryService {
98
220
  return results;
99
221
  }
100
222
  /**
101
- * Find files containing Skyramp marker using cross-platform Node.js file search
223
+ * Classify candidate files as skyramp (has marker), external (test file without
224
+ * marker), or not-a-test. Caches file contents so downstream metadata extraction
225
+ * doesn't need to re-read from disk.
102
226
  */
103
- findSkyrampTestsWithGrep(repositoryPath) {
104
- try {
105
- // Build glob patterns for supported extensions
106
- const globPatterns = this.SUPPORTED_EXTENSIONS.map((ext) => `**/*${ext}`);
107
- // Build ignore patterns for excluded directories
108
- const ignorePatterns = this.EXCLUDED_DIRS.map((dir) => `**/${dir}/**`);
109
- // Use fast-glob to find all matching files
110
- const allFiles = fg.sync(globPatterns, {
111
- cwd: repositoryPath,
112
- ignore: ignorePatterns,
113
- absolute: true,
114
- caseSensitiveMatch: false,
115
- });
116
- logger.debug(`Found ${allFiles.length} candidate files to search`);
117
- // Read files and check for Skyramp marker
118
- const matchingFiles = [];
119
- const marker = this.SKYRAMP_MARKER;
120
- // Process files in batches to avoid memory issues
121
- const batchSize = 100;
122
- for (let i = 0; i < allFiles.length; i += batchSize) {
123
- const batch = allFiles.slice(i, i + batchSize);
124
- const batchResults = batch.filter((file) => {
125
- try {
126
- // Read file and check for marker
127
- const content = fs.readFileSync(file, "utf-8");
128
- return content.includes(marker);
227
+ classifyTestFiles(repositoryPath) {
228
+ const globPatterns = this.SUPPORTED_EXTENSIONS.map((ext) => `**/*${ext}`);
229
+ const ignorePatterns = this.EXCLUDED_DIRS.map((dir) => `**/${dir}/**`);
230
+ const allFiles = fg.sync(globPatterns, {
231
+ cwd: repositoryPath,
232
+ ignore: ignorePatterns,
233
+ absolute: true,
234
+ caseSensitiveMatch: false,
235
+ });
236
+ // Pre-filter to likely test files before reading contents.
237
+ // TEST_FILE_PATTERNS includes Skyramp-generated suffixes (_smoke, _contract, etc.)
238
+ // so Skyramp files outside standard test directories are still discovered.
239
+ // This avoids reading every source file in the repo just to check the marker.
240
+ const testCandidates = allFiles.filter(f => this.isExternalTestFile(f));
241
+ logger.debug(`Found ${allFiles.length} candidate files, narrowed to ${testCandidates.length} likely test files`);
242
+ const result = { skyramp: [], external: [] };
243
+ const contentCache = new Map();
244
+ const marker = this.SKYRAMP_MARKER;
245
+ const batchSize = 100;
246
+ for (let i = 0; i < testCandidates.length; i += batchSize) {
247
+ const batch = testCandidates.slice(i, i + batchSize);
248
+ for (const file of batch) {
249
+ try {
250
+ const content = fs.readFileSync(file, "utf-8");
251
+ if (content.includes(marker)) {
252
+ result.skyramp.push(file);
253
+ contentCache.set(file, content);
129
254
  }
130
- catch (error) {
131
- // Skip files that can't be read (permissions, etc.)
132
- logger.debug(`Skipping file ${file}: ${error}`);
133
- return false;
255
+ else {
256
+ result.external.push(file);
257
+ contentCache.set(file, content);
134
258
  }
135
- });
136
- matchingFiles.push(...batchResults);
259
+ }
260
+ catch (error) {
261
+ logger.debug(`Skipping file ${file}: ${error}`);
262
+ }
137
263
  }
138
- logger.debug(`Found ${matchingFiles.length} files with Skyramp marker`);
139
- return matchingFiles;
140
- }
141
- catch (error) {
142
- logger.error(`File search failed: ${error.message}`);
143
- logger.info("Falling back to directory scanning method");
144
- return [];
145
264
  }
265
+ logger.debug(`Classified ${result.skyramp.length} Skyramp files, ${result.external.length} external test files`);
266
+ return { ...result, contentCache };
267
+ }
268
+ /**
269
+ * Check if a file is an external (non-Skyramp) test file based on naming convention
270
+ * or directory placement.
271
+ */
272
+ isExternalTestFile(filePath) {
273
+ const basename = path.basename(filePath);
274
+ if (this.TEST_FILE_PATTERNS.some(p => p.test(basename)))
275
+ return true;
276
+ if (this.TEST_DIR_PATTERNS.some(p => p.test(filePath)))
277
+ return true;
278
+ return false;
146
279
  }
147
280
  /**
148
281
  * Extract metadata from a test file
149
282
  * File is already confirmed to contain Skyramp marker by file search
283
+ * @param cachedContent Optional pre-read content from classification pass
150
284
  */
151
- async extractTestMetadata(testFile) {
285
+ async extractTestMetadata(testFile, cachedContent) {
152
286
  let content;
153
287
  try {
154
- // Read full file (file search already confirmed it has Skyramp marker)
155
- content = fs.readFileSync(testFile, "utf-8");
288
+ content = cachedContent ?? fs.readFileSync(testFile, "utf-8");
156
289
  }
157
290
  catch (error) {
158
291
  logger.debug(`Could not read file ${testFile}: ${error}`);
@@ -193,6 +326,220 @@ export class TestDiscoveryService {
193
326
  }
194
327
  return [...seen].join(", ");
195
328
  }
329
+ /**
330
+ * Extract metadata from an external (non-Skyramp) test file.
331
+ * Uses heuristic patterns for endpoint extraction, test type, and framework detection.
332
+ * @param cachedContent Optional pre-read content from classification pass
333
+ */
334
+ async extractExternalTestMetadata(testFile, cachedContent) {
335
+ let content;
336
+ try {
337
+ content = cachedContent ?? fs.readFileSync(testFile, "utf-8");
338
+ }
339
+ catch {
340
+ return null;
341
+ }
342
+ const language = this.detectLanguage(testFile);
343
+ const testType = this.detectExternalTestType(testFile, content);
344
+ const framework = this.detectExternalFramework(content);
345
+ const apiEndpoint = this.extractExternalEndpoints(content);
346
+ return {
347
+ testFile,
348
+ testType,
349
+ language,
350
+ framework,
351
+ apiEndpoint,
352
+ source: TestSource.External,
353
+ };
354
+ }
355
+ /**
356
+ * Extract HTTP endpoints from external (non-Skyramp) test files.
357
+ * Matches common patterns: requests.get, axios.post, fetch, supertest, RestAssured.
358
+ */
359
+ extractExternalEndpoints(content) {
360
+ const seen = new Set();
361
+ // Python requests / test client: requests.get("/path"), client.get("/path"), self.client.get("/path")
362
+ // Also handles f-strings: requests.get(f"{BASE_URL}/path") — strips the variable prefix
363
+ for (const m of content.matchAll(/(?:requests|client|self\.client)\.(get|post|put|patch|delete)\(\s*f?["'`](?:\{[^}]+\})?([^"'`\s]+)["'`]/gi)) {
364
+ const ep = this.normalizeEndpointPath(m[2]);
365
+ if (ep)
366
+ seen.add(`${m[1].toUpperCase()} ${ep}`);
367
+ }
368
+ // JS fetch: fetch("/path", { method: "POST" }) — extract path, try to find method
369
+ for (const m of content.matchAll(/fetch\(\s*["'`]([^"'`\s]+)["'`](?:\s*,\s*\{[^}]*method\s*:\s*["'](\w+)["'])?/g)) {
370
+ const ep = this.normalizeEndpointPath(m[1]);
371
+ if (ep)
372
+ seen.add(`${(m[2] || "GET").toUpperCase()} ${ep}`);
373
+ }
374
+ // Axios: axios.get("/path"), axios.post("/path")
375
+ for (const m of content.matchAll(/axios\.(get|post|put|patch|delete)\(\s*["'`]([^"'`\s]+)["'`]/gi)) {
376
+ const ep = this.normalizeEndpointPath(m[2]);
377
+ if (ep)
378
+ seen.add(`${m[1].toUpperCase()} ${ep}`);
379
+ }
380
+ // Supertest: .get("/path"), .post("/path") — only if file references supertest
381
+ if (/supertest|request\(app\)/i.test(content)) {
382
+ for (const m of content.matchAll(/\.(get|post|put|patch|delete)\(\s*["'`](\/[^"'`\s]+)["'`]/gi)) {
383
+ seen.add(`${m[1].toUpperCase()} ${m[2]}`);
384
+ }
385
+ }
386
+ // Java RestAssured: when().get("/path"), .get("/path") after given()/when()
387
+ for (const m of content.matchAll(/(?:when|then)\(\)\s*\.(get|post|put|patch|delete)\(\s*["']([^"']+)["']/gi)) {
388
+ const ep = this.normalizeEndpointPath(m[2]);
389
+ if (ep)
390
+ seen.add(`${m[1].toUpperCase()} ${ep}`);
391
+ }
392
+ // Go net/http: http.Get("/path"), http.Post("/path"), http.NewRequest("METHOD", "/path")
393
+ for (const m of content.matchAll(/http\.(Get|Post|Head)\(\s*["'`]([^"'`\s]+)["'`]/g)) {
394
+ const ep = this.normalizeEndpointPath(m[2]);
395
+ if (ep)
396
+ seen.add(`${m[1].toUpperCase()} ${ep}`);
397
+ }
398
+ for (const m of content.matchAll(/http\.NewRequest\(\s*["'`]?(GET|POST|PUT|PATCH|DELETE|HEAD)["'`]?\s*,\s*["'`]([^"'`\s]+)["'`]/gi)) {
399
+ const ep = this.normalizeEndpointPath(m[2]);
400
+ if (ep)
401
+ seen.add(`${m[1].toUpperCase()} ${ep}`);
402
+ }
403
+ // Ruby Net::HTTP / Faraday / HTTParty: get("/path"), post("/path")
404
+ for (const m of content.matchAll(/(?:Net::HTTP|Faraday|HTTParty|RestClient)\.(get|post|put|patch|delete)\(\s*["']([^"'\s]+)["']/gi)) {
405
+ const ep = this.normalizeEndpointPath(m[2]);
406
+ if (ep)
407
+ seen.add(`${m[1].toUpperCase()} ${ep}`);
408
+ }
409
+ // C# HttpClient: client.GetAsync("/path"), client.PostAsync("/path")
410
+ for (const m of content.matchAll(/\.(Get|Post|Put|Patch|Delete)Async\(\s*["']([^"'\s]+)["']/g)) {
411
+ const ep = this.normalizeEndpointPath(m[2]);
412
+ if (ep)
413
+ seen.add(`${m[1].toUpperCase()} ${ep}`);
414
+ }
415
+ // C# RestSharp: new RestRequest("/path", Method.Get)
416
+ for (const m of content.matchAll(/RestRequest\(\s*["']([^"']+)["']\s*,\s*Method\.(Get|Post|Put|Patch|Delete)/gi)) {
417
+ const ep = this.normalizeEndpointPath(m[1]);
418
+ if (ep)
419
+ seen.add(`${m[2].toUpperCase()} ${ep}`);
420
+ }
421
+ // PHP Guzzle/Laravel: $client->get("/path"), $this->getJson("/path"), $this->postJson("/path")
422
+ for (const m of content.matchAll(/(?:->|\$this->)(get|post|put|patch|delete)(?:Json)?\(\s*["']([^"'\s]+)["']/gi)) {
423
+ const ep = this.normalizeEndpointPath(m[2]);
424
+ if (ep)
425
+ seen.add(`${m[1].toUpperCase()} ${ep}`);
426
+ }
427
+ return [...seen].join(", ");
428
+ }
429
+ /**
430
+ * Normalize an endpoint path extracted from test code.
431
+ * Strips base URL prefixes and returns only the path portion.
432
+ * Returns null if the path doesn't look like an API endpoint.
433
+ */
434
+ normalizeEndpointPath(raw) {
435
+ let ep = raw;
436
+ // Strip http(s)://host:port prefix
437
+ try {
438
+ const url = new URL(ep);
439
+ ep = url.pathname;
440
+ }
441
+ catch {
442
+ // Already a path, not a full URL — strip query string and fragment manually
443
+ const qIdx = ep.indexOf("?");
444
+ if (qIdx !== -1)
445
+ ep = ep.substring(0, qIdx);
446
+ const hIdx = ep.indexOf("#");
447
+ if (hIdx !== -1)
448
+ ep = ep.substring(0, hIdx);
449
+ }
450
+ // Must start with /
451
+ if (!ep.startsWith("/"))
452
+ return null;
453
+ // Skip obviously non-API paths
454
+ if (/^\/(favicon|static|assets|public|_next)\b/.test(ep))
455
+ return null;
456
+ return ep;
457
+ }
458
+ /**
459
+ * Detect test type from external test file path and content.
460
+ * Falls back to directory/filename heuristics since external tests lack Skyramp command lines.
461
+ */
462
+ detectExternalTestType(filePath, content) {
463
+ const lowerPath = filePath.toLowerCase();
464
+ if (/integration|_integration/.test(lowerPath))
465
+ return "integration";
466
+ if (/e2e|end[_-]to[_-]end/.test(lowerPath))
467
+ return "e2e";
468
+ if (/contract/.test(lowerPath))
469
+ return "contract";
470
+ if (/smoke/.test(lowerPath))
471
+ return "smoke";
472
+ if (/load|performance/.test(lowerPath))
473
+ return "load";
474
+ if (/[\\/]unit[\\/]|_unit/.test(lowerPath))
475
+ return "unit";
476
+ // Heuristic: files making HTTP calls are likely integration/API tests
477
+ if (/(?:requests|fetch|axios|supertest|RestAssured)/i.test(content))
478
+ return "integration";
479
+ return "unknown";
480
+ }
481
+ /**
482
+ * Detect test framework from external test file content by checking imports.
483
+ */
484
+ detectExternalFramework(content) {
485
+ // Python
486
+ if (/import pytest|from pytest/.test(content))
487
+ return "pytest";
488
+ if (/import unittest|from unittest/.test(content))
489
+ return "unittest";
490
+ // JavaScript/TypeScript
491
+ if (/from ['"]vitest['"]|import.*vitest|require\(['"]vitest['"]\)|\bvi\./.test(content))
492
+ return "vitest";
493
+ if (/from ['"]@jest\/globals['"]|from ['"]jest['"]|import.*jest|require\(['"]jest['"]\)|jest\./.test(content) ||
494
+ (/\b(?:test|it)\s*\(/.test(content) && /\bexpect\s*\(/.test(content))) {
495
+ return "jest";
496
+ }
497
+ if (/from ['"]mocha['"]|require\(['"]mocha['"]\)/.test(content))
498
+ return "mocha";
499
+ if (/supertest/.test(content))
500
+ return "supertest";
501
+ if (/from ['"]@playwright/.test(content))
502
+ return "playwright";
503
+ if (/from ['"]cypress/.test(content))
504
+ return "cypress";
505
+ // Java / Kotlin — check TestNG before @Test since TestNG also uses @Test
506
+ if (/import org\.testng/.test(content))
507
+ return "testng";
508
+ if (/@Test|import org\.junit/.test(content))
509
+ return "junit";
510
+ if (/RestAssured/.test(content))
511
+ return "rest-assured";
512
+ // Go
513
+ if (/import.*"testing"|func\s+Test\w+\(t\s+\*testing\.T\)/.test(content))
514
+ return "go-testing";
515
+ if (/httptest\.NewServer|httptest\.NewRequest/.test(content))
516
+ return "go-httptest";
517
+ // Ruby
518
+ if (/require ['"]rspec|RSpec\.describe/.test(content))
519
+ return "rspec";
520
+ if (/require ['"]minitest|Minitest::Test/.test(content))
521
+ return "minitest";
522
+ // C# / .NET
523
+ if (/\[TestMethod\]/.test(content))
524
+ return "mstest";
525
+ if (/\[Fact\]|\[Theory\]/.test(content))
526
+ return "xunit";
527
+ if (/\[Test\]|NUnit/.test(content))
528
+ return "nunit";
529
+ // PHP
530
+ if (/extends TestCase|PHPUnit/.test(content))
531
+ return "phpunit";
532
+ // Rust
533
+ if (/\#\[test\]|#\[cfg\(test\)\]/.test(content))
534
+ return "rust-test";
535
+ // Swift
536
+ if (/import XCTest|XCTestCase/.test(content))
537
+ return "xctest";
538
+ // Scala
539
+ if (/import org\.scalatest|FunSuite|FlatSpec/.test(content))
540
+ return "scalatest";
541
+ return "";
542
+ }
196
543
  /**
197
544
  * Detect programming language from file extension
198
545
  */
@@ -202,7 +549,19 @@ export class TestDiscoveryService {
202
549
  ".py": "python",
203
550
  ".js": "javascript",
204
551
  ".ts": "typescript",
552
+ ".tsx": "typescript",
553
+ ".jsx": "javascript",
205
554
  ".java": "java",
555
+ ".kt": "kotlin",
556
+ ".kts": "kotlin",
557
+ ".go": "go",
558
+ ".rb": "ruby",
559
+ ".cs": "csharp",
560
+ ".rs": "rust",
561
+ ".php": "php",
562
+ ".scala": "scala",
563
+ ".swift": "swift",
564
+ ".m": "objective-c",
206
565
  };
207
566
  return languageMap[ext] || "unknown";
208
567
  }