From 6d3da76b8d01261e4921f5c151b6cd8247dd0125 Mon Sep 17 00:00:00 2001 From: linnnn89 <216342082+linnnn89@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:23:59 +0800 Subject: [PATCH] perf: batch local context scans by default in 0.17.1 --- CHANGELOG.md | 7 +- README.md | 10 +- package-lock.json | 4 +- package.json | 3 +- scripts/benchmark-context-work.ts | 140 ++++++++++++++++++ skills/wincode/SKILL.md | 3 +- skills/wincode/references/code.md | 4 + src/Adapters/LocalTextAdapter.ts | 56 ++++++- src/Adapters/RepomixAdapter.ts | 2 + src/Core/CodeQueries.ts | 2 + src/Core/Config.ts | 2 +- src/Core/Context.ts | 29 +++- src/Core/LocalTextScanner.ts | 12 +- src/Core/OperationContext.ts | 3 +- src/Core/OperationMetrics.ts | 24 +++ tests/local-text.test.ts | 67 +++++++++ .../WinCode.Code.Host.csproj | 2 +- tools/WinCode.Tray/WinCode.Tray.csproj | 2 +- .../WinCode.UIA.Host/WinCode.UIA.Host.csproj | 2 +- 19 files changed, 344 insertions(+), 30 deletions(-) create mode 100644 scripts/benchmark-context-work.ts create mode 100644 src/Core/OperationMetrics.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 098b219..bbc3cde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog -## 0.17.0 (unreleased) +## 0.17.1 (2026-10-17) + +- Share one request-local source scan by default for up to four local-text keywords in `prepare_context`, preserving query order, diagnostics and scoped/ranged bypasses. Bounded scans or any query reaching 500 matches fall back to sequential queries under the existing deadline and can cost more. Add opt-in internal phase/work metrics and a model-free paired `benchmark:context` command with an explicit sequential baseline. No new MCP arguments, dependencies, persistent source cache or UI behavior changes. +- Update the managed Skill to explain multi-name context requests, existing scan limits, fallback cost and incomplete-result handling. + +## 0.17.0 - Add an explicit name + CheckBox selector mode to checkbox-audit for controls without AutomationIds. Validate only task-relevant known properties while retaining raw gaps and refusing uncertain identity, ambiguity, unknown state and incomplete coverage. Preserve the existing ID-based parameter, result and validation contracts; named results return exact selectors in request order. No UI actions or default configuration changes. - Reorganize the four managed Skill documents by task. Keep the entry short, place executable UI recipe examples inside the installed Skill, and remove duplicated instructions and historical iteration notes. diff --git a/README.md b/README.md index 55e4cd1..0c1d7fa 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ Use actual paths and line numbers from the search result. `lineRanges` selects k ### Development and documentation -Current source version: **0.17.0**. See [CHANGELOG](CHANGELOG.md) for version history and migration notes. Windows 11 x64 is the reference platform; ports to other operating systems require adaptation and separate validation. +Current source version: **0.17.1**. See [CHANGELOG](CHANGELOG.md) for version history and migration notes. Windows 11 x64 is the reference platform; ports to other operating systems require adaptation and separate validation. ```powershell npm run check # Builds, core regression, stdio integration and delivery verification @@ -182,8 +182,11 @@ npm run check:desktop # WPF and UI-to-source tests; requires an interactive W npm run delivery:verify # Verify Gateway, native components and managed Skill artifacts npm run test:inventory # Verify automated-test suite registration npm run benchmark:agent -- 1 # Run one iteration of the optional scripted benchmark +npm run benchmark:context -- compare 8 # Model-free, paired sequential/batch context experiment ``` +`benchmark:context` accepts `baseline|compare`, 1–100 repetitions, and an optional existing WinCode checkout path. Without a path it creates an isolated fixture under ignored `test-tmp/context-work/`; reports remain there. It exercises real MCP handlers over in-memory transport, records request-local inclusive phase times and application-level scan/read/parse counters, checks paired response equality and source evidence, and verifies input hashes. `first` means a fresh application cache, not cold OS disk cache. Initialization, prewarm and cleanup are excluded; no model, UIA, or network is used. Production local-text context requests batch up to four extracted keywords by default; the benchmark explicitly selects sequential queries as its baseline. Truncated/capped batches fall back to sequential queries and may cost more; no P95 or general Agent speedup is inferred. + [CI](https://github.com/linnnn89/WinCode/actions/workflows/ci.yml) runs on Windows with Node.js 22 and 24 and the pinned .NET SDK. The Node.js 22 job includes additional native and integration checks; desktop tests run separately. Benchmark reports measure scripted scenarios, including calls, response size and execution time. See the linked records for test conditions and results. - [Contributing](CONTRIBUTING.md): builds, test suites and delivery requirements. @@ -346,7 +349,7 @@ npm run delivery:verify ### 开发与文档 -当前源码版本为 **0.17.0**。版本历史和迁移说明见 [CHANGELOG](CHANGELOG.md)。项目以 Windows 11 x64 为基准平台,移植至其他操作系统需要适配并单独验证。 +当前源码版本为 **0.17.1**。版本历史和迁移说明见 [CHANGELOG](CHANGELOG.md)。项目以 Windows 11 x64 为基准平台,移植至其他操作系统需要适配并单独验证。 ```powershell npm run check # 构建、核心回归、stdio 集成和交付校验 @@ -354,8 +357,11 @@ npm run check:desktop # WPF 与 UI 源码关联测试,需要交互式 Windo npm run delivery:verify # 校验 Gateway、原生组件和交付清单中的 Skill 文件 npm run test:inventory # 核对自动化测试的套件注册情况 npm run benchmark:agent -- 1 # 执行一轮可选的脚本化基准测试 +npm run benchmark:context -- compare 8 # 不调用模型,对照逐关键词和批量上下文查询 ``` +`benchmark:context` 接受 `baseline|compare`、1–100 次重复及可选的现有 WinCode 工作区路径。省略路径时,在已忽略的 `test-tmp/context-work/` 创建隔离样本并保留 JSON 报告。通过内存内 MCP transport 调用真实 handler,记录请求级包含子阶段的耗时与应用层扫描、读取、解析计数,核对配对响应、源码证据和输入哈希。`first` 仅表示应用缓存未预热,不代表冷磁盘缓存;计时排除初始化、预热和清理,不调用模型、UIA 或网络。生产 local-text 上下文请求默认合并最多四个提取关键词的扫描;基准显式选择逐关键词路径作为对照。截断或达到结果上限时回退逐查询,可能更慢;报告不推断 P95 或通用 Agent 提速。 + [CI](https://github.com/linnnn89/WinCode/actions/workflows/ci.yml) 在 Windows 环境中使用 Node.js 22、24 和固定版本的 .NET SDK 运行。在 Node.js 22 的任务中,还会额外执行原生组件与集成检查;桌面测试单独运行。基准报告记录脚本化场景中的调用次数、响应大小和执行时间,具体测试条件及结果见以下文档。 - [贡献指南](CONTRIBUTING.md):构建、测试套件和交付要求。 diff --git a/package-lock.json b/package-lock.json index 994b322..4b3006d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wincode-mcp", - "version": "0.17.0", + "version": "0.17.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wincode-mcp", - "version": "0.17.0", + "version": "0.17.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/client": "2.0.0", diff --git a/package.json b/package.json index cf3db6e..74368be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wincode-mcp", - "version": "0.17.0", + "version": "0.17.1", "description": "Windows-first MCP gateway: .NET project graph, evidence-bounded context, honest change-impact, long-running process hygiene", "main": "dist/index.js", "type": "module", @@ -15,6 +15,7 @@ "test": "tsx --test tests/core-cache.test.ts tests/workspace-files.test.ts tests/text-symbols.test.ts tests/context-packing.test.ts tests/composite-tools.test.ts tests/mcp-stdio.test.ts tests/stability-lifecycle.test.ts tests/cache-budgets.test.ts tests/process-failures.test.ts tests/request-concurrency.test.ts tests/evidence-confidence.test.ts tests/watch-invalidation.test.ts tests/resource-cleanup.test.ts tests/stage1-cleanup.test.ts tests/ui-hardening.test.ts tests/ui-source-review.test.ts tests/v071-acceptance.test.ts tests/ui-background.test.ts tests/ui-audit.test.ts tests/ui-query.test.ts tests/context-efficiency.test.ts tests/agent-efficiency-benchmark.test.ts tests/workspace-summary.test.ts tests/runtime-identity.test.ts tests/runtime-contract.test.ts tests/context-coverage.test.ts tests/semantic-identity.test.ts tests/ui-code-candidates.test.ts tests/skill-sync.test.ts tests/local-text.test.ts tests/repomix-disabled.test.ts tests/workspace-watch-close.test.ts tests/workspace-lifecycle.test.ts tests/failure-recovery.test.ts tests/tool-contracts.test.ts tests/architecture-boundaries.test.ts tests/lifecycle-cancellation.test.ts tests/delivery-contract.test.ts tests/roslyn-contracts.test.ts tests/gateway-exit.test.ts tests/owner-process-guard.test.ts tests/manual-release.test.ts tests/tray-client.test.ts tests/runtime-cache-regressions.test.ts tests/architecture-safety.test.ts tests/resource-identity.test.ts tests/check-reporting.test.ts tests/fixed-workspace.test.ts tests/request-admission.test.ts tests/design-time-artifacts.test.ts tests/skill-session.test.ts tests/hybrid-ui-workflow.test.ts tests/hybrid-model.test.ts", "test:verify": "tsx tests/verify.ts", "benchmark:agent": "tsx scripts/benchmark-agent-efficiency.ts", + "benchmark:context": "tsx scripts/benchmark-context-work.ts", "benchmark:hybrid": "tsx scripts/benchmark-hybrid-ui.ts", "build:hybrid-fixture": "node scripts/build-hybrid-fixture.mjs", "test:benchmark": "tsx --test tests/agent-efficiency-benchmark.test.ts", diff --git a/scripts/benchmark-context-work.ts b/scripts/benchmark-context-work.ts new file mode 100644 index 0000000..c5ca0c5 --- /dev/null +++ b/scripts/benchmark-context-work.ts @@ -0,0 +1,140 @@ +/** Local, model-free paired experiment through real MCP handlers and in-memory transport. */ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { getDefaultConfig } from '../src/Core/Config.js'; +import { ToolRouter } from '../src/Core/ToolRouter.js'; +import { ContextManager } from '../src/Core/Context.js'; +import { OperationMetrics } from '../src/Core/OperationMetrics.js'; +import { WinCodeMcpServer } from '../src/Gateway/McpServer.js'; + +const root = path.resolve(import.meta.dirname, '..'); +const mode = process.argv[2] ?? 'baseline'; +const repetitions = Number(process.argv[3] ?? 5); +if (!['baseline', 'compare'].includes(mode) || !Number.isInteger(repetitions) || repetitions < 1 || repetitions > 100 || process.argv.length > 5) + throw new Error('Usage: tsx scripts/benchmark-context-work.ts [baseline|compare] [1-100 repetitions] [existing workspace]'); +const output = path.join(root, 'test-tmp', 'context-work', `${Date.now()}-${process.pid}`); +await fs.mkdir(output, { recursive: true }); +const workspace = process.argv[4] ? path.resolve(process.argv[4]) : path.join(output, 'fixture'); +if (!process.argv[4]) { + await fs.mkdir(workspace); + await fs.writeFile(path.join(workspace, 'package.json'), '{"name":"context-work-fixture"}'); + for (let i = 0; i < 96; i++) { + await fs.writeFile(path.join(workspace, `Service${String(i).padStart(3, '0')}.ts`), + ['Alpha', 'Bravo', 'Charlie', 'Delta'].map(name => `export function ${name}${i}() { return '${name}-${i}'; }`).join('\n') + + '\n' + Array.from({ length: 32 }, (_, n) => `// padding ${n} ${'x'.repeat(64)}`).join('\n')); + } + await fs.writeFile(path.join(workspace, 'Overflow.ts'), Array.from({ length: 510 }, (_, i) => `export function Overflow${i}() {}`).join('\n')); +} +const words = process.argv[4] ? ['ContextManager', 'LocalTextAdapter', 'OperationContext', 'scanLocalFiles'] : ['Alpha', 'Bravo', 'Charlie', 'Delta']; +const cases = [1, 2, 4].map(n => ({ id: `${n}-keywords`, args: { task: words.slice(0, n).join(' '), maxTokens: 8000 } as Record })); +cases.push({ id: 'known-range', args: { task: 'Read exact source', lineRanges: [{ file: process.argv[4] ? 'src/Core/OperationContext.ts' : 'Service000.ts', startLine: 1, endLine: 4 }], maxTokens: 8000 } }); +cases.push({ id: '4-keywords-fulltext', args: { task: words.join(' '), includeFullText: true, maxTokens: 16000 } }); +if (!process.argv[4]) cases.push({ id: 'result-limit', args: { task: 'Overflow NoSuchSymbol', maxTokens: 8000 } }); +const variants = mode === 'compare' ? ['sequential', 'batch'] : ['sequential']; +const samples: Array> = []; +const sourceFiles = ['scripts/benchmark-context-work.ts', 'src/Core/Context.ts', 'src/Core/LocalTextScanner.ts', + 'src/Core/OperationContext.ts', 'src/Core/OperationMetrics.ts', 'src/Adapters/LocalTextAdapter.ts', 'src/Adapters/RepomixAdapter.ts', 'src/Core/CodeQueries.ts']; +const hashes = async () => Object.fromEntries(await Promise.all(sourceFiles.map(async file => + [file, createHash('sha256').update(await fs.readFile(path.join(root, file))).digest('hex')]))); +const sourceHashes = await hashes(); +const listInputs = async () => process.argv[4] ? execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], + { cwd: workspace, encoding: 'utf8', windowsHide: true }).split('\0').filter(Boolean) : await fs.readdir(workspace); +const inputHash = async () => { + const hash = createHash('sha256'); + for (const file of (await listInputs()).sort()) { hash.update(file + '\0'); hash.update(createHash('sha256').update(await fs.readFile(path.join(workspace, file))).digest()); } + return hash.digest('hex'); +}; +const workspaceHash = await inputHash(); +const revision = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', windowsHide: true }).trim(); + +async function run(variant: string, state: string, args: Record) { + const config = getDefaultConfig(workspace); + config.cacheDir = path.join(output, `cache-${samples.length}-${variant}`); + config.adapters.flaui.enabled = false; + config.adapters.repomix.useCli = false; + if (config.adapters.roslyn) config.adapters.roslyn.enabled = false; + const router = new ToolRouter(config); + if (variant === 'sequential') router.context = new ContextManager(config, router.workspace, router.repomix, router.text, { batchSymbols: false }); + let metrics: OperationMetrics | undefined; + const prepare = router.context.prepareContext.bind(router.context); + router.context.prepareContext = async (options, operation) => { + const end = metrics?.start('context.total'); + try { return await prepare(options, { ...operation, metrics }); } finally { end?.(); } + }; + const server = new WinCodeMcpServer(router); + const client = new Client({ name: 'context-work-benchmark', version: '1' }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + try { + await router.initialize(); + await Promise.all([client.connect(ct), (server as any).server.connect(st)]); + if (state === 'warm') assert.notEqual((await client.callTool({ name: 'wincode_prepare_context', arguments: args })).isError, true); + metrics = new OperationMetrics(); + const begin = performance.now(); + const response = await client.callTool({ name: 'wincode_prepare_context', arguments: args }); + const elapsedMs = performance.now() - begin; + const responseFile = `response-${samples.length}-${variant}.json`; + await fs.writeFile(path.join(output, responseFile), JSON.stringify(response, null, 2)); + assert.notEqual(response.isError, true); + const text = (response.content as { type: string; text: string }[]).find(block => block.type === 'text')!.text; + const data = JSON.parse(text); + assert.ok(data.evidence.length > 0, 'Expected usable source evidence.'); + for (const item of data.evidence) { + assert.ok(!path.isAbsolute(item.file) && !item.file.split(/[\\/]/).includes('..')); + const source = await fs.readFile(path.join(workspace, item.file), 'utf8'); + if (args.includeFullText) { + if (item.bodyStatus === 'complete') assert.ok(data.packedContent.includes(source), 'Complete packed file must contain the independently read source.'); + else if (item.bodyStatus === 'partial') { + const marker = `File: ${item.file}\n${'='.repeat(48)}\n`; + const index = data.packedContent.indexOf(marker); + assert.ok(index >= 0, 'Partial packed file must have an observed boundary.'); + assert.ok(source.startsWith(data.packedContent.slice(index + marker.length)), 'Partial packed body must be a source prefix.'); + } else assert.equal(item.bodyStatus, 'omitted'); + continue; + } + const lines = source.split(/\r?\n/); + const actual = lines.slice(item.startLine - 1, item.endLine).join('\n'); + assert.ok(item.snippet === actual || (item.truncated && actual.startsWith(item.snippet)), 'Evidence must match independent file read.'); + } + if (!process.argv[4]) assert.equal(data.queryComplete, args.task !== 'Overflow NoSuchSymbol'); + const keywordCount = String(args.task).split(' ').length; + if (!args.lineRanges && variant === 'sequential') assert.equal(metrics.counters['scan.invocations'], keywordCount, 'Benchmark keywords must survive production stopword filtering.'); + return { elapsedMs, responseFile, responseBytes: Buffer.byteLength(text), responseHash: createHash('sha256').update(text).digest('hex'), + queryComplete: data.queryComplete, truncated: data.truncated, counters: metrics.counters, phases: metrics.phases }; + } finally { await client.close(); await server.stop(); } +} + +const median = (values: number[]) => { const sorted = [...values].sort((a, b) => a - b); return sorted.length % 2 ? sorted[(sorted.length - 1) / 2] : (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2; }; +let failure: string | undefined; +try { + for (let repeat = 0; repeat < repetitions; repeat++) for (const testCase of cases) for (const state of ['first', 'warm']) { + const pair: typeof samples = []; + for (const variant of repeat % 2 ? [...variants].reverse() : variants) { + const sample = { case: testCase.id, state, variant, repeat, ...await run(variant, state, testCase.args) }; + samples.push(sample); pair.push(sample); + } + if (pair.length === 2) assert.equal(pair[0].responseHash, pair[1].responseHash, `Response mismatch: ${testCase.id}/${state}`); + } +} catch (error) { failure = error instanceof Error ? error.stack : String(error); } +const summaries = cases.flatMap(c => ['first', 'warm'].flatMap(state => variants.map(variant => { + const rows = samples.filter(s => s.case === c.id && s.state === state && s.variant === variant); + const names = [...new Set(rows.flatMap(s => Object.keys(s.counters)))]; + const phases = [...new Set(rows.flatMap(s => Object.keys(s.phases)))]; + return { case: c.id, state, variant, samples: rows.length, medianMs: rows.length ? median(rows.map(s => s.elapsedMs)) : null, + counters: Object.fromEntries(names.map(n => [n, median(rows.map(s => s.counters[n] ?? 0))])), + phaseMs: Object.fromEntries(phases.map(n => [n, median(rows.map(s => s.phases[n]?.elapsedMs ?? 0))])) }; +}))); +const sourcesUnchanged = JSON.stringify(sourceHashes) === JSON.stringify(await hashes()); +const inputsUnchanged = workspaceHash === await inputHash(); +const report = { revision, sourceHashes, sourcesUnchanged, workspaceHash, inputsUnchanged, mode, repetitions, workspace, node: process.version, failure, + methodology: { transport: 'in-memory MCP; actual router, handlers, local-text and serialization; no model, UIA or network', + timing: 'callTool wall time; initialization, prewarm, oracle reads and cleanup excluded. first means fresh application cache, not cold OS disk cache.', + counters: 'scan.* covers LocalTextScanner only; bytesRead are bytes returned by FileHandle.read, not physical disk IO. context decoded bytes exclude project discovery and packer IO.', + phases: 'Inclusive and potentially nested; do not sum. Response hashes must match within each pair; fixture evidence checked against independent source reads.', + samples: 'Repeated fixed tasks; no population or P95 claim. Existing workspace input must remain unchanged during measurement.' }, summaries, samples }; +await fs.writeFile(path.join(output, 'report.json'), JSON.stringify(report, null, 2)); +console.log(JSON.stringify({ report: path.join(output, 'report.json'), sourcesUnchanged, inputsUnchanged, failure, summaries }, null, 2)); +if (failure || !sourcesUnchanged || !inputsUnchanged) process.exitCode = 1; diff --git a/skills/wincode/SKILL.md b/skills/wincode/SKILL.md index 0e62d1e..183b76b 100644 --- a/skills/wincode/SKILL.md +++ b/skills/wincode/SKILL.md @@ -5,7 +5,7 @@ description: 使用 WinCode MCP 定位项目源码、查询 C# 引用和变更 # WinCode -适用于 0.17.0。优先复用目标工作区已有的 MCP 连接;需要按需启动时,读[会话入口](references/diagnostics.md#skill-按需会话)。仅加载 Skill 不启动进程。 +适用于 0.17.1。优先复用目标工作区已有的 MCP 连接;需要按需启动时,读[会话入口](references/diagnostics.md#skill-按需会话)。仅加载 Skill 不启动进程。 ## 按任务读取 @@ -21,6 +21,7 @@ description: 使用 WinCode MCP 定位项目源码、查询 C# 引用和变更 - 连接固定到启动工作区。已知根一致时直接查询;`WORKSPACE_MISMATCH` 时换用对应连接,`workspace_open` 只能确认或恢复原根。 - 代码先定位再定向读取。默认 `local-text` 提供文本线索;Roslyn 引用需要当前搜索结果中的完整 `location`。 +- 同一任务需按多个声明名找上下文时,把相关名称放在一次 `wincode_prepare_context.task` 中;local-text 自动合并最多 4 个提取关键词的扫描。已知文件或行号仍优先定向读取,限制与回退见[代码手册](references/code.md#定向上下文)。 - 查看界面保持只读:明确 PID/HWND,使用 `backgroundOnly:true`。操作界面按用户授权执行,动作后读回结果。后台写值显式用 `mode:"setValue"`;默认 `type` 会请求键盘焦点。 - 检查完整结果中的业务状态、范围、截断和缺口。空结果不证明不存在;动作受理不证明业务结果已经发生。结果未知时先观察实际状态,不重发原动作。 - 按需会话在任务内复用,读回 `resultFile` 的完整内容,结束时显式关闭。`readonly-ui` 和 `expand-ui` 是客户端 action,不能作为 MCP 工具名调用。 diff --git a/skills/wincode/references/code.md b/skills/wincode/references/code.md index bd5b4e6..e2ca110 100644 --- a/skills/wincode/references/code.md +++ b/skills/wincode/references/code.md @@ -66,6 +66,10 @@ wincode_prepare_context 参数: {"task":"核对保存失败时的资源释放","lineRanges":[{"file":"src/Service.cs","startLine":50,"endLine":80}],"maxTokens":2000} ``` +未知文件但知道多个相关声明名时,可在同一次 task 中列出名称,例如 `{"task":"ChatViewModel ConversationWindowService 核对窗口关闭流程","maxTokens":8000}`。0.17.1 的 local-text 默认将最多 4 个提取关键词合并为一次工作区扫描;把关键声明名放在描述前面,无需也不要添加 `batchSymbols` 等未公布的 MCP 参数。单关键词、`scopeFiles`、`lineRanges` 和不支持批量查询的提供方保持原有路径,不要为凑批量而扩大任务范围。 + +任一关键词达到 500 条声明匹配,或扫描因文件、总字节、枚举项、时间预算截断时,工具在剩余请求期限内回退逐关键词查询,以保留各查询的结果与缺口;这种情况可能更慢。保持单文件 256KiB、总读取 8MiB、最多 5000 枚举项的每次扫描限制。`Async` 等宽泛词容易碰到结果上限,优先使用具体声明名。合并扫描不提高语义完整性;继续检查 `queryComplete`、`limitations` 及相关范围信息,`lexical-uncertainty` 仍表示有文件无法可靠解析。计时与工作计数供本地基准使用,不是新的工具参数或响应字段。 + 先读 summary 的 scope/status/nextAction,再核对正文与 coverage。requested-lines、packed-files、displayed-snippets 是不同范围;complete 只描述该范围。lineRanges 检查 allRequestedCovered、completeLines、missingRanges;endLineComplete:false 时补读整条尾行。越界可用返回的有效交集 nextRequest;maximum-budget-without-progress 时改用文件读取,不重复原请求。 symbol 只读取声明附近窗口,symbolCoverage:unknown;必要时用 nextRequest 继续。scopeFiles/symbol 的 coverage 可能为 null,不能据 queryComplete 或非空正文认定整个方法已覆盖。candidateFiles 只是优先候选,排他范围必须使用 scopeFiles;focusAreas 仅有限发现目录直属文件。 diff --git a/src/Adapters/LocalTextAdapter.ts b/src/Adapters/LocalTextAdapter.ts index afdc50f..e263a60 100644 --- a/src/Adapters/LocalTextAdapter.ts +++ b/src/Adapters/LocalTextAdapter.ts @@ -4,7 +4,8 @@ import type { WinCodeConfig } from '../Core/Config.js'; import type { AdapterHealth } from '../Core/AdapterStatus.js'; import type { CacheManager } from '../Core/Cache.js'; import { checkOperation, type OperationContext } from '../Core/OperationContext.js'; -import { scanLocalFiles } from '../Core/LocalTextScanner.js'; +import { scanLocalFiles, type LocalScanResult } from '../Core/LocalTextScanner.js'; +import { measureOperation } from '../Core/OperationMetrics.js'; import { parseTextDeclarations } from '../Core/TextDeclarations.js'; import { CodeQueryError, LOCAL_TEXT_LIMITATIONS, computeTypeMatchStats, type CodeSymbol, type SymbolReference, type FindSymbolsResult, type FindReferencesResult } from '../Core/CodeQueries.js'; @@ -43,14 +44,40 @@ export class LocalTextAdapter { async findSymbolsDetailed(query: string, kind?: string, relativePath?: string, operation?: OperationContext): Promise { checkOperation(operation); // Re-enumerate the bounded scan so additions, deletions and missed watch events cannot hide behind a workspace hint. - const scan = await scanLocalFiles(this.config.workspaceRoot, this.config.timeouts.fileScanMs, + const scan = await measureOperation(operation?.metrics, 'symbols.scan', () => scanLocalFiles(this.config.workspaceRoot, this.config.timeouts.fileScanMs, ['.cs', '.ts', '.tsx', '.js', '.jsx', '.py'], 500, - (content, file, extension) => this.cache.memoizeContent( - `text_declarations_v1_${this.config.workspaceRoot}_${file}`, - crypto.createHash('sha256').update(content).digest('hex'), - () => parseTextDeclarations(content, file, extension, () => checkOperation(operation))).filter(symbol => + (content, file, extension) => this.declarations(content, file, extension, operation).filter(symbol => symbol.name.toLowerCase().includes(query.toLowerCase()) && (!kind || symbol.kind.toLowerCase() === kind.toLowerCase())), - relativePath, operation); + relativePath, operation)); + checkOperation(operation); + return this.symbolResult(query, kind, scan); + } + + /** Share enumeration and file reads only within this request; never reuse a disk snapshot across calls. */ + async findSymbolsBatchDetailed(queries: string[], operation?: OperationContext): Promise { + checkOperation(operation); + if (!queries.length || queries.length > 4) throw new Error('Symbol batches require 1-4 queries.'); + if (queries.length === 1) return [await this.findSymbolsDetailed(queries[0], undefined, undefined, operation)]; + operation?.metrics?.count('symbols.batchCalls'); + const needles = queries.map(query => query.toLowerCase()); + const scan = await measureOperation(operation?.metrics, 'symbols.scan', () => scanLocalFiles(this.config.workspaceRoot, + this.config.timeouts.fileScanMs, ['.cs', '.ts', '.tsx', '.js', '.jsx', '.py'], 500 * queries.length, + (content, file, extension) => this.declarations(content, file, extension, operation) + .filter(symbol => needles.some(needle => symbol.name.toLowerCase().includes(needle))), undefined, operation)); + const groups = needles.map(needle => scan.items.filter(symbol => symbol.name.toLowerCase().includes(needle))); + // A bounded scan can stop at different files for different queries. Retain the original per-query + // truncation, issue order and result-limit contracts instead of inventing complete batch results. + if (scan.truncated || groups.some(items => items.length >= 500)) { + operation?.metrics?.count('symbols.batchFallbacks'); + const results: FindSymbolsResult[] = []; + for (const query of queries) results.push(await this.findSymbolsDetailed(query, undefined, undefined, operation)); + return results; + } + checkOperation(operation); + return queries.map((query, index) => this.symbolResult(query, undefined, { ...scan, items: groups[index] })); + } + + private symbolResult(query: string, kind: string | undefined, scan: LocalScanResult): FindSymbolsResult { const stats = computeTypeMatchStats(scan.items, query); const result: FindSymbolsResult = { query, kindFilter: kind, totalFound: scan.items.length, symbols: scan.items, source: 'local-text', @@ -60,10 +87,23 @@ export class LocalTextAdapter { fileIssues: scan.fileIssues, fileIssuesOmitted: scan.fileIssuesOmitted, uniqueTypeMatch: scan.complete && !scan.truncated && stats.uniqueTypeMatch, typeMatchCount: stats.typeMatchCount, }; - checkOperation(operation); return result; } + private declarations(content: string, file: string, extension: string, operation?: OperationContext): CodeSymbol[] { + const end = operation?.metrics?.start('symbols.hashCacheParse'); + try { + operation?.metrics?.count('symbols.contentHashes'); + return this.cache.memoizeContent(`text_declarations_v1_${this.config.workspaceRoot}_${file}`, + crypto.createHash('sha256').update(content).digest('hex'), () => { + operation?.metrics?.count('symbols.parseCalls'); + const endParse = operation?.metrics?.start('symbols.parse'); + try { return parseTextDeclarations(content, file, extension, () => checkOperation(operation)); } + finally { endParse?.(); } + }); + } finally { end?.(); } + } + async findReferences(symbolName: string, relativePath?: string, operation?: OperationContext): Promise { return (await this.findReferencesDetailed(symbolName, relativePath, operation)).references; } diff --git a/src/Adapters/RepomixAdapter.ts b/src/Adapters/RepomixAdapter.ts index 6ae2b0f..6279fc7 100644 --- a/src/Adapters/RepomixAdapter.ts +++ b/src/Adapters/RepomixAdapter.ts @@ -381,6 +381,8 @@ export class RepomixAdapter implements IAdapter { if (stat.isFile() && stat.size < 500_000) { const relPath = path.relative(root, fullPath).replace(/\\/g, '/'); const content = await fs.readFile(fullPath, { encoding: 'utf8', signal: operation?.signal }); + operation?.metrics?.count('pack.fileReadCalls'); + operation?.metrics?.count('pack.decodedTextBytes', Buffer.byteLength(content)); collectedFiles.push({ relPath, content }); } } catch (error) { diff --git a/src/Core/CodeQueries.ts b/src/Core/CodeQueries.ts index 296034f..17800ea 100644 --- a/src/Core/CodeQueries.ts +++ b/src/Core/CodeQueries.ts @@ -139,4 +139,6 @@ export interface CodeReferenceQuery extends CodeSymbolQuery { export interface ContextCodeQuery extends CodeSymbolQuery { findSymbolsInContent(content: string, relativePath: string): CodeSymbol[]; + /** Optional request-local batch; providers without it keep sequential queries. */ + findSymbolsBatchDetailed?(queries: string[], operation?: OperationContext): Promise; } diff --git a/src/Core/Config.ts b/src/Core/Config.ts index 8eada3d..590bc23 100644 --- a/src/Core/Config.ts +++ b/src/Core/Config.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -export const WINCODE_VERSION = '0.17.0'; +export const WINCODE_VERSION = '0.17.1'; /** * Bounded waits for every external process/RPC. None of these may be Infinity. diff --git a/src/Core/Context.ts b/src/Core/Context.ts index ac5d75c..24a1776 100644 --- a/src/Core/Context.ts +++ b/src/Core/Context.ts @@ -1,4 +1,5 @@ import { type OperationContext, checkOperation, rethrowOperationError } from './OperationContext.js'; +import { measureOperation } from './OperationMetrics.js'; /** * Task context for agents: ranked file evidence (path/symbol/line/snippet) inside a token budget. * Does not invent architecture advice when evidence is missing. includeFullText packs only the related set. @@ -194,7 +195,8 @@ export class ContextManager { config: WinCodeConfig, workspace: WorkspaceManager, repomix: ContextPacker, - queries: ContextCodeQuery + queries: ContextCodeQuery, + private readonly options: { batchSymbols?: boolean } = {} ) { this.config = config; this.workspace = workspace; @@ -212,9 +214,9 @@ export class ContextManager { const budgetTokens = Math.max(512, opts.maxTokens ?? 8000); const maxChars = budgetTokens * 4; - const identity: ProjectIdentity = await this.workspace.identifyProject(operation); + const identity: ProjectIdentity = await measureOperation(operation?.metrics, 'context.project', () => this.workspace.identifyProject(operation)); const keywords = scope || ranges.size ? [] : this.extractKeywords(task); - const collected = await this.collectSymbolEvidence(opts, scope, keywords, operation); + const collected = await measureOperation(operation?.metrics, 'context.symbols', () => this.collectSymbolEvidence(opts, scope, keywords, operation)); const { limitations, fileIssues, keySymbols, sourceContents } = collected; let { queryComplete, truncated } = collected; @@ -262,8 +264,8 @@ export class ContextManager { if (opts.symbol && !selectedSymbols.has(rel)) continue; if (ranges.size && !ranges.has(rel)) continue; if (usedChars >= maxChars || evidence.length >= 20) { truncated = true; break; } - const snippet = await this.readSnippet(rel, [...selectedSymbols.values()], includeFullText, - ranges.has(rel) ? maxChars - usedChars : Math.min(4000, maxChars - usedChars), fileIssues, ranges.get(rel), sourceContents.get(rel), operation); + const snippet = await measureOperation(operation?.metrics, 'context.snippets', () => this.readSnippet(rel, [...selectedSymbols.values()], includeFullText, + ranges.has(rel) ? maxChars - usedChars : Math.min(4000, maxChars - usedChars), fileIssues, ranges.get(rel), sourceContents.get(rel), operation)); if (!snippet) continue; const symbol = snippet.symbol; evidence.push({ @@ -296,7 +298,7 @@ export class ContextManager { candidateFiles: evidence.map(item => item.file), maxFiles: 20, }; - const snapshot = await this.repomix.packWorkspace(packOptions, operation); + const snapshot = await measureOperation(operation?.metrics, 'context.pack', () => this.repomix.packWorkspace(packOptions, operation)); snapshotSource = snapshot.source; fromCache = snapshot.fromCache; packedContent = snapshot.content; @@ -331,10 +333,12 @@ export class ContextManager { const uniqueLimitations = Array.from(new Set(limitations)); const guidance = this.generateGuidance(task, identity, evidence, evidenceInsufficient); + const endFormat = operation?.metrics?.start('context.format'); const { executiveSummary, formattedContent } = this.formatContext({ task, identity, budgetTokens, includeFullText, evidence, omittedFiles, evidenceInsufficient, guidance, keySymbols, packedContent, uniqueLimitations, }); + endFormat?.(); checkOperation(operation); const relatedFiles = Array.from(related.entries()).map(([p, reason]) => ({ @@ -403,6 +407,8 @@ export class ContextManager { fileIssues.push({ path: file, reason: 'unsupported-symbol-language' }); continue; } const source = await fs.readFile(full, { encoding: 'utf8', signal: operation?.signal }); + operation?.metrics?.count('context.fileReadCalls'); + operation?.metrics?.count('context.decodedTextBytes', Buffer.byteLength(source)); sourceContents.set(file, source); const matches = this.queries.findSymbolsInContent(source, file) .filter(symbol => symbol.name === opts.symbol); @@ -417,9 +423,12 @@ export class ContextManager { queryComplete = false; limitations.push('Scoped symbol matching uses local declaration patterns, not semantic analysis; matches may be incomplete. Use lineRanges for exact known locations.'); } - for (const kw of keywords.slice(0, 4)) { + const selectedKeywords = keywords.slice(0, 4); + const batch = this.options.batchSymbols !== false && selectedKeywords.length > 1 && this.queries.findSymbolsBatchDetailed + ? await this.queries.findSymbolsBatchDetailed(selectedKeywords, operation) : undefined; + for (const [index, kw] of selectedKeywords.entries()) { checkOperation(operation); - const detailed = + const detailed = batch ? batch[index] : typeof this.queries.findSymbolsDetailed === 'function' ? await this.queries.findSymbolsDetailed(kw, undefined, undefined, operation) : { symbols: await this.queries.findSymbols(kw, undefined, operation), source: 'local-text' as const }; @@ -591,6 +600,10 @@ export class ContextManager { if (!stat.isFile()) { issues.push({ path: rel, reason: 'not-file' }); return null; } if (stat.size >= 500_000) { issues.push({ path: rel, reason: 'file-too-large' }); return null; } const content = sourceContent ?? await fs.readFile(fullPath, { encoding: 'utf8', signal: operation?.signal }); + if (sourceContent === undefined) { + operation?.metrics?.count('context.fileReadCalls'); + operation?.metrics?.count('context.decodedTextBytes', Buffer.byteLength(content)); + } const lines = content.split(/\r?\n/); if (range && range.endLine > lines.length) { issues.push({ path: rel, reason: 'line-range-out-of-bounds', fileLineCount: lines.length }); diff --git a/src/Core/LocalTextScanner.ts b/src/Core/LocalTextScanner.ts index 7e49ef9..3b18b2b 100644 --- a/src/Core/LocalTextScanner.ts +++ b/src/Core/LocalTextScanner.ts @@ -1,6 +1,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { checkOperation, rethrowOperationError, type OperationContext } from './OperationContext.js'; +import { measureOperation } from './OperationMetrics.js'; /** 扫描完成仅指文本扫描范围,不代表语义引用完整。 */ export interface LocalScanResult { @@ -13,6 +14,7 @@ export interface LocalScanResult { export async function scanLocalFiles(root: string, timeoutMs: number, extensions: string[], maxResults: number, extract: (content: string, relPath: string, ext: string, sizeBytes: number) => Iterable, relativePath?: string | string[], operation?: OperationContext ): Promise> { + operation?.metrics?.count('scan.invocations'); const items: T[] = []; const reasons = new Set(); const fileIssues: LocalScanResult['fileIssues'] = []; @@ -62,7 +64,8 @@ export async function scanLocalFiles(root: string, timeoutMs: number, extensi if (visited.has(key)) return; visited.add(key); if (!canContinue()) return; - const handle = await fs.open(actualPath, 'r'); + operation?.metrics?.count('scan.fileOpenAttempts'); + const handle = await measureOperation(operation?.metrics, 'scan.fileOpen', () => fs.open(actualPath, 'r')); try { const stat = await handle.stat(); if (!stat.isFile()) { issue('read-error'); return; } @@ -74,7 +77,9 @@ export async function scanLocalFiles(root: string, timeoutMs: number, extensi let used = 0; let eof = false; while (used < buffer.length && canContinue()) { - const chunk = await handle.read(buffer, used, buffer.length - used, null); + operation?.metrics?.count('scan.readCalls'); + const chunk = await measureOperation(operation?.metrics, 'scan.read', () => handle.read(buffer, used, buffer.length - used, null)); + operation?.metrics?.count('scan.bytesRead', chunk.bytesRead); bytesRead += chunk.bytesRead; used += chunk.bytesRead; if (chunk.bytesRead === 0) { eof = true; break; } @@ -88,6 +93,7 @@ export async function scanLocalFiles(root: string, timeoutMs: number, extensi if (content.includes('\0')) { issue('encoding'); return; } } catch { issue('encoding'); return; } filesRead++; + operation?.metrics?.count('scan.filesDecoded'); for (const item of extract(content, path.relative(root, fullPath), ext, used)) { if (!canContinue()) break; items.push(item); @@ -105,8 +111,10 @@ export async function scanLocalFiles(root: string, timeoutMs: number, extensi const actualPath = await fs.realpath(dir); if (!isInside(actualPath, realRoot)) { mark('invalid-scope', false, false, path.relative(root, dir)); return; } // Stream directory entries so an enormous directory does not allocate an unbounded array. + operation?.metrics?.count('scan.directoryOpenAttempts'); const handle = await fs.opendir(dir); for await (const entry of handle) { + operation?.metrics?.count('scan.entriesVisited'); if (!canContinue()) break; if (++entriesVisited > 5000) { mark('entry-limit', true, true); break; } const fullPath = path.join(dir, entry.name); diff --git a/src/Core/OperationContext.ts b/src/Core/OperationContext.ts index d7ecdc4..447cc91 100644 --- a/src/Core/OperationContext.ts +++ b/src/Core/OperationContext.ts @@ -1,7 +1,8 @@ import { AbortError, TimeoutError, type QueueObserver } from './ResourceManager.js'; +import type { OperationMetrics } from './OperationMetrics.js'; /** Internal request lifetime; never part of the public JSON tool arguments. */ -export interface OperationContext { signal?: AbortSignal; deadline?: number; queue?: QueueObserver } +export interface OperationContext { signal?: AbortSignal; deadline?: number; queue?: QueueObserver; metrics?: OperationMetrics } export function checkOperation(operation?: OperationContext): void { if (operation?.signal?.aborted) { diff --git a/src/Core/OperationMetrics.ts b/src/Core/OperationMetrics.ts new file mode 100644 index 0000000..db89717 --- /dev/null +++ b/src/Core/OperationMetrics.ts @@ -0,0 +1,24 @@ +/** Opt-in, request-local diagnostics. Durations are inclusive; nested phases must not be added. */ +export class OperationMetrics { + readonly counters: Record = {}; + readonly phases: Record = {}; + + count(name: string, amount = 1): void { + this.counters[name] = (this.counters[name] ?? 0) + amount; + } + + start(name: string): () => void { + const started = performance.now(); + return () => { + const phase = this.phases[name] ??= { calls: 0, elapsedMs: 0 }; + phase.calls++; + phase.elapsedMs += performance.now() - started; + }; + } +} + +export function measureOperation(metrics: OperationMetrics | undefined, name: string, run: () => Promise): Promise { + if (!metrics) return run(); + const end = metrics.start(name); + return (async () => { try { return await run(); } finally { end(); } })(); +} diff --git a/tests/local-text.test.ts b/tests/local-text.test.ts index f2c6cfa..b606535 100644 --- a/tests/local-text.test.ts +++ b/tests/local-text.test.ts @@ -8,6 +8,8 @@ import os from 'node:os'; import path from 'node:path'; import { LocalTextAdapter } from '../src/Adapters/LocalTextAdapter.js'; import { getDefaultConfig } from '../src/Core/Config.js'; +import { CacheManager } from '../src/Core/Cache.js'; +import { OperationMetrics } from '../src/Core/OperationMetrics.js'; async function fixture(run: (adapter: LocalTextAdapter, root: string, cached: Map) => Promise) { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-text-bounds-')); @@ -21,6 +23,71 @@ async function fixture(run: (adapter: LocalTextAdapter, root: string, cached: Ma finally { await adapter.dispose(); await fs.rm(root, { recursive: true, force: true, maxRetries: 3 }); } } +it('batch shares one bounded read and preserves ordered query results across edits and deletion', async () => fixture(async (_adapter, root) => { + const adapter = new LocalTextAdapter(getDefaultConfig(root), new CacheManager(path.join(root, '.cache'))); + await fs.writeFile(path.join(root, 'A.ts'), 'export function Alpha() {}\nexport function AlphaBravo() {}'); + await fs.writeFile(path.join(root, 'B.ts'), 'export function Bravo() {}'); + const queries = ['Alpha', 'bravo', 'Missing', 'Alpha']; + const expected = [['Alpha', 'AlphaBravo'], ['AlphaBravo', 'Bravo'], [], ['Alpha', 'AlphaBravo']]; + const metrics = new OperationMetrics(); + const batch = await adapter.findSymbolsBatchDetailed(queries, { metrics }); + assert.deepEqual(batch.map(result => result.symbols.map(symbol => symbol.name)), expected); + assert.equal(metrics.counters['scan.invocations'], 1); + assert.equal(metrics.counters['scan.fileOpenAttempts'], 2); + for (let i = 0; i < queries.length; i++) assert.deepEqual(batch[i], await adapter.findSymbolsDetailed(queries[i])); + const config = getDefaultConfig(root), workspace = new WorkspaceManager(config); + const packer = { packWorkspace: async (): Promise => { throw new Error('Unexpected full-text packing'); } }; + const normalMetrics = new OperationMetrics(), experimentMetrics = new OperationMetrics(); + const normal = await new ContextManager(config, workspace, packer, adapter).prepareContext({ task: 'Alpha Bravo' }, { metrics: normalMetrics }); + const experiment = await new ContextManager(config, workspace, packer, adapter, { batchSymbols: false }) + .prepareContext({ task: 'Alpha Bravo' }, { metrics: experimentMetrics }); + assert.deepEqual(experiment, normal); + assert.equal(normalMetrics.counters['scan.invocations'], 1, 'Production defaults share one scan.'); + assert.equal(experimentMetrics.counters['scan.invocations'], 2, 'The benchmark can retain the sequential baseline.'); + await fs.writeFile(path.join(root, 'A.ts'), 'export function AlphaChanged() {}'); + await fs.unlink(path.join(root, 'B.ts')); + const changed = await adapter.findSymbolsBatchDetailed(['Alpha', 'Bravo']); + assert.deepEqual(changed.map(result => result.symbols.map(symbol => symbol.name)), [['AlphaChanged'], []]); + assert.ok(changed.every(result => result.queryComplete && !result.truncated)); +})); + +it('batch retains per-query result limits and bounded scan diagnostics using sequential fallback', async () => fixture(async (adapter, root) => { + await fs.writeFile(path.join(root, 'Types.cs'), Array.from({ length: 510 }, (_, i) => `class Type${i} {}`).join('\n')); + const metrics = new OperationMetrics(); + const batch = await adapter.findSymbolsBatchDetailed(['Type', 'Type509'], { metrics }); + assert.equal(metrics.counters['symbols.batchFallbacks'], 1); + assert.equal(batch[0].symbols.length, 500); + assert.equal(batch[0].queryComplete, false); + assert.deepEqual(batch[1].symbols.map(symbol => symbol.name), ['Type509']); + assert.equal(batch[1].queryComplete, true); + for (let i = 0; i < batch.length; i++) assert.deepEqual(batch[i], await adapter.findSymbolsDetailed(['Type', 'Type509'][i])); + await fs.writeFile(path.join(root, 'Oversize.ts'), ' '.repeat(256 * 1024 + 1)); + const bounded = await adapter.findSymbolsBatchDetailed(['Missing', 'Absent']); + for (let i = 0; i < bounded.length; i++) { + assert.deepEqual(bounded[i], await adapter.findSymbolsDetailed(['Missing', 'Absent'][i])); + assert.equal(bounded[i].queryComplete, false); + assert.equal(bounded[i].truncated, true); + assert.ok(bounded[i].fileIssues?.some(issue => issue.reason === 'file-byte-limit')); + } +})); + +it('batch keeps encoding gaps visible and propagates cancellation without reading', async () => fixture(async (adapter, root) => { + await fs.writeFile(path.join(root, 'Good.ts'), 'export function Alpha() {}'); + await fs.writeFile(path.join(root, 'Bad.ts'), Buffer.from([0xff, 0xfe, 0x00])); + const batch = await adapter.findSymbolsBatchDetailed(['Alpha', 'Missing']); + for (let i = 0; i < batch.length; i++) { + assert.deepEqual(batch[i], await adapter.findSymbolsDetailed(['Alpha', 'Missing'][i])); + assert.equal(batch[i].queryComplete, false); + assert.equal(batch[i].truncated, false); + assert.ok(batch[i].fileIssues?.some(issue => issue.path === 'Bad.ts' && issue.reason === 'encoding')); + } + const metrics = new OperationMetrics(); + await assert.rejects(adapter.findSymbolsBatchDetailed(['Alpha', 'Missing'], { signal: AbortSignal.abort(), metrics }), /cancel/i); + assert.deepEqual(metrics.counters, {}); + await assert.rejects(adapter.findSymbolsBatchDetailed(['Alpha', 'Missing'], { deadline: 0, metrics }), /timed out/i); + assert.deepEqual(metrics.counters, {}); +})); + it('enforces the reference limit across sibling directories and does not cache partial results', async () => fixture(async (adapter, root, cached) => { for (const dir of ['a', 'b', 'c']) { await fs.mkdir(path.join(root, dir)); diff --git a/tools/WinCode.Code.Host/WinCode.Code.Host.csproj b/tools/WinCode.Code.Host/WinCode.Code.Host.csproj index aa5a622..f2a5cdd 100644 --- a/tools/WinCode.Code.Host/WinCode.Code.Host.csproj +++ b/tools/WinCode.Code.Host/WinCode.Code.Host.csproj @@ -1,6 +1,6 @@ - 0.17.0 + 0.17.1 Exe net10.0 enable diff --git a/tools/WinCode.Tray/WinCode.Tray.csproj b/tools/WinCode.Tray/WinCode.Tray.csproj index 2973787..fbdd305 100644 --- a/tools/WinCode.Tray/WinCode.Tray.csproj +++ b/tools/WinCode.Tray/WinCode.Tray.csproj @@ -1,6 +1,6 @@ - 0.17.0 + 0.17.1 WinExe net10.0-windows win-x64 diff --git a/tools/WinCode.UIA.Host/WinCode.UIA.Host.csproj b/tools/WinCode.UIA.Host/WinCode.UIA.Host.csproj index 94d1c6d..7e1a6ce 100644 --- a/tools/WinCode.UIA.Host/WinCode.UIA.Host.csproj +++ b/tools/WinCode.UIA.Host/WinCode.UIA.Host.csproj @@ -1,7 +1,7 @@ - 0.17.0 + 0.17.1 true Exe net10.0-windows