From 426cc57414393592c76b4f28940b0e9da4991fd5 Mon Sep 17 00:00:00 2001 From: linnnn89 <216342082+linnnn89@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:18:28 +0800 Subject: [PATCH] fix: preserve mutation outcomes and bound reference replies --- CHANGELOG.md | 3 + README.md | 8 +- ...56\346\265\201\350\257\264\346\230\216.md" | 6 +- docs/codex_worklog.md | 10 +++ skills/wincode/references/code.md | 10 ++- src/Adapters/RoslynAdapter.ts | 8 +- src/CompositeTools/ImpactAnalyzer.ts | 25 ++++++ src/Core/CodeQueries.ts | 2 +- src/Core/ToolRouter.ts | 9 +- src/Core/Workspace.ts | 9 +- src/Gateway/CodeTools.ts | 17 +++- src/Gateway/McpServer.ts | 7 +- src/Gateway/ReferenceResponse.ts | 48 ++++++++++ src/Gateway/ToolDefinition.ts | 3 + src/Gateway/WorkspaceTools.ts | 5 +- tests/request-admission.test.ts | 67 ++++++++++++++ tests/roslyn-contracts.test.ts | 70 +++++++++++++++ tests/tool-contracts.test.ts | 90 ++++++++++++++++++- 18 files changed, 371 insertions(+), 26 deletions(-) create mode 100644 src/Gateway/ReferenceResponse.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e811d8..c10522f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.15.0 (unreleased) +- Preserve completed/partial trash outcomes and actual file locations when the request deadline expires during metadata finalization. Propagate cancellation before the move and keep interruption accounting; client cancellation or disconnection still does not guarantee delivery of a final response. +- Add optional Roslyn reference `limit` (1–1000, default 100, requires `symbolLocation`). Impact reports retain `totalReferences` and `referencesTruncated` alongside the existing returned `referencesCount`; unknown totals remain null and semantic risk/coverage rules stay unchanged. +- Bound reference-tool replies by final formatted JSON size with `maxOutputChars` (2048–32768, default 8000). Preserve known totals and exact identities, report returned counts and omitted lists, and reject budgets that cannot hold required metadata. The output limit covers both providers and does not bound Roslyn search work or change internal impact aggregation. - Add bounded literal text search and file outlines with executable source follow-ups, exclusive/deduplicated scopes and file-level lexical/scan diagnostics. Retain local-text evidence boundaries and existing filesystem/cancellation/byte limits. - Add final-output context summaries and observed EOF correction requests without claiming the original range was covered. File-head excerpts now support bounded continuation as well as symbol excerpts. - Return an independent STDIO launch recipe on WORKSPACE_MISMATCH and via `--print-connection --workspace `, without registration, project startup or rebinding. Add opt-in compact UI responses with unchanged snapshot IDs/images, shared C# candidates and live-control expansion requests; default full output remains compatible. diff --git a/README.md b/README.md index 11db182..5573293 100644 --- a/README.md +++ b/README.md @@ -181,10 +181,10 @@ The 2026-09-08 check of the current Codex connection against TavernDesk source p | `wincode_analyze_workspace` | Parse solution structure and declared `.sln`/`.csproj` project references. | | `wincode_prepare_context` | Return code excerpts with file paths and line ranges within a character-based output limit. | | `wincode_find_code_symbol` | Search symbols and report the provider and completeness of the results. | -| `wincode_find_references` | Find references using a returned symbol location; report ambiguous or incomplete results and provider limitations. | +| `wincode_find_references` | Find references using a returned symbol location; optional Roslyn `limit` is 1–1000 (default 100, requires `symbolLocation`). Both providers accept `maxOutputChars` (2048–32768, default 8000) for the final JSON text. Preserve known totals and report returned counts, truncation and output omissions. | | `analyze_change_impact` | Estimate which code a change may affect. Return `riskLevel: "UNKNOWN"` and `confidence: "UNCERTAIN"` for ambiguous symbols, incomplete results or no references. | | `wincode_plan_refactoring` | Suggest pre-edit checks and verification steps based on change impact. | -| `wincode_safe_move_to_trash` | Validate paths, move files to `trash/` and record metadata. | +| `wincode_safe_move_to_trash` | Validate paths, move files to `trash/` and record metadata. Cancellation before the move stops it; finalization preserves the actual completed/partial outcome even after a deadline. | | `wincode_ui_list_windows` | Enumerate visible top-level windows with title/process filters and count limits. | | `wincode_ui_inspect` | Inspect UI control subtrees, interactive states, and optional numbered screenshots. | | `wincode_ui_review` | Return explicit XAML/C# source candidates, lines, hashes and scoped next requests from one UI snapshot. | @@ -436,10 +436,10 @@ Skill 安装和客户端配置方法见 [Skill 与 MCP 配置指南](WinCode-Ski | `wincode_analyze_workspace` | 解析解决方案结构及 `.sln`/`.csproj` 中声明的项目引用。 | | `wincode_prepare_context` | 按文件、符号或行号读取代码片段,返回文件路径和行号,并限制输出字符数。 | | `wincode_find_code_symbol` | 检索代码符号,说明结果来自哪个分析服务,以及查询是否完整。 | -| `wincode_find_references` | 使用返回的符号位置查找引用,报告匹配不唯一、查询不完整或分析服务能力受限的情况。 | +| `wincode_find_references` | 使用返回的符号位置查找引用;Roslyn 可选 `limit` 为 1–1000,默认 100,必须同时提供 `symbolLocation`。两种提供方均支持最终 JSON 字符预算 `maxOutputChars`(2048–32768,默认 8000),保留已知总数,报告实际返回数、截断状态和输出省略项。 | | `analyze_change_impact` | 评估代码改动可能影响的范围。符号不唯一、查询不完整或未找到引用时,返回 `riskLevel: "UNKNOWN"` 和 `confidence: "UNCERTAIN"`。 | | `wincode_plan_refactoring` | 根据改动影响,建议修改前需要检查的内容和修改后的验证步骤。 | -| `wincode_safe_move_to_trash` | 校验路径后将文件移入 `trash/`,并记录元数据。 | +| `wincode_safe_move_to_trash` | 校验路径后移入 `trash/` 并记录元数据;移动前取消会停止操作,移动后的收尾即使超时也保留实际 completed/partial 结果。 | | `wincode_ui_list_windows` | 列出可见顶层窗口,支持按标题或进程名筛选,并限制返回数量。 | | `wincode_ui_inspect` | 读取控件子树、状态和可选的编号截图。 | | `wincode_ui_review` | 根据一次 UI 检查结果,返回可能相关的 XAML/C# 代码、行号、哈希及后续读取参数。 | diff --git "a/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" "b/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" index f7db8d7..2b35cbc 100644 --- "a/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" +++ "b/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" @@ -104,7 +104,7 @@ sequenceDiagram **有界受理和实际执行分开。** 每实例最多 32 个未完成业务请求(含 workspace_open),hello/tools/list 共享 4 个轻量槽。既有 Roslyn/UI/恢复互斥决定 FIFO 等待;其他已有并行能力继续并行。满额在执行前返回 SERVER_BUSY,不驱逐先来者或自动重放。恢复占用业务容量,但不计入它自己等待排空的 inFlight;关闭和手动释放同时考虑未完成受理与实际清理。 -原始参数含未知字段,在归一化前按 UTF-8 JSON 限制为 64 KiB。外层预算包含排队,Router/Adapter 使用剩余 deadline。Router 与准入租约使用同一截止时复用计时器;独立更短的预算保留自己的计时器,异常路径按实际操作上下文保留 REQUEST_TIMEOUT。MCP 在工具执行返回后再次检查截止,不返回已过期的成功结果;租约收尾同时读取实际失败和取消原因,使同步截止检查或更短的适配器预算也计入 timedOut,实际清理完成后才归还容量。health.admission 给出计数和等待/执行耗时;hello 仅读取缓存磁盘观察,诊断才刷新统计。这些限制不能消除 SDK 解析帧的瞬时内存,也不提供挂起 OS I/O 的强制终止保证。 +原始参数含未知字段,在归一化前按 UTF-8 JSON 限制为 64 KiB。外层预算包含排队,Router/Adapter 使用剩余 deadline。Router 与准入租约使用同一截止时复用计时器;独立更短的预算保留自己的计时器,异常路径按实际操作上下文保留 REQUEST_TIMEOUT。MCP 在工具执行返回后再次检查截止,只读工具不返回已过期的成功结果;回收站工具保留已确定的 completed/partial 及实际路径,移动前仍检查取消,移动后完成元数据收尾。租约收尾同时读取实际失败和取消原因,使同步截止检查或更短的适配器预算也计入 timedOut,实际清理完成后才归还容量。客户端取消/断连仍不保证最终响应送达,也不表示副作用回滚。health.admission 给出计数和等待/执行耗时;hello 仅读取缓存磁盘观察,诊断才刷新统计。这些限制不能消除 SDK 解析帧的瞬时内存,也不提供挂起 OS I/O 的强制终止保证。 ## 3. 代码证据的数据流 @@ -143,10 +143,14 @@ flowchart LR 0.13.0 退役外部 Serena 配置、连接及旧 source;local-text 与 roslyn 均不能仅凭来源证明完整性。ImpactAnalyzer 对身份不唯一或查询不完整保留 UNKNOWN;零引用不构成可安全删除的证明。 +公开引用接口可在 symbolLocation 上指定 Roslyn `limit`(1–1000,默认 100),复用 Host 原有限制,不提供分页。影响报告的 `referencesCount` 为实际聚合条数,`totalReferences` 为查询范围内已知总数(未知为 null),`referencesTruncated` 表示引用返回是否被裁剪(未知为 null)。这些字段不把局部引用扩展成全仓覆盖,也不改变 UNKNOWN 规则。 + ### 3.3 输出预算位于最后一公里 普通代码导航由 CodeNavigation 复用 LocalTextScanner:字面量搜索在排他目录/文件范围内进行,文件概览读取实际行数、字节数及文本声明,均返回 prepare_context 续读请求。路径范围先整体校验,实际读取再检查真实路径;扫描预算与最终 JSON 预算分别生效。LocalTextScanner 保留具名文件问题和省略计数,不把词法不确定性隐藏成完整结果。导航不启动语义 Host,也不改变所配置的提供方。 +引用工具由 ReferenceResponse 对最终格式化 JSON 执行 `maxOutputChars` 预算(默认 8000,范围 2048–32768,UTF-16 字符),包含缩进、转义和元数据;与 Roslyn 条数 limit 同时生效。按完整条目裁剪引用、候选和文件问题,返回 `returnedReferences`、`outputOmissions` 和实际预算,保留已知总数及精确身份;输出省略时标记 truncated/queryComplete,必要元数据超预算则明确返回 OUTPUT_BUDGET_EXCEEDED。裁剪不修改原始查询对象,因此不会缩小影响分析内部采用的引用集合,也不约束 Host 查找阶段的计算量。 + `maxTokens` 当前按 UTF-16 字符数 / 4 估算,最终 MCP 文本块的 JSON 转义、元数据及 legacy 附加文本共同占预算。它不是模型 tokenizer 的精确结果。 ContextResponse 在最终裁剪后重新计算范围覆盖,区分读取阶段不足和响应预算不足,并给出缺失区间或后续请求。符号窗口没有解析方法结束边界,`symbolCoverage=unknown` 不能被显示的几行正文替代。 diff --git a/docs/codex_worklog.md b/docs/codex_worklog.md index 14c6166..4d9a953 100644 --- a/docs/codex_worklog.md +++ b/docs/codex_worklog.md @@ -1373,3 +1373,13 @@ - 原报告确认 9 个受管进程全部退出,`survivors=[]`、场景成功;随后 finally 仍逐个启动 PowerShell 做兜底终止,其中一次 `spawnSync powershell.exe ETIMEDOUT`。这是测试收尾失败,不是本次报告中观察到进程泄漏。尚未确定该 PowerShell 调用为何超过 8 秒;Node 22 文档说明 spawnSync 的 timeout 限制子进程运行时间:https://nodejs.org/download/release/v22.23.2/docs/api/child_process.html#child_processspawnsynccommand-args-options。 - 仅调整默认 owner-death 脚本:验收已确认全部退出后不再兜底终止;失败路径保留原有 PID/创建时间核验及清理。没有放宽超时、忽略清理错误或修改生产代码。 - 真实正常路径通过,报告 `test-tmp/owner-death/run-HzZLg9/report.json`。另以临时脚本副本故意保留 Gateway,验证残留仍使验收失败、兜底清理实际执行、随后同身份进程全部消失;故障注入验证通过,报告 `test-tmp/owner-death/run-LTW9Qh/report.json`。未重复无关全量测试;新提交的远端检查尚待运行。 + +## 2026-09-11 — 保留文件移动结果与引用计数,约束引用最终输出 + +- 用户批准实现架构审查中的前两项修复,并同意增加引用输出字符预算;随后要求提交 PR 并合并。基于 main `4720bf8`,本轮只修改相关源码、测试和现有文档,没有增加依赖。 +- 修复 trash 已移动或元数据写入失败后,Gateway 的请求期限检查覆盖实际结果的问题。移动前传递取消并再次检查期限;移动后完成元数据收尾,保留 completed/partial、实际位置及中断计数。客户端取消或断线仍不能保证最终回复送达,没有增加事务日志或自动回滚。 +- 公开 Host 已有的 Roslyn limit(1–1000,默认 100,要求 symbolLocation);影响报告保留已知 totalReferences、referencesTruncated 和实际聚合条数,未知总数仍为 null,UNKNOWN 规则不变。 +- 引用工具最终格式化 JSON 增加 maxOutputChars(2048–32768,默认 8000),两种提供方均适用,计入 UTF-16 字符、缩进、转义及元数据。保留实际返回数、输出省略项、候选和文件问题计数;必要身份及覆盖元数据放不下时明确返回 OUTPUT_BUDGET_EXCEEDED。输出裁剪不修改内部查询对象,不限制 Roslyn 查找阶段的计算成本。 +- 回归先确认旧实现会丢失移动结果/取消信号/引用总数,以及最终 JSON 超预算。实现后定向验证通过:真实临时文件移动与取消、真实 Roslyn Host 的 120 处引用、长路径和转义预览、候选身份及必要元数据边界。本轮累计新增 5 个测试,分属两次授权的局部任务(3 个及 2 个);没有放宽原有超时或断言。 +- 最终完整 `npm run check` 460/460、0 失败、0 跳过,类型检查、Gateway/原生构建、生产 stdio 和交付核验通过。报告:`test-tmp/check/2026-09-11T13-13-40-686Z-core/report.json`;交付 contentId=`7d04eb319ef210868f25ece81496aaaad184e456a2ec3d302a8593e8923537f3`。错误契约 17/17:`test-tmp/error-contracts/run-bAbdGe/report.json`。 +- 自查及本地验证不等同于独立审查;最新 PR 提交仍须通过 Node 22/24 和三项 CodeQL 后再合并。未重新执行无关桌面验收,也未同步已安装 Skill 或重连当前 Codex MCP;本地构建完成不证明活动客户端已更新。 diff --git a/skills/wincode/references/code.md b/skills/wincode/references/code.md index bcd251a..a4cd60b 100644 --- a/skills/wincode/references/code.md +++ b/skills/wincode/references/code.md @@ -78,7 +78,7 @@ Host 监听变化并在查询前后比较内容指纹,变化时丢弃结果并 | `wincode_list_directory` | 无 | `path`: 非空字符串,最长 4096,默认 `.`;`maxDepth`: 整数 1–5,默认 1;`maxEntries`: 整数 1–500,默认 100;`maxOutputChars`: 整数 2048–32768,默认 8000;`includeIgnored`: 布尔值,默认 false | | `wincode_analyze_workspace` | 无 | `maxDepth`: 整数 1–5,默认 2;整份 JSON 最多 32768 个 UTF-16 字符 | | `wincode_find_code_symbol` | `query`: 非空字符串 | `kind`: 字符串,按下述提供方支持范围使用;Roslyn 的 query 最长 256、kind 最长 128。此工具未声明文件范围参数,指定文件取证改用下面的 `scopeFiles` | -| `wincode_find_references` | `symbolName`: 非空字符串 | `relativePath`: 定义文件相对路径(Roslyn 用于限定候选,local-text 不据此缩小引用扫描);`symbolLocation`: Roslyn 搜索返回的 location 对象(snapshotId/project/file/position 均必填,路径各最长 4096);同时提供 relativePath 时必须与 location.file 一致 | +| `wincode_find_references` | `symbolName`: 非空字符串 | `relativePath`: 定义文件相对路径(Roslyn 用于限定候选,local-text 不据此缩小引用扫描);`symbolLocation`: Roslyn 搜索返回的 location 对象(snapshotId/project/file/position 均必填,路径各最长 4096);同时提供 relativePath 时必须与 location.file 一致;`limit`: 整数 1–1000,Roslyn 默认 100,必须同时提供 symbolLocation,不改变 local-text 扫描;`maxOutputChars`: 整数 2048–32768,默认 8000,两种提供方均适用 | | `analyze_change_impact` | `target`: 非空字符串 | `symbolLocation`: 搜索返回的完整定位;提供时 target 必须是该符号的简单名称 | | `wincode_plan_refactoring` | `target`、`goal`: 非空字符串 | `symbolLocation`: 同影响分析 | | `wincode_safe_move_to_trash` | `filePath`: 工作区内相对路径字符串 | `reason`: 字符串;该工具实际移动文件,须符合用户授权 | @@ -136,6 +136,12 @@ lineRanges 查看最终 coverage.allRequestedCovered、completeLines 和 details 选定 Roslyn 重载后,将其 name 和 location 原样传给后续工具:引用使用 symbolName,影响分析及重构使用 target,同时传 symbolLocation。后两者先验证定位再分析,不按名字重选目标;SNAPSHOT_STALE/INPUTS_CHANGED 时须重新搜索。简单名称歧义检查 resolution/candidateCount/candidatesTruncated,不能选第一项。queryComplete=false 不等于零引用。 +Roslyn 引用默认最多 100 条,显式 `limit` 最大 1000。最终 JSON 文本另受 `maxOutputChars` 限制(默认 8000,范围 2048–32768),计入格式缩进、转义及元数据的 UTF-16 字符数,不是模型 token 数。两项限制先到哪个就在哪截断;提高 limit 不保证能返回全部条目,也不限制 Roslyn 查找引用的计算成本。`totalReferences` 保留当前已加载快照内发现的总数,`returnedReferences` 等于实际返回的 `references.length`;local-text 的 totalReferences 仍只是原有扫描范围内已找到的条数。 + +字符预算造成省略时,`outputOmissions` 标明 references、candidates 或 fileIssues,`truncated=true`、`queryComplete=false`。候选总数不改写,`candidatesTruncated` 和 `fileIssuesOmitted` 如实更新;返回条目的位置与预览不裁半,symbolLocation、semanticContext 和覆盖限制保持完整。必要元数据本身放不下时返回 `OUTPUT_BUDGET_EXCEEDED`,按需增加 maxOutputChars;不会静默裁剪身份。需要更多已知引用时,在同一有效 symbolLocation 上按需提高 limit 或 maxOutputChars,例如 `wincode_find_references({symbolName: selected.name, symbolLocation: selected.location, limit: 120, maxOutputChars: 32768})`。这不是分页,不扩大项目、生成代码或动态调用覆盖。 + +输出预算只作用于引用工具的最终回复。影响分析的 `referencesCount` 继续表示参与内部聚合的返回条数,`totalReferences` 和 `referencesTruncated` 保留其引用查询的计数与裁剪情况。未执行引用查询或无法完整计数时总数为 null,未知截断状态为 null;受影响文件/组件仍只来自实际返回引用,UNKNOWN 判定不变。 + 健康同根 workspace_open 保留 Host/snapshot,不等待业务排空;取消概览确认不会使健康实例进入恢复。已知 SDK 重启要求或清理失败仍遵守诊断手册;另一根在任何重置、缓存、watcher 或 trash 变更前被拒绝,内部 WorkspaceManager 也不能改根。 workspace_open 默认返回项目摘要和最多 8 个入口,整份 JSON 默认不超过 8000 个 UTF-16 字符;不生成目录树或统计全仓大小。检查 projectScanComplete,null 统计不等于零。需要目录时用 wincode_list_directory 指定窄路径,查看 scanComplete/truncated/omissions。includeTree:true 可显式取得有界兼容树,不能当成完整仓库清单。maxOutputChars 为 2048–32768;目录 maxDepth 为 1–5,maxEntries 为 1–500。需要生成目录时显式 includeIgnored:true,但不能越过工作区边界。 @@ -186,6 +192,8 @@ bodyStatusScope 明确该字段描述 displayed-snippet 或 packed-file。symbol trash 响应保留 success/trashPath/message,并用 outcome 区分 completed(移动及元数据完成)、not_moved(本次未移动)、partial(已移动但元数据未完成)。partial 的 errorCode=TRASH_METADATA_FAILED、failureStage=metadata,originalPath/trashPath/metadataPath 给出原位置、实际移动位置及预期元数据位置;metadataPath 不证明元数据完整。立即保留并告知用户实际 trashPath,不把 success=false 当作未执行,不重复移动或自动移回。not_moved 的 trashPath 为空,errorCode=TRASH_NOT_MOVED;先检查 failureStage 和文件实际状态。重启不会自动补写元数据或推断原路径;丢失 partial 响应时,本实现不保证自动恢复原目录映射。 +取消/截止检查传递到路径准备和移动前;文件一旦移动,就继续收尾元数据并保留真实 completed/partial 与路径,Gateway 不再用通用超时替换该结果,受理统计仍记录超时或取消。客户端主动取消或断连后不保证收到最终响应,也不代表已经回滚;此版本没有新增事务日志或自动恢复。 + 回收站目标名含唯一标识,过长的原文件名展示部分会截短,以给元数据文件名预留空间;完整原路径保存在 originalPath 和成功写入的元数据中。恢复时使用这些路径,不从截短的目标名推断原文件名或扩展名。 交付时保留 Code Host 整个 publish 目录,包括 deps/runtimeconfig、Roslyn 依赖及 BuildHost-netcore 子目录。`npm run check` 生成并核对交付清单;Host ready 身份必须与 Gateway 版本一致且为 Release、协议 v2,否则 HOST_VERSION_MISMATCH。不要仅复制入口 DLL,也不要把版本握手等同于运行时文件防篡改。 diff --git a/src/Adapters/RoslynAdapter.ts b/src/Adapters/RoslynAdapter.ts index 418a0d3..0b52555 100644 --- a/src/Adapters/RoslynAdapter.ts +++ b/src/Adapters/RoslynAdapter.ts @@ -258,7 +258,9 @@ export class RoslynAdapter implements CodeReferenceQuery, ContextCodeQuery { findSymbolsInContent(content: string, file: string): CodeSymbol[] { return this.textDeclarations(content, file); } /** 精确引用只能使用本次搜索得到的定位;简单名结果保留候选,绝不选择第一个重载。 */ - async findReferencesDetailed(symbolName: string, relativePath?: string, operation?: OperationContext, location?: SymbolLocation): Promise { + async findReferencesDetailed(symbolName: string, relativePath?: string, operation?: OperationContext, location?: SymbolLocation, limit?: number): Promise { + if (limit !== undefined && (!location || !Number.isSafeInteger(limit) || limit < 1 || limit > 1000)) + throw new CodeQueryError('INVALID_ARGUMENT', 'limit must be an integer from 1 to 1000 and requires a Roslyn symbolLocation.'); if (relativePath) this.localPath(relativePath); if (symbolName.includes('/') || /\[\d+\]/.test(symbolName)) throw new CodeQueryError('LEGACY_SYMBOL_ID', 'Serena namePath cannot identify a Roslyn symbol; search again.'); if (!location) { @@ -274,8 +276,8 @@ export class RoslynAdapter implements CodeReferenceQuery, ContextCodeQuery { return this.perform(operation, async () => { this.validateLocation(location); const reply = this.accept(await this.client!.request({ operation: 'references', snapshot: location.snapshotId, project: location.project, - file: location.file, position: location.position, symbolName }, this.options.queryTimeoutMs ?? 30000, operation)); - if (!Array.isArray(reply.references) || reply.references.length > 1000 || typeof reply.truncated !== 'boolean' || !Number.isSafeInteger(reply.totalReferences)) + file: location.file, position: location.position, symbolName, ...(limit === undefined ? {} : { limit }) }, this.options.queryTimeoutMs ?? 30000, operation)); + if (!Array.isArray(reply.references) || reply.references.length > (limit ?? 100) || typeof reply.truncated !== 'boolean' || !Number.isSafeInteger(reply.totalReferences)) throw new CodeQueryError('HOST_PROTOCOL_ERROR', 'Invalid reference result.'); const references: SymbolReference[] = reply.references.map((item: Record) => { this.localPath(item.file as string); this.localPath(item.project as string); diff --git a/src/CompositeTools/ImpactAnalyzer.ts b/src/CompositeTools/ImpactAnalyzer.ts index 4003f50..e029a09 100644 --- a/src/CompositeTools/ImpactAnalyzer.ts +++ b/src/CompositeTools/ImpactAnalyzer.ts @@ -27,6 +27,9 @@ export interface ImpactReport { targetFile: string; targetKind?: string; referencesCount: number; + /** Known total within the provider's query scope; null when unqueried or incompletely counted. */ + totalReferences: number | null; + referencesTruncated: boolean | null; affected: string[]; affectedComponents: AffectedComponent[]; riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' | 'UNKNOWN'; @@ -229,6 +232,8 @@ export class ImpactAnalyzer { } let refs: SymbolReference[] = []; + let totalReferences: number | null = null; + let referencesTruncated: boolean | null = null; // 精确 Roslyn 定位允许收集局部引用;风险/置信度仍保留 queryComplete=false 的 UNKNOWN 限制。 if (assessment.unique && (assessment.queryComplete || matchedSymbol?.location) && !assessment.truncated && typeof this.queries.findReferencesDetailed === 'function') { @@ -236,6 +241,9 @@ export class ImpactAnalyzer { matchedSymbol.name, matchedSymbol.file, operation, matchedSymbol.location ) : await this.queries.findReferencesDetailed(matchedSymbol?.namePath ?? symbolName, matchedSymbol?.file, operation)); refs = refRes.references || []; + // Roslyn counts the snapshot before slicing; an incomplete text scan only knows its returned matches. + totalReferences = refRes.source === 'roslyn' || refRes.queryComplete ? refRes.totalReferences : null; + referencesTruncated = refRes.truncated; if (refRes.source) assessment.source = refRes.source; if (refRes.queryComplete === false) { assessment.queryComplete = false; @@ -318,6 +326,8 @@ export class ImpactAnalyzer { const formattedReport = this.formatReport({ targetFile, referencesCount, + totalReferences, + referencesTruncated, affected, riskLevel, recommendations, @@ -335,6 +345,8 @@ export class ImpactAnalyzer { targetKind: matchedSymbol?.kind, ...(matchedSymbol?.location ? { symbolLocation: matchedSymbol.location } : {}), referencesCount, + totalReferences, + referencesTruncated, affected, affectedComponents, riskLevel, @@ -377,6 +389,8 @@ export class ImpactAnalyzer { const formattedReport = this.formatReport({ targetFile, referencesCount: 0, + totalReferences: null, + referencesTruncated: null, affected: [], riskLevel, recommendations, @@ -393,6 +407,8 @@ export class ImpactAnalyzer { targetFile, targetKind: undefined, referencesCount: 0, + totalReferences: null, + referencesTruncated: null, affected: [], affectedComponents: [], riskLevel, @@ -612,6 +628,8 @@ export class ImpactAnalyzer { private formatReport(data: { targetFile: string; referencesCount: number; + totalReferences: number | null; + referencesTruncated: boolean | null; affected: string[]; riskLevel: string; recommendations: string[]; @@ -637,6 +655,13 @@ export class ImpactAnalyzer { ``, `References:`, `${data.referencesCount}`, + `(Returned references; affected components below are derived from these entries.)`, + ``, + `Known reference total in query scope:`, + `${data.totalReferences ?? 'Unknown'}`, + ``, + `Reference results truncated:`, + `${data.referencesTruncated ?? 'Unknown'}`, ``, `Affected:`, `${affectedLines}`, diff --git a/src/Core/CodeQueries.ts b/src/Core/CodeQueries.ts index e4f589a..db487c1 100644 --- a/src/Core/CodeQueries.ts +++ b/src/Core/CodeQueries.ts @@ -123,7 +123,7 @@ export interface CodeSymbolQuery { export interface CodeReferenceQuery extends CodeSymbolQuery { findReferences(symbolName: string, relativePath?: string, operation?: OperationContext): Promise; - findReferencesDetailed?(symbolName: string, relativePath?: string, operation?: OperationContext, location?: SymbolLocation): Promise; + findReferencesDetailed?(symbolName: string, relativePath?: string, operation?: OperationContext, location?: SymbolLocation, limit?: number): Promise; } export interface ContextCodeQuery extends CodeSymbolQuery { diff --git a/src/Core/ToolRouter.ts b/src/Core/ToolRouter.ts index d33ff4b..4e451e6 100644 --- a/src/Core/ToolRouter.ts +++ b/src/Core/ToolRouter.ts @@ -239,9 +239,10 @@ export class ToolRouter { } /** 精确位置只属于 Roslyn;旧提供方收到该字段必须明确拒绝,不能忽略后再猜符号。 */ - findCodeReferences(symbolName: string, relativePath?: string, signal?: AbortSignal, location?: SymbolLocation) { + findCodeReferences(symbolName: string, relativePath?: string, signal?: AbortSignal, location?: SymbolLocation, limit?: number) { + if (limit !== undefined && !location) throw new CodeQueryError('INVALID_ARGUMENT', 'limit requires a Roslyn symbolLocation.'); if (location && !this.roslyn) throw new CodeQueryError('UNSUPPORTED_SYMBOL_LOCATION', 'Semantic analysis is not configured; local text search cannot accept a Roslyn symbolLocation.'); - return this.runCode(signal, operation => this.roslyn ? this.roslyn.findReferencesDetailed(symbolName, relativePath, operation, location) : + return this.runCode(signal, operation => this.roslyn ? this.roslyn.findReferencesDetailed(symbolName, relativePath, operation, location, limit) : this.text.findReferencesDetailed(symbolName, relativePath, operation)); } @@ -284,8 +285,8 @@ export class ToolRouter { return this.runCode(signal, operation => this.refactor.planRefactoring(target, goal, operation, location)); } - moveToTrash(filePath: string, reason?: string) { - return this.workspace.moveToTrash(filePath, reason); + moveToTrash(filePath: string, reason?: string, signal?: AbortSignal) { + return this.workspace.moveToTrash(filePath, reason, this.admission.operation(signal) ?? { signal }); } listDirectory(options: WorkspaceDirectoryOptions = {}, signal?: AbortSignal) { diff --git a/src/Core/Workspace.ts b/src/Core/Workspace.ts index dbe1b83..ce9b042 100644 --- a/src/Core/Workspace.ts +++ b/src/Core/Workspace.ts @@ -7,7 +7,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { runGit } from './GitClient.js'; import { assertLinkFreePath } from './FileSystemBoundary.js'; -import type { OperationContext } from './OperationContext.js'; +import { checkOperation, rethrowOperationError, type OperationContext } from './OperationContext.js'; import { WinCodeConfig } from './Config.js'; import { randomUUID } from 'node:crypto'; @@ -236,7 +236,8 @@ export class WorkspaceManager { * Safe file deletion policy: Moves files to the project trash directory. * Only accepts non-empty relative paths strictly within the workspace. */ - async moveToTrash(relativeFilePath: string, reason?: string): Promise { + async moveToTrash(relativeFilePath: string, reason?: string, operation?: OperationContext): Promise { + checkOperation(operation); try { validateTrashPath(relativeFilePath, this.root, this.config.trashDir); } catch (error) { return invalidTrashResult(error instanceof Error ? error.message : String(error)); @@ -264,6 +265,7 @@ export class WorkspaceManager { await assertLinkFreePath(this.config.trashDir); failureStage = 'prepare'; + checkOperation(operation); await fs.mkdir(this.config.trashDir, { recursive: true }); await assertLinkFreePath(this.config.trashDir); if (await fs.realpath(this.config.trashDir) !== realTrash) throw new Error('Trash destination changed during preparation.'); @@ -289,9 +291,11 @@ export class WorkspaceManager { failureStage = 'move'; await assertLinkFreePath(this.config.trashDir); if (await this.getRealPath(targetPath) !== realTarget) throw new Error('Trash source changed during preparation.'); + checkOperation(operation); await fs.rename(targetPath, destinationPath); moved = true; + // After the move, finish recording its location rather than cancelling metadata recovery evidence. failureStage = 'metadata'; const metaPath = path.join(this.config.trashDir, `${trashFileName}.meta.json`); await assertLinkFreePath(this.config.trashDir); @@ -315,6 +319,7 @@ export class WorkspaceManager { message: `File safely moved to trash: ${path.relative(this.root, destinationPath)}`, }; } catch (err) { + if (!moved) rethrowOperationError(err, operation); const detail = err instanceof Error ? err.message : String(err); return { success: false, diff --git a/src/Gateway/CodeTools.ts b/src/Gateway/CodeTools.ts index bd47bd0..986fc40 100644 --- a/src/Gateway/CodeTools.ts +++ b/src/Gateway/CodeTools.ts @@ -1,5 +1,6 @@ import { defineTool, jsonResult } from './ToolDefinition.js'; import { contextResponse } from './ContextResponse.js'; +import { referenceResponse } from './ReferenceResponse.js'; import { validateContextScope, type PreparedContextOptions } from '../Core/Context.js'; import type { SymbolLocation } from '../Core/CodeQueries.js'; import { validateTextSearch, validateFileOutline, type TextSearchOptions, type FileOutlineOptions } from '../Core/CodeNavigation.js'; @@ -140,7 +141,7 @@ export const CODE_TOOLS = [ }, { execute: async (args, { router, signal }) => jsonResult(await router.findCodeSymbols(args.query, args.kind, signal), true), }), - defineTool<{ symbolName: string; relativePath?: string; symbolLocation?: SymbolLocation }>({ + defineTool<{ symbolName: string; relativePath?: string; symbolLocation?: SymbolLocation; limit?: number; maxOutputChars?: number }>({ name: 'wincode_find_references', description: 'Queries references within the configured provider scope. For direct Roslyn pass a returned declaration location as symbolLocation and its name as symbolName; simple names return candidates without choosing a potentially ambiguous overload. Stale locations must be searched again. Zero or incomplete references do not imply safe deletion.', inputSchema: { @@ -155,13 +156,21 @@ export const CODE_TOOLS = [ description: 'Defining file relative to the workspace. Roslyn uses it to scope candidates; local text references remain a workspace-wide textual scan.', }, symbolLocation: symbolLocationSchema, + limit: { + type: 'integer', minimum: 1, maximum: 1000, + description: 'Maximum returned Roslyn references, default 100. Requires symbolLocation; does not change local-text search or semantic coverage. totalReferences counts the known snapshot results, not just the returned entries.', + }, + maxOutputChars: { + type: 'integer', minimum: 2048, maximum: 32768, default: 8000, + description: 'Budget for the entire formatted JSON text in UTF-16 characters, including escaping and metadata; not model tokens. Applies to both providers and may return fewer entries than limit. Check returnedReferences, totalReferences, truncated and outputOmissions.', + }, }, required: ['symbolName'], }, }, { - execute: async (args, { router, signal }) => jsonResult(await (args.symbolLocation ? - router.findCodeReferences(args.symbolName, args.relativePath, signal, args.symbolLocation) : - router.findCodeReferences(args.symbolName, args.relativePath, signal)), true), + validate: args => { if (args.limit !== undefined && !args.symbolLocation) throw new Error('limit requires a Roslyn symbolLocation; search and select a declaration first.'); }, + execute: async (args, { router, signal }) => referenceResponse(await router.findCodeReferences( + args.symbolName, args.relativePath, signal, args.symbolLocation, args.limit), args.maxOutputChars), }), defineTool<{ target: string; symbolLocation?: SymbolLocation }>({ name: 'analyze_change_impact', diff --git a/src/Gateway/McpServer.ts b/src/Gateway/McpServer.ts index 48dce92..84da549 100644 --- a/src/Gateway/McpServer.ts +++ b/src/Gateway/McpServer.ts @@ -76,7 +76,12 @@ export class WinCodeMcpServer { checkOperation(lease.operation); lease.workStarted = true; const result = await definition.execute(args, context); - checkOperation(lease.operation); + try { checkOperation(lease.operation); } + catch (error) { + if (!definition.preserveOutcomeOnInterruption) throw error; + // Keep mutation evidence while still accounting for the interrupted request. + failure = error; + } return result; } catch (error) { failure = error; diff --git a/src/Gateway/ReferenceResponse.ts b/src/Gateway/ReferenceResponse.ts new file mode 100644 index 0000000..78ef5d1 --- /dev/null +++ b/src/Gateway/ReferenceResponse.ts @@ -0,0 +1,48 @@ +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { CodeQueryError, type FindReferencesResult } from '../Core/CodeQueries.js'; +import { jsonResult } from './ToolDefinition.js'; + +/** Budget the formatted MCP text without changing the query evidence used by composite tools. */ +export function referenceResponse(result: FindReferencesResult, maxOutputChars = 8000): CallToolResult { + const data: FindReferencesResult & { + returnedReferences: number; limits: { maxOutputChars: number }; outputOmissions: string[]; + } = { ...result, returnedReferences: result.references.length, limits: { maxOutputChars }, outputOmissions: [] }; + const fits = () => JSON.stringify(data, null, 2).length <= maxOutputChars; + if (!fits()) { + data.truncated = true; + data.queryComplete = false; + data.analysisCompleteness = 'incomplete'; + + // Keep whole entries and their exact locations; binary search avoids serializing + // a large result once per omitted reference. Omission metadata shares the budget. + const trim = (field: string, count: number, keep: (count: number) => void) => { + if (!count || fits()) return; + data.outputOmissions.push(field); + let low = 0, high = count - 1; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + keep(middle); + if (fits()) low = middle; + else high = middle - 1; + } + keep(low); + }; + const issues = result.fileIssues ?? []; + trim('fileIssues', issues.length, count => { + data.fileIssues = issues.slice(0, count); + data.fileIssuesOmitted = (result.fileIssuesOmitted ?? 0) + issues.length - count; + }); + trim('references', result.references.length, count => { + data.references = result.references.slice(0, count); + data.returnedReferences = count; + }); + const candidates = result.candidates ?? []; + trim('candidates', candidates.length, count => { + data.candidates = candidates.slice(0, count); + data.candidatesTruncated = true; + }); + if (!fits()) throw new CodeQueryError('OUTPUT_BUDGET_EXCEEDED', + 'Reference identity and coverage metadata exceed maxOutputChars; increase the output budget. Required metadata was not clipped.'); + } + return jsonResult(data, true); +} diff --git a/src/Gateway/ToolDefinition.ts b/src/Gateway/ToolDefinition.ts index dd2e538..34738d9 100644 --- a/src/Gateway/ToolDefinition.ts +++ b/src/Gateway/ToolDefinition.ts @@ -15,6 +15,8 @@ export interface ToolDefinition { allowDuringWorkspaceRecovery?: boolean; requestLane?: 'status'; requestBudget?: 'ui' | 'diagnostics' | 'workspace'; + /** A completed mutation reports its actual outcome even if its request expired during finalization. */ + preserveOutcomeOnInterruption?: boolean; invalidArguments?: (message: string) => CallToolResult; validate?: (args: Record, context: ToolExecutionContext) => void; execute: (args: Record, context: ToolExecutionContext) => Promise; @@ -58,6 +60,7 @@ export function codeRecoveryAction(code: string): string { case 'PROJECT_LOAD_FAILED': return 'check_project'; case 'INPUT_UNAVAILABLE': return 'repair_inputs'; case 'INPUT_BUDGET_EXCEEDED': return 'reduce_scope'; + case 'OUTPUT_BUDGET_EXCEEDED': return 'increase_output_budget'; case 'OUTSIDE_WORKSPACE': case 'UNSUPPORTED_LINK': return 'correct_arguments'; case 'CANCELLED': return 'none'; default: return 'inspect_error'; diff --git a/src/Gateway/WorkspaceTools.ts b/src/Gateway/WorkspaceTools.ts index 39e5617..c5169d7 100644 --- a/src/Gateway/WorkspaceTools.ts +++ b/src/Gateway/WorkspaceTools.ts @@ -133,10 +133,11 @@ export const WORKSPACE_TOOLS = [ required: ['filePath'], }, }, { + preserveOutcomeOnInterruption: true, invalidArguments: message => jsonResult(invalidTrashResult(message), true, true), validate: (args, { router }) => validateTrashPath(args.filePath, router.config.workspaceRoot, router.config.trashDir), - execute: async (args, { router }) => { - const result = await router.moveToTrash(args.filePath, args.reason); + execute: async (args, { router, signal }) => { + const result = await router.moveToTrash(args.filePath, args.reason, signal); return jsonResult(result, true, !result.success); }, }), diff --git a/tests/request-admission.test.ts b/tests/request-admission.test.ts index 74f0ed2..49dd4d8 100644 --- a/tests/request-admission.test.ts +++ b/tests/request-admission.test.ts @@ -45,6 +45,73 @@ it('raw UTF-8 argument budget applies before unknown fields are discarded', asyn assert.notEqual(legal.isError, true); })); +it('trash retains completed and partial file outcomes when metadata finalization crosses the deadline', async t => fixture(async ({ root, router, call }) => { + const now = Date.now.bind(Date), write = fs.writeFile.bind(fs); + let advance = 0, failMetadata = false; + router.config.timeouts.fileScanMs = 1000; + t.mock.method(Date, 'now', () => now() + advance); + const metadataWrite = t.mock.method(fs, 'writeFile', async (...args: Parameters) => { + if (String(args[0]).startsWith(root + path.sep) && String(args[0]).endsWith('.meta.json')) { + advance = 2000; + if (failMetadata) throw new Error('fixture metadata failure after deadline'); + } + return write(...args); + }); + try { + for (const partial of [false, true]) { + advance = 0; failMetadata = partial; + const file = partial ? 'partial.txt' : 'completed.txt'; + const originalPath = path.join(root, file); + await write(originalPath, 'preserve this payload'); + const response = await call('wincode_safe_move_to_trash', { filePath: file }); + const result = body(response); + assert.equal(result.outcome, partial ? 'partial' : 'completed'); + assert.equal(response.isError, partial); + assert.equal(result.originalPath, originalPath); + await assert.rejects(fs.stat(originalPath), { code: 'ENOENT' }); + assert.equal(path.dirname(result.trashPath), router.config.trashDir); + assert.equal(await fs.readFile(result.trashPath, 'utf8'), 'preserve this payload'); + if (partial) { + assert.equal(result.errorCode, 'TRASH_METADATA_FAILED'); + assert.equal(result.failureStage, 'metadata'); + assert.deepEqual(response.structuredContent, result); + await assert.rejects(fs.stat(result.metadataPath), { code: 'ENOENT' }); + } else assert.equal(JSON.parse(await fs.readFile(result.metadataPath, 'utf8')).originalPath, originalPath); + } + assert.equal(router.admission.snapshot().business.timedOut, 2); + assert.equal(router.admission.pendingCount, 0); + } finally { advance = 0; metadataWrite.mock.restore(); } +})); + +it('trash cancellation during path validation leaves the source untouched and releases admission after cleanup', async t => fixture(async ({ root, router, call }) => { + const originalPath = path.join(root, 'keep.txt'); + await fs.writeFile(originalPath, 'keep'); + const entered = deferred(), resume = deferred(), controller = new AbortController(); + const realpath = fs.realpath.bind(fs), move = router.moveToTrash.bind(router); + let operationSignal: AbortSignal | undefined; + t.mock.method(router, 'moveToTrash', (file: string, reason?: string, signal?: AbortSignal) => { + operationSignal = signal; + return (move as any)(file, reason, signal); + }); + const heldPath = t.mock.method(fs, 'realpath', async (...args: Parameters) => { + const result = await realpath(...args); + if (String(args[0]) === originalPath) { entered.resolve(); await resume.promise; } + return result; + }); + const rename = t.mock.method(fs, 'rename', fs.rename.bind(fs)); + const pending = call('wincode_safe_move_to_trash', { filePath: 'keep.txt' }, controller.signal).catch(error => error); + try { + await entered.promise; + assert.ok(operationSignal, 'trash must receive the request cancellation signal'); + controller.abort(); + await until(() => operationSignal!.aborted, 'cancellation must reach the operation before validation resumes'); + } finally { resume.resolve(); await pending; heldPath.mock.restore(); } + await until(() => router.admission.pendingCount === 0, 'cancelled operation must finish cleanup'); + assert.equal(rename.mock.callCount(), 0); + assert.equal(await fs.readFile(originalPath, 'utf8'), 'keep'); + assert.equal(router.admission.snapshot().business.cancelled, 1); +})); + for (const count of [4, 8, 16]) it(`${count} ordinary queued calls complete in FIFO order without overload`, async () => fixture(async ({ router, call }) => { const hold = deferred(), mutex = new Mutex(), order: string[] = []; (router.text as any).findSymbolsDetailed = (query: string, _kind: unknown, _path: unknown, operation: any) => diff --git a/tests/roslyn-contracts.test.ts b/tests/roslyn-contracts.test.ts index f8224b9..d51bc9a 100644 --- a/tests/roslyn-contracts.test.ts +++ b/tests/roslyn-contracts.test.ts @@ -3,16 +3,86 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; import { RoslynHostClient } from '../src/Adapters/RoslynHostClient.js'; import { RoslynAdapter } from '../src/Adapters/RoslynAdapter.js'; import { WINCODE_VERSION, getDefaultConfig } from '../src/Core/Config.js'; import { ResourceManager } from '../src/Core/ResourceManager.js'; import { CodeQueryError } from '../src/Core/CodeQueries.js'; import { ToolRouter, WorkspaceRecoveryRequiredError } from '../src/Core/ToolRouter.js'; +import { WinCodeMcpServer } from '../src/Gateway/McpServer.js'; import { ImpactAnalyzer } from '../src/CompositeTools/ImpactAnalyzer.js'; import { RefactorAssistant } from '../src/CompositeTools/RefactorAssistant.js'; import type { CodeReferenceQuery, SymbolReference } from '../src/Core/CodeQueries.js'; +it('MCP preserves 120 known Roslyn references and accepts bounded limits without changing snapshot coverage', async () => { + const repo = path.resolve(import.meta.dirname, '..'); + const { resolveDotnet, runDotnet } = await import(pathToFileURL(path.join(repo, 'scripts/lib/dotnet.mjs')).href); + const toolchain = resolveDotnet(repo); + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-reference-limit-')); + const config = getDefaultConfig(root); + config.adapters.flaui.enabled = false; config.adapters.repomix.useCli = false; + config.adapters.roslyn = { enabled: true, allowProjectEvaluation: true, project: 'Probe.csproj', configuration: 'Debug', + targetFramework: 'net10.0', dotnetPath: toolchain.dotnet, + hostPath: path.join(repo, 'tools/WinCode.Code.Host/bin/Release/net10.0/publish/WinCode.Code.Host.dll') }; + const router = new ToolRouter(config), server = new WinCodeMcpServer(router); + const client = new Client({ name: 'reference-limit-regression', version: '1' }); + const call = async (name: string, args: Record) => { + const response: any = await client.callTool({ name, arguments: args }, { timeout: 60000 }); + const data = JSON.parse(response.content[0].text); + assert.notEqual(response.isError, true, JSON.stringify(data)); + if (name === 'wincode_find_references') assert.ok(response.content[0].text.length <= (args.maxOutputChars as number ?? 8000)); + return data; + }; + try { + await fs.writeFile(path.join(root, 'Probe.csproj'), 'net10.0false'); + const source = 'public static class Api { public static void Call() {} }\npublic class Consumer { public void Run() {\n' + + Array.from({ length: 120 }, () => 'Api.Call();').join('\n') + '\n} }\n'; + await fs.writeFile(path.join(root, 'Probe.cs'), source); + await fs.copyFile(path.join(repo, 'global.json'), path.join(root, 'global.json')); + const packages = path.join(root, 'empty-package-source'); await fs.mkdir(packages); + runDotnet(toolchain, ['restore', path.join(root, 'Probe.csproj'), '--source', packages, '--nologo'], root, 30000); + await router.initialize(); + const [a, b] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(a), (server as any).server.connect(b)]); + const found = await call('wincode_find_code_symbol', { query: 'Call', kind: 'method' }); + const selected = found.symbols.find((symbol: any) => symbol.name === 'Call'); + assert.ok(selected?.location); + const args = { symbolName: 'Call', symbolLocation: selected.location }; + const defaults = await call('wincode_find_references', args); + assert.equal(defaults.totalReferences, 120); assert.ok(defaults.references.length > 0 && defaults.references.length < 100); + assert.equal(defaults.truncated, true); + assert.deepEqual(defaults.outputOmissions, ['references']); + assert.equal(defaults.returnedReferences, defaults.references.length); + const countBounded = await call('wincode_find_references', { ...args, maxOutputChars: 32768 }); + assert.equal(countBounded.references.length, 100); assert.deepEqual(countBounded.outputOmissions, []); + const impact = await call('analyze_change_impact', { target: 'Call', symbolLocation: selected.location }); + assert.equal(impact.referencesCount, 100); assert.equal(impact.totalReferences, 120); + assert.equal(impact.referencesTruncated, true); assert.equal(impact.riskLevel, 'UNKNOWN'); + for (const limit of [1, 120, 1000]) { + const result = await call('wincode_find_references', { ...args, limit, maxOutputChars: 32768 }); + assert.equal(result.references.length, Math.min(limit, 120)); + assert.equal(result.totalReferences, 120); assert.equal(result.truncated, limit < 120); + assert.equal(result.queryComplete, false); + assert.deepEqual(result.outputOmissions, []); + assert.equal(result.semanticContext.snapshotId, selected.location.snapshotId); + assert.equal(new Set(result.references.map((item: any) => item.start)).size, result.references.length); + for (const reference of result.references) assert.equal(source.slice(reference.start, reference.start + reference.length), 'Call'); + } + for (const invalid of [{ ...args, limit: 0 }, { ...args, limit: 1001 }, { ...args, limit: 1.5 }, { symbolName: 'Call', limit: 120 }]) { + const response: any = await client.callTool({ name: 'wincode_find_references', arguments: invalid }); + assert.equal(response.isError, true); + assert.equal(JSON.parse(response.content[0].text).errorCode, 'INVALID_ARGUMENT'); + } + } finally { + await client.close(); await server.stop(); + assert.equal(router.resources.childProcessCount(), 0); + assert.equal(path.dirname(root), path.resolve(os.tmpdir())); assert.ok(path.basename(root).startsWith('wincode-reference-limit-')); + await fs.rm(root, { recursive: true, force: true }); + } +}); + it('same-root confirmations preserve warm identity, reload state and perform a required restart only once', async t => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-warm-中文 空格-')); const config = getDefaultConfig(root); diff --git a/tests/tool-contracts.test.ts b/tests/tool-contracts.test.ts index 609acdc..652b352 100644 --- a/tests/tool-contracts.test.ts +++ b/tests/tool-contracts.test.ts @@ -8,6 +8,7 @@ import { getDefaultConfig } from '../src/Core/Config.js'; import { ToolRouter } from '../src/Core/ToolRouter.js'; import { WinCodeMcpServer } from '../src/Gateway/McpServer.js'; import { WINCODE_TOOLS, toolsContractHash } from '../src/Gateway/Protocol.js'; +import type { FindReferencesResult } from '../src/Core/CodeQueries.js'; async function fixture(run: (client: Client, router: ToolRouter, admissions: () => number) => Promise) { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-tool-contract-')); @@ -32,6 +33,84 @@ async function fixture(run: (client: Client, router: ToolRouter, admissions: () } } +it('MCP reference output budgets escaped source text and preserves known counts', async t => fixture(async (client, router) => { + const directory = 'nested-' + 'long-path-'.repeat(6); + const file = path.join(directory, '中文-references.ts'); + const preview = `Target(${JSON.stringify('引号" 反斜杠\\ 制表符\t 😀'.repeat(40))});`; + await fs.mkdir(path.join(router.config.workspaceRoot, directory)); + await fs.writeFile(path.join(router.config.workspaceRoot, file), Array.from({ length: 120 }, () => preview).join('\n')); + const counts: number[] = []; + for (const budget of [2048, undefined, 32768]) { + const response: any = await client.callTool({ name: 'wincode_find_references', arguments: { + symbolName: 'Target', ...(budget === undefined ? {} : { maxOutputChars: budget }), + } }); + const body = JSON.parse(response.content[0].text); + assert.notEqual(response.isError, true, JSON.stringify(body)); + assert.ok(response.content[0].text.length <= (budget ?? 8000)); + assert.equal(body.totalReferences, 120); + assert.equal(body.returnedReferences, body.references.length); + assert.ok(body.references.length > 0 && body.references.length < 120); + assert.equal(body.source, 'local-text'); assert.equal(body.queryComplete, false); + assert.equal(body.truncated, true); assert.deepEqual(body.outputOmissions, ['references']); + assert.equal(body.limits.maxOutputChars, budget ?? 8000); + for (const reference of body.references) { + assert.equal(reference.file.replaceAll('\\', '/'), file.replaceAll('\\', '/')); + assert.equal(reference.preview, preview); + assert.ok(reference.line >= 1 && reference.line <= 120); + } + counts.push(body.references.length); + t.diagnostic(JSON.stringify({ budget: budget ?? 8000, textChars: response.content[0].text.length, returned: body.returnedReferences, total: body.totalReferences })); + } + assert.ok(counts[0] < counts[1] && counts[1] < counts[2]); + const empty: any = await client.callTool({ name: 'wincode_find_references', arguments: { symbolName: 'Missing' } }); + const emptyBody = JSON.parse(empty.content[0].text); + assert.equal(emptyBody.totalReferences, 0); assert.equal(emptyBody.returnedReferences, 0); + assert.equal(emptyBody.truncated, false); assert.equal(emptyBody.queryComplete, true); + assert.deepEqual(emptyBody.outputOmissions, []); +})); + +it('MCP reference budgets retain candidate identity, omission counts and required metadata', async () => fixture(async (client, router) => { + const location = { snapshotId: 'a'.repeat(32), project: 'App.csproj', file: 'Target.cs', position: 13 }; + const source: FindReferencesResult = { + symbolName: 'Target', source: 'roslyn', totalReferences: 0, references: [], resolution: 'ambiguous', + analysisCompleteness: 'incomplete', queryComplete: false, truncated: false, + limitations: ['Loaded snapshot only; generated and external callers are not covered.'], + candidates: Array.from({ length: 40 }, (_, index) => ({ name: 'Target', kind: 'class', file: `src/${index}/Target.cs`, line: 1, + signature: 'class Target /* ' + '"\\😀'.repeat(80) + ' */', location: { ...location, file: `src/${index}/Target.cs` } })), + candidateCount: 40, candidatesTruncated: false, + fileIssues: Array.from({ length: 12 }, (_, index) => ({ path: `src/${index}.cs`, reason: 'Cannot read source. '.repeat(20) })), + fileIssuesOmitted: 3, + }; + const original = structuredClone(source); + router.findCodeReferences = async () => source; + const response: any = await client.callTool({ name: 'wincode_find_references', arguments: { symbolName: 'Target', maxOutputChars: 4096 } }); + const body = JSON.parse(response.content[0].text); + assert.notEqual(response.isError, true); assert.ok(response.content[0].text.length <= 4096); + assert.equal(body.resolution, 'ambiguous'); assert.equal(body.totalReferences, 0); assert.equal(body.candidateCount, 40); + assert.ok(body.candidates.length > 0 && body.candidates.length < 40); assert.equal(body.candidatesTruncated, true); + assert.equal(body.truncated, true); assert.equal(body.queryComplete, false); + assert.deepEqual(body.candidates, original.candidates!.slice(0, body.candidates.length)); + assert.deepEqual(body.limitations, original.limitations); + assert.equal(body.fileIssues.length + body.fileIssuesOmitted, 15); + assert.ok(body.outputOmissions.includes('candidates')); assert.ok(body.outputOmissions.includes('fileIssues')); + assert.deepEqual(source, original, 'formatting must not mutate query evidence shared with other consumers'); + + const required: FindReferencesResult = { ...source, resolution: 'resolved', candidates: undefined, candidateCount: undefined, + candidatesTruncated: undefined, fileIssues: undefined, fileIssuesOmitted: undefined, + symbolLocation: { ...location, project: 'p'.repeat(1500) + '.csproj', file: 'f'.repeat(1500) + '.cs' } }; + router.findCodeReferences = async () => required; + const overflow: any = await client.callTool({ name: 'wincode_find_references', arguments: { symbolName: 'Target', maxOutputChars: 2048 } }); + const failure = JSON.parse(overflow.content[0].text); + assert.equal(overflow.isError, true); assert.ok(overflow.content[0].text.length <= 2048); + assert.equal(failure.errorCode, 'OUTPUT_BUDGET_EXCEEDED'); assert.equal(failure.recoveryAction, 'increase_output_budget'); + assert.deepEqual(overflow.structuredContent, failure); + const expanded: any = await client.callTool({ name: 'wincode_find_references', arguments: { symbolName: 'Target', maxOutputChars: 8000 } }); + assert.notEqual(expanded.isError, true); assert.ok(expanded.content[0].text.length <= 8000); + const expandedBody = JSON.parse(expanded.content[0].text); + assert.deepEqual(expandedBody.symbolLocation, required.symbolLocation); + assert.deepEqual(expandedBody.outputOmissions, []); assert.equal(expandedBody.truncated, false); +})); + it('rejects object-valued symbol queries before admission or adapter execution', async () => fixture(async (client, router, admissions) => { let calls = 0; router.text.findSymbolsDetailed = async () => { calls++; return {} as any; }; @@ -65,12 +144,12 @@ const expectedCalls: Record = { wincode_find_code_symbol: { method: 'findCodeSymbols', args: ['Target', 'class', ''] }, wincode_search_text: { method: 'searchText', args: [{ query: 'Target', scopePaths: ['Target.ts'] }, ''] }, wincode_file_outline: { method: 'fileOutline', args: [{ file: 'Target.ts' }, ''] }, - wincode_find_references: { method: 'findCodeReferences', args: ['Target', 'Target.ts', ''] }, + wincode_find_references: { method: 'findCodeReferences', args: ['Target', 'Target.ts', '', undefined, undefined] }, analyze_change_impact: { method: 'analyzeChangeImpact', args: ['Target', ''] }, wincode_analyze_change_impact: { method: 'analyzeChangeImpact', args: ['Target', ''] }, wincode_diagnose_project: { method: 'diagnoseProject', args: [''] }, wincode_plan_refactoring: { method: 'planRefactoring', args: ['Target', 'Improve reliability', ''] }, - wincode_safe_move_to_trash: { method: 'moveToTrash', args: ['Target.ts', 'fixture'] }, + wincode_safe_move_to_trash: { method: 'moveToTrash', args: ['Target.ts', 'fixture', ''] }, wincode_ui_list_windows: { method: 'listUiWindows', args: [{ pid: 5 }, ''] }, wincode_ui_inspect: { method: 'inspectUi', args: [{ pid: 5, query: { name: 'Save' }, hwnd: undefined }, ''] }, wincode_ui_review: { method: 'reviewUi', args: [{ pid: 5, hwnd: undefined }, ['View.xaml'], '', ['Save'], ['View.cs']] }, @@ -83,8 +162,10 @@ it('calls all published tools and the hidden alias; unknown fields do not reach const stub = (method: string, result: unknown) => { (router as any)[method] = async (...args: unknown[]) => { calls.push({ method, args }); return structuredClone(result); }; }; - for (const method of ['openWorkspace', 'listDirectory', 'analyzeWorkspace', 'findCodeSymbols', 'findCodeReferences', 'diagnoseProject', 'planRefactoring', 'searchText', 'fileOutline']) + for (const method of ['openWorkspace', 'listDirectory', 'analyzeWorkspace', 'findCodeSymbols', 'diagnoseProject', 'planRefactoring', 'searchText', 'fileOutline']) stub(method, { success: true }); + stub('findCodeReferences', { symbolName: 'Target', source: 'local-text', totalReferences: 0, references: [], + analysisCompleteness: 'degraded', limitations: [], queryComplete: true, truncated: false }); stub('prepareContext', prepared); stub('moveToTrash', { success: true }); stub('analyzeChangeImpact', { formattedReport: 'Fixture report' }); @@ -194,6 +275,9 @@ it('rejects blank required operation text before admission', async () => fixture it('rejects declared enum, range and nested type violations before admission', async () => fixture(async (client, _router, admissions) => { const cases: Array<[string, Record]> = [ ['wincode_list_directory', { maxDepth: 6 }], ['workspace_open', { path: 'example', maxOutputChars: 2047 }], + ['wincode_find_references', { symbolName: 'Target', maxOutputChars: 2047 }], + ['wincode_find_references', { symbolName: 'Target', maxOutputChars: 32769 }], + ['wincode_find_references', { symbolName: 'Target', maxOutputChars: 8000.5 }], ['wincode_prepare_context', { task: 'x', responseFormat: 'yaml' }], ['wincode_prepare_context', { task: 'x', maxTokens: 511 }], ['wincode_prepare_context', { task: 'x', lineRanges: [{ file: 'A.cs', startLine: '1', endLine: 3 }] }], ['wincode_ui_inspect', { pid: 5, capture: 'interactive' }], ['wincode_ui_inspect', { pid: 5, query: { name: 'Save', maxMatches: 21 } }],