fix: harden security and clean up code quality issues

- git clone now uses execFile (no shell) and rejects URLs with shell metacharacters; removed dead parseRepoName.

- config show masks apiKey; config file chmod 0600 after writing keys.

- CLI launcher reports signal kills as failures and supports FUCK_U_CODE_MAX_MEMORY (default 4096MB).

- File discovery: well-known non-code dirs are defaults, not hard overrides; user-included dirs are analyzed.

- Nested .gitignore loading uses forward slashes so it works on Windows.

- Deduplicated analysis output rendering; fixed fetch timeout cleanup; aligned concurrency default to 8; enabled zh_TW locale end to end.

- Fixed pre-existing lint errors and Windows-flaky MCP tests; added git-utils, secrets, file-discovery tests; release workflow now runs tests.
This commit is contained in:
Codex
2026-08-06 12:22:48 -07:00
parent 13d082ae74
commit 80002aebea
21 changed files with 424 additions and 201 deletions

View File

@@ -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'

View File

@@ -61,7 +61,7 @@ fuck-u-code analyze . -e "**/*.test.ts" # Exclude test files
| `--output <file>` | `-o` | Write to file |
| `--exclude <glob>` | `-e` | Additional exclude patterns |
| `--concurrency <n>` | `-c` | Concurrent workers (default 8) |
| `--locale <lang>` | `-l` | Language: en/zh/ru/zh-tw |
| `--locale <lang>` | `-l` | Language: en/zh/ru/zh_TW |
### AI Code Review

View File

@@ -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');
}

View File

@@ -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;

View File

@@ -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<FileDiscover
rootIgnore.add(pattern);
}
// Default-ignore well-known non-code directories unless the user explicitly
// included them. Adding them to the root matcher also lets the nested
// .gitignore walk skip these trees for performance.
const ignoredDirs = DEFAULT_IGNORED_DIRS.filter((dir) => !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) {

View File

@@ -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 <key>', 'API key (can also use environment variables)')
.option('-t, --top <number>', 'Number of worst files to review (default: 5)', parseInt)
.option('-v, --verbose', 'Show verbose output')
.option('-l, --locale <locale>', 'Language: en, zh, ru')
.option('-l, --locale <locale>', 'Language: en, zh, ru, zh_TW')
.option('-f, --format <format>', t('cmd_ai_review_format_help'))
.option('-o, --output <file>', '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);
}

View File

@@ -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 <file>', 'Write output to file instead of stdout')
.option('-e, --exclude <patterns...>', 'Additional glob patterns to exclude')
.option('-c, --concurrency <number>', 'Number of concurrent workers (default: 8)', parseInt)
.option('-l, --locale <locale>', 'Language: en, zh, ru (default: en)')
.option('-l, --locale <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) {

View File

@@ -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 <file>', 'Write output to file instead of stdout')
.option('-e, --exclude <patterns...>', 'Additional glob patterns to exclude')
.option('-c, --concurrency <number>', 'Number of concurrent workers (default: 8)', parseInt)
.option('-l, --locale <locale>', 'Language: en, zh, ru (default: en)')
.option('-l, --locale <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) {

View File

@@ -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<string, (config: Record<string, unknown>, 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<string, unknown>).locale = value;
@@ -110,7 +111,9 @@ ${t('cli_examples')}
async function showConfig(projectPath: string): Promise<void> {
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<string, unknown>), null, 2))
);
}
async function initConfig(projectPath: string): Promise<void> {
@@ -158,5 +161,10 @@ async function setConfig(key: string, value: string): Promise<void> {
}
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 })));
}

View File

@@ -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<boolean> {
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<void> {
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);
}
}
}

View File

@@ -80,7 +80,7 @@ export async function loadLocaleFromConfig(): Promise<Locale | undefined> {
| undefined;
}
if (locale && ['en', 'zh', 'ru'].includes(locale)) {
if (locale && ['en', 'zh', 'ru', 'zh_TW'].includes(locale)) {
return locale as Locale;
}
} catch (error) {

View File

@@ -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',

View File

@@ -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;

View File

@@ -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()

View File

@@ -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;

View File

@@ -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<bool
}
}
/**
* Parse git URL and extract repository name
* @param gitUrl Git repository URL
* @returns Repository name (without .git suffix)
*/
export function parseRepoName(gitUrl: string): string {
// Remove trailing .git
let url = gitUrl.replace(/\.git$/, '');
// Handle SSH format: git@github.com:user/repo
if (url.startsWith('git@')) {
const match = /git@[^:]+:(.+)/.exec(url);
if (match?.[1]) {
url = match[1];
}
}
// Get the last segment of the path
const parts = url.split('/');
const repoName = parts[parts.length - 1];
return repoName || 'unknown-repo';
}
/**
* Validate git URL format
* @param gitUrl URL to validate
* @returns Whether it is a valid git URL
*/
export function isValidGitUrl(gitUrl: string): boolean {
// Reject whitespace, control characters and shell metacharacters so a URL
// can never be interpreted as more than one argument (defense in depth
// alongside execFile, which does not invoke a shell).
if (/[\s;|&$`"'<>()\\]/.test(gitUrl)) {
return false;
}
// HTTPS format
if (/^https?:\/\/.+/.test(gitUrl)) {
return true;

28
src/utils/secrets.ts Normal file
View File

@@ -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<string, unknown>): Record<string, unknown> {
const copy = structuredClone(config);
const ai = copy.ai;
if (ai && typeof ai === 'object' && typeof (ai as Record<string, unknown>).apiKey === 'string') {
(ai as Record<string, unknown>).apiKey = maskSecret(
(ai as Record<string, unknown>).apiKey as string
);
}
return copy;
}

View File

@@ -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<void> {
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',
]);
});
});

View File

@@ -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);
});
});
});

View File

@@ -41,7 +41,7 @@ function sendMcpRequest(request: object): Promise<string> {
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');

View File

@@ -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');
});
});