@theihtisham/ai-testgen 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +383 -0
- package/dist/analyzers/analyzer.d.ts +10 -0
- package/dist/analyzers/analyzer.d.ts.map +1 -0
- package/dist/analyzers/analyzer.js +131 -0
- package/dist/analyzers/analyzer.js.map +1 -0
- package/dist/analyzers/go-analyzer.d.ts +3 -0
- package/dist/analyzers/go-analyzer.d.ts.map +1 -0
- package/dist/analyzers/go-analyzer.js +244 -0
- package/dist/analyzers/go-analyzer.js.map +1 -0
- package/dist/analyzers/index.d.ts +5 -0
- package/dist/analyzers/index.d.ts.map +1 -0
- package/dist/analyzers/index.js +15 -0
- package/dist/analyzers/index.js.map +1 -0
- package/dist/analyzers/js-ts-analyzer.d.ts +3 -0
- package/dist/analyzers/js-ts-analyzer.d.ts.map +1 -0
- package/dist/analyzers/js-ts-analyzer.js +299 -0
- package/dist/analyzers/js-ts-analyzer.js.map +1 -0
- package/dist/analyzers/python-analyzer.d.ts +3 -0
- package/dist/analyzers/python-analyzer.d.ts.map +1 -0
- package/dist/analyzers/python-analyzer.js +306 -0
- package/dist/analyzers/python-analyzer.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +381 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/defaults.d.ts +6 -0
- package/dist/config/defaults.d.ts.map +1 -0
- package/dist/config/defaults.js +80 -0
- package/dist/config/defaults.js.map +1 -0
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +14 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/loader.d.ts +6 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +126 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/coverage.d.ts +4 -0
- package/dist/coverage.d.ts.map +1 -0
- package/dist/coverage.js +108 -0
- package/dist/coverage.js.map +1 -0
- package/dist/generators/ai-generator.d.ts +4 -0
- package/dist/generators/ai-generator.d.ts.map +1 -0
- package/dist/generators/ai-generator.js +175 -0
- package/dist/generators/ai-generator.js.map +1 -0
- package/dist/generators/generator.d.ts +4 -0
- package/dist/generators/generator.d.ts.map +1 -0
- package/dist/generators/generator.js +121 -0
- package/dist/generators/generator.js.map +1 -0
- package/dist/generators/go-generator.d.ts +3 -0
- package/dist/generators/go-generator.d.ts.map +1 -0
- package/dist/generators/go-generator.js +175 -0
- package/dist/generators/go-generator.js.map +1 -0
- package/dist/generators/index.d.ts +6 -0
- package/dist/generators/index.d.ts.map +1 -0
- package/dist/generators/index.js +16 -0
- package/dist/generators/index.js.map +1 -0
- package/dist/generators/js-ts-generator.d.ts +3 -0
- package/dist/generators/js-ts-generator.d.ts.map +1 -0
- package/dist/generators/js-ts-generator.js +331 -0
- package/dist/generators/js-ts-generator.js.map +1 -0
- package/dist/generators/python-generator.d.ts +3 -0
- package/dist/generators/python-generator.d.ts.map +1 -0
- package/dist/generators/python-generator.js +180 -0
- package/dist/generators/python-generator.js.map +1 -0
- package/dist/incremental.d.ts +16 -0
- package/dist/incremental.d.ts.map +1 -0
- package/dist/incremental.js +146 -0
- package/dist/incremental.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/mutation/index.d.ts +2 -0
- package/dist/mutation/index.d.ts.map +1 -0
- package/dist/mutation/index.js +9 -0
- package/dist/mutation/index.js.map +1 -0
- package/dist/mutation/mutator.d.ts +6 -0
- package/dist/mutation/mutator.d.ts.map +1 -0
- package/dist/mutation/mutator.js +237 -0
- package/dist/mutation/mutator.js.map +1 -0
- package/dist/types.d.ts +199 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/file.d.ts +10 -0
- package/dist/utils/file.d.ts.map +1 -0
- package/dist/utils/file.js +108 -0
- package/dist/utils/file.js.map +1 -0
- package/dist/utils/index.d.ts +4 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +24 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/language.d.ts +8 -0
- package/dist/utils/language.d.ts.map +1 -0
- package/dist/utils/language.js +137 -0
- package/dist/utils/language.js.map +1 -0
- package/dist/utils/logger.d.ts +13 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +57 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/watcher/index.d.ts +2 -0
- package/dist/watcher/index.d.ts.map +1 -0
- package/dist/watcher/index.js +6 -0
- package/dist/watcher/index.js.map +1 -0
- package/dist/watcher/watcher.d.ts +19 -0
- package/dist/watcher/watcher.d.ts.map +1 -0
- package/dist/watcher/watcher.js +122 -0
- package/dist/watcher/watcher.js.map +1 -0
- package/package.json +63 -0
- package/src/analyzers/analyzer.ts +180 -0
- package/src/analyzers/go-analyzer.ts +235 -0
- package/src/analyzers/index.ts +4 -0
- package/src/analyzers/js-ts-analyzer.ts +324 -0
- package/src/analyzers/python-analyzer.ts +306 -0
- package/src/cli.ts +416 -0
- package/src/config/defaults.ts +81 -0
- package/src/config/index.ts +2 -0
- package/src/config/loader.ts +114 -0
- package/src/coverage.ts +128 -0
- package/src/generators/ai-generator.ts +170 -0
- package/src/generators/generator.ts +117 -0
- package/src/generators/go-generator.ts +183 -0
- package/src/generators/index.ts +5 -0
- package/src/generators/js-ts-generator.ts +379 -0
- package/src/generators/python-generator.ts +201 -0
- package/src/incremental.ts +131 -0
- package/src/index.ts +8 -0
- package/src/mutation/index.ts +1 -0
- package/src/mutation/mutator.ts +314 -0
- package/src/types.ts +240 -0
- package/src/utils/file.ts +73 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/language.ts +114 -0
- package/src/utils/logger.ts +61 -0
- package/src/watcher/index.ts +1 -0
- package/src/watcher/watcher.ts +103 -0
- package/tests/analyzer.test.ts +429 -0
- package/tests/config.test.ts +171 -0
- package/tests/coverage.test.ts +197 -0
- package/tests/file-utils.test.ts +121 -0
- package/tests/generators.test.ts +383 -0
- package/tests/incremental.test.ts +108 -0
- package/tests/language.test.ts +90 -0
- package/tests/mutation.test.ts +286 -0
- package/tests/watcher.test.ts +35 -0
- package/tsconfig.json +26 -0
- package/vitest.config.ts +25 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import { SupportedLanguage, TestFramework } from '../types.js';
|
|
4
|
+
import { LANGUAGE_EXTENSIONS, LANGUAGE_FRAMEWORKS } from '../config/defaults.js';
|
|
5
|
+
|
|
6
|
+
export function detectLanguage(filePath: string): SupportedLanguage {
|
|
7
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
8
|
+
const language = LANGUAGE_EXTENSIONS[ext];
|
|
9
|
+
if (!language) {
|
|
10
|
+
throw new Error(`Unsupported file extension: ${ext}. Supported: ${Object.keys(LANGUAGE_EXTENSIONS).join(', ')}`);
|
|
11
|
+
}
|
|
12
|
+
return language;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function detectFramework(language: SupportedLanguage, projectDir: string): TestFramework {
|
|
16
|
+
const frameworks = LANGUAGE_FRAMEWORKS[language];
|
|
17
|
+
if (frameworks.length === 0) {
|
|
18
|
+
throw new Error(`No test framework support for language: ${language}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Check for framework-specific config files
|
|
22
|
+
if (language === 'typescript' || language === 'javascript') {
|
|
23
|
+
const vitestConfig = ['vitest.config.ts', 'vitest.config.js', 'vite.config.ts'].find(
|
|
24
|
+
(f) => fs.existsSync(path.join(projectDir, f)),
|
|
25
|
+
);
|
|
26
|
+
if (vitestConfig) return 'vitest';
|
|
27
|
+
|
|
28
|
+
const jestConfig = ['jest.config.ts', 'jest.config.js', 'jest.config.mjs'].find(
|
|
29
|
+
(f) => fs.existsSync(path.join(projectDir, f)),
|
|
30
|
+
);
|
|
31
|
+
if (jestConfig) return 'jest';
|
|
32
|
+
|
|
33
|
+
// Check package.json for dependencies
|
|
34
|
+
const pkgPath = path.join(projectDir, 'package.json');
|
|
35
|
+
if (fs.existsSync(pkgPath)) {
|
|
36
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
37
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
38
|
+
if (allDeps['vitest']) return 'vitest';
|
|
39
|
+
if (allDeps['jest']) return 'jest';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return 'vitest'; // default for JS/TS
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (language === 'python') return 'pytest';
|
|
46
|
+
if (language === 'go') return 'go-test';
|
|
47
|
+
|
|
48
|
+
return frameworks[0]!;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getTestFileExtension(language: SupportedLanguage): string {
|
|
52
|
+
const map: Record<SupportedLanguage, string> = {
|
|
53
|
+
typescript: 'ts',
|
|
54
|
+
javascript: 'js',
|
|
55
|
+
python: 'py',
|
|
56
|
+
go: 'go',
|
|
57
|
+
rust: 'rs',
|
|
58
|
+
};
|
|
59
|
+
return map[language];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function buildTestFilePath(
|
|
63
|
+
sourceFilePath: string,
|
|
64
|
+
outputDir: string,
|
|
65
|
+
language: SupportedLanguage,
|
|
66
|
+
framework: TestFramework,
|
|
67
|
+
): string {
|
|
68
|
+
const dir = path.dirname(sourceFilePath);
|
|
69
|
+
const ext = path.extname(sourceFilePath);
|
|
70
|
+
const baseName = path.basename(sourceFilePath, ext);
|
|
71
|
+
const testExt = getTestFileExtension(language);
|
|
72
|
+
|
|
73
|
+
let testFileName: string;
|
|
74
|
+
if (framework === 'pytest') {
|
|
75
|
+
testFileName = `test_${baseName}.py`;
|
|
76
|
+
} else if (framework === 'go-test') {
|
|
77
|
+
testFileName = `${baseName}_test.go`;
|
|
78
|
+
} else {
|
|
79
|
+
testFileName = `${baseName}.test.${testExt}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (outputDir === '__tests__' || outputDir.startsWith('./') || outputDir.startsWith('../')) {
|
|
83
|
+
const testDir = path.resolve(path.dirname(sourceFilePath), outputDir);
|
|
84
|
+
return path.join(testDir, testFileName);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return path.join(dir, testFileName);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isTestFile(filePath: string): boolean {
|
|
91
|
+
const base = path.basename(filePath);
|
|
92
|
+
return (
|
|
93
|
+
base.includes('.test.') ||
|
|
94
|
+
base.includes('.spec.') ||
|
|
95
|
+
base.startsWith('test_') ||
|
|
96
|
+
base.endsWith('_test.go')
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function shouldAnalyze(filePath: string, excludePatterns: string[]): boolean {
|
|
101
|
+
if (isTestFile(filePath)) return false;
|
|
102
|
+
|
|
103
|
+
const normalizedPath = filePath.replace(/\\/g, '/');
|
|
104
|
+
for (const pattern of excludePatterns) {
|
|
105
|
+
const regex = pattern
|
|
106
|
+
.replace(/\*\*/g, '.*')
|
|
107
|
+
.replace(/\*/g, '[^/]*')
|
|
108
|
+
.replace(/\?/g, '[^/]');
|
|
109
|
+
if (new RegExp(regex).test(normalizedPath)) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success';
|
|
4
|
+
|
|
5
|
+
let currentLogLevel: LogLevel = 'info';
|
|
6
|
+
|
|
7
|
+
const LOG_ORDER: LogLevel[] = ['debug', 'info', 'warn', 'error', 'success'];
|
|
8
|
+
|
|
9
|
+
export function setLogLevel(level: LogLevel): void {
|
|
10
|
+
currentLogLevel = level;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function shouldLog(level: LogLevel): boolean {
|
|
14
|
+
if (level === 'success') return true;
|
|
15
|
+
if (level === 'debug' && currentLogLevel !== 'debug') return false;
|
|
16
|
+
return LOG_ORDER.indexOf(level) >= LOG_ORDER.indexOf(currentLogLevel);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const logger = {
|
|
20
|
+
debug(message: string, ...args: unknown[]): void {
|
|
21
|
+
if (shouldLog('debug')) {
|
|
22
|
+
console.log(chalk.gray(`[debug] ${message}`), ...args);
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
info(message: string, ...args: unknown[]): void {
|
|
27
|
+
if (shouldLog('info')) {
|
|
28
|
+
console.log(chalk.cyan(`[info] ${message}`), ...args);
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
warn(message: string, ...args: unknown[]): void {
|
|
33
|
+
if (shouldLog('warn')) {
|
|
34
|
+
console.warn(chalk.yellow(`[warn] ${message}`), ...args);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
error(message: string, ...args: unknown[]): void {
|
|
39
|
+
if (shouldLog('error')) {
|
|
40
|
+
console.error(chalk.red(`[error] ${message}`), ...args);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
success(message: string, ...args: unknown[]): void {
|
|
45
|
+
if (shouldLog('success')) {
|
|
46
|
+
console.log(chalk.green(` ${message}`), ...args);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
plain(message: string, ...args: unknown[]): void {
|
|
51
|
+
console.log(message, ...args);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
heading(message: string): void {
|
|
55
|
+
console.log('\n' + chalk.bold.blue(` ${message}`));
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
subheading(message: string): void {
|
|
59
|
+
console.log(chalk.bold.white(` ${message}`));
|
|
60
|
+
},
|
|
61
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { FileWatcher } from './watcher.js';
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { FileChangeEvent, TestGenConfig } from '../types.js';
|
|
4
|
+
import { logger } from '../utils/logger.js';
|
|
5
|
+
|
|
6
|
+
type FileWatcherCallback = (events: FileChangeEvent[]) => Promise<void>;
|
|
7
|
+
|
|
8
|
+
export class FileWatcher {
|
|
9
|
+
private watchers: Map<string, fs.FSWatcher> = new Map();
|
|
10
|
+
private pendingEvents: FileChangeEvent[] = [];
|
|
11
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
12
|
+
private debounceMs: number;
|
|
13
|
+
private ignorePatterns: string[];
|
|
14
|
+
private callback: FileWatcherCallback;
|
|
15
|
+
private running = false;
|
|
16
|
+
|
|
17
|
+
constructor(config: TestGenConfig, callback: FileWatcherCallback) {
|
|
18
|
+
this.debounceMs = config.watch.debounceMs;
|
|
19
|
+
this.ignorePatterns = config.watch.ignorePatterns;
|
|
20
|
+
this.callback = callback;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
start(directories: string[]): void {
|
|
24
|
+
if (this.running) return;
|
|
25
|
+
this.running = true;
|
|
26
|
+
|
|
27
|
+
for (const dir of directories) {
|
|
28
|
+
const absoluteDir = path.resolve(dir);
|
|
29
|
+
if (!fs.existsSync(absoluteDir)) {
|
|
30
|
+
logger.warn(`Watch directory does not exist: ${absoluteDir}`);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const watcher = fs.watch(
|
|
36
|
+
absoluteDir,
|
|
37
|
+
{ recursive: true },
|
|
38
|
+
(eventType, filename) => {
|
|
39
|
+
if (!filename) return;
|
|
40
|
+
if (this.shouldIgnore(filename)) return;
|
|
41
|
+
|
|
42
|
+
const filePath = path.join(absoluteDir, filename);
|
|
43
|
+
|
|
44
|
+
this.pendingEvents.push({
|
|
45
|
+
filePath,
|
|
46
|
+
eventType: eventType as FileChangeEvent['eventType'],
|
|
47
|
+
timestamp: Date.now(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
this.debouncedNotify();
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
this.watchers.set(absoluteDir, watcher);
|
|
55
|
+
logger.info(`Watching: ${absoluteDir}`);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
logger.error(`Failed to watch ${absoluteDir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
stop(): void {
|
|
63
|
+
this.running = false;
|
|
64
|
+
for (const [dir, watcher] of this.watchers) {
|
|
65
|
+
watcher.close();
|
|
66
|
+
logger.debug(`Stopped watching: ${dir}`);
|
|
67
|
+
}
|
|
68
|
+
this.watchers.clear();
|
|
69
|
+
if (this.debounceTimer) {
|
|
70
|
+
clearTimeout(this.debounceTimer);
|
|
71
|
+
this.debounceTimer = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
isRunning(): boolean {
|
|
76
|
+
return this.running;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private shouldIgnore(filename: string): boolean {
|
|
80
|
+
const normalized = filename.replace(/\\/g, '/');
|
|
81
|
+
return this.ignorePatterns.some((pattern) => {
|
|
82
|
+
return normalized.includes(pattern) || normalized.match(
|
|
83
|
+
new RegExp(pattern.replace(/\*/g, '.*')),
|
|
84
|
+
) !== null;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private debouncedNotify(): void {
|
|
89
|
+
if (this.debounceTimer) {
|
|
90
|
+
clearTimeout(this.debounceTimer);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
this.debounceTimer = setTimeout(() => {
|
|
94
|
+
const events = [...this.pendingEvents];
|
|
95
|
+
this.pendingEvents = [];
|
|
96
|
+
if (events.length > 0) {
|
|
97
|
+
this.callback(events).catch((err) => {
|
|
98
|
+
logger.error(`Watch callback error: ${err instanceof Error ? err.message : String(err)}`);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}, this.debounceMs);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import { analyzeSource, detectEdgeCases, detectMocks } from '../src/analyzers/analyzer.js';
|
|
6
|
+
import { analyzePythonSource } from '../src/analyzers/python-analyzer.js';
|
|
7
|
+
import { analyzeGoSource } from '../src/analyzers/go-analyzer.js';
|
|
8
|
+
|
|
9
|
+
describe('Source Analysis', () => {
|
|
10
|
+
let tempDir: string;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'testgen-analyze-'));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('TypeScript/JavaScript Analysis', () => {
|
|
21
|
+
it('analyzes a simple exported function', () => {
|
|
22
|
+
const filePath = path.join(tempDir, 'math.ts');
|
|
23
|
+
fs.writeFileSync(filePath, `
|
|
24
|
+
export function add(a: number, b: number): number {
|
|
25
|
+
return a + b;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function multiply(a: number, b: number): number {
|
|
29
|
+
return a * b;
|
|
30
|
+
}
|
|
31
|
+
`);
|
|
32
|
+
const analysis = analyzeSource(filePath, 'typescript');
|
|
33
|
+
expect(analysis.language).toBe('typescript');
|
|
34
|
+
expect(analysis.functions.length).toBeGreaterThanOrEqual(2);
|
|
35
|
+
expect(analysis.functions.some((f) => f.name === 'add')).toBe(true);
|
|
36
|
+
expect(analysis.functions.some((f) => f.name === 'multiply')).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('analyzes exported classes with methods', () => {
|
|
40
|
+
const filePath = path.join(tempDir, 'service.ts');
|
|
41
|
+
fs.writeFileSync(filePath, `
|
|
42
|
+
export class UserService {
|
|
43
|
+
private users: Map<string, string> = new Map();
|
|
44
|
+
|
|
45
|
+
addUser(id: string, name: string): void {
|
|
46
|
+
this.users.set(id, name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
getUser(id: string): string | undefined {
|
|
50
|
+
return this.users.get(id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async fetchUser(id: string): Promise<string> {
|
|
54
|
+
return this.users.get(id) ?? 'unknown';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
`);
|
|
58
|
+
const analysis = analyzeSource(filePath, 'typescript');
|
|
59
|
+
expect(analysis.classes.length).toBe(1);
|
|
60
|
+
expect(analysis.classes[0]!.name).toBe('UserService');
|
|
61
|
+
expect(analysis.classes[0]!.methods.length).toBeGreaterThanOrEqual(2);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('detects exported arrow functions', () => {
|
|
65
|
+
const filePath = path.join(tempDir, 'utils.ts');
|
|
66
|
+
fs.writeFileSync(filePath, `
|
|
67
|
+
export const formatName = (first: string, last: string): string => {
|
|
68
|
+
return first + ' ' + last;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const parseJSON = (input: string): unknown => {
|
|
72
|
+
return JSON.parse(input);
|
|
73
|
+
};
|
|
74
|
+
`);
|
|
75
|
+
const analysis = analyzeSource(filePath, 'typescript');
|
|
76
|
+
expect(analysis.functions.length).toBeGreaterThanOrEqual(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('analyzes interfaces', () => {
|
|
80
|
+
const filePath = path.join(tempDir, 'types.ts');
|
|
81
|
+
fs.writeFileSync(filePath, `
|
|
82
|
+
export interface User {
|
|
83
|
+
id: string;
|
|
84
|
+
name: string;
|
|
85
|
+
email?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface Repository<T> {
|
|
89
|
+
findById(id: string): T | undefined;
|
|
90
|
+
save(entity: T): void;
|
|
91
|
+
}
|
|
92
|
+
`);
|
|
93
|
+
const analysis = analyzeSource(filePath, 'typescript');
|
|
94
|
+
expect(analysis.interfaces.length).toBeGreaterThanOrEqual(1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('detects imports and dependencies', () => {
|
|
98
|
+
const filePath = path.join(tempDir, 'imports.ts');
|
|
99
|
+
fs.writeFileSync(filePath, `
|
|
100
|
+
import { useState, useEffect } from 'react';
|
|
101
|
+
import * as fs from 'fs';
|
|
102
|
+
import type { Config } from './config';
|
|
103
|
+
|
|
104
|
+
export function useConfig(): Config | null {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
`);
|
|
108
|
+
const analysis = analyzeSource(filePath, 'typescript');
|
|
109
|
+
expect(analysis.imports.length).toBeGreaterThanOrEqual(1);
|
|
110
|
+
expect(analysis.dependencies).toContain('react');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('calculates cyclomatic complexity', () => {
|
|
114
|
+
const filePath = path.join(tempDir, 'complex.ts');
|
|
115
|
+
fs.writeFileSync(filePath, `
|
|
116
|
+
export function complexFunction(x: number): string {
|
|
117
|
+
if (x > 0) {
|
|
118
|
+
if (x > 10) {
|
|
119
|
+
return 'big';
|
|
120
|
+
} else {
|
|
121
|
+
return 'small';
|
|
122
|
+
}
|
|
123
|
+
} else if (x === 0) {
|
|
124
|
+
return 'zero';
|
|
125
|
+
} else {
|
|
126
|
+
for (let i = 0; i < 10; i++) {
|
|
127
|
+
if (i === x) return 'found';
|
|
128
|
+
}
|
|
129
|
+
return 'negative';
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
`);
|
|
133
|
+
const analysis = analyzeSource(filePath, 'typescript');
|
|
134
|
+
const complexFn = analysis.functions.find((f) => f.name === 'complexFunction');
|
|
135
|
+
expect(complexFn).toBeDefined();
|
|
136
|
+
expect(complexFn!.complexity).toBeGreaterThan(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('detects async functions', () => {
|
|
140
|
+
const filePath = path.join(tempDir, 'async.ts');
|
|
141
|
+
fs.writeFileSync(filePath, `
|
|
142
|
+
export async function fetchData(url: string): Promise<string> {
|
|
143
|
+
const response = await fetch(url);
|
|
144
|
+
return response.text();
|
|
145
|
+
}
|
|
146
|
+
`);
|
|
147
|
+
const analysis = analyzeSource(filePath, 'typescript');
|
|
148
|
+
const asyncFn = analysis.functions.find((f) => f.name === 'fetchData');
|
|
149
|
+
expect(asyncFn).toBeDefined();
|
|
150
|
+
expect(asyncFn!.isAsync).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('Python Analysis', () => {
|
|
155
|
+
it('analyzes Python functions', () => {
|
|
156
|
+
const filePath = path.join(tempDir, 'math.py');
|
|
157
|
+
fs.writeFileSync(filePath, `
|
|
158
|
+
def add(a: int, b: int) -> int:
|
|
159
|
+
return a + b
|
|
160
|
+
|
|
161
|
+
def greet(name: str) -> str:
|
|
162
|
+
return f"Hello, {name}"
|
|
163
|
+
|
|
164
|
+
async def fetch_data(url: str) -> dict:
|
|
165
|
+
return {}
|
|
166
|
+
`);
|
|
167
|
+
const analysis = analyzePythonSource(filePath);
|
|
168
|
+
expect(analysis.language).toBe('python');
|
|
169
|
+
expect(analysis.functions.length).toBeGreaterThanOrEqual(2);
|
|
170
|
+
expect(analysis.functions.some((f) => f.name === 'add')).toBe(true);
|
|
171
|
+
expect(analysis.functions.some((f) => f.name === 'greet')).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('analyzes Python classes', () => {
|
|
175
|
+
const filePath = path.join(tempDir, 'service.py');
|
|
176
|
+
fs.writeFileSync(filePath, `
|
|
177
|
+
class UserService:
|
|
178
|
+
def __init__(self, db_url: str):
|
|
179
|
+
self.db_url = db_url
|
|
180
|
+
self.connected = False
|
|
181
|
+
|
|
182
|
+
def connect(self):
|
|
183
|
+
self.connected = True
|
|
184
|
+
|
|
185
|
+
def get_user(self, user_id: str) -> dict:
|
|
186
|
+
if not self.connected:
|
|
187
|
+
raise ConnectionError("Not connected")
|
|
188
|
+
return {"id": user_id}
|
|
189
|
+
`);
|
|
190
|
+
const analysis = analyzePythonSource(filePath);
|
|
191
|
+
expect(analysis.classes.length).toBe(1);
|
|
192
|
+
expect(analysis.classes[0]!.name).toBe('UserService');
|
|
193
|
+
expect(analysis.classes[0]!.methods.length).toBeGreaterThanOrEqual(1);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('extracts Python imports', () => {
|
|
197
|
+
const filePath = path.join(tempDir, 'imports.py');
|
|
198
|
+
fs.writeFileSync(filePath, `
|
|
199
|
+
import os
|
|
200
|
+
import sys
|
|
201
|
+
from typing import List, Optional
|
|
202
|
+
from collections import defaultdict
|
|
203
|
+
|
|
204
|
+
def process(items: List[str]) -> None:
|
|
205
|
+
pass
|
|
206
|
+
`);
|
|
207
|
+
const analysis = analyzePythonSource(filePath);
|
|
208
|
+
expect(analysis.imports.length).toBeGreaterThanOrEqual(2);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe('Go Analysis', () => {
|
|
213
|
+
it('analyzes Go functions', () => {
|
|
214
|
+
const filePath = path.join(tempDir, 'math.go');
|
|
215
|
+
fs.writeFileSync(filePath, `package math
|
|
216
|
+
|
|
217
|
+
func Add(a, b int) int {
|
|
218
|
+
return a + b
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
func Multiply(a, b int) int {
|
|
222
|
+
return a * b
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
func Divide(a, b int) (int, error) {
|
|
226
|
+
if b == 0 {
|
|
227
|
+
return 0, fmt.Errorf("division by zero")
|
|
228
|
+
}
|
|
229
|
+
return a / b, nil
|
|
230
|
+
}
|
|
231
|
+
`);
|
|
232
|
+
const analysis = analyzeGoSource(filePath);
|
|
233
|
+
expect(analysis.language).toBe('go');
|
|
234
|
+
expect(analysis.functions.length).toBeGreaterThanOrEqual(2);
|
|
235
|
+
expect(analysis.functions.some((f) => f.name === 'Add')).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('analyzes Go interfaces', () => {
|
|
239
|
+
const filePath = path.join(tempDir, 'repo.go');
|
|
240
|
+
fs.writeFileSync(filePath, `package repo
|
|
241
|
+
|
|
242
|
+
type Repository interface {
|
|
243
|
+
FindById(id string) (interface{}, error)
|
|
244
|
+
Save(entity interface{}) error
|
|
245
|
+
Delete(id string) error
|
|
246
|
+
}
|
|
247
|
+
`);
|
|
248
|
+
const analysis = analyzeGoSource(filePath);
|
|
249
|
+
expect(analysis.interfaces.length).toBe(1);
|
|
250
|
+
expect(analysis.interfaces[0]!.name).toBe('Repository');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('extracts Go imports', () => {
|
|
254
|
+
const filePath = path.join(tempDir, 'main.go');
|
|
255
|
+
fs.writeFileSync(filePath, `package main
|
|
256
|
+
|
|
257
|
+
import (
|
|
258
|
+
"fmt"
|
|
259
|
+
"net/http"
|
|
260
|
+
"strings"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
func main() {
|
|
264
|
+
fmt.Println("hello")
|
|
265
|
+
}
|
|
266
|
+
`);
|
|
267
|
+
const analysis = analyzeGoSource(filePath);
|
|
268
|
+
expect(analysis.imports.length).toBeGreaterThanOrEqual(1);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe('Edge Case Detection', () => {
|
|
274
|
+
it('detects edge cases for optional parameters', () => {
|
|
275
|
+
const edgeCases = detectEdgeCases({
|
|
276
|
+
name: 'processData',
|
|
277
|
+
isAsync: false,
|
|
278
|
+
isExported: true,
|
|
279
|
+
params: [
|
|
280
|
+
{ name: 'data', type: 'string', optional: true, defaultValue: null },
|
|
281
|
+
{ name: 'count', type: 'number', optional: false, defaultValue: null },
|
|
282
|
+
],
|
|
283
|
+
returnType: 'string',
|
|
284
|
+
throws: [],
|
|
285
|
+
complexity: 1,
|
|
286
|
+
hasSideEffects: false,
|
|
287
|
+
startLine: 1,
|
|
288
|
+
endLine: 5,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(edgeCases.length).toBeGreaterThan(0);
|
|
292
|
+
expect(edgeCases.some((ec) => ec.name.includes('undefined'))).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('detects numeric edge cases', () => {
|
|
296
|
+
const edgeCases = detectEdgeCases({
|
|
297
|
+
name: 'calculate',
|
|
298
|
+
isAsync: false,
|
|
299
|
+
isExported: true,
|
|
300
|
+
params: [
|
|
301
|
+
{ name: 'value', type: 'number', optional: false, defaultValue: null },
|
|
302
|
+
],
|
|
303
|
+
returnType: 'number',
|
|
304
|
+
throws: [],
|
|
305
|
+
complexity: 1,
|
|
306
|
+
hasSideEffects: false,
|
|
307
|
+
startLine: 1,
|
|
308
|
+
endLine: 3,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
expect(edgeCases.some((ec) => ec.name.includes('zero'))).toBe(true);
|
|
312
|
+
expect(edgeCases.some((ec) => ec.inputDescription.includes('NaN'))).toBe(true);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('detects array edge cases', () => {
|
|
316
|
+
const edgeCases = detectEdgeCases({
|
|
317
|
+
name: 'processItems',
|
|
318
|
+
isAsync: false,
|
|
319
|
+
isExported: true,
|
|
320
|
+
params: [
|
|
321
|
+
{ name: 'items', type: 'string[]', optional: false, defaultValue: null },
|
|
322
|
+
],
|
|
323
|
+
returnType: 'void',
|
|
324
|
+
throws: [],
|
|
325
|
+
complexity: 1,
|
|
326
|
+
hasSideEffects: false,
|
|
327
|
+
startLine: 1,
|
|
328
|
+
endLine: 3,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
expect(edgeCases.some((ec) => ec.name.includes('empty array'))).toBe(true);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('detects error path edge cases', () => {
|
|
335
|
+
const edgeCases = detectEdgeCases({
|
|
336
|
+
name: 'validate',
|
|
337
|
+
isAsync: false,
|
|
338
|
+
isExported: true,
|
|
339
|
+
params: [
|
|
340
|
+
{ name: 'input', type: 'string', optional: false, defaultValue: null },
|
|
341
|
+
],
|
|
342
|
+
returnType: 'boolean',
|
|
343
|
+
throws: ['ValidationError'],
|
|
344
|
+
complexity: 2,
|
|
345
|
+
hasSideEffects: false,
|
|
346
|
+
startLine: 1,
|
|
347
|
+
endLine: 5,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
expect(edgeCases.some((ec) => ec.tags.includes('error-path'))).toBe(true);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('detects async edge cases', () => {
|
|
354
|
+
const edgeCases = detectEdgeCases({
|
|
355
|
+
name: 'fetchData',
|
|
356
|
+
isAsync: true,
|
|
357
|
+
isExported: true,
|
|
358
|
+
params: [
|
|
359
|
+
{ name: 'url', type: 'string', optional: false, defaultValue: null },
|
|
360
|
+
],
|
|
361
|
+
returnType: 'Promise<string>',
|
|
362
|
+
throws: [],
|
|
363
|
+
complexity: 1,
|
|
364
|
+
hasSideEffects: true,
|
|
365
|
+
startLine: 1,
|
|
366
|
+
endLine: 5,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
expect(edgeCases.some((ec) => ec.tags.includes('async'))).toBe(true);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe('Mock Detection', () => {
|
|
374
|
+
it('detects external module mocks', () => {
|
|
375
|
+
const analysis = {
|
|
376
|
+
filePath: 'src/app.ts',
|
|
377
|
+
language: 'typescript' as const,
|
|
378
|
+
functions: [],
|
|
379
|
+
classes: [],
|
|
380
|
+
interfaces: [],
|
|
381
|
+
exports: [],
|
|
382
|
+
imports: [
|
|
383
|
+
{
|
|
384
|
+
modulePath: 'axios',
|
|
385
|
+
namedImports: ['axios'],
|
|
386
|
+
defaultImport: null,
|
|
387
|
+
isTypeOnly: false,
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
modulePath: 'lodash',
|
|
391
|
+
namedImports: ['debounce'],
|
|
392
|
+
defaultImport: null,
|
|
393
|
+
isTypeOnly: false,
|
|
394
|
+
},
|
|
395
|
+
],
|
|
396
|
+
dependencies: ['axios', 'lodash'],
|
|
397
|
+
linesOfCode: 10,
|
|
398
|
+
cyclomaticComplexity: 1,
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const mocks = detectMocks(analysis);
|
|
402
|
+
expect(mocks.length).toBeGreaterThanOrEqual(1);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('skips type-only imports', () => {
|
|
406
|
+
const analysis = {
|
|
407
|
+
filePath: 'src/types.ts',
|
|
408
|
+
language: 'typescript' as const,
|
|
409
|
+
functions: [],
|
|
410
|
+
classes: [],
|
|
411
|
+
interfaces: [],
|
|
412
|
+
exports: [],
|
|
413
|
+
imports: [
|
|
414
|
+
{
|
|
415
|
+
modulePath: './types',
|
|
416
|
+
namedImports: ['User'],
|
|
417
|
+
defaultImport: null,
|
|
418
|
+
isTypeOnly: true,
|
|
419
|
+
},
|
|
420
|
+
],
|
|
421
|
+
dependencies: [],
|
|
422
|
+
linesOfCode: 5,
|
|
423
|
+
cyclomaticComplexity: 1,
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const mocks = detectMocks(analysis);
|
|
427
|
+
expect(mocks.length).toBe(0);
|
|
428
|
+
});
|
|
429
|
+
});
|