Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 12 additions & 1 deletion src/cli/commands/queryFilesCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,16 @@ export const queryFilesCommand = new Command('query-files')
.option('--repo-map-symbols <n>', 'Max repo map symbols per file', '5')
.option('--wiki <dir>', 'Wiki directory (default: docs/wiki or wiki)', '')
.action(async (pattern, options) => {
await executeHandler('query-files', { pattern, ...options });
const limit = parseInt(options.limit, 10);
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ WARNING: parseInt 失败时未处理 NaN

parseInt 在处理非数字字符串时返回 NaN(如 --limit abc),后续 executeHandler 收到 NaN 可能导致静默错误或意外行为

建议: 添加验证逻辑,过滤无效值:
const limit = /^-?\d+$/.test(options.limit) ? parseInt(options.limit, 10) : undefined;
或使用默认值兜底

Suggested change
const limit = parseInt(options.limit, 10);
const limit = parseInt(options.limit, 10);
if (isNaN(limit)) {
console.error(`Invalid limit: ${options.limit}`);
return;
}

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 SUGGESTION: 缺乏参数验证提示

CLI 参数错误时未向用户输出友好的错误信息,用户无法得知应如何修正

建议: 捕获无效输入并输出用法提示,帮助用户正确使用命令

const maxCandidates = parseInt(options.maxCandidates, 10);
const repoMapFiles = parseInt(options.repoMapFiles, 10);
const repoMapSymbols = parseInt(options.repoMapSymbols, 10);
await executeHandler('query-files', {
pattern,
...options,
limit,
maxCandidates,
repoMapFiles,
repoMapSymbols,
Comment on lines +18 to +28
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing numeric options with parseInt here weakens validation compared to the Zod schema coercion: values like --limit 10abc will be accepted as 10 instead of rejected. Either pass the raw string values through to executeHandler and let SearchFilesSchema coerce/validate them, or explicitly validate that the entire string is a base-10 integer (and error on NaN / trailing junk).

Suggested change
const limit = parseInt(options.limit, 10);
const maxCandidates = parseInt(options.maxCandidates, 10);
const repoMapFiles = parseInt(options.repoMapFiles, 10);
const repoMapSymbols = parseInt(options.repoMapSymbols, 10);
await executeHandler('query-files', {
pattern,
...options,
limit,
maxCandidates,
repoMapFiles,
repoMapSymbols,
await executeHandler('query-files', {
pattern,
...options,

Copilot uses AI. Check for mistakes.
});
});
121 changes: 41 additions & 80 deletions src/cli/handlers/queryFilesHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,85 +1,55 @@
import path from 'path';
import fs from 'fs-extra';
import { inferWorkspaceRoot, resolveGitRoot } from '../../core/git';
import { defaultDbDir, openTablesByLang, type IndexLang } from '../../core/lancedb';
import { queryManifestWorkspace } from '../../core/workspace';
import { inferSymbolSearchMode, type SymbolSearchMode } from '../../core/symbolSearch';
import { createLogger } from '../../core/log';
import { resolveLangs } from '../../core/indexCheck';
import { generateRepoMap, type FileRank } from '../../core/repoMap';
import type { CLIResult, CLIError } from '../types';
import { success, error } from '../types';
import { resolveRepoContext, validateIndex, resolveLanguages, type RepoContext } from '../helpers';
import type { SearchFilesInput } from '../schemas/queryFilesSchemas';

function isCLIError(value: unknown): value is CLIError {
return typeof value === 'object' && value !== null && 'ok' in value && (value as any).ok === false;
}

async function buildRepoMapAttachment(
repoRoot: string,
options: { wiki: string; repoMapFiles: number; repoMapSymbols: number }
): Promise<{ enabled: boolean; wikiDir: string; files: FileRank[] } | { enabled: boolean; skippedReason: string }> {
try {
const wikiDir = resolveWikiDir(repoRoot, options.wiki);
const files = await generateRepoMap({
repoRoot,
maxFiles: options.repoMapFiles,
maxSymbolsPerFile: options.repoMapSymbols,
wikiDir,
});
return { enabled: true, wikiDir, files };
} catch (e: any) {
return { enabled: false, skippedReason: String(e?.message ?? e) };
}
}

function resolveWikiDir(repoRoot: string, wikiOpt: string): string {
const w = String(wikiOpt ?? '').trim();
if (w) return path.resolve(repoRoot, w);
const candidates = [path.join(repoRoot, 'docs', 'wiki'), path.join(repoRoot, 'wiki')];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
return '';
}

function inferLangFromFile(file: string): IndexLang {
const f = String(file);
if (f.endsWith('.md') || f.endsWith('.mdx')) return 'markdown';
if (f.endsWith('.yml') || f.endsWith('.yaml')) return 'yaml';
if (f.endsWith('.java')) return 'java';
if (f.endsWith('.c') || f.endsWith('.h')) return 'c';
if (f.endsWith('.go')) return 'go';
if (f.endsWith('.py')) return 'python';
if (f.endsWith('.rs')) return 'rust';
return 'ts';
}

function filterWorkspaceRowsByLang(rows: any[], langSel: string): any[] {
const sel = String(langSel ?? 'auto');
if (sel === 'auto' || sel === 'all') return rows;
const target = sel as IndexLang;
return rows.filter(r => inferLangFromFile(String((r as any).file ?? '')) === target);
}
import {
isCLIError,
buildRepoMapAttachment,
filterWorkspaceRowsByLang,
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

filterWorkspaceRowsByLang is imported but no longer used after workspace mode was changed to return an error. Please remove the unused import to keep the module tidy.

Suggested change
filterWorkspaceRowsByLang,

Copilot uses AI. Check for mistakes.
} from './sharedHelpers';

function escapeQuotes(s: string): string {
return s.replace(/'/g, "''");
}

/**
* Convert a glob pattern to a SQL LIKE pattern.
* Escapes SQL LIKE wildcards (% and _), then converts glob * to % and ? to _.
*/
function globToSqlLike(pattern: string): string {
let like = pattern.replace(/\\/g, '\\\\').replace(/([%_])/g, '\\$1');
like = like.replace(/\*/g, '%').replace(/\?/g, '_');
return like;
}

function buildFileWhere(pattern: string, mode: SymbolSearchMode, caseInsensitive: boolean): string | null {
const safe = escapeQuotes(pattern);
if (!safe) return null;
if (!pattern) return null;
const likeOp = caseInsensitive ? 'ILIKE' : 'LIKE';

if (mode === 'prefix') {
const safe = escapeQuotes(pattern);
if (!safe) return null;
return `file ${likeOp} '${safe}%'`;
}

if (mode === 'substring' || mode === 'wildcard') {
if (mode === 'substring') {
const safe = escapeQuotes(pattern);
if (!safe) return null;
return `file ${likeOp} '%${safe}%'`;
}

if (mode === 'wildcard') {
const likePattern = globToSqlLike(pattern);
const safeLike = escapeQuotes(likePattern);
if (!safeLike) return null;
return `file ${likeOp} '${safeLike}'`;
}

// For regex and fuzzy, we'll handle them in memory after fetching
return null;
}
Expand Down Expand Up @@ -190,36 +160,28 @@ export async function handleSearchFiles(input: SearchFilesInput): Promise<CLIRes
const repoRoot = await resolveGitRoot(path.resolve(input.path));
const mode = inferSymbolSearchMode(input.pattern, input.mode);

// Workspace mode is not supported for query-files because
// queryManifestWorkspace queries by symbol, not by file name.
if (inferWorkspaceRoot(repoRoot)) {
const res = await queryManifestWorkspace({
manifestRepoRoot: repoRoot,
keyword: input.pattern,
limit: input.maxCandidates,
});
const filteredByLang = filterWorkspaceRowsByLang(res.rows, input.lang);
const rows = filterAndRankFileRows(
filteredByLang,
input.pattern,
mode,
input.caseInsensitive,
input.limit
);
const durationMs = Date.now() - startedAt;
log.info('query_files', {
ok: true,
ok: false,
repoRoot,
workspace: true,
mode,
case_insensitive: input.caseInsensitive,
limit: input.limit,
max_candidates: input.maxCandidates,
candidates: res.rows.length,
rows: rows.length,
duration_ms: Date.now() - startedAt,
candidates: 0,
rows: 0,
duration_ms: durationMs,
error: 'workspace_mode_not_supported_for_query_files',
});
return error('workspace_mode_not_supported_for_query_files', {
message:
'query-files does not currently support workspace manifests. ' +
'Please run this command from a non-workspace repository root or disable workspace mode.',
});
const repoMap = input.withRepoMap
? { enabled: false, skippedReason: 'workspace_mode_not_supported' }
: undefined;
return success({ ...res, rows, ...(repoMap ? { repo_map: repoMap } : {}) });
}

const ctxOrError = await resolveRepoContext(input.path);
Expand Down Expand Up @@ -256,7 +218,6 @@ export async function handleSearchFiles(input: SearchFilesInput): Promise<CLIRes
const t = byLang[lang as IndexLang];
if (!t) continue;

// Fetch candidates based on mode
// For regex/fuzzy, we fetch all and filter in memory
const shouldFetchAll = mode === 'regex' || mode === 'fuzzy';
const rows = shouldFetchAll
Expand Down
59 changes: 5 additions & 54 deletions src/cli/handlers/queryHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,66 +1,17 @@
import path from 'path';
import fs from 'fs-extra';
import { inferWorkspaceRoot, resolveGitRoot } from '../../core/git';
import { defaultDbDir, openTablesByLang, type IndexLang } from '../../core/lancedb';
import { queryManifestWorkspace } from '../../core/workspace';
import { buildCoarseWhere, filterAndRankSymbolRows, inferSymbolSearchMode, pickCoarseToken, type SymbolSearchMode } from '../../core/symbolSearch';
import { createLogger } from '../../core/log';
import { checkIndex, resolveLangs } from '../../core/indexCheck';
import { generateRepoMap, type FileRank } from '../../core/repoMap';
import type { CLIResult, CLIError } from '../types';
import { success, error } from '../types';
import { resolveRepoContext, validateIndex, resolveLanguages, type RepoContext } from '../helpers';

function isCLIError(value: unknown): value is CLIError {
return typeof value === 'object' && value !== null && 'ok' in value && (value as any).ok === false;
}

async function buildRepoMapAttachment(
repoRoot: string,
options: { wiki: string; repoMapFiles: number; repoMapSymbols: number }
): Promise<{ enabled: boolean; wikiDir: string; files: FileRank[] } | { enabled: boolean; skippedReason: string }> {
try {
const wikiDir = resolveWikiDir(repoRoot, options.wiki);
const files = await generateRepoMap({
repoRoot,
maxFiles: options.repoMapFiles,
maxSymbolsPerFile: options.repoMapSymbols,
wikiDir,
});
return { enabled: true, wikiDir, files };
} catch (e: any) {
return { enabled: false, skippedReason: String(e?.message ?? e) };
}
}

function resolveWikiDir(repoRoot: string, wikiOpt: string): string {
const w = String(wikiOpt ?? '').trim();
if (w) return path.resolve(repoRoot, w);
const candidates = [path.join(repoRoot, 'docs', 'wiki'), path.join(repoRoot, 'wiki')];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
return '';
}

function inferLangFromFile(file: string): IndexLang {
const f = String(file);
if (f.endsWith('.md') || f.endsWith('.mdx')) return 'markdown';
if (f.endsWith('.yml') || f.endsWith('.yaml')) return 'yaml';
if (f.endsWith('.java')) return 'java';
if (f.endsWith('.c') || f.endsWith('.h')) return 'c';
if (f.endsWith('.go')) return 'go';
if (f.endsWith('.py')) return 'python';
if (f.endsWith('.rs')) return 'rust';
return 'ts';
}

function filterWorkspaceRowsByLang(rows: any[], langSel: string): any[] {
const sel = String(langSel ?? 'auto');
if (sel === 'auto' || sel === 'all') return rows;
const target = sel as IndexLang;
return rows.filter(r => inferLangFromFile(String((r as any).file ?? '')) === target);
}
import {
isCLIError,
buildRepoMapAttachment,
filterWorkspaceRowsByLang,
} from './sharedHelpers';

export async function handleSearchSymbols(input: {
keyword: string;
Expand Down
67 changes: 67 additions & 0 deletions src/cli/handlers/sharedHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import path from 'path';
import fs from 'fs-extra';
import type { IndexLang } from '../../core/lancedb';
import { generateRepoMap, type FileRank } from '../../core/repoMap';
import type { CLIError } from '../types';

export function isCLIError(value: unknown): value is CLIError {
return typeof value === 'object' && value !== null && 'ok' in value && (value as any).ok === false;
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 NIT: 类型守卫可进一步优化

使用 (value as any).ok 绕过了类型检查,略显冗余

建议: 使用可选链和更严格的类型检查

Suggested change
return typeof value === 'object' && value !== null && 'ok' in value && (value as any).ok === false;
return (
typeof value === 'object' &&
value !== null &&
'ok' in value &&
(value as CLIError).ok === false
);

}

export async function buildRepoMapAttachment(
repoRoot: string,
options: { wiki: string; repoMapFiles: number; repoMapSymbols: number }
): Promise<{ enabled: boolean; wikiDir: string; files: FileRank[] } | { enabled: boolean; skippedReason: string }> {
try {
const wikiDir = resolveWikiDir(repoRoot, options.wiki);
const files = await generateRepoMap({
repoRoot,
maxFiles: options.repoMapFiles,
maxSymbolsPerFile: options.repoMapSymbols,
wikiDir,
});
return { enabled: true, wikiDir, files };
} catch (e: any) {
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ WARNING: 使用 any 丢失错误类型信息

catch 块中使用 e: any 掩盖了错误的类型信息,不利于调试和错误追踪

建议: 使用 unknown 替代 any,并通过类型守卫安全访问错误信息

Suggested change
} catch (e: any) {
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
return { enabled: false, skippedReason: message };
}

return { enabled: false, skippedReason: String(e?.message ?? e) };
}
}

/**
* Resolve wiki directory, ensuring the resolved path stays within repoRoot
* to prevent path traversal attacks.
*/
export function resolveWikiDir(repoRoot: string, wikiOpt: string): string {
Comment on lines +29 to +33
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says wiki path traversal is addressed, but only query/query-files use the new sharedHelpers.resolveWikiDir. repo-map and semantic handlers still have their own resolveWikiDir implementations without the containment check (see src/cli/handlers/repoMapHandler.ts and src/cli/handlers/semanticHandlers.ts). Consider switching those handlers to import/use resolveWikiDir from sharedHelpers as well so the protection is consistent across commands.

Copilot uses AI. Check for mistakes.
const w = String(wikiOpt ?? '').trim();
if (w) {
const resolved = path.resolve(repoRoot, w);
// Prevent path traversal outside repoRoot
if (!resolved.startsWith(repoRoot + path.sep) && resolved !== repoRoot) {
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ WARNING: 路径遍历检查存在跨平台兼容性问题

使用 path.sep 进行路径比较在 Windows (\) 和 Unix (/) 混合环境中可能失效。推荐使用 path.relative() 进行更健壮的路径校验

建议: 改用 path.relative 进行安全校验

Suggested change
if (!resolved.startsWith(repoRoot + path.sep) && resolved !== repoRoot) {
const relative = path.relative(repoRoot, resolved);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
return '';
}

return '';
}
return resolved;
}
const candidates = [path.join(repoRoot, 'docs', 'wiki'), path.join(repoRoot, 'wiki')];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
Comment on lines +30 to +46
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveWikiDir only checks the lexical path prefix via startsWith, which can be bypassed by symlinks inside the repoRoot (e.g., --wiki docs/wikiLink pointing outside). Since generateRepoMap will read files from wikiDir, this can still allow reading outside the repo. Consider comparing fs.realpathSync(repoRoot) vs fs.realpathSync(resolved) (and/or using path.relative on realpaths) and returning an explicit error when the resolved realpath escapes repoRoot rather than silently returning ''.

Suggested change
* Resolve wiki directory, ensuring the resolved path stays within repoRoot
* to prevent path traversal attacks.
*/
export function resolveWikiDir(repoRoot: string, wikiOpt: string): string {
const w = String(wikiOpt ?? '').trim();
if (w) {
const resolved = path.resolve(repoRoot, w);
// Prevent path traversal outside repoRoot
if (!resolved.startsWith(repoRoot + path.sep) && resolved !== repoRoot) {
return '';
}
return resolved;
}
const candidates = [path.join(repoRoot, 'docs', 'wiki'), path.join(repoRoot, 'wiki')];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
* Check whether `candidate` is inside (or equal to) `root`, using a relative path
* comparison. Both arguments must be absolute, normalized paths.
*/
function isPathInside(root: string, candidate: string): boolean {
const rel = path.relative(root, candidate);
if (rel === '') return true;
return rel !== '..' && !rel.startsWith('..' + path.sep);
}
/**
* Resolve wiki directory, ensuring the resolved path stays within repoRoot
* (taking symlinks into account) to prevent path traversal attacks.
*/
export function resolveWikiDir(repoRoot: string, wikiOpt: string): string {
const w = String(wikiOpt ?? '').trim();
const rootReal = fs.realpathSync(repoRoot);
if (w) {
const resolved = path.resolve(repoRoot, w);
// If the target exists, validate using realpath to avoid symlink escapes.
if (fs.existsSync(resolved)) {
const resolvedReal = fs.realpathSync(resolved);
if (!isPathInside(rootReal, resolvedReal)) {
throw new Error(`Wiki directory must be inside the repository root: ${resolvedReal}`);
}
return resolvedReal;
}
// For non-existent paths, fall back to lexical containment check against the
// realpath of repoRoot. This still prevents obvious escapes like "../../..".
if (!isPathInside(rootReal, resolved)) {
throw new Error(`Wiki directory must be inside the repository root: ${resolved}`);
}
return resolved;
}
const candidates = [path.join(repoRoot, 'docs', 'wiki'), path.join(repoRoot, 'wiki')];
for (const c of candidates) {
if (!fs.existsSync(c)) continue;
const cReal = fs.realpathSync(c);
if (isPathInside(rootReal, cReal)) {
return cReal;
}
}

Copilot uses AI. Check for mistakes.
return '';
}

export function inferLangFromFile(file: string): IndexLang {
const f = String(file);
if (f.endsWith('.md') || f.endsWith('.mdx')) return 'markdown';
if (f.endsWith('.yml') || f.endsWith('.yaml')) return 'yaml';
if (f.endsWith('.java')) return 'java';
if (f.endsWith('.c') || f.endsWith('.h')) return 'c';
if (f.endsWith('.go')) return 'go';
if (f.endsWith('.py')) return 'python';
if (f.endsWith('.rs')) return 'rust';
return 'ts';
}

export function filterWorkspaceRowsByLang(rows: any[], langSel: string): any[] {
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 SUGGESTION: 函数参数和返回值缺少类型定义

函数签名使用 any[] 类型,丢失了 TypeScript 的类型安全优势

建议: 定义明确的接口类型来描述 rows 的结构

Suggested change
export function filterWorkspaceRowsByLang(rows: any[], langSel: string): any[] {
interface WorkspaceRow {
file: string;
// 其他字段...
}
export function filterWorkspaceRowsByLang(rows: WorkspaceRow[], langSel: string): WorkspaceRow[] {

const sel = String(langSel ?? 'auto');
if (sel === 'auto' || sel === 'all') return rows;
const target = sel as IndexLang;
return rows.filter(r => inferLangFromFile(String((r as any).file ?? '')) === target);
}
Loading
Loading