From 409da6c6983ce2291831283fb8c49b9c03873372 Mon Sep 17 00:00:00 2001 From: v833 <2507301541@qq.com> Date: Tue, 26 May 2026 14:06:43 +0800 Subject: [PATCH] feat: add file mention completion Fixes #4 --- .env.example | 1 + AGENTS.md | 6 +- README.md | 20 +- src/config/runtime-config.ts | 4 + src/index.ts | 36 +- src/mentions/file-mentions.ts | 916 ++++++++++++++++++ src/mentions/index.ts | 1 + src/observability/audit.ts | 1 + src/terminal/App.tsx | 78 +- .../components/CommandSuggestions.tsx | 7 +- src/terminal/input.ts | 18 + src/terminal/runtime.tsx | 3 + src/utils/env.ts | 4 + tests/unit/file-mentions.test.ts | 235 +++++ tests/unit/runtime-config.test.ts | 5 + 15 files changed, 1328 insertions(+), 7 deletions(-) create mode 100644 src/mentions/file-mentions.ts create mode 100644 src/mentions/index.ts create mode 100644 tests/unit/file-mentions.test.ts diff --git a/.env.example b/.env.example index 9d32969..a3ce418 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,7 @@ Q_CODE_AUDIT_MAX_FILE_BYTES=52428800 Q_CODE_AUDIT_MAX_QUEUE_SIZE=1000 Q_CODE_AUDIT_PII= Q_CODE_CRASH_GUARD=true +Q_CODE_MENTION_ALLOW_ABS=false Q_CODE_SHELL_TIMEOUT_MS=60000 Q_CODE_SHELL_TIMEOUT_MAX_MS=1800000 Q_CODE_SHELL_MAX_BUFFER=4194304 diff --git a/AGENTS.md b/AGENTS.md index 27d4707..39d7eaf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ `q-code` 是一个基于 Vercel AI SDK 的 TypeScript 命令行 Agent 框架。核心能力包括: -- **Agent / 任务**:Agent Loop、Plan Mode、Task V2、TodoWrite、上下文压缩、会话持久化(JSONL append-only)、项目记忆、Skills、SubAgent、Agent Teams、Worktree 隔离。 +- **Agent / 任务**:Agent Loop、Plan Mode、Task V2、TodoWrite、上下文压缩、会话持久化(JSONL append-only)、`@file` 文件引用注入、项目记忆、Skills、SubAgent、Agent Teams、Worktree 隔离。 - **工具执行**:文件/搜索工具、可配置超时与 spill 的 Shell 工具、后台 Shell job(`f_status` / `f_tail` / `f_kill` / `f_list`)。 - **集成扩展**:MCP server、Hooks(pre/post tool-use 决策)、Slash 命令注册表、企业 AI 基建同步(Infra)、GitLab Wiki 知识库。 - **可观测性**:NDJSON 审计日志(默认开启)、崩溃保护(crash guard,默认开启)与 crash report、Usage / Cache / 成本统计、Token Budget。 @@ -76,6 +76,7 @@ pnpm build # 调 scripts/build.mjs,产出 dist/ - `src/runtime/`:早期 CLI 子命令路由(help/version/update/audit)、`getPackageVersion`、`runCliUpdate`、`installCrashGuard` 与崩溃报告生成。 - `src/config/`:`runtime-config.ts` 负责加载 `~/.q-code/config.toml`、`/.q-code/config.toml`、`.env`,统一映射到 `process.env`(支持多 section/alias)。 - `src/session/`:`SessionStore`(JSONL append-only、原子写入、cache 模式与 usage 记录持久化)。 +- `src/mentions/`:`@file` 文件引用解析、git/递归文件索引、fuzzy 排序、路径安全校验、文件内容截断和本轮上下文注入。 - `src/usage/`:token 归一化、定价、cache 策略、`UsageTracker` 与 `/usage` 渲染。 - `src/infra/`:企业 AI 基建配置同步(base URL / token / sync 状态 / 知识候选上报)。 - `src/gitlab-kb/`:GitLab Wiki 知识库读取/搜索/发布(`/gitlab-kb` 命令背后逻辑)。 @@ -103,6 +104,7 @@ pnpm build # 调 scripts/build.mjs,产出 dist/ - Prompt、工具描述、项目说明多为中文;新增用户可见文案时优先保持中文一致性。 - 新增环境变量需同时更新:(a) `.env.example`;(b) `src/config/runtime-config.ts` 的 `SECTION_ALIASES`(让 toml 配置可用);(c) README 配置表。 - 工具默认通过 `ToolRegistry.toAISDKFormat` 包装,会自动写 `tool.call` / `tool.result` 审计事件;新增工具入口或绕过 registry 时需自行接审计与 Hooks 管线(参考 `src/observability/audit.ts::getAuditLogger`)。 +- `@file` mention 默认只能引用当前工作目录内文件,并必须校验 symlink 解析后的真实路径;绝对路径必须显式设置 `Q_CODE_MENTION_ALLOW_ABS=true`,并写 `user.mention` 审计事件。单文件/总附件预算变更需同步 README 和 `src/mentions/file-mentions.ts` 常量。 - Shell 工具默认只能在当前 `cwd` 内执行;跳出目录必须显式设置 `Q_CODE_SHELL_ALLOW_ABS_CWD=true`。长命令优先使用 `timeoutMs` 或 `background=true`,超大输出通过 `/shell-spills` 恢复全文,后台 job 元数据写 `/shell-jobs`。 - 自定义工具目录固定为 `~/.q-code/tools//` 与 `/.q-code/tools//`;项目级覆盖用户级,用户级覆盖内置工具。每个工具目录必须提供 `schema.json`,其结构为 `Omit & { execute: string }`,其中 `execute` 会在该工具目录下作为 shell 命令运行。 - 新增 Slash 命令通过 `createSlashCommandRegistry` + `command(...)` 注册(见 `src/index.ts::createBuiltinSlashCommands`),并填好 `category`、`aliases`、`usage`,以便 `/help` 输出友好。 @@ -121,6 +123,7 @@ pnpm build # 调 scripts/build.mjs,产出 dist/ - Tool registry 改动:`vitest run tests/unit/tool-registry.test.ts` - Shell 工具改动:`vitest run tests/unit/shell-tools.test.ts tests/integration/shell-streaming.test.ts` - 自定义工具目录改动:`vitest run tests/unit/custom-tools.test.ts tests/unit/tool-registry.test.ts` + - `@file` 文件引用:`vitest run tests/unit/file-mentions.test.ts tests/unit/terminal.test.ts tests/unit/runtime-config.test.ts` - 终端/输入状态机改动:`vitest run tests/unit/terminal.test.ts` - 运行时配置/CLI 子命令:`vitest run tests/unit/runtime-config.test.ts tests/unit/cli-info.test.ts tests/unit/update.test.ts` - 崩溃保护:`vitest run tests/unit/crash-guard.test.ts tests/unit/mcp-bootstrap.test.ts tests/unit/audit-logger.test.ts` @@ -135,3 +138,4 @@ pnpm build # 调 scripts/build.mjs,产出 dist/ - 工作区可能存在用户改动;修改前先查看状态,避免覆盖不相关变更。 - pre-commit hook 由 `simple-git-hooks` 安装,默认执行 `pnpm precommit`。 - 只有在用户明确要求时才跳过 hook 或执行提交。 +- 发现值得提issue的想法时,可以直接提到github issue中 diff --git a/README.md b/README.md index 596b248..a362799 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # q-code -基于 AI SDK 的命令行 Agent 框架,支持工具调用、可后台运行的 Shell 长任务、Plan Mode、Task V2 持久化任务图、上下文自动压缩、会话持久化、跨对话项目记忆、Skills 渐进式披露、后台 SubAgent、Worktree 隔离、Agent Teams 多智能体协作和 MCP 扩展。 +基于 AI SDK 的命令行 Agent 框架,支持工具调用、可后台运行的 Shell 长任务、Plan Mode、Task V2 持久化任务图、上下文自动压缩、会话持久化、`@file` 文件引用、跨对话项目记忆、Skills 渐进式披露、后台 SubAgent、Worktree 隔离、Agent Teams 多智能体协作和 MCP 扩展。 ## 技术栈 @@ -110,6 +110,7 @@ cp .env.example .env | `Q_CODE_AUDIT_MAX_QUEUE_SIZE` | ❌ | 审计写入内存队列上限,默认 1000 | | `Q_CODE_AUDIT_PII` | ❌ | 默认不写 prompt/tool 原文;设为 `full` 才写入原文 | | `Q_CODE_CRASH_GUARD` | ❌ | 崩溃保护开关,默认开启;设为 `false` 可关闭全局兜底 handler | +| `Q_CODE_MENTION_ALLOW_ABS` | ❌ | 设为 true 后允许 `@file` 引用绝对路径;默认只允许当前目录内路径 | | `Q_CODE_SHELL_TIMEOUT_MS` | ❌ | `f` 同步命令默认超时,默认 60000ms | | `Q_CODE_SHELL_TIMEOUT_MAX_MS` | ❌ | `f.timeoutMs` 上限,默认 1800000ms(30 分钟) | | `Q_CODE_SHELL_MAX_BUFFER` | ❌ | `f` 同步输出内存阈值,默认 4194304(4MB),超出后落盘 spill | @@ -163,6 +164,22 @@ pnpm run continue # 恢复上次会话 默认在交互式 TTY 中启动 Ink TUI;非 TTY、`--classic` 或 `Q_CODE_TUI=0` 会回退到传统 readline。TUI 将 Agent 输出、工具调用、上下文占用、任务进度、后台 Agent 和 token 用量统一渲染为事件流,支持 `Shift+Enter`/`Ctrl+J` 多行输入、`Ctrl+R` 历史搜索、`Esc` 清空/恢复输入、忙时 `Ctrl+C` 中断当前任务和 Markdown 代码块/列表/表格展示。输入区使用真实终端光标锚定输入法候选窗,避免 macOS IME 跑到屏幕角落。 +### @file 文件引用 + +在 TUI 输入框中输入 `@` 后跟文件名片段,会出现基于仓库文件索引的 fuzzy 候选;使用方向键切换,`Tab` 插入当前候选。例如输入 `@rou` 可以补全到匹配的源码或文档路径。 + +提交消息时,`@file` 会把文件内容注入本轮用户上下文,并写入 `user.mention` 审计事件。支持以下形式: + +```text +请解释 @src/runtime/cli-info.ts +只看一行 @src/runtime/cli-info.ts:42 +只看范围 @src/runtime/cli-info.ts:10-30 +定位正则 @src/runtime/cli-info.ts:#getEarlyCliCommand +路径含空格 @"My Project/notes.md" +``` + +默认只允许引用当前工作目录内的文件,并会校验 symlink 指向的真实路径;绝对路径如 `@/etc/passwd` 会被阻止,确需引用绝对路径时设置 `Q_CODE_MENTION_ALLOW_ABS=true`。单个引用最多注入 50KB,单轮全部引用合计最多 200KB,超出时会截断或明确提示丢弃。文件候选优先使用 git 索引,非 git 目录会回退递归扫描;超过 20000 个文件时候选会裁剪并在 TUI 中提示。 + ### npm 发布 仓库已配置为可发布的 npm CLI 包: @@ -214,6 +231,7 @@ src/ │ └── memory-types.ts# 记忆类型定义与引导指令 ├── session/ │ └── store.ts # JSONL 会话持久化 +├── mentions/ # @file 文件引用解析、索引、fuzzy 补全和上下文注入 ├── skills/ # SKILL.md 加载、渐进式披露、条件激活 ├── agents/ │ ├── bootstrap.ts # SubAgent 启动加载 diff --git a/src/config/runtime-config.ts b/src/config/runtime-config.ts index 718448e..fe0c7f2 100644 --- a/src/config/runtime-config.ts +++ b/src/config/runtime-config.ts @@ -53,6 +53,7 @@ const SECTION_ALIASES: Record> = { audit_max_queue_size: 'Q_CODE_AUDIT_MAX_QUEUE_SIZE', audit_pii: 'Q_CODE_AUDIT_PII', crash_guard: 'Q_CODE_CRASH_GUARD', + mention_allow_abs: 'Q_CODE_MENTION_ALLOW_ABS', shell_timeout_ms: 'Q_CODE_SHELL_TIMEOUT_MS', shell_timeout_max_ms: 'Q_CODE_SHELL_TIMEOUT_MAX_MS', shell_max_buffer: 'Q_CODE_SHELL_MAX_BUFFER', @@ -66,6 +67,9 @@ const SECTION_ALIASES: Record> = { allow_abs_cwd: 'Q_CODE_SHELL_ALLOW_ABS_CWD', kill_bg_on_exit: 'Q_CODE_SHELL_KILL_BG_ON_EXIT' }, + mention: { + allow_abs: 'Q_CODE_MENTION_ALLOW_ABS' + }, audit: { enabled: 'Q_CODE_AUDIT_ENABLED', dir: 'Q_CODE_AUDIT_DIR', diff --git a/src/index.ts b/src/index.ts index f68ca75..aebb94f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -171,6 +171,12 @@ import { createUserPromptPayload, getAuditLogger } from './observability/audit' +import { + createFileMentionIndex, + createUserMentionPayload, + expandFileMentions, + type FileMentionIndex +} from './mentions' const packageVersion = getPackageVersion() const earlyCliCommand = getEarlyCliCommand(process.argv.slice(2)) @@ -903,6 +909,9 @@ async function main() { category: 'Skills' })) ] + const fileMentionIndex: FileMentionIndex | undefined = useTui + ? await createFileMentionIndex(activeStore.cwd) + : undefined if (useTui) { registry.setQuiet(true) @@ -915,6 +924,7 @@ async function main() { cwd: activeStore.cwd, initialEvents: pendingTerminalEvents, slashCommands: buildSlashCommandSuggestions(), + fileMentionIndex, onSubmit: handleInput, onInterrupt: interruptActiveTurn, onExit: closeCli @@ -1045,7 +1055,31 @@ async function main() { } async function runAgentTurn(userContent: string): Promise { - const userMsg: ModelMessage = { role: 'user', content: userContent } + const mentionExpansion = expandFileMentions(userContent, { cwd: activeStore.cwd }) + if (mentionExpansion.results.length > 0) { + getAuditLogger().emit( + 'user.mention', + createUserMentionPayload(mentionExpansion), + { sessionId, cwd: activeStore.cwd, agent: { kind: 'main' } } + ) + + if (mentionExpansion.included.length > 0) { + print( + `\n [@file] 已注入 ${mentionExpansion.included.length} 个文件,合计 ${mentionExpansion.totalBytes} bytes` + ) + } + for (const warning of mentionExpansion.warnings) { + print(`\n [@file] ${warning}`) + } + + const activated = activateConditionalSkillsForPaths(mentionExpansion.paths, activeStore.cwd) + if (activated.length > 0) { + print(`\n [Skills] 条件激活: ${activated.join(', ')}`) + emitTerminal({ type: 'slash_commands', commands: buildSlashCommandSuggestions() }) + } + } + + const userMsg: ModelMessage = { role: 'user', content: mentionExpansion.prompt } await runAgentTurnWithMessages([userMsg], userContent) } diff --git a/src/mentions/file-mentions.ts b/src/mentions/file-mentions.ts new file mode 100644 index 0000000..4b5b97d --- /dev/null +++ b/src/mentions/file-mentions.ts @@ -0,0 +1,916 @@ +import { spawn } from 'node:child_process' +import { + closeSync, + existsSync, + lstatSync, + openSync, + readdirSync, + readSync, + realpathSync, + statSync +} from 'node:fs' +import { basename, isAbsolute, relative, resolve, sep } from 'node:path' +import { StringDecoder } from 'node:string_decoder' +import { isInsideDirectory } from '../tools/path-policy' +import { isTrueEnv } from '../utils/env' + +export const FILE_MENTION_MAX_INDEX_FILES = 20_000 +export const FILE_MENTION_SINGLE_FILE_MAX_BYTES = 50 * 1024 +export const FILE_MENTION_TOTAL_MAX_BYTES = 200 * 1024 + +const DEFAULT_SUGGESTION_LIMIT = 8 +const READ_CHUNK_BYTES = 64 * 1024 +const MAX_SELECTOR_SCAN_BYTES = 2 * 1024 * 1024 +const MAX_REGEX_PATTERN_CHARS = 120 +const MAX_REGEX_LINE_CHARS = 200 +const FALLBACK_SKIP_DIRS = new Set([ + '.git', + '.hg', + '.svn', + 'node_modules', + 'dist', + 'coverage', + '.q-code', + '.sessions' +]) + +export type FileMentionIndexSource = 'git' | 'walk' | 'empty' + +export interface FileMentionIndex { + cwd: string + files: string[] + totalFiles: number + truncated: boolean + source: FileMentionIndexSource + error?: string +} + +export interface FileMentionSuggestion { + path: string + score: number +} + +export interface FileMentionAtCursor { + start: number + end: number + token: string + query: string +} + +export type FileMentionSelector = + | { type: 'line'; line: number } + | { type: 'range'; startLine: number; endLine: number } + | { type: 'regex'; pattern: string } + +export interface ParsedFileMentionTarget { + path: string + selector?: FileMentionSelector +} + +export interface FileMentionResult { + raw: string + path: string + absolutePath?: string + selector?: FileMentionSelector + selectorLabel?: string + status: 'included' | 'blocked' | 'missing' | 'binary' | 'dropped' | 'invalid' + chars: number + bytes: number + truncated: boolean + reason?: string + content?: string +} + +export interface FileMentionExpansion { + prompt: string + results: FileMentionResult[] + included: FileMentionResult[] + warnings: string[] + paths: string[] + totalBytes: number +} + +export interface ExpandFileMentionsOptions { + cwd: string + allowAbsolute?: boolean + singleFileMaxBytes?: number + totalMaxBytes?: number +} + +export async function createFileMentionIndex( + cwd: string, + maxFiles = FILE_MENTION_MAX_INDEX_FILES +): Promise { + const root = resolve(cwd) + const fromGit = await readGitFileIndex(root, maxFiles) + if (fromGit) return fromGit + return walkFileIndex(root, maxFiles) +} + +export function createEmptyFileMentionIndex(cwd: string): FileMentionIndex { + return { + cwd: resolve(cwd), + files: [], + totalFiles: 0, + truncated: false, + source: 'empty' + } +} + +export function searchFileMentionIndex( + index: FileMentionIndex, + query: string, + limit = DEFAULT_SUGGESTION_LIMIT +): FileMentionSuggestion[] { + const normalizedQuery = normalizeQuery(query) + const scored = index.files + .map((path) => { + const score = scoreFileMentionCandidate(normalizedQuery, path) + return score === null ? null : { path, score } + }) + .filter((item): item is FileMentionSuggestion => item !== null) + .sort((a, b) => b.score - a.score || a.path.length - b.path.length || a.path.localeCompare(b.path)) + + return scored.slice(0, limit) +} + +export function scoreFileMentionCandidate(query: string, candidatePath: string): number | null { + const candidate = normalizeQuery(candidatePath) + if (!query) { + return 20 - Math.min(candidate.length, 200) / 20 + } + + let score = 0 + let cursor = 0 + let lastMatch = -1 + for (const char of query) { + const index = candidate.indexOf(char, cursor) + if (index < 0) return null + + score += 8 + if (index === lastMatch + 1) score += 10 + if (index === 0 || isPathBoundary(candidate[index - 1])) score += 6 + cursor = index + 1 + lastMatch = index + } + + const base = basename(candidate) + if (candidate.includes(query)) score += 30 + if (base.startsWith(query)) score += 40 + if (candidate.startsWith(query)) score += 20 + score -= Math.min(candidate.length, 240) / 30 + return score +} + +export function findFileMentionAtCursor(value: string, cursor: number): FileMentionAtCursor | null { + const chars = splitTextUnits(value) + const safeCursor = Math.max(0, Math.min(chars.length, cursor)) + + for (let start = safeCursor - 1; start >= 0; start--) { + if (chars[start] !== '@') continue + if (start > 0 && !isTokenBoundary(chars[start - 1] ?? '')) continue + + const quote = chars[start + 1] + if (quote === '"' || quote === "'") { + const closing = findClosingQuote(chars, start + 2, quote) + if (closing >= 0 && safeCursor > closing + 1) return null + const end = closing >= 0 ? closing + 1 : safeCursor + const rawQuery = unescapeQuotedPath(chars.slice(start + 2, Math.min(safeCursor, end)).join('')) + if (!rawQuery) return null + return { + start, + end, + token: chars.slice(start, end).join(''), + query: stripSelectorFromQuery(rawQuery) + } + } + + if (chars.slice(start + 1, safeCursor).some(isTokenBoundary)) return null + let end = safeCursor + while (end < chars.length && !isTokenBoundary(chars[end] ?? '')) end++ + const token = chars.slice(start, end).join('') + const rawQuery = token.slice(1) + if (!rawQuery || rawQuery.startsWith('@')) return null + return { + start, + end, + token, + query: stripSelectorFromQuery(rawQuery) + } + } + + return null +} + +export function parseFileMentionTarget(rawTarget: string): ParsedFileMentionTarget { + const target = rawTarget.trim() + const regexIndex = target.lastIndexOf(':#') + if (regexIndex > 0) { + const path = target.slice(0, regexIndex) + const pattern = target.slice(regexIndex + 2) + return pattern ? { path, selector: { type: 'regex', pattern } } : { path: target } + } + + const colonIndex = target.lastIndexOf(':') + if (colonIndex > 0) { + const suffix = target.slice(colonIndex + 1) + const singleLine = suffix.match(/^(\d+)$/) + if (singleLine) { + return { + path: target.slice(0, colonIndex), + selector: { type: 'line', line: Number(singleLine[1]) } + } + } + + const range = suffix.match(/^(\d+)-(\d+)$/) + if (range) { + return { + path: target.slice(0, colonIndex), + selector: { + type: 'range', + startLine: Number(range[1]), + endLine: Number(range[2]) + } + } + } + } + + return { path: target } +} + +export function extractFileMentionTokens(input: string): string[] { + const tokens: string[] = [] + const chars = splitTextUnits(input) + for (let index = 0; index < chars.length; index++) { + if (chars[index] !== '@') continue + if (index > 0 && !isTokenBoundary(chars[index - 1] ?? '')) continue + + const quote = chars[index + 1] + if (quote === '"' || quote === "'") { + const closing = findClosingQuote(chars, index + 2, quote) + if (closing < 0) continue + const raw = unescapeQuotedPath(chars.slice(index + 2, closing).join('').trim()) + if (raw) tokens.push(raw) + index = closing + continue + } + + let end = index + 1 + while (end < chars.length && !isTokenBoundary(chars[end] ?? '')) end++ + const raw = trimMentionToken(chars.slice(index + 1, end).join('').trim()) + if (raw) tokens.push(raw) + index = end + } + return tokens +} + +export function expandFileMentions( + input: string, + options: ExpandFileMentionsOptions +): FileMentionExpansion { + const rawMentions = dedupePreservingOrder(extractFileMentionTokens(input)) + if (rawMentions.length === 0) { + return { + prompt: input, + results: [], + included: [], + warnings: [], + paths: [], + totalBytes: 0 + } + } + + const singleFileMaxBytes = options.singleFileMaxBytes ?? FILE_MENTION_SINGLE_FILE_MAX_BYTES + const totalMaxBytes = options.totalMaxBytes ?? FILE_MENTION_TOTAL_MAX_BYTES + const allowAbsolute = + options.allowAbsolute ?? isTrueEnv(process.env.Q_CODE_MENTION_ALLOW_ABS) + const cwd = resolve(options.cwd) + const results: FileMentionResult[] = [] + let totalBytes = 0 + + for (const raw of rawMentions) { + const parsed = parseFileMentionTarget(raw) + const result = readMention(parsed, raw, { + cwd, + allowAbsolute, + singleFileMaxBytes + }) + + if (result.status === 'included' && totalBytes + result.bytes > totalMaxBytes) { + results.push({ + ...result, + status: 'dropped', + content: undefined, + chars: 0, + bytes: 0, + reason: `@file 附件总量超过 ${formatBytes(totalMaxBytes)},已丢弃` + }) + continue + } + + if (result.status === 'included') totalBytes += result.bytes + results.push(result) + } + + const included = results.filter((item) => item.status === 'included') + const warnings = results + .filter((item) => item.status !== 'included' || item.truncated) + .map(formatMentionWarning) + + return { + prompt: renderPromptWithMentions(input, included, warnings), + results, + included, + warnings, + paths: included.map((item) => item.absolutePath ?? item.path), + totalBytes + } +} + +export function createUserMentionPayload(expansion: FileMentionExpansion): Record { + return { + count: expansion.results.length, + included: expansion.included.length, + totalChars: expansion.included.reduce((sum, item) => sum + item.chars, 0), + totalBytes: expansion.totalBytes, + mentions: expansion.results.map((item) => ({ + path: item.path, + ...(item.selectorLabel ? { range: item.selectorLabel } : {}), + status: item.status, + chars: item.chars, + bytes: item.bytes, + truncated: item.truncated, + ...(item.reason ? { reason: item.reason } : {}) + })) + } +} + +export function fileMentionIndexNotice(index: FileMentionIndex): string | undefined { + if (!index.truncated) return undefined + return `@file 候选已裁剪到 ${FILE_MENTION_MAX_INDEX_FILES} 个文件,继续输入可缩小范围` +} + +function readMention( + parsed: ParsedFileMentionTarget, + raw: string, + options: { + cwd: string + allowAbsolute: boolean + singleFileMaxBytes: number + } +): FileMentionResult { + const selectorLabel = parsed.selector ? formatSelector(parsed.selector) : undefined + const baseResult = { + raw, + path: normalizeDisplayPath(parsed.path), + selector: parsed.selector, + selectorLabel, + chars: 0, + bytes: 0, + truncated: false + } + + if (!parsed.path) { + return { ...baseResult, status: 'invalid', reason: '空文件路径' } + } + + let absolutePath: string + try { + absolutePath = resolveMentionPath(options.cwd, parsed.path, options.allowAbsolute) + } catch (error) { + return { + ...baseResult, + status: 'blocked', + reason: error instanceof Error ? error.message : String(error) + } + } + + if (!existsSync(absolutePath)) { + return { ...baseResult, absolutePath, status: 'missing', reason: '文件不存在' } + } + + try { + assertRealPathAllowed(options.cwd, absolutePath, parsed.path, options.allowAbsolute) + } catch (error) { + return { + ...baseResult, + absolutePath, + status: 'blocked', + reason: error instanceof Error ? error.message : String(error) + } + } + + const stat = statSync(absolutePath) + if (!stat.isFile()) { + return { ...baseResult, absolutePath, status: 'invalid', reason: '路径不是文件' } + } + if (looksLikeBinaryFile(absolutePath, stat.size)) { + return { ...baseResult, absolutePath, status: 'binary', reason: '文件看起来是二进制内容' } + } + + let selection: TextSelection + try { + selection = readMentionTextSelection(absolutePath, parsed.selector, options.singleFileMaxBytes) + } catch (error) { + return { + ...baseResult, + absolutePath, + status: 'invalid', + reason: error instanceof Error ? error.message : String(error) + } + } + + return { + ...baseResult, + absolutePath, + status: 'included', + content: selection.content, + chars: selection.content.length, + bytes: Buffer.byteLength(selection.content, 'utf-8'), + truncated: selection.truncated + } +} + +function resolveMentionPath(cwd: string, inputPath: string, allowAbsolute: boolean): string { + const root = resolve(cwd) + if (isAbsolute(inputPath) && !allowAbsolute) { + throw new Error( + `绝对路径默认被阻止: ${inputPath}。若确实需要引用绝对路径,请设置 Q_CODE_MENTION_ALLOW_ABS=true。` + ) + } + + const absolutePath = isAbsolute(inputPath) ? resolve(inputPath) : resolve(root, inputPath) + const inside = isInsideDirectory(root, absolutePath) + if (!inside && !(allowAbsolute && isAbsolute(inputPath))) { + throw new Error(`路径越界: ${inputPath} 不在当前工作目录内`) + } + + return absolutePath +} + +function assertRealPathAllowed( + cwd: string, + absolutePath: string, + inputPath: string, + allowAbsolute: boolean +): void { + const realRoot = realpathSync.native(resolve(cwd)) + const realTarget = realpathSync.native(absolutePath) + const inside = isInsideDirectory(realRoot, realTarget) + if (!inside && !(allowAbsolute && isAbsolute(inputPath))) { + throw new Error(`路径越界: ${inputPath} 指向当前工作目录外的真实路径`) + } +} + +interface TextSelection { + content: string + truncated: boolean +} + +function readMentionTextSelection( + filePath: string, + selector: FileMentionSelector | undefined, + maxBytes: number +): TextSelection { + if (!selector) return readTextPrefix(filePath, maxBytes) + if (selector.type === 'line') { + if (selector.line < 1) throw new Error('行号必须大于 0') + return readSelectedLines(filePath, selector.line, selector.line, maxBytes) + } + if (selector.type === 'range') { + if (selector.startLine < 1 || selector.endLine < 1) throw new Error('行号范围必须大于 0') + if (selector.endLine < selector.startLine) { + throw new Error(`行号范围无效: ${selector.startLine}-${selector.endLine}`) + } + return readSelectedLines(filePath, selector.startLine, selector.endLine, maxBytes) + } + return readRegexLine(filePath, selector.pattern, maxBytes) +} + +function renderPromptWithMentions( + input: string, + included: FileMentionResult[], + warnings: string[] +): string { + if (included.length === 0 && warnings.length === 0) return input + + const blocks = included.map((item) => + [ + ``, + '', + '' + ].join('\n') + ) + const warningBlocks = warnings.map((warning) => `${escapeXmlText(warning)}`) + + return [ + input, + '', + '', + '以下内容来自用户输入中的 @file 引用,请作为本轮上下文使用。', + ...blocks, + ...warningBlocks, + '' + ].join('\n') +} + +function formatMentionWarning(item: FileMentionResult): string { + if (item.status === 'included' && item.truncated) { + return `${item.path}${item.selectorLabel ? `:${item.selectorLabel}` : ''} 超过单文件 ${formatBytes(FILE_MENTION_SINGLE_FILE_MAX_BYTES)},已截断` + } + return `${item.path}${item.selectorLabel ? `:${item.selectorLabel}` : ''} ${item.reason ?? item.status}` +} + +function readGitFileIndex(cwd: string, maxFiles: number): Promise { + return new Promise((resolveIndex) => { + const child = spawn('git', ['ls-files', '-co', '--exclude-standard', '-z'], { + cwd, + stdio: ['ignore', 'pipe', 'ignore'] + }) + const files: string[] = [] + let buffered = Buffer.alloc(0) + let totalFiles = 0 + let truncated = false + let settled = false + + const settle = (index: FileMentionIndex | null) => { + if (settled) return + settled = true + resolveIndex(index) + } + + child.stdout?.on('data', (chunk: Buffer) => { + buffered = Buffer.concat([buffered, chunk]) + let nullIndex = buffered.indexOf(0) + while (nullIndex >= 0) { + const file = normalizeDisplayPath(buffered.subarray(0, nullIndex).toString('utf-8')) + buffered = buffered.subarray(nullIndex + 1) + if (file) { + totalFiles++ + if (files.length < maxFiles) files.push(file) + if (totalFiles > maxFiles) { + truncated = true + child.kill() + break + } + } + nullIndex = buffered.indexOf(0) + } + }) + + child.once('error', () => settle(null)) + child.once('close', (code) => { + if (code !== 0 && files.length === 0) { + settle(null) + return + } + const sorted = [...new Set(files)].sort((a, b) => a.localeCompare(b)) + settle({ + cwd, + files: sorted, + totalFiles, + truncated, + source: 'git' + }) + }) + }) +} + +function walkFileIndex(cwd: string, maxFiles: number): FileMentionIndex { + const files: string[] = [] + const stack = [cwd] + let seen = 0 + + while (stack.length > 0) { + const dir = stack.pop() + if (!dir) continue + + let entries: string[] + try { + entries = readdirSync(dir) + } catch { + continue + } + + for (const entry of entries.sort((a, b) => b.localeCompare(a))) { + const absolutePath = resolve(dir, entry) + let stat + try { + stat = lstatSync(absolutePath) + } catch { + continue + } + + if (stat.isSymbolicLink()) continue + if (stat.isDirectory()) { + if (!FALLBACK_SKIP_DIRS.has(entry)) stack.push(absolutePath) + continue + } + if (!stat.isFile()) continue + + seen++ + if (files.length < maxFiles) { + files.push(normalizeDisplayPath(relative(cwd, absolutePath))) + } + if (seen > maxFiles) { + return { + cwd, + files: files.sort((a, b) => a.localeCompare(b)), + totalFiles: seen, + truncated: true, + source: 'walk' + } + } + } + } + + return { + cwd, + files: files.sort((a, b) => a.localeCompare(b)), + totalFiles: seen, + truncated: false, + source: 'walk' + } +} + +function stripSelectorFromQuery(query: string): string { + return parseFileMentionTarget(query).path +} + +export function formatFileMentionTarget(path: string): string { + return /\s/.test(path) ? `@"${path.replace(/"/g, '\\"')}"` : `@${path}` +} + +function trimMentionToken(value: string): string { + return value.replace(/[),.;!?,。;!?)]+$/u, '') +} + +function normalizeQuery(value: string): string { + return stripSelectorFromQuery(value).replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase() +} + +function normalizeDisplayPath(value: string): string { + return value.replaceAll(sep, '/').replace(/\\/g, '/').replace(/^\.\//, '') +} + +function isPathBoundary(char: string | undefined): boolean { + return char === '/' || char === '-' || char === '_' || char === '.' +} + +function isTokenBoundary(char: string): boolean { + return /\s/.test(char) +} + +function formatSelector(selector: FileMentionSelector): string { + if (selector.type === 'line') return String(selector.line) + if (selector.type === 'range') return `${selector.startLine}-${selector.endLine}` + return `#${selector.pattern}` +} + +function readTextPrefix(filePath: string, maxBytes: number): TextSelection { + const fd = openSync(filePath, 'r') + try { + const buffer = Buffer.alloc(maxBytes + 4) + const bytesRead = readSync(fd, buffer, 0, buffer.length, 0) + const decoder = new StringDecoder('utf8') + const decoded = decoder.write(buffer.subarray(0, bytesRead)) + const content = truncateUtf8(decoded, maxBytes) + return { + content, + truncated: bytesRead > maxBytes || Buffer.byteLength(decoded, 'utf-8') > maxBytes + } + } finally { + closeSync(fd) + } +} + +function readSelectedLines( + filePath: string, + startLine: number, + endLine: number, + maxBytes: number +): TextSelection { + let output = '' + let truncated = false + const scan = forEachTextLine(filePath, (line, lineNo) => { + if (lineNo < startLine) return true + if (lineNo > endLine) { + return false + } + + const rendered = output ? `\n${line}` : line + const next = appendWithinByteLimit(output, rendered, maxBytes) + output = next.content + if (next.truncated) { + truncated = true + return false + } + return true + }) + return { content: output, truncated: truncated || scan.hitScanLimit } +} + +function readRegexLine(filePath: string, pattern: string, maxBytes: number): TextSelection { + if (pattern.length > MAX_REGEX_PATTERN_CHARS) { + throw new Error(`正则过长: 最大 ${MAX_REGEX_PATTERN_CHARS} 字符`) + } + if (isPotentiallyUnsafeRegex(pattern)) { + throw new Error('正则包含高风险回溯结构,已拒绝执行') + } + + let regex: RegExp + try { + regex = new RegExp(pattern) + } catch (error) { + throw new Error(`正则无效: ${error instanceof Error ? error.message : String(error)}`) + } + + let matched: string | undefined + const scan = forEachTextLine( + filePath, + (line) => { + const candidate = line.slice(0, MAX_REGEX_LINE_CHARS) + if (regex.test(candidate)) { + matched = candidate + return false + } + return true + }, + MAX_SELECTOR_SCAN_BYTES + ) + + if (matched === undefined) { + throw new Error( + scan.hitScanLimit + ? `未在前 ${formatBytes(MAX_SELECTOR_SCAN_BYTES)} 内找到匹配正则: ${pattern}` + : `未找到匹配正则: ${pattern}` + ) + } + return { + content: truncateUtf8(matched, maxBytes), + truncated: Buffer.byteLength(matched, 'utf-8') > maxBytes + } +} + +interface LineScanResult { + hitScanLimit: boolean +} + +function forEachTextLine( + filePath: string, + onLine: (line: string, lineNo: number) => boolean, + maxScanBytes = MAX_SELECTOR_SCAN_BYTES +): LineScanResult { + const fd = openSync(filePath, 'r') + const decoder = new StringDecoder('utf8') + const buffer = Buffer.alloc(READ_CHUNK_BYTES) + let pending = '' + let lineNo = 0 + let scannedBytes = 0 + let stopped = false + let reachedEof = false + + try { + while (scannedBytes < maxScanBytes) { + const bytesToRead = Math.min(buffer.length, maxScanBytes - scannedBytes) + const bytesRead = readSync(fd, buffer, 0, bytesToRead, null) + if (bytesRead === 0) { + reachedEof = true + break + } + scannedBytes += bytesRead + pending += decoder.write(buffer.subarray(0, bytesRead)) + + let nextBreak = findLineBreak(pending) + while (nextBreak) { + const line = pending.slice(0, nextBreak.index) + pending = pending.slice(nextBreak.nextIndex) + lineNo++ + if (!onLine(line, lineNo)) { + stopped = true + return { hitScanLimit: false } + } + nextBreak = findLineBreak(pending) + } + } + + pending += decoder.end() + if (pending) { + lineNo++ + if (!onLine(pending, lineNo)) stopped = true + } + return { hitScanLimit: !reachedEof && !stopped && scannedBytes >= maxScanBytes } + } finally { + closeSync(fd) + } +} + +function findLineBreak(value: string): { index: number; nextIndex: number } | null { + for (let index = 0; index < value.length; index++) { + const char = value[index] + if (char === '\n') return { index, nextIndex: index + 1 } + if (char === '\r') { + return { index, nextIndex: value[index + 1] === '\n' ? index + 2 : index + 1 } + } + } + return null +} + +function appendWithinByteLimit(current: string, addition: string, maxBytes: number): TextSelection { + const next = current + addition + if (Buffer.byteLength(next, 'utf-8') <= maxBytes) return { content: next, truncated: false } + return { content: truncateUtf8(next, maxBytes), truncated: true } +} + +function isPotentiallyUnsafeRegex(pattern: string): boolean { + return ( + /\([^)]*[*+][^)]*\)\s*[*+{]/.test(pattern) || + /\([^)]*\{[^)]*\}[^)]*\)\s*[*+{]/.test(pattern) || + /\([^)]*\|[^)]*\)\s*[*+{]/.test(pattern) || + /\\[1-9]/.test(pattern) || + /(\.\*){2,}/.test(pattern) || + /(\.\+){2,}/.test(pattern) + ) +} + +function splitTextUnits(value: string): string[] { + if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) { + const Segmenter = Intl.Segmenter + const segmenter = new Segmenter(undefined, { granularity: 'grapheme' }) + return Array.from(segmenter.segment(value), (segment) => segment.segment) + } + return Array.from(value) +} + +function findClosingQuote(chars: string[], start: number, quote: string): number { + for (let index = start; index < chars.length; index++) { + if (chars[index] === quote && chars[index - 1] !== '\\') return index + } + return -1 +} + +function unescapeQuotedPath(value: string): string { + return value.replace(/\\(["'\\])/g, '$1') +} + +function truncateUtf8(value: string, maxBytes: number): string { + if (Buffer.byteLength(value, 'utf-8') <= maxBytes) return value + + let low = 0 + let high = value.length + while (low < high) { + const mid = Math.ceil((low + high) / 2) + if (Buffer.byteLength(value.slice(0, mid), 'utf-8') <= maxBytes) low = mid + else high = mid - 1 + } + return value.slice(0, low) +} + +function looksLikeBinaryFile(filePath: string, fileSize: number): boolean { + if (fileSize === 0) return false + + const fd = openSync(filePath, 'r') + try { + const buffer = Buffer.alloc(Math.min(8192, fileSize)) + const bytesRead = readSync(fd, buffer, 0, buffer.length, 0) + return buffer.subarray(0, bytesRead).includes(0) + } finally { + closeSync(fd) + } +} + +function escapeXmlAttr(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>') +} + +function escapeXmlText(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>') +} + +function escapeCdata(value: string): string { + return value.replace(/\]\]>/g, ']]]]>') +} + +function dedupePreservingOrder(values: string[]): string[] { + const seen = new Set() + const result: string[] = [] + for (const value of values) { + if (seen.has(value)) continue + seen.add(value) + result.push(value) + } + return result +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + const kb = bytes / 1024 + if (kb < 1024) return `${kb.toFixed(1)} KB` + return `${(kb / 1024).toFixed(1)} MB` +} diff --git a/src/mentions/index.ts b/src/mentions/index.ts new file mode 100644 index 0000000..7a27b64 --- /dev/null +++ b/src/mentions/index.ts @@ -0,0 +1 @@ +export * from './file-mentions' diff --git a/src/observability/audit.ts b/src/observability/audit.ts index e2d084a..6825dd3 100644 --- a/src/observability/audit.ts +++ b/src/observability/audit.ts @@ -17,6 +17,7 @@ export type AuditEventName = | "session.end" | "session.resume" | "user.prompt" + | "user.mention" | "mode.change" | "agent.step.start" | "agent.step.end" diff --git a/src/terminal/App.tsx b/src/terminal/App.tsx index f9c59ee..38fa7b6 100644 --- a/src/terminal/App.tsx +++ b/src/terminal/App.tsx @@ -12,6 +12,7 @@ import { newline, recallNext, recallPrevious, + replaceRange, searchHistoryPrevious, submitInput } from './input' @@ -29,6 +30,14 @@ import { filterSlashCommandSuggestions, type SlashCommandSuggestion } from '../slash' +import { + createEmptyFileMentionIndex, + fileMentionIndexNotice, + findFileMentionAtCursor, + formatFileMentionTarget, + searchFileMentionIndex, + type FileMentionIndex +} from '../mentions' const ASSISTANT_STREAM_FLUSH_MS = 80 const CLEAR_TERMINAL = '\u001B[2J\u001B[3J\u001B[H' @@ -42,6 +51,7 @@ export interface TerminalAppProps { sessionId?: string cwd?: string slashCommands?: SlashCommandSuggestion[] + fileMentionIndex?: FileMentionIndex } export function TerminalApp(props: TerminalAppProps): React.JSX.Element { @@ -65,11 +75,27 @@ export function TerminalApp(props: TerminalAppProps): React.JSX.Element { const hasStreamingAssistant = state.activeAssistantId !== undefined const slashCommands = state.slashCommands.length > 0 ? state.slashCommands : props.slashCommands ?? [] + const fileMentionIndex = useMemo( + () => props.fileMentionIndex ?? createEmptyFileMentionIndex(props.cwd ?? process.cwd()), + [props.cwd, props.fileMentionIndex] + ) + const fileMentionAtCursor = useMemo( + () => findFileMentionAtCursor(input.value, input.cursor), + [input.cursor, input.value] + ) + const filteredFileMentions = useMemo( + () => + fileMentionAtCursor + ? searchFileMentionIndex(fileMentionIndex, fileMentionAtCursor.query) + : [], + [fileMentionAtCursor, fileMentionIndex] + ) const filteredSlashCommands = useMemo( () => filterSlashCommandSuggestions(input.value, slashCommands), [input.value, slashCommands] ) const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1) + const [selectedFileMentionIndex, setSelectedFileMentionIndex] = useState(-1) const renderedSlashCommands = useMemo( () => filteredSlashCommands.map((item, index) => ({ @@ -78,7 +104,20 @@ export function TerminalApp(props: TerminalAppProps): React.JSX.Element { })), [filteredSlashCommands, selectedCommandIndex] ) - const showSlashCommands = filteredSlashCommands.length > 0 + const renderedFileMentions = useMemo( + () => + filteredFileMentions.map((item, index) => ({ + name: `@${item.path}`, + description: 'Tab 插入文件引用', + usage: `@${item.path}`, + category: '@file', + isSelected: index === selectedFileMentionIndex + })), + [filteredFileMentions, selectedFileMentionIndex] + ) + const showFileMentions = fileMentionAtCursor !== null && filteredFileMentions.length > 0 + const showSlashCommands = fileMentionAtCursor === null && filteredSlashCommands.length > 0 + const suggestionNotice = fileMentionAtCursor ? fileMentionIndexNotice(fileMentionIndex) : undefined useEffect(() => { const flushAssistantDelta = () => { @@ -145,6 +184,11 @@ export function TerminalApp(props: TerminalAppProps): React.JSX.Element { if (selectedCommandIndex >= filteredSlashCommands.length) setSelectedCommandIndex(-1) }, [filteredSlashCommands.length, selectedCommandIndex, showSlashCommands]) + useEffect(() => { + if (!showFileMentions) setSelectedFileMentionIndex(-1) + if (selectedFileMentionIndex >= filteredFileMentions.length) setSelectedFileMentionIndex(-1) + }, [filteredFileMentions.length, selectedFileMentionIndex, showFileMentions]) + useEffect(() => { const rememberRawInput = (data: Buffer | string) => { lastRawInput.current = Buffer.isBuffer(data) ? data.toString() : data @@ -186,6 +230,33 @@ export function TerminalApp(props: TerminalAppProps): React.JSX.Element { return } + if (showFileMentions && fileMentionAtCursor) { + if (key.upArrow) { + setSelectedFileMentionIndex((current) => + current <= 0 ? filteredFileMentions.length - 1 : current - 1 + ) + return + } + if (key.downArrow) { + setSelectedFileMentionIndex((current) => + current >= filteredFileMentions.length - 1 ? 0 : current + 1 + ) + return + } + if (key.tab || (key.return && selectedFileMentionIndex >= 0)) { + const selected = filteredFileMentions[ + selectedFileMentionIndex >= 0 ? selectedFileMentionIndex : 0 + ] + if (selected) { + setInput((current) => + replaceRange(current, fileMentionAtCursor.start, fileMentionAtCursor.end, `${formatFileMentionTarget(selected.path)} `) + ) + setSelectedFileMentionIndex(-1) + return + } + } + } + if (showSlashCommands) { if (key.upArrow) { setSelectedCommandIndex((current) => @@ -285,7 +356,10 @@ export function TerminalApp(props: TerminalAppProps): React.JSX.Element {
- + + {notice ? {notice} : null} {groups.map((group) => ( {group.category} diff --git a/src/terminal/input.ts b/src/terminal/input.ts index 1b9746a..3d417ba 100644 --- a/src/terminal/input.ts +++ b/src/terminal/input.ts @@ -66,6 +66,24 @@ export function newline(state: InputState): InputState { return insertText(state, '\n') } +export function replaceRange( + state: InputState, + start: number, + end: number, + text: string +): InputState { + const chars = splitChars(state.value) + const safeStart = Math.max(0, Math.min(chars.length, start)) + const safeEnd = Math.max(safeStart, Math.min(chars.length, end)) + const insertChars = splitChars(text) + return { + ...clearTransientInputState(state), + value: `${chars.slice(0, safeStart).join('')}${text}${chars.slice(safeEnd).join('')}`, + cursor: safeStart + insertChars.length, + historyIndex: undefined + } +} + export function submitInput(state: InputState): { input: string; state: InputState } { const input = state.value.trimEnd() const history = input.trim() diff --git a/src/terminal/runtime.tsx b/src/terminal/runtime.tsx index 2a5a79a..ff94106 100644 --- a/src/terminal/runtime.tsx +++ b/src/terminal/runtime.tsx @@ -3,6 +3,7 @@ import { render, type Instance } from 'ink' import { InMemoryTerminalEventBus, type TerminalEvent, type TerminalEventBus } from './events' import { TerminalApp } from './App' import type { SlashCommandSuggestion } from '../slash' +import type { FileMentionIndex } from '../mentions' export interface TerminalRuntimeOptions { title?: string @@ -10,6 +11,7 @@ export interface TerminalRuntimeOptions { cwd?: string initialEvents?: TerminalEvent[] slashCommands?: SlashCommandSuggestion[] + fileMentionIndex?: FileMentionIndex onSubmit: (input: string) => Promise | void onInterrupt?: () => Promise | void onExit: () => Promise | void @@ -33,6 +35,7 @@ export function startTerminalRuntime(options: TerminalRuntimeOptions): TerminalR sessionId={options.sessionId} cwd={options.cwd} slashCommands={options.slashCommands} + fileMentionIndex={options.fileMentionIndex} onSubmit={options.onSubmit} onInterrupt={options.onInterrupt} onExit={options.onExit} diff --git a/src/utils/env.ts b/src/utils/env.ts index 915fa22..a0100b2 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -1,3 +1,7 @@ export function isFalseEnv(value: string | undefined): boolean { return ['0', 'false', 'off', 'no'].includes((value ?? '').trim().toLowerCase()) } + +export function isTrueEnv(value: string | undefined): boolean { + return ['1', 'true', 'on', 'yes'].includes((value ?? '').trim().toLowerCase()) +} diff --git a/tests/unit/file-mentions.test.ts b/tests/unit/file-mentions.test.ts new file mode 100644 index 0000000..0e6e147 --- /dev/null +++ b/tests/unit/file-mentions.test.ts @@ -0,0 +1,235 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + createFileMentionIndex, + expandFileMentions, + findFileMentionAtCursor, + formatFileMentionTarget, + parseFileMentionTarget, + scoreFileMentionCandidate, + searchFileMentionIndex, + type FileMentionIndex +} from '../../src/mentions' + +const tempDirs: string[] = [] + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +describe('file mention parsing', () => { + it('parses line, range and regex selectors', () => { + expect(parseFileMentionTarget('src/index.ts:42')).toEqual({ + path: 'src/index.ts', + selector: { type: 'line', line: 42 } + }) + expect(parseFileMentionTarget('src/index.ts:10-20')).toEqual({ + path: 'src/index.ts', + selector: { type: 'range', startLine: 10, endLine: 20 } + }) + expect(parseFileMentionTarget('src/index.ts:#runAgentTurn')).toEqual({ + path: 'src/index.ts', + selector: { type: 'regex', pattern: 'runAgentTurn' } + }) + }) + + it('finds the @file token at the input cursor', () => { + expect(findFileMentionAtCursor('修一下 @src/runt', 12)).toMatchObject({ + start: 4, + end: 13, + query: 'src/runt' + }) + expect(findFileMentionAtCursor('mail@example.com', 6)).toBeNull() + }) +}) + +describe('file mention fuzzy index', () => { + it('sorts stronger fuzzy matches first', () => { + const index: FileMentionIndex = { + cwd: 'C:/repo', + files: ['src/runtime/cli-info.ts', 'docs/routes.md', 'src/terminal/App.tsx'], + totalFiles: 3, + truncated: false, + source: 'git' + } + + const suggestions = searchFileMentionIndex(index, 'rou') + + expect(suggestions[0]?.path).toBe('docs/routes.md') + expect(scoreFileMentionCandidate('rti', 'src/runtime/cli-info.ts')).not.toBeNull() + }) + + it('falls back to recursive walk and marks truncated indexes', async () => { + const cwd = tmp() + mkdirSync(join(cwd, 'src'), { recursive: true }) + writeFileSync(join(cwd, 'a.ts'), 'a', 'utf-8') + writeFileSync(join(cwd, 'src', 'b.ts'), 'b', 'utf-8') + writeFileSync(join(cwd, 'src', 'c.ts'), 'c', 'utf-8') + writeFileSync(join(cwd, 'src', 'd.ts'), 'd', 'utf-8') + + const index = await createFileMentionIndex(cwd, 3) + + expect(index.source).toBe('walk') + expect(index.files).toHaveLength(3) + expect(index.truncated).toBe(true) + }) + + it('streams git indexes and marks truncated repositories', async () => { + const cwd = tmp() + execFileSync('git', ['init'], { cwd, stdio: 'ignore' }) + for (let index = 0; index < 5; index++) { + writeFileSync(join(cwd, `file-${index}.ts`), String(index), 'utf-8') + } + + const index = await createFileMentionIndex(cwd, 3) + + expect(index.source).toBe('git') + expect(index.files).toHaveLength(3) + expect(index.truncated).toBe(true) + }) +}) + +describe('file mention expansion', () => { + it('injects selected file content into the user prompt', () => { + const cwd = tmp() + mkdirSync(join(cwd, 'src'), { recursive: true }) + writeFileSync(join(cwd, 'src', 'runtime.ts'), 'export const answer = 42\n', 'utf-8') + + const expansion = expandFileMentions('解释 @src/runtime.ts。', { cwd }) + + expect(expansion.included).toHaveLength(1) + expect(expansion.prompt).toContain('') + expect(expansion.prompt).toContain('export const answer = 42') + expect(expansion.results[0]).toMatchObject({ + path: 'src/runtime.ts', + status: 'included' + }) + }) + + it('supports line, range and regex selectors', () => { + const cwd = tmp() + writeFileSync(join(cwd, 'note.txt'), ['alpha', 'beta', 'gamma'].join('\n'), 'utf-8') + + expect(expandFileMentions('@note.txt:2', { cwd }).included[0]?.content).toBe('beta') + expect(expandFileMentions('@note.txt:1-2', { cwd }).included[0]?.content).toBe('alpha\nbeta') + expect(expandFileMentions('@note.txt:#gam+', { cwd }).included[0]?.content).toBe('gamma') + }) + + it('supports quoted paths with spaces', () => { + const cwd = tmp() + mkdirSync(join(cwd, 'My Project'), { recursive: true }) + writeFileSync(join(cwd, 'My Project', 'note.txt'), 'quoted', 'utf-8') + + const expansion = expandFileMentions('解释 @"My Project/note.txt"', { cwd }) + + expect(formatFileMentionTarget('My Project/note.txt')).toBe('@"My Project/note.txt"') + expect(findFileMentionAtCursor('解释 @"My Pro', 11)).toMatchObject({ query: 'My Pro' }) + expect(expansion.results[0]).toMatchObject({ status: 'included', path: 'My Project/note.txt' }) + expect(expansion.prompt).toContain('quoted') + }) + + it('blocks absolute paths and path traversal by default', () => { + const cwd = tmp() + const outside = join(tmp(), 'secret.txt') + writeFileSync(outside, 'secret', 'utf-8') + + const absolute = expandFileMentions(`看 @${outside}`, { cwd }) + const traversal = expandFileMentions('看 @../secret.txt', { cwd }) + + expect(absolute.results[0]?.status).toBe('blocked') + expect(absolute.results[0]?.reason).toContain('绝对路径默认被阻止') + expect(traversal.results[0]?.status).toBe('blocked') + expect(traversal.results[0]?.reason).toContain('路径越界') + }) + + it('allows absolute paths only when explicitly enabled', () => { + const cwd = tmp() + const outside = join(tmp(), 'note.txt') + writeFileSync(outside, 'outside', 'utf-8') + + const expansion = expandFileMentions(`看 @${outside}`, { cwd, allowAbsolute: true }) + + expect(expansion.results[0]?.status).toBe('included') + expect(expansion.prompt).toContain('outside') + }) + + it('blocks symlinks that resolve outside cwd by default', () => { + const cwd = tmp() + const outside = tmp() + writeFileSync(join(outside, 'secret.txt'), 'secret', 'utf-8') + + try { + symlinkSync(outside, join(cwd, 'linked'), process.platform === 'win32' ? 'junction' : 'dir') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EPERM') return + throw error + } + + const expansion = expandFileMentions('@linked/secret.txt', { cwd }) + + expect(expansion.results[0]?.status).toBe('blocked') + expect(expansion.results[0]?.reason).toContain('真实路径') + }) + + it('truncates oversized single files and drops attachments over the total budget', () => { + const cwd = tmp() + writeFileSync(join(cwd, 'big-a.txt'), 'a'.repeat(120), 'utf-8') + writeFileSync(join(cwd, 'big-b.txt'), 'b'.repeat(80), 'utf-8') + + const expansion = expandFileMentions('@big-a.txt @big-b.txt', { + cwd, + singleFileMaxBytes: 100, + totalMaxBytes: 150 + }) + + expect(expansion.results[0]).toMatchObject({ + status: 'included', + truncated: true, + bytes: 100 + }) + expect(expansion.results[1]).toMatchObject({ + status: 'dropped', + reason: expect.stringContaining('附件总量超过') + }) + expect(expansion.prompt).not.toContain('b'.repeat(80)) + }) + + it('does not read unselected oversized file content into the prompt', () => { + const cwd = tmp() + writeFileSync(join(cwd, 'huge.txt'), 'x'.repeat(1024 * 1024), 'utf-8') + + const expansion = expandFileMentions('@huge.txt', { + cwd, + singleFileMaxBytes: 100, + totalMaxBytes: 200 + }) + + expect(expansion.results[0]).toMatchObject({ + status: 'included', + truncated: true, + bytes: 100 + }) + expect(expansion.prompt).not.toContain('x'.repeat(200)) + }) + + it('rejects high-risk regex selectors', () => { + const cwd = tmp() + writeFileSync(join(cwd, 'note.txt'), 'aaaaaaaaaaaaaaaaaaaaaaaa!', 'utf-8') + + const expansion = expandFileMentions('@note.txt:#(a+)+$', { cwd }) + + expect(expansion.results[0]).toMatchObject({ + status: 'invalid', + reason: expect.stringContaining('高风险回溯') + }) + }) +}) + +function tmp(): string { + const dir = mkdtempSync(join(tmpdir(), 'q-code-file-mentions-')) + tempDirs.push(dir) + return dir +} diff --git a/tests/unit/runtime-config.test.ts b/tests/unit/runtime-config.test.ts index f60b5e9..e32e654 100644 --- a/tests/unit/runtime-config.test.ts +++ b/tests/unit/runtime-config.test.ts @@ -11,6 +11,7 @@ const ENV_KEYS = [ 'Q_CODE_GITLAB_TOKEN', 'Q_CODE_GITLAB_PROJECT_ID', 'Q_CODE_GITLAB_KB_PREFIX', + 'Q_CODE_MENTION_ALLOW_ABS', 'Q_CODE_SHELL_TIMEOUT_MS', 'Q_CODE_SHELL_TIMEOUT_MAX_MS', 'Q_CODE_SHELL_MAX_BUFFER', @@ -153,7 +154,10 @@ describe('runtime config', () => { 'token_budget = 12345', '[q_code]', 'debug = true', + 'mention_allow_abs = true', 'shell_timeout_ms = 90000', + '[mention]', + 'allow_abs = false', '[gitlab_kb]', 'url = "https://gitlab.example.com/group/project"', 'token = "glpat-test"', @@ -173,6 +177,7 @@ describe('runtime config', () => { expect(process.env.OPENAI_MODEL).toBe('alias-model') expect(process.env.TOKEN_BUDGET).toBe('12345') expect(process.env.Q_CODE_DEBUG).toBe('true') + expect(process.env.Q_CODE_MENTION_ALLOW_ABS).toBe('false') expect(process.env.Q_CODE_SHELL_TIMEOUT_MS).toBe('90000') expect(process.env.Q_CODE_SHELL_TIMEOUT_MAX_MS).toBe('120000') expect(process.env.Q_CODE_SHELL_MAX_BUFFER).toBe('2097152')