mirror of
https://github.com/Done-0/fuck-u-code.git
synced 2026-09-03 06:34:37 +08:00
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:
3
.github/workflows/auto-release.yml
vendored
3
.github/workflows/auto-release.yml
vendored
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })));
|
||||
}
|
||||
|
||||
68
src/cli/output/render-analysis.ts
Normal file
68
src/cli/output/render-analysis.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
28
src/utils/secrets.ts
Normal 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;
|
||||
}
|
||||
57
tests/unit/file-discovery.test.ts
Normal file
57
tests/unit/file-discovery.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
90
tests/unit/git-utils.test.ts
Normal file
90
tests/unit/git-utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
22
tests/unit/secrets.test.ts
Normal file
22
tests/unit/secrets.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user