diff --git a/docs/reference/spec-hwpod-harness.md b/docs/reference/spec-hwpod-harness.md index f5ce9ba4..199e4f34 100644 --- a/docs/reference/spec-hwpod-harness.md +++ b/docs/reference/spec-hwpod-harness.md @@ -124,13 +124,15 @@ Keil MDK 装配规则:当 `spec.workspace.toolchain` 为 `keil-mdk`、`keil` | `node.inventory` | 查询 workspace/probe/io 能力 | | `workspace.ls` | 列目录 | | `workspace.cat` | 读文件 | -| `workspace.rg` | 搜索文件内容 | +| `workspace.rg` | 递归搜索文件内容,标准别名还有 `grep` / `search` | | `workspace.apply-patch` | 应用 Codex apply_patch envelope 源码补丁 | | `workspace.write` | 带 `expectedSha`、换行策略和 diff 摘要的整文件写入 | | `workspace.replace` | 带 `expectedSha`、唯一匹配检查和 diff 摘要的精确文本替换 | | `workspace.insert-after` | 按精确 anchor 插入文本,避免 PowerShell/cmd quoting | | `cmd.run` | 通用 host 命令透传,用于组合 Keil、serial-monitor、job status、下载、复位和临时维护动作 | +`workspace.rg` 是查找符号、结构体、函数签名和错误文本的标准入口。它在 node 侧直接做受限递归文本搜索,不依赖目标 host 预装 `rg`,并支持 `--context`、`--before-context`、`--after-context`、`--max-matches`、`--max-files`、`--max-bytes-per-file` 和 `--max-line-bytes`。输出会保留结构化 `matches`、`scannedFiles`、`truncated` 和 `limits`,用于在不爆炸输出的前提下追踪上下文。需要精准读整文件时才用 `workspace.cat`;查找 ARM-2D 头文件、函数原型或 Demo 入口时,优先用 `workspace.rg` / `workspace.search`,不要再把 `cat` 管道给 shell `grep`。 + 若历史实现中仍存在 `debug.*` 或 `io.*` 这类专用 op,它们只能作为兼容存量观察对象,不再作为新 case 或新 harness 业务的扩展方向。标准路径是:compiler 根据 spec 生成 `cmd.run` 的 `command` + `argv`,node 只负责在目标 host 上忠实执行并返回 stdout/stderr/exit code。 `hwpod-node` 是 HWPOD 的唯一受控节点执行器。第一阶段的业务扩展优先落在 `hwpod-compiler-cli` / `hwpod-cli`,通过已有文件读写 op 和 `cmd.run` 组合 Keil、serial-monitor 等成熟工具;不要因为某个 case 需要 build、job status、download、UART 或临时维护动作就新增专用 node op。`cmd.run` 的命令解析、PATH 稳定性和跨平台差异必须在 `hwpod-node` 本体内处理;当 Windows service、后台进程或 D601 host 的环境变量与交互 shell 不一致时,不得用 gateway shell、手工 PowerShell、预先手工创建 worktree 或其他旁路代替 `hwpod-node-ops -> hwpod-node`。D601/Windows 实地调试应使用 UniDesk SSH 透传 `D601:win` 观察进程、PATH、工具安装位置和日志,但修复必须回到 `hwpod-node` 源码、配置或启动入口,并用 `/v1/hwpod-node-ops` 原链路复测。只有 `cmd.run` 自身的执行语义、环境继承或可见性不满足真实 host 执行时,才修改 `hwpod-node`;否则优先改 compiler/CLI。 @@ -180,6 +182,7 @@ bun tools/hwpod-ctl.ts spec validate --spec .hwlab/hwpod-spec.yaml bun tools/hwpod-compiler-cli.ts compile --spec .hwlab/hwpod-spec.yaml --intent workspace.ls --args '{"path":"."}' bun tools/hwpod-cli.ts inspect --spec .hwlab/hwpod-spec.yaml --dry-run bun tools/hwpod-cli.ts workspace read --spec .hwlab/hwpod-spec.yaml projects/01_baseline/User/main.c +bun tools/hwpod-cli.ts workspace rg arm_2d_init projects/01_baseline/Middlewares/Arm-2D --context 3 --spec .hwlab/hwpod-spec.yaml bun tools/hwpod-cli.ts workspace replace --spec .hwlab/hwpod-spec.yaml --path projects/01_baseline/User/main.c --find "old text" --replace "new text" --expected-sha bun tools/hwpod-cli.ts workspace insert-after --spec .hwlab/hwpod-spec.yaml --path projects/01_baseline/User/main.c --anchor "while (1)" --line " /* marker */" bun tools/hwpod-cli.ts download --spec .hwlab/hwpod-spec.yaml @@ -207,6 +210,7 @@ export HWLAB_RUNTIME_ENDPOINT_LOCKED=1 bun tools/hwpod-ctl.ts spec validate --spec .hwlab/hwpod-spec.yaml bun tools/hwpod-cli.ts inspect --spec .hwlab/hwpod-spec.yaml bun tools/hwpod-cli.ts workspace cat --spec .hwlab/hwpod-spec.yaml --path projects/01_baseline/User/main.c +bun tools/hwpod-cli.ts workspace rg arm_2d_init projects/01_baseline/Middlewares/Arm-2D --context 3 --spec .hwlab/hwpod-spec.yaml bun tools/hwpod-cli.ts build --spec .hwlab/hwpod-spec.yaml bun tools/hwpod-cli.ts download --spec .hwlab/hwpod-spec.yaml bun tools/hwpod-cli.ts job status --spec .hwlab/hwpod-spec.yaml @@ -225,6 +229,7 @@ code agent 必须在自己的 AgentRun workspace 内调用同一套入口,例 hwpod-ctl spec validate --spec .hwlab/hwpod-spec.yaml hwpod inspect --spec .hwlab/hwpod-spec.yaml hwpod workspace cat --spec .hwlab/hwpod-spec.yaml --path projects/01_baseline/User/main.c +hwpod workspace rg arm_2d_init projects/01_baseline/Middlewares/Arm-2D --context 3 --spec .hwlab/hwpod-spec.yaml hwpod workspace replace --spec .hwlab/hwpod-spec.yaml --path projects/01_baseline/User/main.c --find --replace --expected-sha hwpod build --spec .hwlab/hwpod-spec.yaml hwpod download --spec .hwlab/hwpod-spec.yaml diff --git a/skills/hwpod-cli/SKILL.md b/skills/hwpod-cli/SKILL.md index 5420bb75..4df1e289 100644 --- a/skills/hwpod-cli/SKILL.md +++ b/skills/hwpod-cli/SKILL.md @@ -22,7 +22,8 @@ The HWPOD spec is code-agent-side state. The default path is `.hwlab/hwpod-spec. ## Commands - Inspect the HWPOD path: `hwpod inspect --spec .hwlab/hwpod-spec.yaml`. -- Workspace read/edit: use `hwpod workspace ls|cat|read|write|replace|insert-after|apply-patch --spec .hwlab/hwpod-spec.yaml`. `read` is an alias for `cat`; `--file` and `--remote-path` are accepted as path aliases for file-oriented commands. +- Workspace search/read/edit: use `hwpod workspace ls|cat|read|rg|grep|search|write|replace|insert-after|apply-patch --spec .hwlab/hwpod-spec.yaml`. `read` is an alias for `cat`; `grep` and `search` are aliases for `rg`; `--file` and `--remote-path` are accepted as path aliases for file-oriented commands. +- For symbol lookup or ARM-2D header hunting, prefer `hwpod workspace rg [path] --context 3 --spec .hwlab/hwpod-spec.yaml` instead of `cat | grep`. - Build/download/reset: use `hwpod build`, `hwpod download`, and `hwpod reset`; the compiler must translate these through existing file ops and `cmd.run` where possible. - Keil async status: after build/download returns a job id, use separate short `hwpod job status --spec .hwlab/hwpod-spec.yaml` calls instead of shell polling wrappers such as `sleep &&`, `timeout`, `watch`, `head`, pipes, or loops. - UART read: use `hwpod uart read --spec .hwlab/hwpod-spec.yaml --port uart1`; the compiler runs the configured serial-monitor CLI through `cmd.run`. diff --git a/skills/hwpod-ctl/SKILL.md b/skills/hwpod-ctl/SKILL.md index 26695c5e..eb9e1564 100644 --- a/skills/hwpod-ctl/SKILL.md +++ b/skills/hwpod-ctl/SKILL.md @@ -11,6 +11,8 @@ This skill follows Skill(cli-spec). It is the standard management entry for the `hwpod-ctl` is a peer of `hwpod-cli`, not a fallback task path. Use it to maintain `.hwlab/hwpod-spec.yaml`, validate the four HWPOD elements, bind a node, and run smoke or temporary maintenance actions. +`hwpod-ctl` does not replace `hwpod-cli` for task execution. When a task needs source lookup, use `hwpod workspace rg/search` for symbol hunting and `hwpod workspace cat` only for full file bodies; when a task needs build/download/UART/job status, hand it back to `hwpod-cli` and its compiler-backed plan path. + ## Commands - Initialize or repair local spec: `hwpod-ctl spec init --spec .hwlab/hwpod-spec.yaml --node `. diff --git a/tools/hwpod-harness.test.ts b/tools/hwpod-harness.test.ts index f70d2858..489c744a 100644 --- a/tools/hwpod-harness.test.ts +++ b/tools/hwpod-harness.test.ts @@ -186,6 +186,27 @@ test("hwpod-cli dry-run invokes hwpod-compiler-cli and exposes hwpod-node-ops pl assert.equal(read.payload.plan.ops[0].op, "workspace.cat"); assert.equal(read.payload.plan.ops[0].args.path, "projects/01_baseline/User/main.c"); + const rg = await runHwpodCli(["workspace", "rg", "--spec", specPath, "arm_2d_init", "projects/01_baseline/Middlewares/Arm-2D", "--context", "3", "--max-matches", "20", "--ignore-case", "--dry-run"], { now: () => NOW }); + assert.equal(rg.exitCode, 0); + assert.equal(rg.payload.plan.ops[0].op, "workspace.rg"); + assert.equal(rg.payload.plan.ops[0].args.pattern, "arm_2d_init"); + assert.equal(rg.payload.plan.ops[0].args.path, "projects/01_baseline/Middlewares/Arm-2D"); + assert.equal(rg.payload.plan.ops[0].args.context, 3); + assert.equal(rg.payload.plan.ops[0].args.maxMatches, 20); + assert.equal(rg.payload.plan.ops[0].args.ignoreCase, true); + + const grepAlias = await runHwpodCli(["workspace", "grep", "--spec", specPath, "arm_2d_tile_t", "projects/01_baseline/Middlewares/Arm-2D", "--dry-run"], { now: () => NOW }); + assert.equal(grepAlias.exitCode, 0); + assert.equal(grepAlias.payload.plan.ops[0].op, "workspace.rg"); + assert.equal(grepAlias.payload.plan.ops[0].args.pattern, "arm_2d_tile_t"); + + const grepShortOptions = await runHwpodCli(["workspace", "grep", "--spec", specPath, "typedef struct", "projects/01_baseline/Middlewares/Arm-2D", "-B", "2", "-A5", "-i", "--dry-run"], { now: () => NOW }); + assert.equal(grepShortOptions.exitCode, 0); + assert.equal(grepShortOptions.payload.plan.ops[0].op, "workspace.rg"); + assert.equal(grepShortOptions.payload.plan.ops[0].args.beforeContext, 2); + assert.equal(grepShortOptions.payload.plan.ops[0].args.afterContext, 5); + assert.equal(grepShortOptions.payload.plan.ops[0].args.ignoreCase, true); + await runHwpodCtl(["spec", "set", "spec.ioProbe.uart.port", "COM9", "--spec", specPath], { now: () => NOW }); await runHwpodCtl(["spec", "set", "spec.ioProbe.uart.baudrate", "115200", "--spec", specPath], { now: () => NOW }); const uart = await runHwpodCli(["uart", "read", "--spec", specPath, "--port", "uart1", "--max-bytes", "512", "--dry-run"], { now: () => NOW }); diff --git a/tools/hwpod-node.test.ts b/tools/hwpod-node.test.ts index 34e4287f..064a5fe5 100644 --- a/tools/hwpod-node.test.ts +++ b/tools/hwpod-node.test.ts @@ -30,6 +30,39 @@ test("hwpod-node executes minimal workspace node ops", async () => { await rm(root, { recursive: true, force: true }); } }); + +test("hwpod-node searches workspace text without requiring host rg", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-rg-")); + try { + await mkdir(path.join(root, "Middlewares", "Arm-2D"), { recursive: true }); + await mkdir(path.join(root, "Objects"), { recursive: true }); + await writeFile(path.join(root, "Middlewares", "Arm-2D", "arm_2d.c"), "static int before;\nvoid arm_2d_init(void) {\n}\nstatic int after;\n", "utf8"); + await writeFile(path.join(root, "Objects", "ignored.txt"), "arm_2d_init should not be scanned\n", "utf8"); + + const result = await executeHwpodNodeOpsPlan({ + contractVersion: "hwpod-node-ops-v1", + planId: "hwpod_plan_rg", + hwpodId: "hwpod-local", + nodeId: "pc-host-1", + ops: [{ opId: "op_rg", op: "workspace.rg", args: { workspacePath: root, pattern: "arm_2d_init", path: "Middlewares", context: 1 } }] + }, { now: () => "2026-06-05T00:00:00.000Z" }); + + assert.equal(result.ok, true); + assert.equal(result.results[0].ok, true); + assert.equal(result.results[0].output.engine, "node-recursive-search"); + assert.equal(result.results[0].output.matchCount, 1); + assert.ok(result.results[0].output.stdout.includes("Middlewares/Arm-2D/arm_2d.c-1-static int before")); + assert.ok(result.results[0].output.stdout.includes("Middlewares/Arm-2D/arm_2d.c:2:void arm_2d_init")); + assert.ok(result.results[0].output.stdout.includes("Middlewares/Arm-2D/arm_2d.c-3-}")); + assert.equal(result.results[0].output.matches[0].line, 2); + assert.equal(result.results[0].output.matches[0].before[0].line, 1); + assert.equal(result.results[0].output.matches[0].after[0].line, 3); + assert.equal(result.results[0].output.limits.beforeContext, 1); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("hwpod-node HTTP endpoint accepts hwpod-node-ops plans", async () => { const server = createHwpodNodeServer({ now: () => "2026-06-05T00:00:00.000Z", nodeId: "pc-host-1" }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); diff --git a/tools/src/hwpod-harness-lib.ts b/tools/src/hwpod-harness-lib.ts index 3be4fb18..9f13a33d 100644 --- a/tools/src/hwpod-harness-lib.ts +++ b/tools/src/hwpod-harness-lib.ts @@ -14,7 +14,7 @@ const COMPILER_NAME = "hwpod-compiler-cli"; const CTL_NAME = "hwpod-ctl"; const CLI_NAME = "hwpod-cli"; const DEFAULT_TIMEOUT_MS = 30000; -const BOOLEAN_OPTIONS = new Set(["all", "allowMultiple", "dryRun", "finalNewline", "force", "full", "help", "h", "json", "noAuth", "sessionOnly", "wait"]); +const BOOLEAN_OPTIONS = new Set(["all", "allowMultiple", "dryRun", "finalNewline", "force", "full", "help", "h", "ignoreCase", "json", "noAuth", "sessionOnly", "wait"]); const DEFAULT_KEIL_COMMAND_TIMEOUT_MS = 30000; type EnvLike = Record; type ParsedArgs = Record & { _: string[] }; @@ -188,6 +188,7 @@ function cliHelp() { "bun tools/hwpod-cli.ts workspace ls . --dry-run", "bun tools/hwpod-cli.ts workspace cat projects/01_baseline/User/main.c --spec .hwlab/hwpod-spec.yaml", "bun tools/hwpod-cli.ts workspace read projects/01_baseline/User/main.c --spec .hwlab/hwpod-spec.yaml", + "bun tools/hwpod-cli.ts workspace rg arm_2d_init projects/01_baseline/Middlewares/Arm-2D --context 3 --spec .hwlab/hwpod-spec.yaml", "cat patch.txt | bun tools/hwpod-cli.ts workspace apply-patch --spec .hwlab/hwpod-spec.yaml --reason \"edit subject workspace through hwpod-node\"", "bun tools/hwpod-cli.ts workspace replace --path projects/01_baseline/User/main.c --find \"old text\" --replace \"new text\" --expected-sha ", "bun tools/hwpod-cli.ts workspace insert-after --path projects/01_baseline/User/main.c --anchor \"while (1)\" --line \" /* marker */\"", @@ -245,6 +246,9 @@ function hwpodWorkspaceHelp(subcommand = "") { "hwpod workspace read --spec .hwlab/hwpod-spec.yaml", "hwpod workspace cat --file --spec .hwlab/hwpod-spec.yaml", "hwpod workspace cat --remote-path --spec .hwlab/hwpod-spec.yaml", + "hwpod workspace rg [path] --context 3 --max-matches 20 --spec .hwlab/hwpod-spec.yaml", + "hwpod workspace grep [path] --spec .hwlab/hwpod-spec.yaml", + "hwpod workspace search [path] --spec .hwlab/hwpod-spec.yaml", "hwpod workspace write --path --content --expected-sha ", "hwpod workspace replace --path --find --replace --expected-sha ", "hwpod workspace insert-after --path --anchor --line --expected-sha ", @@ -253,6 +257,8 @@ function hwpodWorkspaceHelp(subcommand = "") { ], aliases: { read: "workspace.cat", + grep: "workspace.rg", + search: "workspace.rg", file: "--path", remotePath: "--path", patchContent: "--patch" @@ -424,7 +430,7 @@ function commandToIntent(parsed: ParsedArgs, stdinText?: string) { const subcommand = parsed._[1] || "ls"; if (subcommand === "ls") return { intent: "workspace.ls", args: { path: text(parsed.path ?? parsed._[2] ?? ".") } }; if (subcommand === "cat" || subcommand === "read") return { intent: "workspace.cat", args: { path: requiredWorkspacePath(parsed, 2) } }; - if (subcommand === "rg") return { intent: "workspace.rg", args: { pattern: requiredText(parsed.pattern ?? parsed._[2], "pattern"), path: text(parsed.path ?? parsed._[3] ?? "."), ignoreCase: parsed.ignoreCase === true } }; + if (subcommand === "rg" || subcommand === "grep" || subcommand === "search") return { intent: "workspace.rg", args: workspaceSearchArgs(parsed) }; if (subcommand === "apply-patch") return { intent: "workspace.apply-patch", args: { patch: patchText(parsed.patch ?? parsed.patchText ?? parsed.patchContent ?? stdinText), patchBase64: text(parsed.patchBase64), reason: text(parsed.reason) } }; if (subcommand === "write") return { intent: "workspace.write", args: clean({ path: requiredWorkspacePath(parsed, 2), content: patchText(parsed.content ?? parsed.contentText ?? stdinText), contentBase64: text(parsed.contentBase64), expectedSha: text(parsed.expectedSha), lineEnding: text(parsed.lineEnding), finalNewline: parsed.finalNewline === true, dryRun: parsed.dryRun === true, reason: text(parsed.reason) }) }; if (subcommand === "replace") return { intent: "workspace.replace", args: cleanTextEditArgs({ path: requiredWorkspacePath(parsed, 2), find: requiredRawString(parsed.find ?? parsed._[3], "find"), replace: rawString(parsed.replace ?? parsed.replacement ?? parsed._[4]), expectedSha: text(parsed.expectedSha), all: parsed.all === true, dryRun: parsed.dryRun === true, reason: text(parsed.reason) }) }; @@ -453,6 +459,21 @@ function commandToIntent(parsed: ParsedArgs, stdinText?: string) { throw cliError("unsupported_hwpod_command", `unsupported hwpod-cli command: ${command}`); } +function workspaceSearchArgs(parsed: ParsedArgs) { + return clean({ + pattern: requiredText(parsed.pattern ?? parsed._[2], "pattern"), + path: text(parsed.path ?? parsed._[3] ?? "."), + ignoreCase: parsed.ignoreCase === true, + beforeContext: numberValue(parsed.beforeContext), + afterContext: numberValue(parsed.afterContext), + context: numberValue(parsed.context), + maxMatches: numberValue(parsed.maxMatches), + maxFiles: numberValue(parsed.maxFiles), + maxBytesPerFile: numberValue(parsed.maxBytesPerFile), + maxLineBytes: numberValue(parsed.maxLineBytes) + }); +} + function opsForIntent(intent: string, args: any, document: any) { const common = commonOpArgs(document); if (intent === "inspect") return [ @@ -462,7 +483,7 @@ function opsForIntent(intent: string, args: any, document: any) { if (intent === "node.health" || intent === "node.version" || intent === "node.inventory") return [{ op: intent, args: common }]; if (intent === "workspace.ls") return [{ op: "workspace.ls", args: { ...common, path: text(args.path) || "." } }]; if (intent === "workspace.cat") return [{ op: "workspace.cat", args: { ...common, path: requiredText(args.path, "path") } }]; - if (intent === "workspace.rg") return [{ op: "workspace.rg", args: { ...common, pattern: requiredText(args.pattern, "pattern"), path: text(args.path) || ".", ignoreCase: args.ignoreCase === true } }]; + if (intent === "workspace.rg") return [{ op: "workspace.rg", args: clean({ ...common, pattern: requiredText(args.pattern, "pattern"), path: text(args.path) || ".", ignoreCase: args.ignoreCase === true, beforeContext: numberValue(args.beforeContext), afterContext: numberValue(args.afterContext), context: numberValue(args.context), maxMatches: numberValue(args.maxMatches), maxFiles: numberValue(args.maxFiles), maxBytesPerFile: numberValue(args.maxBytesPerFile), maxLineBytes: numberValue(args.maxLineBytes) }) }]; if (intent === "workspace.apply-patch") return [{ op: "workspace.apply-patch", args: clean({ ...common, patch: patchText(args.patch), patchBase64: text(args.patchBase64), reason: text(args.reason) }) }]; if (intent === "workspace.write") return [{ op: "workspace.write", args: clean({ ...common, path: requiredText(args.path, "path"), content: patchText(args.content), contentBase64: text(args.contentBase64), expectedSha: text(args.expectedSha), lineEnding: text(args.lineEnding), finalNewline: args.finalNewline === true, dryRun: args.dryRun === true, reason: text(args.reason) }) }]; if (intent === "workspace.replace") return [{ op: "workspace.replace", args: cleanTextEditArgs({ ...common, path: requiredText(args.path, "path"), find: requiredRawString(args.find, "find"), replace: rawString(args.replace), expectedSha: text(args.expectedSha), all: args.all === true, dryRun: args.dryRun === true, reason: text(args.reason) }) }]; @@ -1019,6 +1040,11 @@ function parseOptions(argv: string[]): ParsedArgs { parsed.help = true; continue; } + if (["-i", "-A", "-B", "-C"].includes(token) || /^-[ABC]\d+$/u.test(token)) { + applyShortSearchOption(parsed, token, argv[index + 1]); + if (["-A", "-B", "-C"].includes(token)) index += 1; + continue; + } const [rawKey, rawInline] = token.slice(2).split(/=(.*)/su, 2); const key = camelCase(rawKey); if (BOOLEAN_OPTIONS.has(key)) { @@ -1040,6 +1066,19 @@ function parseOptions(argv: string[]): ParsedArgs { return parsed; } +function applyShortSearchOption(parsed: ParsedArgs, token: string, next: string | undefined) { + if (token === "-i") { + parsed.ignoreCase = true; + return; + } + const flag = token[1]; + const inlineValue = token.length > 2 ? token.slice(2) : next; + const value = numberValue(inlineValue); + if (flag === "A") parsed.afterContext = value; + if (flag === "B") parsed.beforeContext = value; + if (flag === "C") parsed.context = value; +} + function parseJsonObject(value: unknown, name: string) { if (value === undefined || value === null || value === "") return {}; if (isPlainObject(value)) return value; diff --git a/tools/src/hwpod-node-lib.ts b/tools/src/hwpod-node-lib.ts index e824a660..cbe2a460 100644 --- a/tools/src/hwpod-node-lib.ts +++ b/tools/src/hwpod-node-lib.ts @@ -379,10 +379,156 @@ async function workspaceCat(args: any) { async function workspaceRg(args: any) { const pattern = requiredText(args.pattern, "pattern"); const cwd = workspaceRoot(args); - const rgPath = text(args.path) || "."; - const command = ["rg", "--line-number", "--color", "never", ...(args.ignoreCase === true ? ["--ignore-case"] : []), pattern, rgPath]; - const output = await spawnOutput(command, { cwd, timeoutMs: numberValue(args.timeoutMs) ?? 10000 }); - return { cwd, command, ...output }; + const searchPath = text(args.path) || "."; + const target = resolveWorkspacePath(args, searchPath); + const flags = args.ignoreCase === true ? "iu" : "u"; + let matcher: RegExp; + try { + matcher = new RegExp(pattern, flags); + } catch (error) { + throw cliError("workspace_rg_invalid_pattern", `invalid workspace.rg pattern: ${pattern}`, { pattern, message: error?.message ?? String(error) }); + } + + const maxFiles = numberValue(args.maxFiles) ?? 5000; + const maxMatches = numberValue(args.maxMatches) ?? 200; + const maxBytesPerFile = numberValue(args.maxBytesPerFile) ?? 1024 * 1024; + const lineLimit = numberValue(args.maxLineBytes) ?? 600; + const contextLines = Math.max(numberValue(args.context) ?? 0, 0); + const beforeContext = Math.max(numberValue(args.beforeContext) ?? contextLines, 0); + const afterContext = Math.max(numberValue(args.afterContext) ?? contextLines, 0); + const matches: Array<{ path: string; line: number; text: string; before?: Array<{ line: number; text: string }>; after?: Array<{ line: number; text: string }> }> = []; + const state = { scannedFiles: 0, skippedFiles: 0, skippedDirectories: 0, truncated: false }; + + async function scanFile(filePath: string) { + if (state.truncated || state.scannedFiles >= maxFiles || matches.length >= maxMatches) { + state.truncated = true; + return; + } + const info = await stat(filePath).catch(() => null); + if (!info?.isFile?.()) { + state.skippedFiles += 1; + return; + } + if (info.size > maxBytesPerFile) { + state.skippedFiles += 1; + return; + } + const buffer = await readFile(filePath).catch(() => null); + if (!buffer || buffer.includes(0)) { + state.skippedFiles += 1; + return; + } + state.scannedFiles += 1; + const displayPath = workspaceDisplayPath(cwd, filePath); + const lines = buffer.toString("utf8").split(/\r\n|\n|\r/u); + for (let index = 0; index < lines.length; index += 1) { + if (matcher.test(lines[index])) { + matches.push(workspaceSearchMatch(displayPath, lines, index, beforeContext, afterContext, lineLimit)); + if (matches.length >= maxMatches) { + state.truncated = true; + return; + } + } + } + } + + async function walk(directory: string) { + if (state.truncated) return; + const entries = await readdir(directory, { withFileTypes: true }).catch(() => null); + if (!entries) { + state.skippedDirectories += 1; + return; + } + for (const entry of entries) { + if (state.truncated) return; + const child = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (isWorkspaceSearchSkippedDirectory(entry.name)) { + state.skippedDirectories += 1; + continue; + } + await walk(child); + } else if (entry.isFile()) { + await scanFile(child); + } else { + state.skippedFiles += 1; + } + } + } + + const info = await stat(target).catch(() => null); + if (!info) throw cliError("workspace_rg_path_not_found", `workspace.rg path not found: ${searchPath}`, { workspacePath: cwd, path: searchPath, target }); + if (info.isDirectory()) await walk(target); + else await scanFile(target); + + const stdout = renderWorkspaceSearchStdout(matches); + return { + ok: true, + engine: "node-recursive-search", + cwd, + path: searchPath, + target, + pattern, + ignoreCase: args.ignoreCase === true, + exitCode: 0, + stdout, + stderr: "", + scannedFiles: state.scannedFiles, + skippedFiles: state.skippedFiles, + skippedDirectories: state.skippedDirectories, + matchCount: matches.length, + matches, + truncated: state.truncated, + limits: { maxFiles, maxMatches, maxBytesPerFile, maxLineBytes: lineLimit, beforeContext, afterContext } + }; +} + +function workspaceSearchMatch(displayPath: string, lines: string[], index: number, beforeContext: number, afterContext: number, lineLimit: number) { + const before = []; + const after = []; + for (let lineIndex = Math.max(0, index - beforeContext); lineIndex < index; lineIndex += 1) { + before.push({ line: lineIndex + 1, text: truncateText(lines[lineIndex], lineLimit) }); + } + for (let lineIndex = index + 1; lineIndex <= Math.min(lines.length - 1, index + afterContext); lineIndex += 1) { + after.push({ line: lineIndex + 1, text: truncateText(lines[lineIndex], lineLimit) }); + } + const match: { path: string; line: number; text: string; before?: Array<{ line: number; text: string }>; after?: Array<{ line: number; text: string }> } = { + path: displayPath, + line: index + 1, + text: truncateText(lines[index], lineLimit) + }; + if (before.length) match.before = before; + if (after.length) match.after = after; + return match; +} + +function renderWorkspaceSearchStdout(matches: Array<{ path: string; line: number; text: string; before?: Array<{ line: number; text: string }>; after?: Array<{ line: number; text: string }> }>) { + const lines: string[] = []; + for (const match of matches) { + for (const context of match.before ?? []) lines.push(`${match.path}-${context.line}-${context.text}`); + lines.push(`${match.path}:${match.line}:${match.text}`); + for (const context of match.after ?? []) lines.push(`${match.path}-${context.line}-${context.text}`); + } + return lines.join("\n"); +} + +function isWorkspaceSearchSkippedDirectory(name: string) { + return new Set([".git", ".worktree", "node_modules", "Objects", "Listings", "Output", "build", "dist"]).has(name); +} + +function workspaceDisplayPath(root: string, filePath: string) { + const relative = path.relative(root, filePath) || path.basename(filePath); + return relative.replace(/[\\]+/gu, "/"); +} + +function truncateText(value: string, maxBytes: number) { + if (Buffer.byteLength(value, "utf8") <= maxBytes) return value; + let output = ""; + for (const char of value) { + if (Buffer.byteLength(`${output}${char}`, "utf8") > maxBytes) break; + output += char; + } + return `${output}...`; } async function workspaceApplyPatch(args: any) {