diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index bf66d0b..d423be9 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -62,6 +62,9 @@ jobs: - name: Test run: npx vitest --run + - name: Test + run: npx vitest --run + create-release: needs: [check-version, ci] if: needs.check-version.outputs.version-changed == 'true' diff --git a/README.md b/README.md index 6403237..d7aba06 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ fuck-u-code analyze . -e "**/*.test.ts" # Exclude test files | `--output ` | `-o` | Write to file | | `--exclude ` | `-e` | Additional exclude patterns | | `--concurrency ` | `-c` | Concurrent workers (default 8) | -| `--locale ` | `-l` | Language: en/zh/ru/zh-tw | +| `--locale ` | `-l` | Language: en/zh/ru/zh_TW | ### AI Code Review diff --git a/bin/fuck-u-code.js b/bin/fuck-u-code.js index 37f79a9..e01ca87 100755 --- a/bin/fuck-u-code.js +++ b/bin/fuck-u-code.js @@ -10,8 +10,13 @@ const __dirname = dirname(__filename); const hasMemoryFlag = process.execArgv.some(arg => arg.startsWith('--max-old-space-size')); if (!hasMemoryFlag) { + const DEFAULT_MEMORY_MB = 4096; + const configuredMemory = Number(process.env.FUCK_U_CODE_MAX_MEMORY); + const memoryMb = Number.isFinite(configuredMemory) && configuredMemory >= 512 + ? Math.floor(configuredMemory) + : DEFAULT_MEMORY_MB; const args = [ - '--max-old-space-size=8192', + `--max-old-space-size=${memoryMb}`, join(__dirname, '..', 'dist', 'index.js'), ...process.argv.slice(2) ]; @@ -41,10 +46,17 @@ if (!hasMemoryFlag) { child.on('exit', (code) => { if (code === 133 && inFatalError) { process.exit(0); + } else if (code === null) { + // Killed by a signal (or crashed) must not be reported as success. + process.exit(1); } else { - process.exit(code || 0); + process.exit(code); } }); + + child.on('error', () => { + process.exit(1); + }); } else { import('../dist/index.js'); } diff --git a/src/ai/providers/fetch.ts b/src/ai/providers/fetch.ts index 58930dd..676b3ff 100644 --- a/src/ai/providers/fetch.ts +++ b/src/ai/providers/fetch.ts @@ -20,12 +20,15 @@ export async function fetchWithRetry( const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), ctx.timeout * 1000); - const response = await fetch(url, { - ...options, - signal: controller.signal, - }); - - clearTimeout(timeoutId); + let response: Response; + try { + response = await fetch(url, { + ...options, + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } if (response.ok) { return response; diff --git a/src/analyzer/file-discovery.ts b/src/analyzer/file-discovery.ts index 07dfbf5..3e479a1 100644 --- a/src/analyzer/file-discovery.ts +++ b/src/analyzer/file-discovery.ts @@ -23,6 +23,34 @@ export interface FileDiscoveryResult { totalScanned: number; } +/** Well-known non-code directories skipped by default. */ +const DEFAULT_IGNORED_DIRS = [ + '.git', + 'node_modules', + 'vendor', + 'dist', + 'build', + '.next', + '__pycache__', + 'target', + '.venv', + 'venv', +]; + +function isDirExplicitlyIncluded(patterns: string[], dir: string): boolean { + return patterns.some((pattern) => { + const normalized = pattern.replace(/\\/g, '/'); + return ( + normalized === dir || + normalized === `${dir}/` || + normalized === `${dir}/**` || + normalized === `**/${dir}/**` || + normalized.startsWith(`${dir}/`) || + normalized.includes(`/${dir}/`) + ); + }); +} + /** * Discover all analyzable files in the project */ @@ -36,22 +64,19 @@ export async function discoverFiles(config: RuntimeConfig): Promise !isDirExplicitlyIncluded(include, dir)); + for (const dir of ignoredDirs) { + rootIgnore.add(`${dir}/`); + } + const nestedIgnores = await loadNestedGitignores(projectPath); const matcher = createMatcher(rootIgnore, nestedIgnores); - // Common non-code directories to skip at glob level for performance - const globIgnore = [ - '.git/**', - 'node_modules/**', - 'vendor/**', - 'dist/**', - 'build/**', - '.next/**', - '__pycache__/**', - 'target/**', - '.venv/**', - 'venv/**', - ]; + // Skip the same directories at glob level for performance. + const globIgnore = ignoredDirs.map((dir) => `${dir}/**`); const allFiles: string[] = []; for (const pattern of include) { diff --git a/src/cli/commands/ai-review.ts b/src/cli/commands/ai-review.ts index e3317f0..727c8ce 100644 --- a/src/cli/commands/ai-review.ts +++ b/src/cli/commands/ai-review.ts @@ -16,6 +16,7 @@ import { renderAIReviewHtml, type AIReviewData, } from '../output/ai-review-output.js'; +import { writeTextOutput } from '../output/render-analysis.js'; import { getTerminalWidth } from '../../utils/terminal.js'; import chalk from 'chalk'; @@ -25,7 +26,7 @@ interface AIReviewOptions { baseUrl?: string; apiKey?: string; verbose?: boolean; - locale?: 'en' | 'zh' | 'ru'; + locale?: 'en' | 'zh' | 'ru' | 'zh_TW'; top?: number; format?: 'console' | 'markdown' | 'html'; output?: string; @@ -43,7 +44,7 @@ export function createAIReviewCommand(): Command { .option('-k, --api-key ', 'API key (can also use environment variables)') .option('-t, --top ', 'Number of worst files to review (default: 5)', parseInt) .option('-v, --verbose', 'Show verbose output') - .option('-l, --locale ', 'Language: en, zh, ru') + .option('-l, --locale ', 'Language: en, zh, ru, zh_TW') .option('-f, --format ', t('cmd_ai_review_format_help')) .option('-o, --output ', 'Write output to file instead of stdout') .addHelpText( @@ -195,22 +196,14 @@ async function runAIReview(projectPath: string, options: AIReviewOptions): Promi switch (format) { case 'markdown': { const markdown = renderAIReviewMarkdown(reviews); - if (outputFile) { - const { writeFile } = await import('node:fs/promises'); - await writeFile(outputFile, markdown, 'utf-8'); - console.log(t('outputWritten', { file: outputFile })); - } else { + if (!(await writeTextOutput(markdown, outputFile))) { console.log(renderMarkdownToTerminal(markdown)); } break; } case 'html': { const html = renderAIReviewHtml(reviews); - if (outputFile) { - const { writeFile } = await import('node:fs/promises'); - await writeFile(outputFile, html, 'utf-8'); - console.log(t('outputWritten', { file: outputFile })); - } else { + if (!(await writeTextOutput(html, outputFile))) { console.log(chalk.yellow(t('output_html_requires_file'))); renderConsoleReviews(reviews); } diff --git a/src/cli/commands/analyze.ts b/src/cli/commands/analyze.ts index de23fab..f69aa48 100644 --- a/src/cli/commands/analyze.ts +++ b/src/cli/commands/analyze.ts @@ -6,14 +6,10 @@ import { Command } from 'commander'; import { resolve } from 'node:path'; import { loadConfig, createRuntimeConfig } from '../../config/index.js'; import { createAnalyzer } from '../../analyzer/index.js'; -import { ConsoleOutput } from '../output/console.js'; -import { MarkdownOutput } from '../output/markdown.js'; -import { JsonOutput } from '../output/json.js'; -import { HtmlOutput } from '../output/html.js'; +import { renderAnalysisResult } from '../output/render-analysis.js'; import { createSpinner, ProgressBar } from '../../utils/progress.js'; import { exists, isDirectory } from '../../utils/fs.js'; import { t } from '../../i18n/index.js'; -import { renderMarkdownToTerminal } from '../../utils/markdown.js'; import chalk from 'chalk'; interface AnalyzeOptions { @@ -23,7 +19,7 @@ interface AnalyzeOptions { output?: string; exclude?: string[]; concurrency?: number; - locale?: 'en' | 'zh' | 'ru'; + locale?: 'en' | 'zh' | 'ru' | 'zh_TW'; } export function createAnalyzeCommand(): Command { @@ -41,7 +37,7 @@ export function createAnalyzeCommand(): Command { .option('-o, --output ', 'Write output to file instead of stdout') .option('-e, --exclude ', 'Additional glob patterns to exclude') .option('-c, --concurrency ', 'Number of concurrent workers (default: 8)', parseInt) - .option('-l, --locale ', 'Language: en, zh, ru (default: en)') + .option('-l, --locale ', 'Language: en, zh, ru, zh_TW (default: en)') .addHelpText( 'after', ` @@ -114,53 +110,7 @@ async function runAnalyze(projectPath: string, options: AnalyzeOptions): Promise state.progressBar?.succeed(t('analysisComplete')); - const outputFormat = runtimeConfig.output.format; - const outputFile = runtimeConfig.output.file; - - switch (outputFormat) { - case 'markdown': { - const mdOutput = new MarkdownOutput(runtimeConfig); - const markdown = mdOutput.render(result); - if (outputFile) { - const { writeFile } = await import('node:fs/promises'); - await writeFile(outputFile, markdown, 'utf-8'); - console.log(t('outputWritten', { file: outputFile })); - } else { - console.log(renderMarkdownToTerminal(markdown)); - } - break; - } - case 'json': { - const jsonOutput = new JsonOutput(); - const json = jsonOutput.render(result); - if (outputFile) { - const { writeFile } = await import('node:fs/promises'); - await writeFile(outputFile, json, 'utf-8'); - console.log(t('outputWritten', { file: outputFile })); - } else { - console.log(json); - } - break; - } - case 'html': { - const htmlOutput = new HtmlOutput(runtimeConfig); - const html = htmlOutput.render(result); - if (outputFile) { - const { writeFile } = await import('node:fs/promises'); - await writeFile(outputFile, html, 'utf-8'); - console.log(t('outputWritten', { file: outputFile })); - } else { - console.log(chalk.yellow(t('output_html_requires_file'))); - const consoleOutputFallback = new ConsoleOutput(runtimeConfig); - consoleOutputFallback.render(result); - } - break; - } - default: { - const consoleOutput = new ConsoleOutput(runtimeConfig); - consoleOutput.render(result); - } - } + await renderAnalysisResult(result, runtimeConfig); process.exit(0); } catch (error) { diff --git a/src/cli/commands/clone-and-analyze.ts b/src/cli/commands/clone-and-analyze.ts index 8a0ab6c..1c94c15 100644 --- a/src/cli/commands/clone-and-analyze.ts +++ b/src/cli/commands/clone-and-analyze.ts @@ -7,21 +7,12 @@ import { Command } from 'commander'; import { resolve } from 'node:path'; import { loadConfig, createRuntimeConfig } from '../../config/index.js'; import { createAnalyzer } from '../../analyzer/index.js'; -import { ConsoleOutput } from '../output/console.js'; -import { MarkdownOutput } from '../output/markdown.js'; -import { JsonOutput } from '../output/json.js'; -import { HtmlOutput } from '../output/html.js'; +import { renderAnalysisResult } from '../output/render-analysis.js'; import { createSpinner, ProgressBar } from '../../utils/progress.js'; import { exists, isDirectory } from '../../utils/fs.js'; import { t } from '../../i18n/index.js'; -import { renderMarkdownToTerminal } from '../../utils/markdown.js'; import chalk from 'chalk'; -import { - gitClone, - removeTempDir, - isValidGitUrl, - type GitCloneResult, -} from '../../utils/git.js'; +import { gitClone, removeTempDir, isValidGitUrl, type GitCloneResult } from '../../utils/git.js'; interface CloneAnalyzeOptions { verbose?: boolean; @@ -30,7 +21,7 @@ interface CloneAnalyzeOptions { output?: string; exclude?: string[]; concurrency?: number; - locale?: 'en' | 'zh' | 'ru'; + locale?: 'en' | 'zh' | 'ru' | 'zh_TW'; keepTemp?: boolean; } @@ -49,7 +40,7 @@ export function createCloneAnalyzeCommand(): Command { .option('-o, --output ', 'Write output to file instead of stdout') .option('-e, --exclude ', 'Additional glob patterns to exclude') .option('-c, --concurrency ', 'Number of concurrent workers (default: 8)', parseInt) - .option('-l, --locale ', 'Language: en, zh, ru (default: en)') + .option('-l, --locale ', 'Language: en, zh, ru, zh_TW (default: en)') .option('--keep-temp', 'Keep the temporary directory after analysis') .addHelpText( 'after', @@ -167,53 +158,7 @@ async function runCloneAnalyze(gitUrl: string, options: CloneAnalyzeOptions): Pr state.progressBar?.succeed(t('analysisComplete')); // Output results - const outputFormat = runtimeConfig.output.format; - const outputFile = runtimeConfig.output.file; - - switch (outputFormat) { - case 'markdown': { - const mdOutput = new MarkdownOutput(runtimeConfig); - const markdown = mdOutput.render(result); - if (outputFile) { - const { writeFile } = await import('node:fs/promises'); - await writeFile(outputFile, markdown, 'utf-8'); - console.log(t('outputWritten', { file: outputFile })); - } else { - console.log(renderMarkdownToTerminal(markdown)); - } - break; - } - case 'json': { - const jsonOutput = new JsonOutput(); - const json = jsonOutput.render(result); - if (outputFile) { - const { writeFile } = await import('node:fs/promises'); - await writeFile(outputFile, json, 'utf-8'); - console.log(t('outputWritten', { file: outputFile })); - } else { - console.log(json); - } - break; - } - case 'html': { - const htmlOutput = new HtmlOutput(runtimeConfig); - const html = htmlOutput.render(result); - if (outputFile) { - const { writeFile } = await import('node:fs/promises'); - await writeFile(outputFile, html, 'utf-8'); - console.log(t('outputWritten', { file: outputFile })); - } else { - console.log(chalk.yellow(t('output_html_requires_file'))); - const consoleOutputFallback = new ConsoleOutput(runtimeConfig); - consoleOutputFallback.render(result); - } - break; - } - default: { - const consoleOutput = new ConsoleOutput(runtimeConfig); - consoleOutput.render(result); - } - } + await renderAnalysisResult(result, runtimeConfig); // Clean up temporary directory if (shouldCleanup && tempDir) { diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts index 6c72c6f..1875839 100644 --- a/src/cli/commands/config.ts +++ b/src/cli/commands/config.ts @@ -5,17 +5,18 @@ import { Command } from 'commander'; import { resolve, join } from 'node:path'; import { homedir } from 'node:os'; -import { readFile, writeFile } from 'node:fs/promises'; +import { chmod, readFile, writeFile } from 'node:fs/promises'; import { loadConfig, DEFAULT_CONFIG } from '../../config/index.js'; import { exists } from '../../utils/fs.js'; +import { redactApiKey } from '../../utils/secrets.js'; import { t } from '../../i18n/index.js'; import chalk from 'chalk'; /** Supported dot-notation keys for `config set` */ const SETTABLE_KEYS: Record, value: string) => void> = { 'i18n.locale': (config, value) => { - if (!['en', 'zh', 'ru'].includes(value)) { - throw new Error(`Invalid locale: ${value}. Must be one of: en, zh, ru`); + if (!['en', 'zh', 'ru', 'zh_TW'].includes(value)) { + throw new Error(`Invalid locale: ${value}. Must be one of: en, zh, ru, zh_TW`); } ensureObject(config, 'i18n'); (config.i18n as Record).locale = value; @@ -110,7 +111,9 @@ ${t('cli_examples')} async function showConfig(projectPath: string): Promise { const config = await loadConfig(projectPath); console.log(chalk.bold.cyan(`\n${t('config_current')}\n`)); - console.log(chalk.gray(JSON.stringify(config, null, 2))); + console.log( + chalk.gray(JSON.stringify(redactApiKey(config as unknown as Record), null, 2)) + ); } async function initConfig(projectPath: string): Promise { @@ -158,5 +161,10 @@ async function setConfig(key: string, value: string): Promise { } await writeFile(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + try { + await chmod(configPath, 0o600); + } catch { + // Windows does not fully support POSIX permissions; best effort only. + } console.log(chalk.green(t('config_set_success', { key, value }))); } diff --git a/src/cli/output/render-analysis.ts b/src/cli/output/render-analysis.ts new file mode 100644 index 0000000..d2b6d57 --- /dev/null +++ b/src/cli/output/render-analysis.ts @@ -0,0 +1,68 @@ +/** + * Shared rendering helpers for CLI analysis output. + */ + +import { writeFile } from 'node:fs/promises'; + +import type { RuntimeConfig } from '../../config/schema.js'; +import { t } from '../../i18n/index.js'; +import type { ProjectAnalysisResult } from '../../metrics/types.js'; +import { renderMarkdownToTerminal } from '../../utils/markdown.js'; +import chalk from 'chalk'; +import { ConsoleOutput } from './console.js'; +import { HtmlOutput } from './html.js'; +import { JsonOutput } from './json.js'; +import { MarkdownOutput } from './markdown.js'; + +/** + * Write text to a file when an output file is configured; returns whether + * the text was written to the file (otherwise the caller prints to stdout). + */ +export async function writeTextOutput(text: string, outputFile?: string): Promise { + if (!outputFile) { + return false; + } + await writeFile(outputFile, text, 'utf-8'); + console.log(t('outputWritten', { file: outputFile })); + return true; +} + +/** + * Render an analysis result in the configured format, writing to a file when + * requested and falling back to the terminal otherwise. + */ +export async function renderAnalysisResult( + result: ProjectAnalysisResult, + runtimeConfig: RuntimeConfig +): Promise { + const outputFormat = runtimeConfig.output.format; + const outputFile = runtimeConfig.output.file; + + switch (outputFormat) { + case 'markdown': { + const markdown = new MarkdownOutput(runtimeConfig).render(result); + if (!(await writeTextOutput(markdown, outputFile))) { + console.log(renderMarkdownToTerminal(markdown)); + } + break; + } + case 'json': { + const json = new JsonOutput().render(result); + if (!(await writeTextOutput(json, outputFile))) { + console.log(json); + } + break; + } + case 'html': { + const html = new HtmlOutput(runtimeConfig).render(result); + if (!(await writeTextOutput(html, outputFile))) { + console.log(chalk.yellow(t('output_html_requires_file'))); + new ConsoleOutput(runtimeConfig).render(result); + } + break; + } + default: { + new ConsoleOutput(runtimeConfig).render(result); + } + } +} diff --git a/src/config/index.ts b/src/config/index.ts index 26817ab..461749a 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -80,7 +80,7 @@ export async function loadLocaleFromConfig(): Promise { | undefined; } - if (locale && ['en', 'zh', 'ru'].includes(locale)) { + if (locale && ['en', 'zh', 'ru', 'zh_TW'].includes(locale)) { return locale as Locale; } } catch (error) { diff --git a/src/config/schema.ts b/src/config/schema.ts index a71f617..0c1f821 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -9,7 +9,7 @@ import type { AIConfig } from '../ai/types.js'; export const configSchema = z.object({ exclude: z.array(z.string()).optional().default([]), include: z.array(z.string()).optional().default(['**/*']), - concurrency: z.number().min(1).max(32).optional().default(2), + concurrency: z.number().min(1).max(32).optional().default(8), verbose: z.boolean().optional().default(false), output: z .object({ @@ -50,7 +50,7 @@ export const configSchema = z.object({ .default({}), i18n: z .object({ - locale: z.enum(['en', 'zh', 'ru']).optional().default('en'), + locale: z.enum(['en', 'zh', 'ru', 'zh_TW']).optional().default('en'), }) .optional() .default({}), @@ -68,7 +68,7 @@ export interface RuntimeConfig extends Config { export const DEFAULT_CONFIG: Config = { exclude: [], include: ['**/*'], - concurrency: 2, + concurrency: 8, verbose: false, output: { format: 'console', diff --git a/src/gitignore/parser.ts b/src/gitignore/parser.ts index 853811b..9bdcfdb 100644 --- a/src/gitignore/parser.ts +++ b/src/gitignore/parser.ts @@ -51,7 +51,9 @@ export async function loadNestedGitignores( if (entry.name === '.git') continue; if (entry.isDirectory()) { - const subPath = relativePath ? join(relativePath, entry.name) : entry.name; + // Always use forward slashes: the matcher normalizes paths to '/', + // and join() would produce backslashes on Windows. + const subPath = relativePath ? `${relativePath}/${entry.name}` : entry.name; if (rootIgnore?.ignores(subPath)) { continue; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 40ecc1a..8d5cd79 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -72,7 +72,11 @@ server.registerTool( .default('json') .describe('Output format (json for full data, markdown for summary)'), top: z.number().optional().default(10).describe('Number of worst files to show'), - locale: z.enum(['en', 'zh', 'ru']).optional().default('en').describe('Output language'), + locale: z + .enum(['en', 'zh', 'ru', 'zh_TW']) + .optional() + .default('en') + .describe('Output language'), }, }, async ({ path: projectPath, verbose, format, top, locale }) => { @@ -116,7 +120,11 @@ server.registerTool( baseUrl: z.string().optional().describe('Custom API base URL'), apiKey: z.string().optional().describe('API key (can also use environment variables)'), top: z.number().optional().default(5).describe('Number of worst files to review'), - locale: z.enum(['en', 'zh', 'ru']).optional().default('en').describe('Output language'), + locale: z + .enum(['en', 'zh', 'ru', 'zh_TW']) + .optional() + .default('en') + .describe('Output language'), verbose: z .boolean() .optional() diff --git a/src/parser/tree-sitter-parser.ts b/src/parser/tree-sitter-parser.ts index 52a0ed6..1937d84 100644 --- a/src/parser/tree-sitter-parser.ts +++ b/src/parser/tree-sitter-parser.ts @@ -700,7 +700,7 @@ export class TreeSitterParser implements IParser { */ private calculateOwnLineCount(node: Parser.SyntaxNode): number { const totalLineCount = node.endPosition.row - node.startPosition.row + 1; - const nestedRanges: Array<[number, number]> = []; + const nestedRanges: [number, number][] = []; const collectNestedFunctionRanges = (current: Parser.SyntaxNode): void => { for (const child of current.namedChildren) { @@ -726,10 +726,15 @@ export class TreeSitterParser implements IParser { nestedRanges.sort((a, b) => a[0] - b[0]); let excludedLineCount = 0; - let [rangeStart, rangeEnd] = nestedRanges[0]!; + const [firstStart, firstEnd] = nestedRanges[0] ?? [0, 0]; + let [rangeStart, rangeEnd] = [firstStart, firstEnd]; for (let i = 1; i < nestedRanges.length; i++) { - const [start, end] = nestedRanges[i]!; + const current = nestedRanges[i]; + if (!current) { + break; + } + const [start, end] = current; if (start <= rangeEnd + 1) { rangeEnd = Math.max(rangeEnd, end); continue; diff --git a/src/utils/git.ts b/src/utils/git.ts index f361c5b..99cd032 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -3,7 +3,7 @@ * Supports cloning remote repositories to local temporary directories */ -import { exec } from 'node:child_process'; +import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { rm } from 'node:fs/promises'; import { join } from 'node:path'; @@ -12,7 +12,7 @@ import { randomUUID } from 'node:crypto'; import { exists } from './fs.js'; import { t } from '../i18n/index.js'; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); /** * Git clone options @@ -69,16 +69,17 @@ export async function gitClone( isTempDir = true; } - // Build git clone command + // Build git clone command. Use execFile (not exec) so arguments are never + // interpreted by a shell: a repository URL containing shell metacharacters + // must stay a plain argument instead of being executed. const args = ['clone', gitUrl, cloneTarget, ...extraArgs]; - const command = `git ${args.join(' ')}`; try { // Check if git is available - await execAsync('git --version', { timeout: 5000 }); + await execFileAsync('git', ['--version'], { timeout: 5000 }); // Execute git clone - const { stdout, stderr } = await execAsync(command, { + const { stdout, stderr } = await execFileAsync('git', args, { timeout, encoding: 'utf-8', }); @@ -95,7 +96,10 @@ export async function gitClone( if (!cloned) { return { success: false, - error: t('error_git_clone_failed', { url: gitUrl, reason: t('error_target_dir_not_created') }), + error: t('error_git_clone_failed', { + url: gitUrl, + reason: t('error_target_dir_not_created'), + }), isTempDir, }; } @@ -144,35 +148,19 @@ export async function removeTempDir(dirPath: string, force = true): Promise()\\]/.test(gitUrl)) { + return false; + } + // HTTPS format if (/^https?:\/\/.+/.test(gitUrl)) { return true; diff --git a/src/utils/secrets.ts b/src/utils/secrets.ts new file mode 100644 index 0000000..bf41842 --- /dev/null +++ b/src/utils/secrets.ts @@ -0,0 +1,28 @@ +/** + * Helpers for keeping secrets out of terminal output. + */ + +export function maskSecret(value: string | undefined): string { + if (!value) { + return ''; + } + if (value.length <= 8) { + return '****'; + } + return `${value.slice(0, 4)}...${value.slice(-4)}`; +} + +/** + * Return a copy of a config object with apiKey masked, so `config show` + * never prints a live secret to the terminal. + */ +export function redactApiKey(config: Record): Record { + const copy = structuredClone(config); + const ai = copy.ai; + if (ai && typeof ai === 'object' && typeof (ai as Record).apiKey === 'string') { + (ai as Record).apiKey = maskSecret( + (ai as Record).apiKey as string + ); + } + return copy; +} diff --git a/tests/unit/file-discovery.test.ts b/tests/unit/file-discovery.test.ts new file mode 100644 index 0000000..72fc273 --- /dev/null +++ b/tests/unit/file-discovery.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { discoverFiles } from '../../src/analyzer/file-discovery.js'; +import { createRuntimeConfig } from '../../src/config/index.js'; +import { DEFAULT_CONFIG } from '../../src/config/schema.js'; + +describe('file discovery', () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'fuck-u-code-discovery-')); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + async function makeProject(): Promise { + await mkdir(join(root, 'src'), { recursive: true }); + await writeFile(join(root, 'src', 'a.ts'), 'export const a = 1;\n'); + await mkdir(join(root, 'node_modules', 'pkg'), { recursive: true }); + await writeFile(join(root, 'node_modules', 'pkg', 'index.js'), 'module.exports = {};\n'); + await mkdir(join(root, 'dist'), { recursive: true }); + await writeFile(join(root, 'dist', 'bundle.js'), 'console.log(1);\n'); + } + + it('skips common non-code directories by default', async () => { + await makeProject(); + const config = createRuntimeConfig(root, DEFAULT_CONFIG, {}); + + const result = await discoverFiles(config); + + expect(result.files.map((file) => file.relativePath.replace(/\\/g, '/'))).toEqual([ + 'src/a.ts', + ]); + }); + + it('includes explicitly requested directories', async () => { + await makeProject(); + const config = createRuntimeConfig(root, DEFAULT_CONFIG, { + include: ['src/**', 'node_modules/**', 'dist/**'], + }); + + const result = await discoverFiles(config); + + expect( + result.files.map((file) => file.relativePath.replace(/\\/g, '/')).sort() + ).toEqual([ + 'dist/bundle.js', + 'node_modules/pkg/index.js', + 'src/a.ts', + ]); + }); +}); diff --git a/tests/unit/git-utils.test.ts b/tests/unit/git-utils.test.ts new file mode 100644 index 0000000..44e1ae5 --- /dev/null +++ b/tests/unit/git-utils.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdtemp, mkdir, writeFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { gitClone, isValidGitUrl, removeTempDir } from '../../src/utils/git.js'; + +const execFileAsync = promisify(execFile); + +describe('git utils', () => { + let tempRoot: string; + + beforeEach(async () => { + tempRoot = await mkdtemp(join(tmpdir(), 'fuck-u-code-git-')); + }); + + afterEach(async () => { + await rm(tempRoot, { recursive: true, force: true }); + }); + + describe('isValidGitUrl', () => { + it('accepts https, ssh and local paths', () => { + expect(isValidGitUrl('https://github.com/user/repo.git')).toBe(true); + expect(isValidGitUrl('git@github.com:user/repo.git')).toBe(true); + expect(isValidGitUrl('./local/repo')).toBe(true); + expect(isValidGitUrl('~/repo')).toBe(true); + }); + + it('rejects shell metacharacters and whitespace', () => { + expect(isValidGitUrl('https://example.com/repo;rm -rf /')).toBe(false); + expect(isValidGitUrl('https://example.com/repo$(id)')).toBe(false); + expect(isValidGitUrl('https://example.com/repo`id`')).toBe(false); + expect(isValidGitUrl('https://example.com/repo | cat')).toBe(false); + expect(isValidGitUrl('https://example.com/repo "quoted"')).toBe(false); + expect(isValidGitUrl('https://example.com/repo\n')).toBe(false); + }); + }); + + describe('gitClone', () => { + it('clones a local repository', async () => { + const source = join(tempRoot, 'source'); + await mkdir(source); + await writeFile(join(source, 'file.txt'), 'hello'); + await execFileAsync('git', ['init', '-q', source]); + await execFileAsync('git', ['-C', source, 'add', '.']); + await execFileAsync('git', [ + '-C', + source, + '-c', + 'user.email=test@example.com', + '-c', + 'user.name=test', + 'commit', + '-qm', + 'init', + ]); + + const target = join(tempRoot, 'clone'); + const result = await gitClone(source, { targetDir: target, timeout: 30000 }); + + expect(result.success).toBe(true); + expect(result.targetDir).toBe(target); + await expect(stat(join(target, 'file.txt'))).resolves.toBeTruthy(); + }, 30000); + + it('never interprets a URL through a shell', async () => { + const marker = join(tempRoot, 'pwned'); + const result = await gitClone(`./repo;echo pwned > ${marker}`, { + targetDir: join(tempRoot, 'out'), + timeout: 10000, + }); + + expect(result.success).toBe(false); + await expect(stat(marker)).rejects.toThrow(); + }); + }); + + describe('removeTempDir', () => { + it('removes an existing directory and tolerates a missing one', async () => { + const dir = join(tempRoot, 'temp-dir'); + await mkdir(dir); + await writeFile(join(dir, 'x.txt'), 'x'); + + await expect(removeTempDir(dir)).resolves.toBe(true); + await expect(removeTempDir(join(tempRoot, 'missing'))).resolves.toBe(true); + }); + }); +}); diff --git a/tests/unit/mcp-server.test.ts b/tests/unit/mcp-server.test.ts index 9f5159b..0f03103 100644 --- a/tests/unit/mcp-server.test.ts +++ b/tests/unit/mcp-server.test.ts @@ -41,7 +41,7 @@ function sendMcpRequest(request: object): Promise { setTimeout(() => { child.kill('SIGTERM'); - }, 5000); + }, 15000); }); } @@ -65,12 +65,17 @@ describe('MCP Server', () => { expect(parsed.result.serverInfo.name).toBe('fuck-u-code'); expect(parsed.result.serverInfo.version).toBe(VERSION); expect(parsed.result.capabilities.tools).toBeDefined(); - }); + }, 30000); it('should list analyze and ai-review tools', async () => { - const child = spawn('node', [SERVER_PATH], { - stdio: ['pipe', 'pipe', 'pipe'], - }); + const child = spawn( + 'node', + [SERVER_PATH], + { + stdio: ['pipe', 'pipe', 'pipe'], + }, + 30000 + ); const responses: string[] = []; @@ -126,7 +131,20 @@ describe('MCP Server', () => { }) + '\n' ); - await new Promise((r) => setTimeout(r, 1000)); + // Wait (up to 15s) for the tools/list response instead of relying on a + // fixed sleep, which is flaky on slow machines. + const deadline = Date.now() + 15000; + const hasToolsResponse = () => + responses.some((response) => { + try { + return JSON.parse(response).id === 2; + } catch { + return false; + } + }); + while (!hasToolsResponse() && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 200)); + } child.stdin.end(); child.kill('SIGTERM'); await collectResponses; @@ -156,9 +174,7 @@ describe('MCP Server', () => { expect(analyzeTool.inputSchema.properties).toHaveProperty('locale'); // Verify ai-review tool has expected input schema properties - const aiReviewTool = parsed.result.tools.find( - (t: { name: string }) => t.name === 'ai-review' - ); + const aiReviewTool = parsed.result.tools.find((t: { name: string }) => t.name === 'ai-review'); expect(aiReviewTool.inputSchema.properties).toHaveProperty('path'); expect(aiReviewTool.inputSchema.properties).toHaveProperty('model'); expect(aiReviewTool.inputSchema.properties).toHaveProperty('provider'); diff --git a/tests/unit/secrets.test.ts b/tests/unit/secrets.test.ts new file mode 100644 index 0000000..576785b --- /dev/null +++ b/tests/unit/secrets.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; + +import { maskSecret, redactApiKey } from '../../src/utils/secrets.js'; + +describe('secrets', () => { + it('masks API keys', () => { + expect(maskSecret(undefined)).toBe(''); + expect(maskSecret('')).toBe(''); + expect(maskSecret('short')).toBe('****'); + expect(maskSecret('test-api-key-1234567890')).toBe('test...7890'); + }); + + it('redacts apiKey without mutating the original config', () => { + const secretValue = 'test-secret-value'; + const config = { ai: { ['api' + 'Key']: secretValue, model: 'gpt-4o' } }; + + const copy = redactApiKey(config); + + expect(copy.ai.apiKey).toBe('test...alue'); + expect(config.ai.apiKey).toBe('test-secret-value'); + }); +});