From a2d76f6a115cf5d9e41d22ee358597483b1a433d Mon Sep 17 00:00:00 2001 From: linnnn89 <216342082+linnnn89@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:53:35 +0800 Subject: [PATCH] feat: add Roslyn integration and workspace recovery improvements --- README.md | 78 +++- ...43\350\256\241\345\210\222\344\271\246.md" | 248 +++++++++++- ...43\350\267\257\347\272\277\345\233\276.md" | 24 +- docs/codex_worklog.md | 137 +++++++ package.json | 9 +- scripts/serena-isolated-launcher.py | 26 ++ scripts/verify-error-contracts.ts | 74 ++++ scripts/verify-failure-recovery.ts | 124 ++++++ scripts/verify-mixed-load.ts | 180 +++++++++ scripts/verify-repomix-real.ts | 136 +++++++ scripts/verify-roslyn-gateway.mjs | 254 ++++++++++++ scripts/verify-roslyn-host.mjs | 378 ++++++++++++++++++ scripts/verify-serena-real.ts | 46 +++ skills/wincode/SKILL.md | 4 +- skills/wincode/references/code.md | 50 ++- skills/wincode/references/diagnostics.md | 20 +- src/Adapters/RepomixAdapter.ts | 19 +- src/Adapters/RoslynAdapter.ts | 279 +++++++++++++ src/Adapters/RoslynHostClient.ts | 163 ++++++++ src/Adapters/SerenaAdapter.ts | 3 +- src/CompositeTools/ImpactAnalyzer.ts | 23 +- src/CompositeTools/ProjectDiagnostics.ts | 12 +- src/Core/CodeQueries.ts | 43 +- src/Core/Config.ts | 16 + src/Core/ResourceManager.ts | 8 + src/Core/ToolRouter.ts | 148 +++++-- src/Core/Workspace.ts | 97 +++-- src/Gateway/CodeTools.ts | 23 +- src/Gateway/McpServer.ts | 13 +- src/Gateway/ToolDefinition.ts | 1 + src/Gateway/WorkspaceTools.ts | 9 +- src/index.ts | 17 + tests/failure-recovery.test.ts | 254 ++++++++++++ tests/repomix-disabled.test.ts | 26 ++ tests/roslyn-contracts.test.ts | 108 +++++ tests/stage1-cleanup.test.ts | 1 - tests/tool-contracts.test.ts | 2 +- tools/WinCode.Code.Host/OwnedProcessJob.cs | 71 ++++ tools/WinCode.Code.Host/Program.cs | 174 ++++++++ .../WinCode.Code.Host.csproj | 15 + tools/WinCode.Code.Host/WorkspaceInputs.cs | 113 ++++++ tools/WinCode.Code.Host/WorkspaceSession.cs | 317 +++++++++++++++ tools/WinCode.Code.Host/packages.lock.json | 188 +++++++++ 43 files changed, 3794 insertions(+), 137 deletions(-) create mode 100644 scripts/serena-isolated-launcher.py create mode 100644 scripts/verify-error-contracts.ts create mode 100644 scripts/verify-failure-recovery.ts create mode 100644 scripts/verify-mixed-load.ts create mode 100644 scripts/verify-repomix-real.ts create mode 100644 scripts/verify-roslyn-gateway.mjs create mode 100644 scripts/verify-roslyn-host.mjs create mode 100644 src/Adapters/RoslynAdapter.ts create mode 100644 src/Adapters/RoslynHostClient.ts create mode 100644 tests/failure-recovery.test.ts create mode 100644 tests/roslyn-contracts.test.ts create mode 100644 tools/WinCode.Code.Host/OwnedProcessJob.cs create mode 100644 tools/WinCode.Code.Host/Program.cs create mode 100644 tools/WinCode.Code.Host/WinCode.Code.Host.csproj create mode 100644 tools/WinCode.Code.Host/WorkspaceInputs.cs create mode 100644 tools/WinCode.Code.Host/WorkspaceSession.cs create mode 100644 tools/WinCode.Code.Host/packages.lock.json diff --git a/README.md b/README.md index 847bd98..4c8a0e5 100644 --- a/README.md +++ b/README.md @@ -38,32 +38,49 @@ npm run check npm run delivery:verify ``` -Add WinCode as a stdio MCP server in your agent client configuration: +Add WinCode as a stdio MCP server in your agent client configuration (for clients that support `mcpServers`). Choose either startup mode below. + +**Choose a project when needed:** If the target project is not yet known, or you want to query different projects in sequence, configure only the server entry point: + +```json +{ + "mcpServers": { + "wincode": { + "command": "node", + "args": ["C:/path/to/WinCode/dist/index.js"] + } + } +} +``` + +Without `--workspace`, WinCode initially uses the server process's current working directory, which may differ from your intended project. Before querying, ask the agent to call `workspace_open` with the target project's absolute path, for example `workspace_open({"path":"C:/path/to/project"})`. Repeat this when switching projects; the server installation path stays the same. One server process has one active workspace, so calls sharing that process must not interleave queries for different projects. For concurrent independent projects, configure separate server instances with distinct names and explicit workspace paths. + +**Specify a project at startup:** Add `--workspace` followed by the project directory: ```json { "mcpServers": { "wincode": { "command": "node", - "args": ["~/WinCode/dist/index.js", "--workspace", "~/target-project"] + "args": ["C:/path/to/WinCode/dist/index.js", "--workspace", "C:/path/to/project"] } } } ``` -> **Path Note:** `~` is a placeholder. Replace `~/WinCode` with your absolute installation path (e.g., `I:/WinCode`), and `~/target-project` with your target repository's absolute path. Do not copy `~` literally if your client does not expand shell tildes. +> **Path note:** All paths above are illustrative placeholders. Replace them with your actual absolute installation and project paths. The server entry point and the project directory serve different purposes and need not be in the same directory. For graphical configuration interfaces: -| Field | Value | -| --- | --- | -| Name / Type | `wincode` / `stdio` | -| Command | `node` | -| Argument 1 | `~/WinCode/dist/index.js` | -| Argument 2 | `--workspace` | -| Argument 3 | `~/target-project` | +| Field | Choose a project when needed | Specify a project at startup | +| --- | --- | --- | +| Name / Type | `wincode` / `stdio` | `wincode` / `stdio` | +| Command | `node` | `node` | +| Argument 1 | `C:/path/to/WinCode/dist/index.js` | `C:/path/to/WinCode/dist/index.js` | +| Argument 2 | Omit | `--workspace` | +| Argument 3 | Omit | `C:/path/to/project` | -Add each argument as a separate entry. Ensure `node` is available in PATH, or specify its absolute executable path. +Add each argument as a separate entry, without extra surrounding quotes even when a path contains spaces. To defer project selection, remove both `--workspace` and its value; do not leave an empty value. Ensure `node` is available in PATH, or specify its absolute executable path. No additional environment variables are required for this basic configuration. For prompt engineering and token-efficient skill routing, refer to the optional [Skill and MCP setup guide](WinCode-Skill制作与MCP配置指南.md). @@ -231,32 +248,49 @@ npm run check npm run delivery:verify ``` -在 Agent 客户端配置文件中添加 stdio MCP 服务(以支持 `mcpServers` 的客户端为例): +在 Agent 客户端配置文件中添加 stdio MCP 服务(以支持 `mcpServers` 的客户端为例),可按需要选择以下两种启动方式。 + +**使用时再选择项目:**如果暂时不确定目标项目,或需要依次查询多个项目,只配置服务入口: + +```json +{ + "mcpServers": { + "wincode": { + "command": "node", + "args": ["C:/path/to/WinCode/dist/index.js"] + } + } +} +``` + +省略 `--workspace` 时,WinCode 初始使用服务进程的当前工作目录,它不一定是你要分析的项目。查询前,让 Agent 调用 `workspace_open` 并传入目标项目的绝对路径,例如 `workspace_open({"path":"C:/path/to/project"})`。换项目时再次调用即可,服务安装路径无需修改。一个服务进程只有一个活动工作区,共享该进程的调用不能交错查询不同项目;如需同时独立查询多个项目,应配置名称不同、各自明确指定工作区路径的服务实例。 + +**启动时指定项目:**添加 `--workspace`,并在其后填写项目目录: ```json { "mcpServers": { "wincode": { "command": "node", - "args": ["~/WinCode/dist/index.js", "--workspace", "~/target-project"] + "args": ["C:/path/to/WinCode/dist/index.js", "--workspace", "C:/path/to/project"] } } } ``` -> **路径说明:**配置中的 `~` 仅为路径占位符。请将 `~/WinCode` 替换为你本地安装 WinCode 的绝对路径(如 `I:/WinCode`),将 `~/target-project` 替换为待分析项目的绝对路径。若客户端不支持自动展开波浪号,请勿直接照抄 `~`。 +> **路径说明:**以上路径均为通用占位示例,请替换为实际的安装目录和项目目录绝对路径。服务入口与待分析项目目录用途不同,不必位于同一个目录。 若通过图形界面添加: -| 配置字段 | 填写内容 | -| --- | --- | -| 服务名称 / 类型 | `wincode` / `stdio` | -| 启动命令 | `node` | -| 参数 1 | `~/WinCode/dist/index.js` | -| 参数 2 | `--workspace` | -| 参数 3 | `~/target-project` | +| 配置字段 | 使用时再选择项目 | 启动时指定项目 | +| --- | --- | --- | +| 服务名称 / 类型 | `wincode` / `stdio` | `wincode` / `stdio` | +| 启动命令 | `node` | `node` | +| 参数 1 | `C:/path/to/WinCode/dist/index.js` | `C:/path/to/WinCode/dist/index.js` | +| 参数 2 | 不添加 | `--workspace` | +| 参数 3 | 不添加 | `C:/path/to/project` | -注意每个参数独立添加为一行。确保系统环境变量 PATH 中包含 `node`,或直接填写 node.exe 的绝对路径。 +每个参数独立添加为一行,路径包含空格时也无需额外加引号。使用时再选择项目,应同时删除 `--workspace` 及其值,不要保留空值。确保系统环境变量 PATH 中包含 `node`,或直接填写 node.exe 的绝对路径。这一基础配置无需额外设置环境变量。 如需配合 Agent Skill 获得低 Token 开销的精准任务路由,请参阅可选的 [Skill 与 MCP 配置指南](WinCode-Skill制作与MCP配置指南.md)。 diff --git "a/WinCode-\344\270\213\344\270\200\350\275\256\345\267\245\347\250\213\345\214\226\350\277\255\344\273\243\350\256\241\345\210\222\344\271\246.md" "b/WinCode-\344\270\213\344\270\200\350\275\256\345\267\245\347\250\213\345\214\226\350\277\255\344\273\243\350\256\241\345\210\222\344\271\246.md" index 8c4ce2a..bb0115f 100644 --- "a/WinCode-\344\270\213\344\270\200\350\275\256\345\267\245\347\250\213\345\214\226\350\277\255\344\273\243\350\256\241\345\210\222\344\271\246.md" +++ "b/WinCode-\344\270\213\344\270\200\350\275\256\345\267\245\347\250\213\345\214\226\350\277\255\344\273\243\350\256\241\345\210\222\344\271\246.md" @@ -1,6 +1,6 @@ # WinCode 下一轮工程化迭代计划书 -更新日期:2026-09-08(北京时间)。实施基线:**0.12.5 / main 10496e0**。状态:**以下工作尚未实施;涉及行为取舍的策略需在实施前确认。** +更新日期:2026-09-09(北京时间)。核对基线:**0.12.5 / main bbc20ff**。状态:**E1/E2 补修与 E3 有界及真实上游验收完成。显式配置的直接 Roslyn 路径已接入现有 MCP,最新端到端 13 场景、核心回归 344/344 通过。默认后端迁移、E4 公共迁移、Code Host 正式打包及干净环境验收尚未完成。变更尚未提交,最新范围与限制见末尾实施记录。** 已完成的 WP1–WP5、Repomix 安全修复和真实 Serena 隔离验收已从待办移除,历史见 [CHANGELOG](CHANGELOG.md) 与 [工作记录](docs/codex_worklog.md)。方向总览见 [路线图](WinCode-迭代路线图.md),现状见 [架构说明](WinCode-架构与数据流说明.md)。 @@ -12,7 +12,7 @@ ## E1:工作区切换失败一致性(优先) -**当前证据:** [ToolRouter.openWorkspace](src/Core/ToolRouter.ts) 在工作区根更新后还会重置、初始化和绑定适配器;这些后续步骤失败时,不能仅凭 Workspace 内部回滚推定整个切换原子性。当前为静态审查发现的风险,尚无本轮故障注入复现。 +**基线风险与本轮证据:** [ToolRouter.openWorkspace](src/Core/ToolRouter.ts) 在工作区根更新后还会重置、初始化和绑定适配器。本轮故障注入已复现失败后仍准入请求、部分根/会话/watcher 不一致和提交后取消未识别;修复后原 13 个 E1/E2 故障用例不再报告这些问题。 1. 在根切换、缓存/会话更新、适配器 dispose/reset/initialize/rebind 各阶段注入失败与取消。 2. 检查失败后的根路径、缓存命名空间、watcher、上游绑定、请求占用和下一次请求行为。 @@ -20,11 +20,13 @@ **验收:** 各故障点结果可解释;后续请求只读同一工作区,或明确拒绝并给出恢复动作;无旧缓存串入、重复 watcher 或自有进程残留。覆盖成功、失败、取消及再次切换。 -**USER_DECISION_REQUIRED:** 若需改变外部行为,推荐切换未提交时保留旧工作区,提交后无法完整恢复时明确进入需恢复状态;是否要求失败后一律自动回滚,应在故障证据齐备后确认。不预先承诺跨适配器事务回滚。 +**2026-09-09 用户已确认并实施:** 变更前失败保留旧工作区;变更后无法确认一致性时拒绝业务请求,重新 workspace_open 完成恢复。hello 可被动读取恢复状态。同根恢复不走快速路径;恢复再次失败仍保持拒绝。取消发生于根变更后同样进入恢复状态。不实施跨适配器自动回滚。 + +**复核补修:** 内部 Serena 清理或旧 watcher 关闭失败会保留失败状态,返回 recoveryAction=restart_gateway,提示检查自有资源清理后重启 Gateway;不再让 workspace_open 重复修改会话或承诺可以恢复。可重试的 watcher 创建失败仍返回 workspace_open。绑定结束及切换提交前均核对 watcher;创建失败或初始化中 watcher 出错不会成功提交切换。已覆盖内部关闭异常、底层 watcher 创建/关闭失败及初始化期间事件错误。 ## E2:trash 部分完成与恢复 -**当前证据:** [Workspace.moveToTrash](src/Core/Workspace.ts) 先 rename 再写元数据;后一步失败可能返回失败,但文件已经移动。需用隔离夹具复现,不能对真实用户文件试错。 +**基线风险与本轮证据:** [Workspace.moveToTrash](src/Core/Workspace.ts) 先 rename 再写元数据。本轮已用生成文件复现元数据失败但文件已移动;已增加 completed/not_moved/partial、失败阶段和实际路径,保留旧字段。测试覆盖目录准备失败、移动失败、元数据失败、重复请求、重启后文件保留及同名文件在同一时间戳下分别保留。 1. 注入 rename 失败、rename 成功后元数据失败、恢复步骤失败。 2. 保留源路径、实际目标位置与失败阶段,使已移动文件可找回。 @@ -32,7 +34,9 @@ **验收:** 任何结果均能解释文件实际位置;不丢失或覆盖内容;重复请求和重启后恢复有明确边界。 -**USER_DECISION_REQUIRED:** 推荐准确报告部分完成并提供恢复信息;若选择自动移回,需要明确冲突与回滚失败的行为。公共结果字段变更须先确认兼容方案,再更新 Skill 和契约测试。 +**2026-09-09 用户已确认并实施:** 准确报告部分完成和实际位置,不自动移回;保留 success/trashPath/message 并增加状态字段。仓内 Skill 已补充恢复说明,未部署至用户全局 Skill。丢失部分完成响应且元数据未完成时,不保证自动恢复原目录映射;本次不新增恢复数据库或自动回滚。 + +**复核补修:** UUID 与时间戳保留唯一性,展示用原文件名按 Unicode 码点截短,为 .meta.json 留出空间;最终元数据文件名不超过 255 UTF-8 字节(同时约束 Windows UTF-16 长度)。完整 originalPath 保留不变。183–255 字符 ASCII 边界及中文/emoji 文件名夹具均验证正文和元数据完整。 ## E3:有界混合负载验收 @@ -42,7 +46,18 @@ **验收:** 无跨工作区证据污染、请求占用永久不释放、自有进程残留;区分启动增长、缓存稳定平台与持续增长趋势。报告实际采样条件和不可观察项目,不把一次内存峰值当泄漏或把短测当耐久证明。复用现有脚本和报告目录,不建大型基准平台。 -固定 Serena 1.7.0/Roslyn 的七项真实验收已经通过;在其相关路径变更后按需重跑。真实 Repomix 包完整兼容性仍待获准环境准备后验证。普通 CI 不自动覆盖这些上游。 +固定 Serena 1.7.0/Roslyn 的真实验收已在本轮复测并增加切换检查;固定 Repomix 1.18.0 已完成下述实际打包验收。普通 CI 不自动安装或运行这些上游。 + +**2026-09-09 本地小样本:** scripts/verify-mixed-load.ts 记录 80 次 Core 操作(另有夹具初始化/握手)、10 轮采样、10 个真实 Node 模拟上游进程退出,结束时无自有子进程残留,查询/切换检查未发现跨根证据。采样仅约 1 秒,RSS 从约 134 MiB 增至 137 MiB、heapUsed 从约 34 MiB 增至 42 MiB;尚未观察稳定平台,不能判断长期增长或宣称无泄漏。Windows 句柄数、子进程 RSS 和真实上游兼容性仍未覆盖。 + +**复核后交错小样本:** 原脚本的调用前取消和顺序切换不足以验证运行中交错,已改为实际进入模拟上游 RPC 后再发起切换;5 轮运行中取消、5 轮上游退出,均断言切换等待旧请求、旧根在请求占用期间不变、结束后新根查询正确。共记录 70 次 Core 操作、10 个自有进程,1934 ms,结束时无自有进程残留。报告 test-tmp/mixed-load/run-ftSuQB/report.json;此结果只补齐受控交错场景,不是耐久性或真实语义上游验收。 + +**后续完成(2026-09-09):** `npm run test:mixed-load -- --sample-interval-ms=10000` 在同一 100 次/5 分钟预算内完成 70 次操作、10 轮、97235 ms。Windows 指定 PID 采样均成功:每轮结束 Gateway 句柄均为 234,dispose 后 233;工作集启动阶段下降后,第 2–9 轮约 103.4→105.2 MiB,仍有小幅增长,不能把这一短窗判为长期稳定平台。10 个上游工作集约 53.9–56.4 MiB,结束时均无残留。报告 `test-tmp/mixed-load/run-NUo0VL/report.json`。指标含采样点而非峰值,无强制 GC、持续高负载或耐久性证明;E3 按原有有界标准完成,不把未授权的长时间压力测试提升为本轮新增验收条件。 + +用户随后授权补齐环境,所有持久组件最终位于 `.deps`:Python 3.13.15、Serena 1.7.0 固定提交 949a27e、上游锁定 Roslyn 5.5.0-2.26078.4、Repomix 1.18.0。新增组件和下载缓存逻辑大小合计约 716 MiB(硬链接可能重复计数,非物理分配量),已有 SDK/NuGet 不计入。环境回执 `.deps/environment-receipt-20260909.json`;不改系统 PATH、全局 SDK、项目主依赖锁或 Codex 注册。 + +- Serena:`npm run test:serena-real -- "绝对项目路径/.deps/serena-venv/Scripts/python.exe" "绝对项目路径/scripts/serena-isolated-launcher.py"`。固定 8 项全部通过,包括原 7 项语义检查和 Router A→B→A 的缓存/新查询切换、每次实际重连与自有 PID 退出;报告 `test-tmp/serena-acceptance/1788923903497-28124/report.json`。专用启动器在导入上游之前设置隔离环境,避免 MCP SDK 默认环境白名单丢弃 SERENA_HOME。首轮误生成的用户目录配置/日志已依据创建时间和上游“原文件不存在”日志核对后归档 `.deps/serena-first-attempt`,不遗留全局 Serena 配置。 +- Repomix:`npm run test:repomix-real -- "绝对项目路径/.deps/repomix/node_modules/repomix/bin/repomix.cjs"`。10 项通过:安装握手、Markdown/XML/plain、真实压缩、空包、watcher 后缓存失效、封闭候选集、实际 CLI 取消、启动超时与输出清理;报告 `test-tmp/repomix-acceptance/中文 & (real)-hCTcgs/report.json`。实测修复了说明文字被误计为正文文件的问题;使用独立 CLI 摘要,缺少受支持摘要时降级而不猜测数量。未宣称覆盖任意版本、用户配置或所有打包功能。 ## E4:结果与错误契约渐进整理 @@ -52,6 +67,30 @@ **USER_DECISION_REQUIRED:** 新公共响应字段及兼容策略需在盘点后确认;不把 MCP 的可选结构化输出能力当作必须全面重写接口的理由。 +E1/E2 所需 WORKSPACE_RECOVERY_REQUIRED、TRASH_NOT_MOVED、TRASH_METADATA_FAILED 已按用户确认的兼容方案局部实现;不等于所有工具的错误与证据模型已完成统一。其余范围、歧义、预算和上游错误已完成下述盘点,公共字段兼容方案待确认。 + +### 2026-09-09 E4 盘点与待确认兼容方案 + +`scripts/verify-error-contracts.ts` 已通过 InMemory MCP 实测 10 个场景,报告 `test-tmp/error-contracts/run-0L5l8H/report.json`。取消/普通异常使用隔离 Router 的操作错误注入,其余为实际校验、读取或关闭路径;没有操作真实 UI。 + +| 场景 | 当前可机器读取的事实 | 缺口与处理意见 | +| --- | --- | --- | +| 未知工具、代码参数/范围无效 | MCP isError=true;content 为普通错误文本 | 增加独立 structuredContent,分别使用 UNKNOWN_TOOL / INVALID_ARGUMENT;修正工具名/参数后才能再试 | +| UI 参数无效 | success=false、INVALID_ARGUMENT、errorMessage | 保留现有 content,补相同的附加元数据;不调用原生 UI | +| 目标歧义 | 引用 resolution=ambiguous、candidates;context 的 fileIssues.reason 标识歧义且 evidence 为空 | 保持现有候选/范围信息,不伪造成功命中;按候选缩小范围,暂不重写成功结果信封 | +| 预算不足 | truncated、queryComplete、evidenceInsufficient;行范围还含 missingRanges/nextRequest | 这是部分证据,不统一变成执行失败;继续使用最终序列化后计算的预算和覆盖信息 | +| 上游不可用 | source=serena-adapter-fallback、analysisCompleteness=degraded/incomplete;health 的 upstream/lastError | 来源能区分降级,但并不说明唯一故障原因;不从 queryError 自然语言猜码,不把空本地结果说成语义零结果 | +| 执行中取消 | success=false、CANCELLED;必要时 workspaceRecovery | 保留现有字段;恢复状态优先于一般重试提示 | +| 关闭时拒绝 | status=failed、reason=cancelled、provider=wincode、recoverable=false | 与执行中取消不同;附加 GATEWAY_SHUTTING_DOWN,提示重启,而不是盲目重试 | +| 普通执行异常 | MCP isError=true;content 为普通错误文本 | 附加 TOOL_EXECUTION_FAILED;原因未知时标记 inspect_error,不承诺自动重试 | +| trash 部分完成、切换恢复 | outcome/failureStage/实际路径;WORKSPACE_RECOVERY_REQUIRED/recoveryAction | 继续使用已确认字段,不覆盖实际位置或永久恢复动作 | + +**建议的首批公共兼容方案(USER_DECISION_REQUIRED,尚未实施):** 只给 Gateway 已失败响应增加 `structuredContent={success:false,errorCode,errorMessage,provider:"wincode",retryable:false,recoveryAction}`,旧 content、isError、成功响应及工具参数不变。retryable=false 表示不推荐原样自动重发;recoveryAction 表示先修正参数、检查错误、重新打开或重启后再请求。已执行部分副作用的取消使用现有 workspaceRecovery 动作;trash 不被通用提示覆盖。未知原因仅用 TOOL_EXECUTION_FAILED,绝不凭文字猜测上游错误类型。 + +例如无效 query 当前仍返回 `Tool Execution Error: ...`;附加结构将为 `{"success":false,"errorCode":"INVALID_ARGUMENT","errorMessage":"原错误信息","provider":"wincode","retryable":false,"recoveryAction":"fix_arguments"}`。取消、关闭、未知工具、普通异常分别用 CANCELLED、GATEWAY_SHUTTING_DOWN、UNKNOWN_TOOL、TOOL_EXECUTION_FAILED;未知工具动作使用 fix_arguments,取消使用 retry_after_cancellation(若已有工作区恢复动作则优先)。此批不增加统一成功信封,不新增工具,不改变预算,也不实施语义图/UI 绑定路线。 + +验收:旧 content 逐项保持一致;新客户端无需解析自然语言识别上述失败;无效参数在副作用前拒绝;取消/关闭/永久恢复动作不混淆;成功、歧义、预算截断、空降级结果原样保留。未知扩展字段继续容忍并忽略。 + ## 交付与检查关口 每个工作包独立形成可审查变更,先记录触发问题和失败样例,再做最小修复。影响交付输入时执行 `npm run check`;UI 路径变化增加 `npm run check:desktop`,上游路径变化增加对应 opt-in 实测。文档单独修改只做链接、命令、版本和事实一致性核对。 @@ -59,3 +98,200 @@ 沿用已授权的版本流程:针对性复测与 debug → 对应版本和 Skill 同步 → PR 精确提交的 Node 22/24 与三项 CodeQL 检查 → 合并 → 主分支交付核对。实际客户端重连另行核对,不能用磁盘版本或新测试会话代替。每步写入既有工作日志;作者自审不等同独立审核。 每包验收失败即停在该包定位原因,不叠加下一包掩盖失败。新依赖、运行环境、重要公共接口或恢复政策超出既有决定时先确认。本计划不预设版本号或工期,避免把尚未复现的风险包装成已确定修复规模。 + +## 2026-09-09:对照“架构分析优化建议”的后续计划 + +来源:[架构分析优化建议](chatgpt-conversation://6aa0185d-0bb8-83e8-9281-594f16e8c6d2)。已读取两轮完整问答,并与上述源码基线对照。对话中的架构判断作为建议输入;优先级与实施范围以下述核对为准,尚未获得实施新架构的授权。 + +### 建议与当前实现的差距 + +| 对话建议 | 当前源码证据 | 真正待完成的工作 | +| --- | --- | --- | +| Capability Registry | Gateway/ToolRegistry.ts 已集中注册、发布、校验、别名和契约哈希;ToolDefinition.ts 已定义执行接口 | 先盘点现有描述和状态信息能否表达适用任务、前置条件、降级路径;仅为真实选择困难补元数据,不另建重复注册体系 | +| Evidence Model | Core/CodeQueries.ts 已表达来源、完整性、截断和局限;UI 映射也有候选状态和文件哈希 | 在 E4 中对齐共有语义及错误码;逐类兼容迁移,不把供应方名称或任意数值置信度当正确性保证 | +| Semantic Graph / Impact Analysis | Core/DotNetGraph.ts 有声明级项目图;Serena 提供符号/引用查询;CompositeTools/ImpactAnalyzer.ts 已有影响报告 | 验证能否复用这些能力构造有来源的有限符号关系;声明依赖、引用和真实调用关系分别标注,不能把当前实现说成全仓调用图 | +| Code ↔ UI Mapping | UiSourceMapper.ts / UiCodeMapper.ts 已提供 XAML 与 C# 候选链 | 当前明确 runtimeSourceVerified=false、运行构建与源码身份未知、模板解析不支持;优先改善候选消歧与语义关联,真实 Binding/DataContext 解析仍是条件性研究 | +| Incremental Index | Cache.ts / WorkspaceWatch.ts 已有缓存、指纹和变化失效 | 这些不等于持久化符号/引用增量索引;先测重复扫描成本,再决定是否需要新索引及存储 | +| 统一错误与 CI | McpServer.ts 取消错误已结构化,普通异常仍返回文本;ci.yml 已运行 Windows Node 22/24 的 npm run check | 错误整理沿用 E4;CI 继续补与改动相匹配的回归,不重复建设已有流水线,真实桌面和上游实测保持独立边界 | + +### 建议执行顺序与验收 + +1. **先处理可靠性底座(E1 → E2 → E3)。** 用隔离夹具证明工作区切换和 trash 部分完成问题,再按确认后的恢复政策修复。验收沿用各工作包,尤其检查“请求报错但状态已改变”的场景。混合负载遵守既有小样本预算;本轮未执行故障注入,也未判定风险已复现。 +2. **渐进统一证据、错误与能力描述(E4)。** 先交付字段/失败场景对照表及兼容方案,再做局部实现。优先涵盖取消、上游不可用、歧义、截断和部分完成。验收要求旧客户端仍能调用,新增字段可机器判读,空结果与不完整查询不混淆。能力声明复用既有 Registry 和 Skill;不额外增加同义 MCP 工具。 +3. **语义关系最小验证。** 建议先以 C# 小型多项目夹具验证,复用 Serena 符号身份与引用、现有项目图和 ImpactAnalyzer;先支持唯一符号的一跳关系及影响证据。覆盖同名/重载、跨项目、缺失上游、查询截断,确保每条关系可定位来源,未知关系保持未知。先评估可行性,再决定是否扩展关系类型、引入新解析器或持久化图;不承诺完整调用图或确定性“会不会坏”。 +4. **深化 UI 到代码的证据链。** 在现有 XAML/C# 候选基础上,验证一个明确 WPF 场景的 AutomationId → XAML → Command 候选 → 语义声明/引用链。覆盖重复标识、多个候选、模板和运行二进制与源码不一致;无法证明运行时绑定时继续标记候选。只有实际任务被阻塞,再评估 R8 的应用内诊断路线。 +5. **按测量结果决定增量索引及语言扩展。** 固定任务比较冷/热查询、少量文件变更后的耗时、扫描量和资源趋势;只有现有缓存/上游复用仍不足时才提出索引方案。增量方案须验证文件修改、删除、重命名与工作区切换后不返回旧证据。SQLite、新服务、TypeScript/Python 扩展均不预先列入必做实现。 + +对话强调的长期价值仍是语义关系、UI 到代码映射和可追溯证据;实施顺序建议先稳住工作区一致性,再逐步增强这些已有能力。继续维持 MCP 能力层定位,当前计划不包含自建 Agent、向量数据库或大量扩增工具。 + +**USER_DECISION_REQUIRED:** E1/E2 的恢复政策、E4 的公共字段兼容方案,以及后续是否采用 C# 优先的最小语义范围,均在调查形成具体方案后确认。新的依赖、持久化存储或应用内注入另行确认。本次拉取与计划整理不包含安装、构建部署、客户端重连或远端提交授权。 + +## 2026-09-09:直接集成 Roslyn 的设计稿 + +**授权与状态:** 用户要求开始设计绕过 Serena、直接集成 Roslyn,并进一步解释 E4 利弊。本节取代上述后续语义路线中“继续经 Serena 实现”的默认建议;历史验收仍保留。当前仅完成设计,尚未新增 Roslyn 生产依赖、编译语义 Host、切换提供方或删除现有 Serena 实现。 + +**后续复核:** 本文末尾“社区实践与第一性原理复核”修订了首个原型范围、身份设计顺序、监听失效要求及 E4 推荐顺序;涉及这些取舍时以该复核为最新建议,以下保留为原设计记录。 + +### 目标与第一版能力 + +WinCode 自行管理 C# 语义查询,使用 Microsoft.CodeAnalysis 系列库;用户不再为这条能力安装 Serena、Python 或独立 Roslyn 语言服务器。产品包携带 WinCode 自有语义 Host 和所需 Roslyn 组件。加载真实项目仍可能需要匹配的 .NET SDK、目标框架引用包和已恢复的项目依赖;“随产品提供分析组件”不等于任意项目零前置条件。 + +首版建议限于 Windows 上 SDK 风格的 C# `.csproj`/`.sln`:声明查找、重载/同名符号消歧、指定符号的跨项目源码引用、精确引用位置、向现有影响报告提供有范围和完整性标记的证据。无 Serena 条件下完成验收是必要条件。TS/JS/Python 保持已有文本能力,明确不提供 Roslyn 语义分析;VB/.NET Framework 特殊项目、自动重命名/写代码、完整动态调用图、WPF 运行时 Binding、持久化图索引不纳入首版。 + +### 模块边界与部署 + +建议调用链:`现有 MCP 工具 → ToolRouter / CodeQueries → RoslynAdapter → WinCode.Code.Host → Roslyn 库`。这是随 WinCode 分发、按需启动的本地子进程,不新增用户注册的 MCP、不监听网络、不要求安装另一款工具。Roslyn 库直接在自有 .NET Host 中执行;采用进程边界是因为当前 Gateway 为 Node.js,也便于超时回收和释放 .NET 工作区资源。 + +| 方案 | 收益 | 代价/判断 | +| --- | --- | --- | +| 加入现有 UIA Host | 交付上少一个可执行入口 | UI Host 当前为一次请求读取 stdin 到 EOF,并含 DPI、桌面通知、FlaUI;语义 Workspace 需要跨查询驻留。改造成混合生命周期会耦合桌面和编译资源,不推荐 | +| 新增 WinCode.Code.Host(推荐) | C# 工作区独立生命周期,随同一个产品包交付,故障不会占用 UIA 请求 | 增加一个可执行组件及内部协议;MSBuildWorkspace 还可能启动自身 BuildHost,须把后代进程纳入清理,不能声称整个功能只有一个 OS 进程 | +| Node 进程内直接加载 .NET | 减少显式子进程通信 | 引入 FFI/运行时桥接和额外兼容链,现有工具链无此基础,不推荐 | + +复用 CodeSymbolQuery/CodeReferenceQuery/ContextCodeQuery、ResourceManager、现有请求占用/切换锁、WorkspaceWatch 和交付指纹。不另建泛化插件框架;只有实际共用逻辑才提取。拟新增 `src/Adapters/RoslynAdapter.ts` 与 `tools/WinCode.Code.Host/`;调整 CodeQueries、ToolRouter、ImpactAnalyzer 和健康报告中的提供方耦合,Gateway 保留现有工具名。 + +候选依赖为 Microsoft.CodeAnalysis.CSharp.Workspaces、Microsoft.CodeAnalysis.Workspaces.MSBuild、Microsoft.Build.Locator,统一选择兼容版本并锁定 NuGet。版本、完整传递依赖、发布字节数和包许可证清单在实现首个最小原型时核验,不从 Serena 的语言服务器包版本推断 Roslyn 库版本,也不直接依赖 SDK 私有目录内的 DLL。构建沿用项目既有 SDK 策略;分析目标的 SDK 选择另遵从该项目 global.json,缺失时报告,不静默下载或挑任意新版。MSBuild 定位规则参考[微软文档](https://learn.microsoft.com/en-us/visualstudio/msbuild/find-and-use-msbuild-versions?view=visualstudio)。 + +### 项目加载与事实边界 + +不能靠枚举 `.cs` 文件和补几个引用就承诺完整语义。真实编译还涉及 Compile 条目、条件符号、引用、imports、目标框架、生成文件。建议使用 MSBuildWorkspace 读取实际项目配置,显式记录 Configuration、Platform、TargetFramework 和加载诊断。多目标框架不能任选一个后把结果说成覆盖全部;首版限定一个明确配置,存在多种且未指定时要求选择。 + +**重要取舍:** 项目加载通常涉及 design-time build。微软说明其用途是获得源文件/引用/选项,会调用额外 MSBuild targets,并随配置/框架而变化。[设计时构建说明](https://github.com/dotnet/project-system/blob/main/docs/design-time-builds.md)。因此“不调用 dotnet build/restore”不能保证任意用户项目绝无执行副作用;项目自定义 targets 仍是需要信任的代码。 + +建议政策:未获项目执行信任时仅提供语法/文本证据;明确允许设计时求值后才进入项目语义模式,许可按工作区及加载策略保存,普通查询不重复询问。默认不自动 restore、不编译目标程序、不运行目标程序、不主动运行项目分析器/源生成器;若 MSBuild 自定义目标本身执行代码,这一政策不能当作沙盒保证。源码生成相关引用缺失时标记不完整;已有 obj 生成文件也不能未经身份核对就当作当前源码。首版在已授权的生成夹具中实现,再确认真实用户项目的信任交互。 + +外部项目引用、链接文件、SDK/NuGet 元数据有不同用途:源码取证范围必须经过根目录包含性校验;根外源码不自动展开,列出范围缺口。授权使用的 SDK/包元数据可参与类型分析,不等于授权读取任意根外源码或加载其中分析器。项目加载失败、依赖缺失、条件配置不明确时不输出 queryComplete=true。 + +### 查询、身份和变化一致性 + +1. 声明查找使用语法树与编译符号,显示名只用于展示。重载、泛型、partial、接口实现不能按字符串等同。引用查询采用 Roslyn 的 SymbolFinder.FindReferencesAsync,范围为实际加载的 Solution;精确位置由源码 Location/span 得到,行/列对外统一一基,不能把所在方法起点冒充调用点。[官方引用 API](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.findsymbols.symbolfinder.findreferencesasync?view=roslyn-dotnet-4.13.0)。这仍不覆盖反射、运行时动态绑定或仓外调用。 +2. 建议引入不透明 `symbolId` 与 `snapshotId`,身份绑定工作区、项目/TFM 和文档版本;由当前快照的符号映射解析,不依赖 Serena 的 `/Save[0]` 序号。名称/签名用于显示与可读消歧。ID 在编辑、切换或 Host 重启后可能过期,返回明确失效状态并要求重新定位,不承诺跨版本永久 ID。ID 表有界且随快照释放。 +3. 每个 Gateway 最多维护一个活动语义工作区;按需启动 Host,持有可复用 Solution 快照。内部 stdio 协议包含 requestId、workspaceGeneration、snapshotId 和协议版本,stdout 仅协议,stderr 为有界日志;有限帧长度、并发数、队列、取消和超时,不另建网络服务。 +4. WorkspaceWatch 收到 `.cs` 变化时更新/失效文档快照;首版可保守重载,优化增量复用后置。`.csproj`、global.json、Directory.Build.*、assets 文件变化触发项目重载。watcher 本身不保证原子文件快照;响应提交时核对代次和参与文档的版本/哈希,发现变化返回不完整或取消,禁止把不同快照的身份与引用拼成一个完整结果。 +5. 切换沿用 E1:排空旧请求后释放旧 Host/子进程,再初始化新工作区;释放失败不能假装恢复成功。取消是请求级操作,超时无法收敛时关闭自有 Host 树并废弃快照,后续只读查询再按明确状态重建。预算包括加载与查询时间、返回条数/字节及快照资源;初始值根据生成多项目夹具测量,不宣称仅裁剪输出就限制了内部计算内存。 + +### 兼容迁移与交付验收 + +- 保留现有工具名和普通参数;拟为精确引用新增可选 symbolId/snapshotId。旧简单名称继续支持,但歧义必须重新选择。已有 Serena namePath/序号不可静默套到 Roslyn 顺序上,旧身份请求提示重新定位。 +- `source` 目前是 Serena 专用枚举,ImpactAnalyzer 也按 serena-mcp 判断语义来源。必须明确引入 Roslyn 来源并修改类型、消费方和回归;不返回假的 serena-mcp。旧客户端若穷举 source,新增值仍可能不兼容,不能宣传为完全无损替换。建议先显式选择 roslyn 验证,再决定切为默认的版本迁移;不自动偷偷回退到 Serena。 +- 查询完整性由项目加载、查询范围、诊断和预算共同决定;Roslyn 来源不自动提高 confidence。零引用继续不能推出安全删除。错误契约可复用 E4,但 E4 不需要等待 Roslyn 才实施,Roslyn 原型也不需要先改全部工具信封。 +- 首个验证阶段只做自有 Code Host + 生成的两项目 C# 夹具,覆盖同名/重载、泛型、partial、接口、跨项目、合法空结果和精确坐标;以明确预期源码位置为判据,Serena 仅可作可选对照,不能作唯一正确性判据。 +- 接入阶段覆盖冷/热查询、编辑/删除/重命名、A→B→A、旧 ID、配置/TFM 切换、缺失 SDK/引用、未信任项目零设计时执行、取消/崩溃/关闭、预算截断。保留 E1/E2 行为与当前核心回归;不运行目标应用。 +- 交付阶段将 Code Host、Roslyn 与必要 BuildHost 文件、NuGet 锁、协议/提供方契约纳入版本和交付指纹。现有交付清单有 512 目录条目、单文件 64 MiB、合计 256 MiB 等界限,须按实物发布包审核,不能直接关掉校验。验证无 Python/Serena 可用的干净环境仍能完成首版 C# 验收后,才迁移默认提供方和移除运行依赖。此阶段不自动删除当前 .deps 中用于对照的安装。 + +**落地前待确认:** 建议首版 C# SDK 项目/单配置、WinCode 自有 Code Host、显式项目设计时求值信任、先可选后默认的迁移。用户已确认直接集成方向;上述范围、执行边界和身份/source 公共字段属于本设计的具体取舍,当前没有把它们当作已获实现批准。 + +## 2026-09-09:E4 兼容方案的利弊与修订建议 + +E4 解决的是“不同工具报错方式不同,调用方不得不猜文字”,不提升代码理解能力。当前真实样例包括普通 `Tool Execution Error: ...`、CANCELLED JSON、关闭时 reason=cancelled 的旧对象。成功结果已有自己的范围/歧义/截断事实,这些不应为了统一而删改。 + +### 三种迁移方式 + +| 方式 | 好处 | 代价/风险 | +| --- | --- | --- | +| 保留原 content,失败时附加 structuredContent(此前建议,推荐作首批过渡) | 老的文本消费路径变化最小;新客户端可按固定错误码分支;改动集中 Gateway,可独立回退 | 老客户端若只读 content,得不到新收益;严格拒绝未知字段的客户端仍可能不兼容;两个表达必须由同一分类事实生成;需要实测实际宿主是否把结构化部分交给模型 | +| 错误 content 改成规范 JSON,同时提供同一 structuredContent | 文本与结构一致;只读文本的模型也能看到错误码;便于统一 schema | 原来按固定前缀/纯文本解析的客户端要迁移;不属于“旧错误文本完全不变” | +| 保留第一条旧文本,再追加 JSON 文本及 structuredContent | 保留旧首条文本,同时让只读 content 的消费者看到机器字段 | 额外文本与字段重复,模型输入可能更长;只允许一个文本块的客户端仍可能不兼容;只能承诺保留首块,不能承诺 content 数组逐项不变 | + +MCP 官方将 structuredContent 与 outputSchema 设为可选,并建议返回 structuredContent 时同时提供其 JSON 文本表示。[工具规范](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)。因此第一种仅保留旧自然语言的方案是为了迁移而做的取舍,不能声称已经满足“同一 JSON 文本镜像”的兼容建议。也不能因为 SDK 能解析 structuredContent,就保证当前 Codex 会按它执行恢复。上线前需明确选定文本策略并验收实际客户端;测试连接不能冒充用户当前连接。 + +### 收益、维护成本与语义风险 + +- 收益:INVALID_ARGUMENT 可提示改参数;WORKSPACE_RECOVERY_REQUIRED 可区分 workspace_open/restart_gateway;关闭状态与普通取消可区分。错误文案修改/翻译不必导致程序分支改变。后续 Roslyn 的加载失败与过期身份可以沿用同一表达办法。字段只帮助调用者选择动作,不能保证 AI 正确执行,也不会自动实施恢复。 +- 成本:需要维护错误码、分类映射、字段类型、手册和兼容回归;structuredContent 不是加一个 JSON.stringify 就结束。不得从自然语言匹配关键字猜故障类型,只在明确校验分支/已知错误类型分类。额外结构增加传输字节,实际 token 增量由客户端如何渲染决定,暂不宣称固定 token 成本。 +- 成功与副作用:success=false/isError=true 只表示请求没完成预期结果,绝不表示文件没动、工作区没变。trash 的 partial、实际路径及 workspaceRecovery 必须保留且优先,不能被通用恢复提示覆盖。预算截断/候选歧义可能是有用但不完整的结果,不能一律改成失败或自动重试。 +- 重试语义:原提案统一 retryable=false 仅想禁止原样自动重发,但很容易被理解为“永远不能再调用”。**修订建议是首批省略这个新增布尔字段,使用明确 recoveryAction;如未来确有自动重试需求,再依据幂等性、执行阶段与副作用设计 retryable。**取消后恢复动作建议 inspect_state(若已有 workspaceRecovery 则用其动作),不笼统写 retry_after_cancellation。已存在的 recoverable 字段保留,不静默重定义。 +- provider=wincode 仅表示错误由 Gateway 报告,不代表根因一定在 WinCode;底层原因未知时不可冒充 roslyn/serena 故障。未知普通异常保持 TOOL_EXECUTION_FAILED。错误信息不新增完整栈、凭据或未返回的用户源码。 +- outputSchema 若只描述失败对象,会与成功结果不匹配;首批暂不为整工具声明这种不完整 schema,用内部类型/测试校验新增字段。以后明确成功/失败联合结构再发布工具输出 schema。 +- 未知工具和真实协议层失败是另一个边界:MCP 规范将未知工具列为协议错误,当前 Gateway 实际返回 isError 文本。此前把 UNKNOWN_TOOL 一并列入只是兼容盘点,不应以 E4 名义悄悄改传输语义;修订后的首批建议先保持它的现有行为,协议规范化单独审查。 + +**推荐的缩小版首批(未实施):** 对已知工具的参数错误、执行异常、取消/关闭、工作区恢复,保留旧 content/isError,附加 success、errorCode、errorMessage、provider、recoveryAction;不新增统一 retryable,不重写成功结果,不改未知工具传输行为,不覆盖领域部分完成信息。先验证字段与原文本一致、旧客户端仍能调用、实际模型能否看到新增信息。若需要只读 content 的消费者也获得全部错误码,再由用户选择 JSON 文本迁移或附加第二块,而不是声称第一种已经覆盖所有客户端。 + +当前用户要求详细解释利弊,尚未批准此修订字段集合或具体文本策略;先保留设计稿。直接 Roslyn 集成和 E4 分别验收,不打包成一次不可分割的大改。 + +## 2026-09-09:社区实践与第一性原理复核 + +**状态:** 用户要求复核此前建议、参考优秀社区经验。本节是对设计的修订建议,未实施生产架构或 E4 迁移。只核对当前代码、官方资料、社区作者的一手记录,并使用项目现有依赖执行隔离 SDK 探针。没有以帖子热度、工具数量或其他项目的性能宣传替代 WinCode 验收。 + +### 从需求推导必要部分 + +WinCode 要交付的是:在明确的项目配置与源码版本内,确定查询指向哪个符号,返回可核对的声明/引用,并说明未覆盖的范围。三个必要条件是编译上下文正确、符号身份明确、证据没有混用版本;“去掉 Serena”“统一 JSON”是服务这一目标的手段。性能比较还应先保证任务正确完成,再比较冷/热耗时、调用次数、输出量和资源,不用减少依赖层数推导一定更快。 + +据此保留直接 Roslyn 库 + 随产品交付的 WinCode.Code.Host。当前 Gateway 是 Node,现有 UIA Host 是带桌面状态的一次请求进程,独立 C# 工作区生命周期有具体用途。WinCode 只承担加载、查询、生命周期和证据输出;类型解析、重载匹配、引用查找仍交给 Roslyn。减少 Serena/Python 的部署环节会把项目加载、版本兼容和故障恢复的维护责任转给 WinCode,不等于维护成本归零,也不保证完整语义超越同样使用 Roslyn 的 Serena。 + +### 采纳的社区经验及适用边界 + +| 一手资料 | 可采纳经验 | WinCode 的处理意见 | +| --- | --- | --- | +| [csharp-ls 项目说明](https://github.com/razzmatazz/csharp-language-server) | 使用 Roslyn 实现语言服务;诊断分析器可单独关闭,并说明开启的 CPU/延迟成本 | 复用编译器能力,分开查询所需语义、诊断分析器和源生成器的职责;不因要找引用就默认运行全部诊断扩展 | +| [RoslynMcp 作者实测](https://github.com/MadQ/RoslynMcp/blob/dev/docs/battle-test-results.md) | 作者记录了冷启动负担、简单名称搜索的低成本,以及只取指定方法可能漏看邻近代码问题的案例 | 文本检索/文件浏览与语义查询互补;不强制所有查询先加载完整 Solution;精确引用附有界上下文,不把精准片段说成完整任务覆盖。该文为作者测试,性能数值不移植到 WinCode | +| [RoslynMcp 工作区模式](https://github.com/MadQ/RoslynMcp/blob/dev/docs/reference/WORKSPACE_MODES.md) | 自建源码工作区缺少项目配置、NuGet 与项目引用等上下文 | 源码扫描可以给语法证据,不能冒充真实项目语义;缺依赖时说明缺口,不能靠换成 AdhocWorkspace 获得“完整”结果 | +| [共享工作区设计稿](https://github.com/MadQ/RoslynMcp/blob/dev/docs/plans/multi-instance-architecture.md) | 讨论多个客户端重复加载工作区的成本;页面明确仍是后续设计 | 首轮仅在一个 Gateway 内复用一个工作区;没有 WinCode 多进程重复加载的实测需求前,不照搬 named pipe 守护服务、共享缓存或跨客户端资源系统 | +| [MCP SDK 问题 #654](https://github.com/modelcontextprotocol/typescript-sdk/issues/654) 与[已合并修复 #655](https://github.com/modelcontextprotocol/typescript-sdk/pull/655) | 成功输出校验曾遮蔽工具原本的失败信息;修复选择对工具错误跳过该校验 | 错误应完整到达调用者;区分协议文字、当前 SDK 行为及真实宿主行为,不从其中一层推断所有客户端兼容 | + +这些是可检查的实现经验,不构成社区共识或推荐安装上述产品。库/API 的行为另由[微软 Workspace 模型](https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/work-with-workspace)、[符号位置查询 API](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.findsymbols.symbolfinder.findsymbolatpositionasync?view=roslyn-dotnet-4.13.0)及前述 MSBuild 文档核对。 + +### 原方案需要纠正或收缩的部分 + +1. **先证明加载与引用闭环,再冻结公共身份协议。** 前案把 symbolId/snapshotId 及映射表提前列入首版公共接口;其必要性还没有原型证据。首个 Host 原型建议只使用内部的项目上下文、Document、声明标识符的 UTF-16 位置及当前 Solution 代次取得 ISymbol。路径/行号本身不够:同一文件可在多个项目配置中编译,同一行也可有多个重载。这个内部定位方式需验证后才能决定对外短期句柄或位置参数;不把跨编辑永久 ID、独立符号注册系统或新的公共字段作为原型前置条件。必要的快照代次、请求关联、取消和帧边界仍保留。 +2. **不能原样复用现有监听作为语义正确性保证。** 当前 `src/Core/WorkspaceWatch.ts` 忽略 obj/bin,并在默认 150 ms 防抖结束后才调用无路径参数的 onChange。前案同时要求 assets 变化重载,二者不一致。将来接入时,应在相关变更到达即标记语义状态待更新,只对重载防抖;按实际加载输入识别 project.assets.json、生成源码、imports、项目配置和 Compile 文件集合的变化,避免简单去掉所有忽略项引入输出目录事件风暴。文件新增/删除、未知文件名事件、监听失败或无法确认来源的新旧状态,不能当作“无变更”。 +3. **不透明 ID 和文件哈希不等于完整性。** Roslyn Solution 的不可变模型可避免查询内部混用逻辑快照,但不能证明磁盘始终没变化。仅复核返回的文件,会漏掉“另一个新文件新增了引用”这种负面证据失效。引用范围的文件集合与加载输入也属于待验证上下文;旧快照必须注明范围/代次,不能宣称磁盘实时完整。源码生成缺失、加载诊断和范围缺口继续显式报告,不能因为 provider=roslyn 就提高 confidence。这里指出的是待实现设计的缺口,未声称已复现一个尚不存在的 RoslynAdapter 故障。 +4. **项目执行边界保留,但先在原型中证明。** MSBuild 设计时构建会执行 targets;“不主动 build/restore”或“关闭诊断分析器”均不等于禁止项目代码执行。诊断分析器与为编译贡献源码的生成器也不能混为一谈。首个已授权生成夹具使用不依赖外部生成器的明确配置,并核对加载副作用;对真实项目是否允许设计时求值及生成器的政策仍待用户决定,不预建复杂信任管理系统。对不支持的生成来源如实标记缺口,不以手工拼装引用弥补后宣称完整。 +5. **E4 的默认过渡建议需要调整。** “原文本不变 + 新 structuredContent”只有已知消费者确实依赖旧文本时才有明确价值;不能为假设中的旧客户端永久保留两套表达。仓内检查发现多数测试客户端从第一个 text 块 JSON.parse,未找到已知工具必须保留 `Tool Execution Error:` 前缀的消费分支;未知工具另有文本断言,仍单独保持。此调查不证明所有外部客户端都兼容。推荐终态为同一个错误对象生成一份 JSON 文本及可选 structuredContent;只读 content 的调用者也能看到错误码,errorMessage 保留可读解释。是否直接迁移还是短期保留旧文本,由真实兼容要求决定,仍是公共契约待决事项。 +6. **撤回“以后必须先有成功/失败联合 schema”的过强推断。** 当前 client/server 2.0.0 的 Client + 项目所用低层 Server,在有成功 outputSchema 时,isError=true 的错误无 structuredContent 或携带不同形状均能原样收到;成功结果的缺失/不匹配仍被拒绝。局部实测 5/5,通过[探针脚本](test-tmp/review-20260909/e4-sdk-output-schema.mjs)与[回执](test-tmp/review-20260909/e4-sdk-output-schema-report.json)可复核。因此 E4 不必绑定全工具成功输出重构。原来“仅失败对象的 schema 不能覆盖成功结果”仍成立;也不能把本机 SDK 的错误豁免说成所有宿主的保证。[2025-11-25 工具规范](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)有结构化内容的 JSON 文本镜像建议;实际协商版本及宿主展示仍需针对部署验收。 + +E4 建议进一步精简:新增公共事实优先限于 errorCode、errorMessage,以及有明确定义时的恢复动作;既有 success、recoverable、workspaceRecovery、trash outcome/实际路径按原含义保留。provider=wincode、统一 retryable 和新通用成功包装都不作为必加字段。领域对象已说明实际发生什么时,不重复制造一个可能相互矛盾的恢复结论;未知普通错误不猜测可自动重试。即使请求失败,已移动的文件也不能重移,已变更的工作区也不能忽略恢复状态。 + +### 修订后的进入顺序与验收 + +1. **最小独立 Host 验证。** 拟在隔离的两项目、单配置/TFM 夹具完成“加载 → 定位具体重载 → 跨项目找引用 → 返回精确位置与少量上下文”。同名干扰、合法空结果和缺失引用必须表现不同;测冷/热耗时、资源、超时和关闭。使用声明位置及人工定义的调用点为真值,避免只与 Serena 比较。这个阶段不迁移公共参数、不切默认提供方;仍需按已确认方向对具体实现及依赖选择对齐。 +2. **一致性与现有入口接入。** 原型证明后,再确定所需身份/source 字段并接到现有 CodeQueries/ToolRouter;覆盖编辑后立即查询、obj/assets 改动、新文件新增引用、A→B→A、旧定位失效、取消/崩溃与 E1 清理失败。文本浏览继续可用,不创建另一套通用插件层。公共范围标记及完整性必须与实际支持的项目配置相符。 +3. **独立迁移 E4 与发布验收。** E4 可与 Host 分别实施,不强制先完成全工具重构。按选定的文本策略跑现有失败样例、部分完成样例和真实目标客户端;最后核对完整交付包、必要 BuildHost 文件和无 Serena/Python 环境。是否完成由可运行证据决定,设计稿、SDK 探针、历史 337 项回归均不替代直接 Roslyn 功能验收。 + +**USER_DECISION_REQUIRED:** 直接集成方向已确认;本文未替用户批准首版具体项目执行政策、公共身份/source 迁移、E4 文本兼容取舍。最新推荐是先做上述小型 Host 验证,E4 以单一错误事实和可见 JSON 为目标,旧文本兼容仅在实际需要时短期保留。本次复核未安装依赖、运行真实项目求值、修改生产代码、切换客户端或提交远端。 + +## 2026-09-09:第一阶段 Host 原型已实现 + +用户同意按复核方向开始。本阶段实现并验收自有 C# Host 的最小引用闭环;没有将它切为 Gateway 默认后端,E4 公共错误迁移也尚未实施。此前的“未新增 Host/依赖”为当时状态,当前进展以本节为准。 + +- 实现位于 [Program.cs](tools/WinCode.Code.Host/Program.cs),启动参数显式要求允许项目求值、工作区根、入口 csproj、Configuration 和单一 TargetFramework。一次加载后复用 Solution;引用查询使用指定项目中的文档和 UTF-16 偏移,返回准确源码 span、一基行/列和有界上下文。内部 JSON 行协议尚不作为稳定公共 API。 +- Roslyn 库固定 5.9.0、Build.Locator 1.11.2;Framework 17.11.48 只作编译引用,设置 ExcludeAssets=runtime/PrivateAssets=all,避免与 Locator 加载的 MSBuild 冲突。依赖通过 [packages.lock.json](tools/WinCode.Code.Host/packages.lock.json)锁定,使用现有项目内 SDK 10.0.303 和 NuGet 路径;没有安装全局 SDK。回执记录 22 个锁定包及其声明的 MIT 许可;本次构建目录为 112 文件、26,859,048 字节,含必要辅助文件,但不是最终发布包或新增磁盘占用的测量。 +- 复现入口:`npm run test:roslyn-host`,对应[验收脚本](scripts/verify-roslyn-host.mjs)。脚本仅生成 test-tmp 两项目夹具、还原夹具依赖和构建 Host;不运行 Serena、目标应用或真实用户项目。当前入口要求已具备 `.deps/dotnet-10.0.303`,不是面向任意新机器的安装器。 +- 最新[回执](test-tmp/roslyn-host/fixture-4E9UyF/report.json):18 场景通过。覆盖显式许可缺失、两项目加载、重载/同名类型隔离、精确引用位置、合法零引用、热查询、截断、旧快照、根外文件、无效位置/项目/预算、1 ms 冷查询取消及后续可用、缺失依赖、不生成目标编译文件、关闭/EOF。Build 0 警告、0 错误。最后一次冷就绪约 2.83 秒,后续有效查询 382 ms,热查询低于毫秒整数计时分辨率,工作集约 125.6 MB;这是单个小夹具样本,不是性能承诺。 +- 资源检查按 PID 和创建时间核对已观测进程退出;本次采样捕获 Host 与 conhost,未捕获 BuildHost,因此不声称已经验证全部短寿命辅助进程。进程树硬回收和真正 Gateway 取消/切换仍属于接入验收。 +- 按用户新增要求,所有新增 C# 函数及主要 JS 验收函数已补充中文 XML/JSDoc 注释;协议注释覆盖必填字段、UTF-16 坐标单位、返回值、失败行为、超时及快照生命周期。后续新增函数和接口沿用此要求,注释应解释契约及非显然约束。 + +**验收边界:** 这是固定语义快照原型,没有 watcher、重载或磁盘新鲜度保证,响应明确 diskFreshnessVerified=false。编译前移除 AnalyzerReference,以防引用查询间接执行生成器;当前夹具排除了 12 个 SDK 分析器/生成器引用,完整性因而保守标为 false。源生成覆盖、配置扩展、真实项目执行政策和根外导入不作为本阶段已完成能力;自定义 MSBuild targets 仍是用户批准执行的项目代码,源码路径校验不构成执行沙盒。取消为协作超时,返回 limit 不约束 Roslyn 内部搜索内存。 + +下一阶段先处理变化失效与接入生命周期,再定公共定位/source 字段并连接 CodeQueries/ToolRouter;E4 可独立迁移。完整发布清单、默认后端切换及真正无 Serena 环境验收尚未完成,不把本阶段 18 项结果替代这些工作。 + +## 2026-09-09:Host 输入一致性、重载与取消已实现 + +用户同意继续后,本次完成独立 Host 的变化失效与请求生命周期。该进展更新上一节的固定快照限制;公共定位/source、Gateway 提供方和 E4 保持尚未迁移的状态。 + +- 新增 [WorkspaceInputs.cs](tools/WinCode.Code.Host/WorkspaceInputs.cs) 与 [WorkspaceSession.cs](tools/WinCode.Code.Host/WorkspaceSession.cs):查询前后检查输入文件集合与内容,包括源码增删改名、obj/assets、csproj、祖先常规配置及实际加载的文档/元数据;文档文本在编译前固定。监听事件直接推进代次,不依赖现有 Gateway watcher 的 150 ms 防抖。结果只能描述检查点覆盖范围,不能证明任意自定义 targets 的外部输入或整个磁盘原子一致。 +- 内部协议升级 v2:显式 reload 生成新快照,开始重载后失败保持失效;不自动运行第二次业务请求。MSBuild 返回部分项目而未抛异常时,按 WorkspaceDiagnosticKind.Failure 返回 PROJECT_LOAD_FAILED;源码编译错误仍可随不完整引用保留。global.json 变化、监听或清理失败要求重启 Host。 +- [Program.cs](tools/WinCode.Code.Host/Program.cs) 分离输入控制与串行工作队列:最多等待 8 项、重复活动 id 拒绝、预算从接纳时开始、主动 cancel、shutdown/EOF 取消并排空后清理。排队时已到期的 reload 在改动状态前退出,旧快照仍可用;已开始重载后失败不恢复旧身份。取消仍为协作机制,生产进程树硬回收未实现。 +- 输入预算为 20000 个枚举条目、5000 个文件、总计 128 MiB、单文件 32 MiB;超限拒绝,不生成部分指纹。freshness 明确覆盖范围;diskFreshnessVerified=false、externalCustomInputsVerified=false、queryComplete=false 保留。排除生成器的限制也保留,不以准确的现有调用位置证明全局覆盖。 +- 最终[验收回执](test-tmp/roslyn-host/fixture-09LFFy/report.json) 42 场景通过,锁定构建 0 警告/0 错误。覆盖编辑后立即查询、新文件、重命名/删除、assets 和真实条件编译变化、Compile 排除、损坏项目失败与修复、过期身份、队列超时/冲突/背压、主动取消、活动请求 EOF 清理及 SDK 变更重启要求。突发 19 帧得到 8 个成功、9 个 BUSY、1 个重复 id 和 1 个取消,全部有回执。 +- 本次单夹具冷就绪约 4.06 秒、有效首查 484 ms、热查 136 ms、工作集 184123392 字节;初始跟踪 195 文件/6163683 字节。热查询现在包含前后内容校验,不能用此前无校验的亚毫秒样本作同口径性能比较。构建输出仍为 112 文件,26891268 字节,不是最终发布包测量;仅对采样到的 Host/conhost 验证退出,BuildHost 未观测。 +- 中文函数/接口注释和仓内 [Skill](skills/wincode/SKILL.md)、代码及诊断手册已同步;手册校验、现有接口/同步测试 11/11、脚本语法与 diff 检查通过。未找到已安装 wincode Skill 的受查目标,没有创建全局安装或声称当前客户端已更新。 + +后续仍需完成 RoslynAdapter/CodeQueries/ToolRouter 接入、实际 A→B→A 切换与崩溃/硬取消回收、公共身份/source 契约、E4 及完整交付和无 Serena/Python 环境验收。监听溢出与 Dispose 异常的真实注入、任意外部 targets 输入、非当前单配置和生成器覆盖未验证。此前核心 337/337 未在本次重跑,独立 Host 的 42 项不能代替生产入口验收。 + +## 2026-09-09 13:46:Roslyn 接入现有 MCP 与生命周期验收完成(北京时间) + +用户同意开始后,完成公共定位契约、RoslynAdapter/CodeQueries/ToolRouter 接入与真实 MCP 生命周期验证。上节“尚未接入/未硬回收”为当时状态;本节是当前进展。保持 15 个工具名,默认配置仍使用 Serena,只有显式 Roslyn 配置才启用新路径。 + +- 新增 [RoslynAdapter](src/Adapters/RoslynAdapter.ts) 和 [RoslynHostClient](src/Adapters/RoslynHostClient.ts),连接自有 Code Host。启动时通过 `--roslyn-config` 指定绝对 JSON 配置路径,显式给出项目求值许可、根内入口 csproj、Configuration、单一 TFM、已有 dotnet 和 Host 路径;配置示例见 [Skill 代码手册](skills/wincode/references/code.md)。不自动读取仓内配置以获得执行许可,也不允许普通 MCP 查询改可执行路径。 +- `wincode_find_code_symbol` 返回 `source:"roslyn"` 与精确 `location={snapshotId,project,file,position}`;`wincode_find_references` 新增可选 `symbolLocation`,仍保留 `symbolName`。position 为零基 UTF-16 偏移,行/列仍为一基;同名/重载返回候选供选择,旧 Serena 序号身份明确拒绝,名字与位置不匹配不会被静默忽略。内部 v2 协议补充 symbols 操作,partial 去重且指定文件范围时返回该文件中的真实声明位置。 +- 两类查询均携带 `semanticContext`,说明加载快照、排除的分析器/生成器和有界输入检查点。ImpactAnalyzer 可使用已定位符号的真实引用,但保持 `queryComplete=false`、`UNCERTAIN/UNKNOWN`,不因来源为 Roslyn 推断完整。显式 TS/JS/Python 范围仍可使用现有本地文本能力,Roslyn 模式不启动或回退到 Serena。 +- 编辑后拒绝旧证据;下一次显式符号搜索才重载并产生新定位,不自动重放失败请求。`workspace_open` 同根重开及 A→B→A 均关闭旧 Host、失效旧身份。hello 只报告已知提供方/状态,不触发项目求值。清理失败保留 E1 的 `WORKSPACE_RECOVERY_REQUIRED/restart_gateway`,不能用重复打开掩盖失败。 +- 取消先发请求取消,超时或无响应时硬回收自有进程树;启动阶段未进入协议循环也能取消。Windows Host 在 MSBuild 初始化前进入自有 Job,Host 崩溃时由系统关闭 Job 清理其继承的子进程;这是资源所有权机制,不是任意项目代码的执行沙盒。实现依据 [Windows Job Objects](https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects) 和 [扩展限制结构](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-jobobject_extended_limit_information)。 +- 新增 [真实 MCP 验收脚本](scripts/verify-roslyn-gateway.mjs) 与 [契约回归](tests/roslyn-contracts.test.ts)。最终 `npm run test:roslyn-gateway` [回执](test-tmp/roslyn-gateway/run-nP7SpF/report.json) 13 场景通过,覆盖真实重载/引用、编辑、切换、加载失败修复、实际 MSBuild 执行中的取消/崩溃/超时及最终关闭。后三类各捕获 7 个自有进程,包含 Host、BuildHost、测试 target 的 cmd/node 和控制台,按 PID/创建时间确认退出;这次已实际观测 BuildHost,不再沿用此前未捕获的证明缺口。 +- 最终 `npm run check` [回执](test-tmp/check/2026-09-09T05-41-07-067Z-core/report.json) 12 阶段通过,344/344、0 失败/0 跳过;生产 stdio 的 15 工具契约和现有交付指纹匹配。该交付清单仍覆盖现有 Gateway/UIA Host/受管 Skill,不代表 Code Host 已纳入正式发布。独立 Host 在本轮早期另通过 42 场景([回执](test-tmp/roslyn-host/fixture-whR9f2/report.json));后续位置范围及接入修订以最终 13 场景和核心回归为证,未把中间结果重复计数。 +- 新函数、接口及生命周期约束配中文注释;仓内 Skill、代码与诊断手册同步完成。相关定向测试 18/18、Skill UTF-8 验证通过。实现期间发现并修复旧 Serena 调用多传一个 undefined 的兼容问题;进程验收初次误把 Gateway 自身控制台计入切换时必须退出的 Code Host 树,按真实父子关系修正后通过。失败回执和自审细节见工作日志,没有削弱 Code Host 子树的退出断言。 + +**剩余范围:** 本轮只在生成的 SDK 风格 C# 两项目、单入口/配置/TFM 夹具中验证;入口 ProjectReference 可达图不等于完整仓库、所有反向依赖或 `.sln`。生成器、任意外部 targets 输入、通过外部服务创建的进程、非 Windows 平台和真实用户项目未在本轮证明。此为作者自审,没有独立审核;新 stdio 测试连接也不是当前 Codex 连接。 + +**下一阶段:** 按已讨论方向分别推进 E4 公共错误迁移及 Code Host 正式交付,把 Roslyn/BuildHost、锁文件、协议和 Skill 纳入可核对的安装包,再完成无 Serena/Python 的干净环境与实际客户端验收。默认后端切换及旧运行依赖移除在这些证据齐备后处理;具体 E4 文本兼容策略和真实项目求值授权仍需按实际范围确认。本轮未引入新依赖、修改全局环境/客户端配置或提交推送。 diff --git "a/WinCode-\350\277\255\344\273\243\350\267\257\347\272\277\345\233\276.md" "b/WinCode-\350\277\255\344\273\243\350\267\257\347\272\277\345\233\276.md" index 6f98a80..537b9aa 100644 --- "a/WinCode-\350\277\255\344\273\243\350\267\257\347\272\277\345\233\276.md" +++ "b/WinCode-\350\277\255\344\273\243\350\267\257\347\272\277\345\233\276.md" @@ -1,6 +1,6 @@ # WinCode 迭代路线图 -更新日期:2026-09-08(北京时间)。当前源码基线:**0.12.5 / main 10496e0**。 +更新日期:2026-09-09(北京时间)。当前提交基线:**0.12.5 / main bbc20ff**;E1/E2 修复和 E3 有界/真实上游验收完成。显式 Roslyn 配置已接入现有 MCP,最新端到端 13 场景、核心回归 344/344 通过;默认迁移、E4 和 Code Host 正式交付未完成,尚未提交。 本文件只保留未完成方向与进入条件。已完成的 R1–R6、WP1–WP5 不再作为待办重复执行;版本变更见 [CHANGELOG](CHANGELOG.md),过程与验收边界见 [工作记录](docs/codex_worklog.md)。 @@ -14,19 +14,29 @@ ## 下一轮优先级 +本轮 E1(工作区失败后阻止业务请求,区分重新打开与重启 Gateway)和 E2(trash 部分完成、实际路径及长文件名)已完成本地实现与复核补修。E3 的 97 秒/70 次调用采样、固定 Serena 8 项和 Repomix 10 项真实验收通过;实测另修复 Repomix 文件数误报。核心回归 337/337、生产 stdio 通过;没有改动系统 SDK 或 global.json,没有提交/推送,也不代表真实桌面或当前 Codex 连接已验收。 + | 顺序 | 未完成方向 | 进入与完成标准 | | --- | --- | --- | -| 1 | 工作区切换中途失败的一致性 | 故障注入确定提交边界;失败后每个请求只使用同一个工作区的根、缓存与适配器状态 | -| 2 | trash 移动成功、元数据写入失败的恢复语义 | 可定位已移动文件,结果准确表达部分完成,重试不造成二次误操作 | -| 3 | 有界混合负载与真实上游验证 | 在明确时间和资源预算内测试切换、取消、查询、上游退出;记录进程、资源趋势及跨工作区隔离 | -| 4 | 逐步统一错误与证据输出 | 先统一高频失败的稳定错误码和必要元数据,保留已有客户端兼容性 | +| 1 | 逐步统一错误与证据输出 | E1/E2 所需错误码已局部实现;10 个失败/不完整场景已盘点。社区复核建议同一错误对象生成 JSON 文本及可选 structuredContent,旧文本仅按实际兼容需求过渡;具体公共策略待确认,独立于 Roslyn 验收 | +| 2 | 完成 Roslyn 发布与默认迁移,移除 C# 语义路径对 Serena 的运行依赖 | 显式选择 Roslyn 的 MCP 接入、精确定位/source、编辑失效、A→B→A 和实际 MSBuild 取消/崩溃/超时清理已验收,13 场景通过。后续为 Code Host/BuildHost 正式打包及指纹、无 Serena/Python 干净环境、实际客户端验收,再决定默认切换 | + +具体工作包、验收与待决策略见 [下一轮工程化迭代计划书](WinCode-下一轮工程化迭代计划书.md)。现有分层见 [架构与数据流说明](WinCode-架构与数据流说明.md)。本地实现、针对性验证、完整交付和真实上游验收分别记录,不将其中一项替代其他关口。 + +2026-09-09 的直接 Roslyn 设计已更新后续语义方向:目标是随 WinCode 提供分析组件,无须用户另装 Serena/Python/独立语言服务器;这不消除加载目标项目所需 SDK、引用包等前置条件。E4 另补三种兼容方式的利弊,修订建议暂不增加统一 retryable 布尔值,不混改未知工具的协议层行为;仍待用户选择文本策略。 + +同日社区与第一性原理复核进一步收缩了原型范围,保留文本探索能力,后置公共符号句柄设计及跨客户端共享服务。现有监听忽略 obj 并防抖,不能原样作为语义状态失效保证。本机 MCP SDK 隔离探针 5/5 验证工具错误可绕过成功 outputSchema 校验;这不等于真实客户端兼容已验证。最新处理意见见计划书末尾复核节;没有新增生产依赖或切换提供方。 + +用户随后批准开始,现已新增 tools/WinCode.Code.Host、锁定 NuGet 依赖及 npm run test:roslyn-host。最小语义闭环和函数/协议注释已完成;固定快照、生成器排除、辅助进程采样等限制详见计划书最新实施节。尚未改变现有 Gateway 的 Serena 路径,E4 公共迁移也未落地。 + +继续实施后,Host 内部协议 v2 已补上源码/配置/obj 变化失效、reload/cancel、有界队列和活动请求关闭;仓内 Skill 同步更新。输入指纹只验证声明范围,任意外部 targets 与全磁盘原子一致仍不保证;生成器排除和未观测 BuildHost 的边界保留。最新 42 项证据与性能成本见计划书末尾;旧“没有 watcher/重载”的描述属于上一阶段历史,不再作为当前 Host 状态。 -具体工作包、验收与待决策略见 [下一轮工程化迭代计划书](WinCode-下一轮工程化迭代计划书.md)。现有分层见 [架构与数据流说明](WinCode-架构与数据流说明.md)。以上是待实施建议,不是已修复结论。 +2026-09-09 13:46 接入阶段完成后,`--roslyn-config` 可显式启用 RoslynAdapter,现有 MCP 符号/引用、上下文和影响报告均能使用该路径;默认 Serena 配置仍保留。公共 `symbolLocation` 绑定快照/项目/文件/UTF-16 位置,失效后需重新搜索;source 不代替完整性或置信度。真实 MCP 验收已捕获并确认 BuildHost 及受控 target 子进程退出,更新上述历史采样限制。最终核心回归 344/344、stdio 与现有交付清单通过;仓内 Skill 和中文注释同步。Code Host 尚不在正式交付清单内,真实客户端与干净环境未验收,详见计划书最新实施节及工作日志。 ## 尚需补齐的验收 - **实际客户端重连**:最近一次观测的 Codex 实例仍为 0.11.2;不能用新 stdio 测试替代宿主连接验证。重连后核对 hello 的版本、实例、构建与 schema,再执行代表性工具请求。该历史观察不是对任意当前客户端的实时判断。 -- **真实 Repomix 包兼容性**:已验证真实 Node 启动夹具和参数安全边界,尚未验证固定版本上游 Repomix 的完整打包行为。需要准备获准的隔离环境后再测。 +- **更长时间或其他上游版本**:当前固定版本、有界样本已经通过,不等于全配置兼容或耐久性证明。只有出现实际需求或持续增长证据后再扩大预算;不作为 E3 原有有界验收的缺项。 - **成本对照**:现有固定任务验证不等于 8–12 个真实任务的完整对照。只有决定继续优化检索成本时才补齐;比较正确完成率、调用数、输出量、重复取证和耗时,字符数不冒充 token。 ## 条件性研究,不列入近期必做版本 diff --git a/docs/codex_worklog.md b/docs/codex_worklog.md index 56ddc6a..16c938b 100644 --- a/docs/codex_worklog.md +++ b/docs/codex_worklog.md @@ -527,3 +527,140 @@ - 用户要求上传至 linnnn89/WinCode;沿用既有 PR、必需检查通过后合并流程。本次仅提交当前九份 Markdown 变更,不变更软件版本或运行环境。 - 上传前确认 origin 地址正确、本地 main 与 origin/main 一致、无其他打开的 PR,git diff --check 通过。实际远端检查及合并结果以该 PR 回执为准。 + +## 2026-09-09 09:57 — 拉取最新版与架构建议计划对照(北京时间) + +- 用户要求拉取 linnnn89/WinCode 最新仓库并根据“架构分析优化建议”总结下一步计划。确认精确根目录 D:/CODEX PROJECT/WinCode MCP、origin 地址和干净 main 后,执行 git pull --ff-only,从 580e75a 更新至 bbc20ffe99d34842bc68aac213d5fe6989327118(0.12.5);HEAD 与 origin/main 差异计数 0/0。 +- 读取引用对话的两轮完整问答,对照 Registry、CI、代码证据、项目图/影响分析、UI 源码候选、缓存 watcher 和既有 E1–E4 计划。发现多项建议已有基础实现,更新既有计划书的核对基线并增补差距表、执行顺序、验收与待决范围。 +- 建议先做 E1–E3 可靠性验证,再通过 E4 渐进整理契约,之后验证有限语义关系与更深 UI 候选链;增量索引按性能证据决定。明确候选映射不等于运行时绑定、引用不等于调用图,未把历史测试数字作为本轮结果。 +- 本轮仅同步仓库和整理计划;没有修改生产代码、安装依赖、运行构建/回归、控制目标应用、重连客户端或推送远端。计划实施和重要路线仍待后续授权。 + +## 2026-09-09 — 启动 E1/E2 故障注入,依赖同步待确认(北京时间) + +- 用户要求开始进行。新增 scripts/verify-failure-recovery.ts,设计 11 个工作区切换阶段/取消用例及 2 个 trash 失败用例;禁用外部适配器,使用 test-tmp 下唯一生成目录,记录根、会话、watcher、请求准入与文件实际位置,不将缺陷行为固化为通过的回归断言。 +- 实际执行 node node_modules/tsx/dist/cli.mjs scripts/verify-failure-recovery.ts,在模块导入阶段因 ERR_MODULE_NOT_FOUND: @modelcontextprotocol/client 退出;13 个用例均未执行,未复现或修复任何故障。Node 实测 v24.19.0;已有 node_modules 不满足拉取后的 0.12.5 依赖。 +- 锁文件为 v3,含 46 个包条目(包括平台可选包),核心 client/server 均为 2.0.0;安装脚本标记出现在 esbuild/fsevents。建议在项目根执行 npm ci --no-audit --no-fund,同步锁定依赖;需要网络及本地 node_modules 重建,实际下载量未核实,不涉及全局安装。 +- USER_DECISION_REQUIRED:用户协作契约第四节要求安装依赖事先确认,本次开始实施不明确包含依赖同步;先请求上述操作授权。生产代码未变更,故障脚本运行验证未完成;git diff --check 通过。 + +## 2026-09-09 10:22 — E1/E2 修复及 E3 小样本(北京时间) + +- 用户授权项目内重建依赖;npm ci --no-audit --no-fund 成功安装 20 个当前平台适用包。npm 提示 esbuild postinstall 未获 allowScripts 授权,本轮未修改其脚本授权;现有平台包足以执行 tsx 和编译。package-lock.json 未变更。 +- 修复前故障报告 test-tmp/failure-recovery/run-MaSxLX/report.json:13 个用例、15 条症状记录(不是 15 个独立缺陷)。用户随后明确选择:变更前失败保留旧根,变更后失败阻止业务请求、重新打开恢复;trash 部分完成保留实际位置,不自动移回,并保留旧响应字段。 +- ToolRouter 记录恢复状态,覆盖根准备后的取消和各绑定阶段失败;MCP 返回 WORKSPACE_RECOVERY_REQUIRED,hello 被动可读,同根重新打开也完整初始化,恢复失败保持阻止。Workspace 返回 completed/not_moved/partial 与失败阶段,目录准备/移动失败不声称已有目标;元数据失败保留实际路径。同名文件加入 UUID 避免同时间戳目的路径碰撞,不实施自动回滚。 +- 新增 tests/failure-recovery.test.ts 并纳入 npm test 清单;更新仓内代码/诊断手册、既有计划与路线图,未同步全局 Skill 或重连客户端。13 个原故障用例修复后报告零问题(test-tmp/failure-recovery/run-zRs9nX/report.json)。回归测试首次 8 项失败来自错误预期 resetConnection 只调用一次;核对 Serena.initialize 会 dispose/reset 后,修正为现有两次调用事实,未改变生产生命周期。随后 37 项针对性测试通过;增补同名碰撞测试后的故障恢复与 Skill 测试 16/16 通过,最终 typecheck 通过。 +- scripts/verify-mixed-load.ts 小样本:test-tmp/mixed-load/run-cE5x6P/report.json;80 次记录的 Core 操作、10 轮、10 个真实 Node 模拟上游进程,963 ms,结束时无自有子进程残留。未发现被断言检查的跨根证据/占用泄漏;RSS 134→137 MiB、heapUsed 34→42 MiB,未观察稳定平台,不宣称无内存泄漏。真实 Serena/Repomix、Windows 句柄和子进程 RSS 未覆盖。 +- npm run check 实际通过 typecheck、build-gateway,restore-host 因 SDK 10.0.303 缺失退出;报告 test-tmp/check/2026-09-09T02-18-41-301Z-core/report.json。未修改 global.json,本机已装 10.0.302。生产 stdio 实测通过(15 工具、契约及构建身份一致),不代表 Codex 当前连接或 UI Host 已更新。 +- 全量 npm test 实际为 327 项、324 通过、3 失败(日志 test-tmp/recovery-regression-20260909-101859.log;此时尚未添加最后的同名碰撞用例)。两项 C# 夹具明确受 SDK 缺失阻塞;FlaUI 缓存健康测试依赖 Release Host 的路径解析,当前产物缺失,断言 available=true 失败。保留失败,不跳过或弱化测试。E1/E2 生产代码已实现,但完整交付关口尚未通过。 +- USER_DECISION_REQUIRED:为完成交付检查,已询问是否可在项目内隔离安装锁定 SDK 10.0.303 并恢复 NuGet 依赖;等待答复。没有全局环境修改、版本发布、Git 提交/推送或远端变更。E4 其余契约统一及语义/UI 深化仍未实施。 + +## 2026-09-09 10:34 — 项目隔离 SDK 与核心交付关口完成(北京时间) + +- 用户明确授权项目内隔离安装 SDK 10.0.303 并恢复锁定 NuGet 依赖。先按 [微软安装脚本文档](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-install-script) 获取官方脚本,指定版本、x64、项目 .deps/dotnet-10.0.303 和 NoPath;脚本长时间未进入可观察的下载阶段,停止该自有安装进程,未反复重试。 +- 官方 releases.json 确认 ZIP 地址 https://builds.dotnet.microsoft.com/dotnet/Sdk/10.0.303/dotnet-sdk-10.0.303-win-x64.zip。使用 curl 有界下载 297570534 字节,SHA-512 与官方元数据一致:ad4ef6202e55babde1c65e1be7b468c3ecb738ccc2fb8bd3c2bb408a4f45d247c8c5a5f57fedc54ebee7cb5fc4f487772997f2d053010d2e1903b974cc64216d;解压至同一项目目录,dotnet --version 实测 10.0.303。 +- 验证子进程环境:DOTNET_ROOT/DOTNET_ROOT_X64 指向项目 .deps/dotnet-10.0.303,PATH 仅在当前 PowerShell 子进程前置该目录;DOTNET_CLI_HOME、NUGET_PACKAGES、NUGET_HTTP_CACHE_PATH 分别指向项目 .deps/dotnet-cli-home、.deps/nuget-packages、.deps/nuget-http-cache。设置 CLI 遥测退出、关闭 ASP.NET 证书生成与全局工具 PATH 添加;未修改系统 SDK、持久 PATH、global.json 或包锁文件。 +- 执行 node scripts/check.mjs(npm run check 的实际入口),12 个阶段全部通过:typecheck、Gateway 编译、3 项 locked restore、Host publish、2 项夹具 build、核心回归、生产 stdio、交付清单生成及校验。报告 test-tmp/check/2026-09-09T02-32-50-227Z-core/report.json;328 tests / 328 pass / 0 fail / 0 skipped,耗时约 60 秒。此前 3 项环境相关失败均消失。 +- 交付清单 matched=true,contentId=d70c6fe34050340486b82b79a2c8a232e4d0f8e54818cf5e18e1b25ebe77b35e。更新既有计划与路线图,E1/E2 标记本地核心交付验收完成;E3 真实上游/更长采样、E4 其余契约及语义/UI 方向仍待后续工作。 +- 本轮未修改生产代码或测试,未运行不相关的真实桌面闭环、全局 Skill 部署、客户端重连、远端 CI 或 Git 提交/推送。普通新终端仍默认使用系统 SDK;重跑检查须在其子进程中使用上述项目 SDK 环境。git diff --check 通过;核心验收不等同真实桌面、上游兼容性或长时间稳定性证明。 + +## 2026-09-09 10:54 — 复核问题获准修复与完整复验(北京时间) + +- 用户先要求只读复核,再明确“同意修复”。隔离复核报告 test-tmp/review-d92e747f05e04a0e8ca7d17bd66db159/observations.json 证明:内部 client.close 一次失败后,3 次 workspace_open 均失败且 close 只执行一次;190 字符文件名移动后元数据失败、200 字符无法移动,旧命名均成功;底层 fs.watch 创建失败仍曾允许切换提交。复核阶段未修改生产代码。 +- 获准后新增 GatewayRestartRequiredError 表达被保留、无法在原实例恢复的清理失败。Serena 内部关闭失败及 watcher 关闭失败返回 restart_gateway;ToolRouter 对永久状态直接拒绝重复 workspace_open,保留原会话与失败信息。提示用户先检查 Gateway 自有资源清理后按客户端正常流程重启;未自动重启、清除失败记录或终止目标应用。一般绑定失败仍允许重新打开恢复。 +- bindWatch 及切换提交前检查实际 watcher 绑定;原生创建失败和初始化期间异步 watcher 错误均进入恢复状态。原 watcher 关闭失败与创建失败分别测试,不以替换整个 bindWatch/resetConnection 代替底层异常验证。 +- trash 保留 UUID 防碰撞,展示用 basename 按完整 Unicode 码点及 UTF-8 字节预算截短,为 .meta.json 预留空间;去掉截短后尾部点/空格,完整原路径继续保存在响应与元数据。测试覆盖 183、184、190、193、194、200、220、255 字符 ASCII、中文及 emoji,逐项验证正文、元数据和源文件位置。 +- 改写 verify-mixed-load.ts:在真实自有 Node 模拟上游 RPC 已开始后发起切换,用受控调度确认旧根与请求占用,再触发运行中取消或观察上游退出。5 轮取消、5 轮退出,共 70 次 Core 操作、10 个自有进程、1934 ms,全部交错断言通过且无自有进程残留。报告 test-tmp/mixed-load/run-ftSuQB/report.json;只证明受控交错,不宣称耐久性、真实 Serena/Repomix 兼容或无内存泄漏。 +- 针对性恢复/watcher 测试 23/23,typecheck 通过。第一次完整检查 test-tmp/check/2026-09-09T02-52-33-897Z-core/report.json 为 333 项中 332 通过、1 失败:stage1-cleanup 的关闭时序夹具把 bindWatch 置空,不符合新增实际绑定校验。移除空 mock、使用隔离目录真实 watcher,保持原时序/拒绝/清理断言;该文件 11/11 通过,未弱化生产检查。 +- 最终完整检查 test-tmp/check/2026-09-09T02-54-08-398Z-core/report.json:333/333、0 失败/0 跳过,生产 stdio、锁定构建和交付清单均通过。contentId=0b2dd10f93c67daee51a764429d7c9d30028c113b0f31d22a162f87471c91bbb,matched=true。沿用项目隔离 SDK/缓存环境,未增加依赖或修改版本锁。 +- 更新仓内代码/诊断手册及现有计划、路线图,未部署全局 Skill、重连客户端、提交/推送或运行真实桌面检查。E1/E2 复核补修完成;E3 真实上游与长期趋势、E4 其余统一契约及语义/UI 深化仍属后续工作。git diff --check 通过。 + +## 2026-09-09 11:20 — E3 环境与真实验收、E4 盘点(北京时间) + +- 用户要求继续完成待办,随后明确“补齐环境”。保留现有架构与 E3 100 次/5 分钟预算;没有启动新架构、独立 Agent 或长期压力服务。E4 涉及公共字段,先完成 10 个响应样例和兼容方案,再通过问题请求确认,当前仍待答复。 +- verify-mixed-load 增加可选间隔、指定自有 PID 的 Windows Get-Process 句柄/工作集/私有字节采样及逐轮进度。实际 70 次 Core 操作、10 轮、97235 ms,报告 test-tmp/mixed-load/run-NUo0VL/report.json;采样全部可用、无缺失 PID,每轮结束句柄 234,dispose 后 233,无自有上游残留。工作集前两轮从约 119.8 降至 107.2 MiB,第 2–9 轮约 103.4→105.2 MiB;小幅增长不能判为泄漏,也不能证明长期平台。自有子进程工作集约 53.9–56.4 MiB,采样进程同步结束;不是持续高负载或峰值 RSS。 +- 隔离安装:uv 复用已有工具,下载 Python 3.13.15 到 .deps/python;Serena v1.7.0 tag 与 commit 949a27ef1e5fda1a6e7b561e777bcece345c6ffd 一致,uv sync --frozen --no-dev --no-editable 安装 75 包到 .deps/serena-venv,缓存 .deps/uv-cache。Repomix 1.18.0 使用项目子目录 npm install --save-exact --ignore-scripts --no-audit --no-fund,171 包;真实压缩验证证明本次不需要执行安装脚本。两个依赖锁及版本/目录大小回执在 .deps/environment-receipt-20260909.json;主 package-lock、global.json 未改。 +- 首次 Serena 直接 exe 连接后查询 15 秒超时,报告 test-tmp/serena-acceptance/1788923155570-17692/report.json。查明 SDK 子进程默认白名单不传 SERENA_HOME/DOTNET_CLI_HOME 等;上游在用户目录新建 .serena。新增 scripts/serena-isolated-launcher.py,在导入前明确设置项目路径;只改变测试启动环境,不扩大全局环境继承或生产公共接口。根据上游日志“configuration file not found, autogenerating”、全部文件的创建时间与精确两文件清单核对,将本次 .serena 归档 .deps/serena-first-attempt;确认用户目录路径不再存在。未删除或覆盖用户已有配置。 +- 通过该启动器执行 project index 预热专用生成 C# 项目。Serena 使用其固定 Roslyn 5.5.0-2.26078.4 和 SHA-256 校验下载;预热成功。后续测试保留生产默认超时,没有为通过验收加大超时。新组件加缓存逻辑文件大小 751061663 字节,约 716 MiB(包括可能重复计数的硬链接,不是物理磁盘占用;不含此前 SDK/NuGet)。 +- 真实 Serena 原 7 项通过后增补 Router A→B→A。初版用 Marker 查询 Marker0 时命中文件节点,改为不同文件中的同名精确类;下一版把回到 A 的合法缓存命中错误地要求产生第 3 个进程,改为同时检查重复查询及每轮新的 Probe 查询,保留跨根文件名断言并确实触发 3 次连接。最终 8/8 通过,报告 test-tmp/serena-acceptance/1788923903497-28124/report.json;自有 PID 均退出。 +- 新增 verify-repomix-real。首个生成根受到父仓 test-tmp 的 Git ignore 影响导致空包,增加独立 git init 后确认两个文件确实打包;发现生产使用 /File: |`,有界读取用户显式指定的配置;需 enabled/项目求值许可、根内入口 csproj、明确 Configuration/TFM、已有 dotnet/Host 路径。只在配置启用时选 Roslyn,hello 不求值;不自动采用仓内配置,也不通过普通工具参数选择可执行程序。Host 子进程单独设置匹配的 DOTNET_ROOT/DOTNET_HOST_PATH,不改全局环境;Roslyn 路径不初始化/查询外部 Serena,显式文件范围的文本能力仍复用本地解析。 +- 保持 15 个 MCP 工具名。符号搜索返回 source=roslyn、location;引用工具新增可选 symbolLocation={snapshotId,project,file,position},原 symbolName 保留并核对匹配。重载/同名返回候选,旧 Serena 序号身份明确拒绝。Host v2 新增 symbols,按实际语义符号去重 partial;指定文件范围时选择该文件中的声明,避免跳到范围外的另一半声明。两类查询提供 semanticContext 的范围、检查点与排除数;准确调用 span 不等于全仓完整性,影响报告保留 UNCERTAIN/UNKNOWN。 +- 生命周期:编辑后旧身份失败,下一次显式搜索才重载,失败业务请求不自动重放;工作区同根重开/A→B→A 关闭旧 Host。传输有界帧、ID/信封校验、取消监听清理与 1 秒协作宽限,超时/初始加载未响应可硬回收。真实关闭/协议错误在进程退出后仍保留清理失败,进入 E1 restart_gateway 阻止后续业务;纯关闭超时在确认硬回收成功后可完成释放。初次项目加载失败按预期失败启动清理,不能把退出码 1 错判成永久恢复失败;损坏项目修复后可显式搜索恢复。 +- Windows Host 在初始化 MSBuild 前加入自有匿名、不继承句柄的 Job,KILL_ON_JOB_CLOSE 在 Host 退出/崩溃时回收继承的后代;句柄保持到进程结束,避免在关闭确认前误杀自身。依据微软 [Job Objects](https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects) 与 [JOBOBJECT_EXTENDED_LIMIT_INFORMATION](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-jobobject_extended_limit_information),未增加 P/Invoke 包或第三方服务。此为自有资源机制,不能约束自定义 targets 通过外部服务创建的进程,也不是项目执行沙盒。 +- 失败与修正:首批 62 项定向回归有 1 项失败,ImpactAnalyzer 把额外 undefined 传给旧 Serena 三参数调用;改为仅有 Roslyn 定位时使用第四参数,原断言保持,相关 40/40 后通过。MCP 首两轮 [run-3dIsQZ](../test-tmp/roslyn-gateway/run-3dIsQZ/report.json)、[run-yb8YjT](../test-tmp/roslyn-gateway/run-yb8YjT/report.json) 误把 Gateway 自己的 conhost 计入切换时必须退出的 Host 树;核对父进程后,改为切换检查 Code Host 子树、最终退出检查整个 Gateway 树,仍逐一核对 PID/创建时间。之后 12 场景中间回执通过,最终增加加载失败修复场景至 13;不把中间数量相加。 +- 最终 `npm run test:roslyn-gateway` [回执](../test-tmp/roslyn-gateway/run-nP7SpF/report.json) 13 场景全部通过。使用生产 dist/index.js 与新的 SDK stdio 客户端,生成 A/B 两套项目夹具,覆盖精确重载、partial 文件范围、TS 显式上下文、错误位置/旧身份、编辑、A→B→A、初始加载损坏与修复、真实 MSBuild 中取消/崩溃/超时和关闭。后三项各观测 7 个实际自有进程(Host、BuildHost、受控 target 的 cmd/node 及控制台),均确认退出,恢复搜索成功;无 Serena/Python 启动。受控 target 只运行夹具自有等待脚本,不执行目标应用。 +- 最终 `npm run check` [回执](../test-tmp/check/2026-09-09T05-41-07-067Z-core/report.json) 12 阶段成功,344/344、0 失败/取消/跳过;锁定恢复、UIA Host 构建、生产 stdio、15 工具 schema 与现有交付清单验证通过。buildId=1c62d2c8e4b10ff345eba1721745a81998368ef3413eef399b000b9fbdac1aa6,delivery contentId=7bafc6bc5d8eb872618bc4cc2b25352491a5a43c1c0d6fe22d0304429e8084ee。现有清单仍是 Gateway/UIA Host/受管 Skill,不把其成功冒充 Code Host 正式交付。构建使用项目内 SDK 10.0.303 与缓存,仅当前命令环境生效。 +- 本轮早期独立 Host [42 场景回执](../test-tmp/roslyn-host/fixture-whR9f2/report.json) 通过;其后的 scoped partial 与接入修订由上述最终 MCP 场景和核心回归验证,未声称旧 42 项在最终状态重跑。代码/接口新增中文说明,按 skill-creator 同步 SKILL.md、references/code.md、references/diagnostics.md;Skill/工具/Roslyn 定向测试 18/18 与 Python UTF-8 quick_validate 通过。未找到既有受查 Skill 安装目标,没有新建全局安装或声称当前 Codex 已加载新版。 +- 反证自审:引用位置准确但加载图缺少反向依赖/生成器时仍不能判安全删除;因此 semanticContext 与 queryComplete=false 保留,ImpactAnalyzer 只提供已证实的有界证据。Host 崩溃时只确认父进程退出不足以证明释放,因此测试在真实 MSBuild target 阻塞期间采样 BuildHost 和后代再验证退出。初始加载失败也不能误触发永久清理失败,最终 MCP 增加损坏/修复实证。以上是作者自审,未进行独立模型/人工审核。 +- 更新既有计划、路线图和本日志。下一阶段为 E4 公共错误迁移、Code Host/Roslyn/BuildHost 正式打包与指纹、无 Serena/Python 干净环境及实际客户端验收,再推进默认后端迁移。当前 SDK 风格单入口/配置/TFM、ProjectReference 可达图和生成夹具证据,不涵盖任意 `.sln`、外部自定义输入/服务创建进程、非 Windows 或真实用户项目。E4 文本兼容及真实项目求值政策仍需在对应范围确认;本轮已有接入授权无需重复申请。 +- 收尾核对生产 ToolDefinition 中的实际工具名,并修正计划新增段的简称;3 份计划/日志的代码围栏、18 个本地链接检查通过,`git diff --check` 通过。仅补记非受管文档后再次 `npm run delivery:verify`,上述 contentId 仍 matched=true;没有因文档记录重复执行全套构建或扩展测试范围。 + +## 2026-09-09 — 当前工作版本提交与推送准备(北京时间) + +- 用户明确要求将当前版本推送 GitHub;纳入当前 README、稳定性/恢复机制、Roslyn Host/Gateway 接入、验证脚本与既有文档改动,不升级版本号。目标 origin/main。 +- 推送前 npm run typecheck 通过,git diff --check 通过;43 个待提交文件的常见凭据标记及大文件筛查未发现命中,构建产物和测试临时目录由现有忽略规则排除。 +- npm test 执行失败:日志报告当前可发现的 .NET SDK 为 10.0.302,缺少 global.json 锁定的 10.0.303,ui-query-check 无法启动。未安装 SDK、放宽版本锁或将历史通过结果作为本轮验证。日志位于本地 test-tmp/pre-push-tests.log。本次推送保存当前工作版本,不表示完整回归或发布验收通过。 diff --git a/package.json b/package.json index 8c5e84e..2bea98c 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,16 @@ "build": "node scripts/build.mjs", "start": "node dist/index.js", "dev": "tsx src/index.ts --development", - "test": "tsx --test tests/tdd-suite.test.ts tests/v05-stability.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/serena-identity.test.ts tests/ui-code-candidates.test.ts tests/skill-sync.test.ts tests/serena-fallback.test.ts tests/repomix-disabled.test.ts tests/workspace-watch-close.test.ts tests/workspace-lifecycle.test.ts tests/tool-contracts.test.ts tests/architecture-boundaries.test.ts tests/lifecycle-cancellation.test.ts tests/delivery-contract.test.ts", + "test": "tsx --test tests/tdd-suite.test.ts tests/v05-stability.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/serena-identity.test.ts tests/ui-code-candidates.test.ts tests/skill-sync.test.ts tests/serena-fallback.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", "test:verify": "tsx tests/verify.ts", "benchmark:agent": "tsx scripts/benchmark-agent-efficiency.ts", "test:benchmark": "tsx --test tests/agent-efficiency-benchmark.test.ts", "test:product": "tsx scripts/verify-product-tasks.ts", "test:serena-real": "tsx scripts/verify-serena-real.ts", + "test:repomix-real": "tsx scripts/verify-repomix-real.ts", + "test:mixed-load": "tsx scripts/verify-mixed-load.ts", + "test:error-contracts": "tsx scripts/verify-error-contracts.ts", + "test:roslyn-host": "node scripts/verify-roslyn-host.mjs", "test:tavern-context": "tsx scripts/verify-tavern-context.ts", "test:e2e": "tsx scripts/test-mcp-client.ts", "typecheck": "tsc -p tsconfig.test.json", @@ -26,7 +30,8 @@ "test:ui-query": "tsx scripts/verify-ui-query.ts", "test:ui-code": "tsx --test tests/ui-code-runtime.test.ts", "skill:check": "node scripts/sync-skill.mjs", - "skill:sync": "node scripts/sync-skill.mjs --apply" + "skill:sync": "node scripts/sync-skill.mjs --apply", + "test:roslyn-gateway": "node scripts/verify-roslyn-gateway.mjs" }, "keywords": [ "mcp", diff --git a/scripts/serena-isolated-launcher.py b/scripts/serena-isolated-launcher.py new file mode 100644 index 0000000..6d85f5f --- /dev/null +++ b/scripts/serena-isolated-launcher.py @@ -0,0 +1,26 @@ +"""Explicit environment for the opt-in, project-local Serena installation.""" +import os +from pathlib import Path + +repo = Path(__file__).resolve().parent.parent +deps = repo / ".deps" +sdk = deps / "dotnet-10.0.303" +os.environ.update({ + "SERENA_HOME": str(deps / "serena-home"), + "DOTNET_ROOT": str(sdk), + "DOTNET_ROOT_X64": str(sdk), + "DOTNET_CLI_HOME": str(deps / "dotnet-cli-home"), + "NUGET_PACKAGES": str(deps / "nuget-packages"), + "NUGET_HTTP_CACHE_PATH": str(deps / "nuget-http-cache"), + "DOTNET_CLI_TELEMETRY_OPTOUT": "1", + "DOTNET_NOLOGO": "1", + "DOTNET_GENERATE_ASPNET_CERTIFICATE": "false", + "DOTNET_ADD_GLOBAL_TOOLS_TO_PATH": "false", + "PATH": str(sdk) + os.pathsep + os.environ.get("PATH", ""), +}) + +# Import only after setting paths; upstream modules resolve directories on import. +from serena.cli import top_level + +if __name__ == "__main__": + top_level() diff --git a/scripts/verify-error-contracts.ts b/scripts/verify-error-contracts.ts new file mode 100644 index 0000000..cf4f8fa --- /dev/null +++ b/scripts/verify-error-contracts.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { getDefaultConfig } from '../src/Core/Config.js'; +import { ToolRouter } from '../src/Core/ToolRouter.js'; +import { WinCodeMcpServer } from '../src/Gateway/McpServer.js'; +import { AbortError } from '../src/Core/ResourceManager.js'; + +// Inventory current public responses before proposing an additive contract. +// Generated inputs only; native UI and external adapters are disabled. +const parent = path.resolve('test-tmp/error-contracts'); +await fs.mkdir(parent, { recursive: true }); +const root = await fs.mkdtemp(path.join(parent, 'run-')); +await fs.writeFile(path.join(root, 'A.cs'), 'class Same {}\n'); +await fs.writeFile(path.join(root, 'B.cs'), 'class Same {}\n'); +await fs.writeFile(path.join(root, 'Long.cs'), Array.from({ length: 100 }, (_, i) => `// ${i} ${'x'.repeat(100)}`).join('\n')); +const config = getDefaultConfig(root); +config.adapters.serena.enabled = false; +config.adapters.flaui.enabled = false; +config.adapters.repomix.useCli = false; +const router = new ToolRouter(config); +const server = new WinCodeMcpServer(router); +const client = new Client({ name: 'error-contract-inventory', version: '1' }); +const [left, right] = InMemoryTransport.createLinkedPair(); +const observations: unknown[] = []; +let failure: string | undefined; +async function observe(scenario: string, name: string, args: Record, check: (result: any, body: any) => void) { + const result: any = await client.callTool({ name, arguments: args }); + let body: any = null; + try { body = JSON.parse(result.content[0].text); } catch { /* Plain-text errors are part of this inventory. */ } + observations.push({ scenario, tool: name, result }); + check(result, body); +} +try { + await router.initialize(); + await Promise.all([client.connect(left), (server as any).server.connect(right)]); + await observe('unknown tool', 'missing_tool', {}, result => assert.equal(result.isError, true)); + await observe('invalid code arguments', 'wincode_find_code_symbol', { query: 5 }, result => assert.equal(result.isError, true)); + await observe('outside workspace scope', 'wincode_prepare_context', { task: 'read', scopeFiles: ['../outside.cs'] }, result => assert.equal(result.isError, true)); + await observe('invalid UI arguments before native access', 'wincode_ui_inspect', {}, (result, body) => { + assert.equal(result.isError, true); assert.equal(body.errorCode, 'INVALID_ARGUMENT'); + }); + await observe('ambiguous context target', 'wincode_prepare_context', { task: 'read', scopeFiles: ['A.cs', 'B.cs'], symbol: 'Same' }, (_result, body) => { + assert.equal(body.evidence.length, 0); assert.ok(body.fileIssues.some((item: any) => item.reason.includes('ambiguous'))); + }); + await observe('response budget truncation', 'wincode_prepare_context', { task: 'read', lineRanges: [{ file: 'Long.cs', startLine: 1, endLine: 100 }], maxTokens: 512 }, (result, body) => { + assert.equal(body.truncated, true); assert.ok(result.content[0].text.length <= 2048); + }); + await observe('unavailable semantic upstream with empty local result', 'wincode_find_code_symbol', { query: 'Absent' }, (_result, body) => { + assert.equal(body.source, 'serena-adapter-fallback'); assert.equal(body.analysisCompleteness, 'degraded'); + assert.equal(body.totalFound, 0); + }); + const original = router.findCodeSymbols; + try { + router.findCodeSymbols = async () => { throw new AbortError('inventory'); }; + await observe('execution cancellation (injected operation error)', 'wincode_find_code_symbol', { query: 'Same' }, (result, body) => { + assert.equal(result.isError, true); assert.equal(body.errorCode, 'CANCELLED'); + }); + router.findCodeSymbols = async () => { throw new Error('inventory execution failure'); }; + await observe('unclassified execution exception', 'wincode_find_code_symbol', { query: 'Same' }, result => assert.equal(result.isError, true)); + } finally { router.findCodeSymbols = original; } + await router.dispose(); + await observe('shutdown rejection before admission', 'wincode_find_code_symbol', { query: 'Same' }, (result, body) => { + assert.equal(result.isError, true); assert.equal(body.reason, 'cancelled'); assert.equal(body.recoverable, false); + }); +} catch (error) { failure = String(error); process.exitCode = 1; } +finally { + await client.close(); + await server.stop(); + const reportFile = path.join(root, 'report.json'); + await fs.writeFile(reportFile, JSON.stringify({ success: !failure, failure, observations }, null, 2)); + console.log(JSON.stringify({ reportFile, success: !failure, scenarios: observations.length, failure })); +} diff --git a/scripts/verify-failure-recovery.ts b/scripts/verify-failure-recovery.ts new file mode 100644 index 0000000..a22e3b9 --- /dev/null +++ b/scripts/verify-failure-recovery.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { ToolRouter } from '../src/Core/ToolRouter.js'; +import { getDefaultConfig } from '../src/Core/Config.js'; + +// Bounded diagnostic: disabled external adapters, generated files only. +// This records failure semantics without enshrining faulty behavior as a passing regression. +const reportRoot = path.resolve('test-tmp/failure-recovery'); +await fs.mkdir(reportRoot, { recursive: true }); +const runRoot = await fs.mkdtemp(path.join(reportRoot, 'run-')); +const observations: Record[] = []; +const failures: string[] = []; + +async function fixture(name: string, work: (router: ToolRouter, a: string, b: string) => Promise) { + const directory = path.join(runRoot, name); + const a = path.join(directory, 'a'); + const b = path.join(directory, 'b'); + await fs.mkdir(a, { recursive: true }); + await fs.mkdir(b, { recursive: true }); + await fs.writeFile(path.join(a, 'OnlyA.cs'), 'class OnlyA {}'); + await fs.writeFile(path.join(b, 'OnlyB.cs'), 'class OnlyB {}'); + const config = getDefaultConfig(a); + config.adapters.serena.enabled = false; + config.adapters.flaui.enabled = false; + config.adapters.repomix.useCli = false; + const router = new ToolRouter(config); + try { + await router.initialize(); + await work(router, a, b); + } finally { + await router.dispose(); + assert.equal(router.inFlightRequests, 0); + assert.equal((await router.getRuntimeHealth()).workspaceWatch.active, false); + } +} + +const stages = ['root-before', 'root-after', 'namespace', 'session', 'watch', + 'repomix-dispose', 'serena-reset', 'repomix-initialize', 'serena-initialize', 'composites', 'cancel-after-root']; + +for (const stage of stages) { + await fixture(stage, async (router, a, b) => { + const controller = new AbortController(); + const targets: Record = { + 'root-before': [router.workspace, 'openWorkspace'], + 'root-after': [router.workspace, 'openWorkspace'], + 'cancel-after-root': [router.workspace, 'openWorkspace'], + namespace: [router.cache, 'setNamespace'], session: [router.session, 'open'], + watch: [router as any, 'bindWatch'], + 'repomix-dispose': [router.repomix, 'dispose'], + 'serena-reset': [router.serena, 'resetConnection'], + 'repomix-initialize': [router.repomix, 'initialize'], + 'serena-initialize': [router.serena, 'initialize'], + composites: [router as any, 'bindCompositeTools'], + }; + const [target, method] = targets[stage]; + const original = target[method]; + target[method] = stage.endsWith('after-root') || stage === 'root-after' + ? async function (this: any, ...args: unknown[]) { + const result = await original.apply(this, args); + if (stage === 'cancel-after-root') { controller.abort(); return result; } + throw new Error(`injected:${stage}`); + } + : function () { throw new Error(`injected:${stage}`); }; + let switchOutcome = 'resolved'; + try { await router.openWorkspace(b, {}, controller.signal); } + catch (error) { switchOutcome = String(error); } + finally { target[method] = original; } + + const health = await router.getRuntimeHealth(); + let accepted = false; + try { await router.acquireRequestSlot(); accepted = true; } + catch { /* A fail-closed recovery state would reject the request. */ } + finally { if (accepted) router.endRequest(); } + const rootsAgree = router.config.workspaceRoot === health.session?.workspaceRoot + && router.config.workspaceRoot === health.workspaceWatch.root; + observations.push({ stage, switchOutcome, requestAccepted: accepted, + root: router.config.workspaceRoot, sessionRoot: health.session?.workspaceRoot, + watchRoot: health.workspaceWatch.root, cacheNamespace: router.cache.currentNamespace, + rootsAgree, switching: router.isSwitchingWorkspace }); + if (switchOutcome !== 'resolved' && router.config.workspaceRoot !== a && accepted) + failures.push(`${stage}: switch failed after root changed but next request was admitted`); + if (accepted && !rootsAgree) failures.push(`${stage}: admitted request with inconsistent roots`); + if (controller.signal.aborted && switchOutcome === 'resolved') + failures.push(`${stage}: cancellation after root change was not observed`); + // A subsequent normal switch must at least release admission and clean up. + await router.openWorkspace(a); + assert.equal(router.config.workspaceRoot, a); + assert.equal(router.isSwitchingWorkspace, false); + }); +} + +for (const stage of ['rename', 'metadata']) { + await fixture(`trash-${stage}`, async (router, a) => { + const source = path.join(a, 'OnlyA.cs'); + const original = stage === 'rename' ? fs.rename : fs.writeFile; + const method = stage === 'rename' ? 'rename' : 'writeFile'; + (fs as any)[method] = async (...args: any[]) => { + if (stage === 'rename' || String(args[0]).endsWith('.meta.json')) throw new Error(`injected:${stage}`); + return (original as any)(...args); + }; + let result; + try { result = await router.moveToTrash('OnlyA.cs'); } + finally { (fs as any)[method] = original; } + const exists = async (file: string) => fs.stat(file).then(() => true, () => false); + const sourceExists = await exists(source); + const destinationExists = await exists(result.trashPath); + assert.equal(sourceExists || destinationExists, true, 'generated content must remain locatable'); + const content = await fs.readFile(sourceExists ? source : result.trashPath, 'utf8'); + assert.equal(content, 'class OnlyA {}'); + const retry = await router.moveToTrash('OnlyA.cs'); + observations.push({ stage: `trash-${stage}`, result, sourceExists, destinationExists, retry }); + if (!sourceExists && destinationExists && result.outcome !== 'partial') + failures.push('trash-metadata: failed response does not explicitly distinguish completed move from failed metadata'); + }); +} + +const report = { node: process.version, generatedAt: new Date().toISOString(), + externalAdapters: 'disabled; local fallback only', observations, failures, + scope: 'Injected local failure semantics; does not validate real upstream binding or endurance.' }; +const reportFile = path.join(runRoot, 'report.json'); +await fs.writeFile(reportFile, JSON.stringify(report, null, 2) + '\n'); +console.log(JSON.stringify({ reportFile, cases: observations.length, findings: failures.length, failures }, null, 2)); +if (failures.length) process.exitCode = 1; diff --git a/scripts/verify-mixed-load.ts b/scripts/verify-mixed-load.ts new file mode 100644 index 0000000..9c756bb --- /dev/null +++ b/scripts/verify-mixed-load.ts @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import cp from 'node:child_process'; +import { syncBuiltinESMExports } from 'node:module'; +import { ToolRouter } from '../src/Core/ToolRouter.js'; +import { getDefaultConfig } from '../src/Core/Config.js'; +import { withTimeout } from '../src/Core/ResourceManager.js'; + +const intervalArg = process.argv.slice(2); +assert.ok(intervalArg.length <= 1 && (!intervalArg.length || /^--sample-interval-ms=\d+$/.test(intervalArg[0])), + 'Usage: verify-mixed-load.ts [--sample-interval-ms=0..15000]'); +const sampleIntervalMs = intervalArg.length ? Number(intervalArg[0].split('=')[1]) : 0; +assert.ok(sampleIntervalMs <= 15000, 'sample interval must preserve the five-minute budget'); + +// Only inspect this Gateway fixture and its directly owned upstream PIDs. The +// sampler is synchronous so every PowerShell process has exited before returning. +function processMetrics(pids: number[]) { + if (process.platform !== 'win32') return { available: false, reason: 'Windows Get-Process unavailable' }; + assert.ok(pids.every(pid => Number.isSafeInteger(pid) && pid > 0)); + try { + const output = cp.execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', + `@(Get-Process -Id ${pids.join(',')} -ErrorAction SilentlyContinue | Select-Object Id,HandleCount,WorkingSet64,PrivateMemorySize64) | ConvertTo-Json -Compress`], + { windowsHide: true, timeout: 5000, maxBuffer: 65536, encoding: 'utf8' }); + const parsed = output.trim() ? JSON.parse(output) : []; + const processes = Array.isArray(parsed) ? parsed : [parsed]; + return { available: true, processes, missingPids: pids.filter(pid => !processes.some(item => item.Id === pid)) }; + } catch (error) { return { available: false, reason: String(error) }; } +} + +const parent = path.resolve('test-tmp/mixed-load'); +await fs.mkdir(parent, { recursive: true }); +const root = await fs.mkdtemp(path.join(parent, 'run-')); +const roots = [path.join(root, 'a'), path.join(root, 'b')]; +for (const [index, directory] of roots.entries()) { + await fs.mkdir(directory); + await fs.writeFile(path.join(directory, `Only${index}.cs`), [`class Only${index} {}`, + ...Array.from({ length: 10 }, (_, round) => `class Probe${round}Only${index} {}`)].join('\n')); +} +const config = getDefaultConfig(roots[0]); +config.adapters.serena.enabled = false; +config.adapters.flaui.enabled = false; +config.adapters.repomix.useCli = false; +config.timeouts.serenaConnectMs = 3000; +config.timeouts.serenaCallMs = 3000; +const router = new ToolRouter(config); +const children: cp.ChildProcess[] = []; +const originalSpawn = cp.spawn; +cp.spawn = ((...args: any[]) => { + const child = (originalSpawn as any)(...args) as cp.ChildProcess; + children.push(child); return child; +}) as typeof cp.spawn; +syncBuiltinESMExports(); +const started = Date.now(), deadline = started + 300000; +const samples: Record[] = [], calls: Record[] = []; +const interleavings: Record[] = []; +let error: string | undefined; +async function call(name: string, work: () => Promise): Promise { + assert.ok(Date.now() < deadline && calls.length < 100, 'bounded run budget'); + const begin = Date.now(); + const result = await work(); + calls.push({ name, durationMs: Date.now() - begin, outputChars: JSON.stringify(result)?.length ?? 0 }); + return result; +} +async function query(index: number) { + await router.acquireRequestSlot(); + try { + const result = await router.findCodeSymbols(`Only${index}`); + assert.ok(result.symbols.some(symbol => symbol.name === `Only${index}`)); + assert.ok(result.symbols.every(symbol => !symbol.file.includes(`Only${1 - index}`))); + return result; + } finally { router.endRequest(); } +} +try { + await router.initialize(); + for (let round = 0; round < 10; round++) { + if (round && sampleIntervalMs) await new Promise(resolve => setTimeout(resolve, sampleIntervalMs)); + const index = round % 2; + await call('switch', () => router.openWorkspace(roots[index])); + await call('query-before-interleaving', () => query(index)); + // Real owned Node fixture: hang an RPC for cancellation, or exit during it. + // Keep the production RPC/reset implementation intact; the gate controls scheduling only. + const cancel = round % 2 === 0; + router.config.adapters.serena.enabled = true; + router.config.adapters.serena.customCommand = process.execPath; + router.config.adapters.serena.customArgs = [path.resolve('tests/fixtures/mock-serena-mcp.mjs'), cancel ? '--hang' : '--crash']; + await router.serena.initialize(); + assert.equal(await router.serena.ensureConnected(), true); + const upstreamMetrics = processMetrics([process.pid, ...children + .filter(child => child.exitCode === null && child.signalCode === null && child.pid) + .map(child => child.pid!)]); + const controller = new AbortController(); + let entered!: () => void, release!: () => void; + const ready = new Promise(resolve => { entered = resolve; }); + const gate = new Promise(resolve => { release = resolve; }); + const adapter = router.serena as any; + const originalCall = adapter.callSerenaTool; + adapter.callSerenaTool = async (...args: unknown[]) => { + const outcome = Promise.resolve(originalCall.apply(adapter, args)).then( + value => ({ ok: true as const, value }), error => ({ ok: false as const, error })); + entered(); + await gate; + const result = await outcome; + if (!result.ok) throw result.error; + return result.value; + }; + const queryWork = call(cancel ? 'cancel-in-flight' : 'exit-in-flight', async () => { + await router.acquireRequestSlot(); + try { + const pending = router.findCodeSymbols(`Probe${round}`, undefined, controller.signal); + if (cancel) { + await assert.rejects(pending, /abort|cancel/i); + return { cancelled: true }; + } + const result = await pending; + assert.notEqual(result.source, 'serena-mcp'); + assert.ok(result.symbols.some(symbol => symbol.name === `Probe${round}Only${index}`)); + return result; + } finally { router.endRequest(); } + }); + let switching: Promise | undefined; + // Attach handlers immediately: even setup assertion failures must settle owned work. + void queryWork.catch(() => {}); + try { + await withTimeout(ready, 5000, 'mixed-load-query-entry'); + switching = call('switch-during-query', () => router.openWorkspace(roots[1 - index])); + void switching.catch(() => {}); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(router.isSwitchingWorkspace, true); + assert.equal(router.inFlightRequests, 1); + assert.equal(router.config.workspaceRoot, roots[index], 'root cannot change while the old query owns its slot'); + interleavings.push({ round, mode: cancel ? 'cancel' : 'upstream-exit', + rpcStarted: true, switchWaiting: true, oldRootPreserved: true }); + if (cancel) controller.abort(); + release(); + await Promise.all([queryWork, switching]); + } finally { + controller.abort(); release(); + await Promise.allSettled([queryWork, ...(switching ? [switching] : [])]); + adapter.callSerenaTool = originalCall; + } + router.config.adapters.serena.enabled = false; + await router.serena.initialize(); + await Promise.all([call('query-after-interleaving-1', () => query(1 - index)), + call('query-after-interleaving-2', () => query(1 - index))]); + const health = await call('health', () => router.getRuntimeHealth()); + assert.equal(health.inFlightRequests, 0); + assert.equal(health.workspaceRecovery, null); + assert.equal(health.session?.workspaceRoot, roots[1 - index]); + assert.equal(health.workspaceWatch.root, roots[1 - index]); + samples.push({ round, elapsedMs: Date.now() - started, gatewayPid: process.pid, + upstreamMetrics, settledMetrics: processMetrics([process.pid]), + memory: process.memoryUsage(), activeResources: process.getActiveResourcesInfo(), + spawnedPids: children.map(child => child.pid), + liveOwnedChildren: children.filter(child => child.exitCode === null && child.signalCode === null).length }); + console.log(JSON.stringify({ round, calls: calls.length, elapsedMs: Date.now() - started })); + } +} catch (caught) { + error = caught instanceof Error ? caught.stack : String(caught); +} finally { + try { await router.dispose(); } + catch (caught) { error = `${error ?? ''}\nCleanup: ${String(caught)}`; } + cp.spawn = originalSpawn; + syncBuiltinESMExports(); +} +const liveOwnedPids = children.filter(child => child.exitCode === null && child.signalCode === null).map(child => child.pid); +if (liveOwnedPids.length) error = `${error ?? ''}\nOwned child processes still live: ${liveOwnedPids.join(',')}`; +const report = { success: !error, node: process.version, elapsedMs: Date.now() - started, callCount: calls.length, + sampleIntervalMs, finalMetrics: processMetrics([process.pid]), + budget: { maxCalls: 100, maxMs: 300000 }, calls, samples, interleavings, liveOwnedPids, error, + limitations: ['Local generated workspaces and mock upstream only; real Serena/Repomix compatibility untested.', + 'Bounded paced sample is not an endurance or leak proof; no forced GC or continuous high-load claim.', + 'Windows WorkingSet64 is a point-in-time working set, not peak RSS. Metrics availability/missing PIDs are recorded explicitly.', + 'outputChars are serialized UTF-16 characters, not model tokens.'], +}; +const reportFile = path.join(root, 'report.json'); +await fs.writeFile(reportFile, JSON.stringify(report, null, 2) + '\n'); +console.log(JSON.stringify({ reportFile, success: report.success, elapsedMs: report.elapsedMs, callCount: calls.length, + spawnedProcesses: children.length, liveOwnedPids, error }, null, 2)); +if (error) process.exitCode = 1; diff --git a/scripts/verify-repomix-real.ts b/scripts/verify-repomix-real.ts new file mode 100644 index 0000000..9771887 --- /dev/null +++ b/scripts/verify-repomix-real.ts @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import cp from 'node:child_process'; +import { once } from 'node:events'; +import { syncBuiltinESMExports } from 'node:module'; +import { RepomixAdapter } from '../src/Adapters/RepomixAdapter.js'; +import { CacheManager } from '../src/Core/Cache.js'; +import { getDefaultConfig } from '../src/Core/Config.js'; +import { withTimeout } from '../src/Core/ResourceManager.js'; +import { WorkspaceWatch } from '../src/Core/WorkspaceWatch.js'; + +// Opt-in real package acceptance. No installation, network packing or user files. +const [entry] = process.argv.slice(2); +assert.ok(entry && path.isAbsolute(entry), 'Provide an absolute installed Repomix JavaScript entry'); +await fs.access(entry); +const parent = path.resolve('test-tmp/repomix-acceptance'); +await fs.mkdir(parent, { recursive: true }); +const root = await fs.mkdtemp(path.join(parent, '中文 & (real)-')); +// Isolate Git ignore discovery from the parent repository's test-tmp exclusion. +cp.execFileSync('git', ['init', '--quiet', root], { windowsHide: true }); +await fs.writeFile(path.join(root, 'A.ts'), 'export function alpha(value: number) { return value + 1; }\n'); +await fs.writeFile(path.join(root, 'B.ts'), 'export const beta = "ONLY_B_CONTENT";\n'); +await fs.mkdir(path.join(root, 'trash')); +await fs.writeFile(path.join(root, 'trash/Hidden.ts'), 'DO_NOT_PACK_TRASH'); +const config = getDefaultConfig(root); +config.adapters.repomix.useCli = true; +config.adapters.repomix.customCliPath = entry; +const cache = new CacheManager(config.cacheDir); +await cache.initialize(); +const adapter = new RepomixAdapter(config, cache); +const children: cp.ChildProcess[] = []; +const originalSpawn = cp.spawn; +cp.spawn = ((...args: any[]) => { + const child = (originalSpawn as any)(...args) as cp.ChildProcess; + children.push(child); return child; +}) as typeof cp.spawn; +syncBuiltinESMExports(); +const report: any = { entry, root, node: process.version, stages: [], success: false }; +async function stage(name: string, run: () => Promise) { + const start = Date.now(); + try { const result = await run(); report.stages.push({ name, passed: true, ms: Date.now() - start, result }); } + catch (error) { report.stages.push({ name, passed: false, error: String(error) }); throw error; } +} +try { + await stage('installed package handshake', async () => { + await adapter.initialize(); + const health = await adapter.checkHealth(); + assert.equal(health.source, 'installed'); assert.equal(health.available, true); + return health; + }); + for (const outputFormat of ['markdown', 'xml', 'plain'] as const) { + await stage(`${outputFormat} bodies, include scope and file count`, async () => { + const result = await adapter.packWorkspace({ include: ['*.ts'], outputFormat }); + report.lastPack = result; + assert.equal(result.source, 'repomix-cli'); assert.equal(result.fileCount, 2); + assert.ok(result.content.includes('alpha')); assert.ok(result.content.includes('ONLY_B_CONTENT')); + assert.ok(!result.content.includes('DO_NOT_PACK_TRASH')); + assert.equal(result.totalCharacters, result.content.length); + return result; + }); + } + await stage('real compression retains selected declaration', async () => { + const result = await adapter.packWorkspace({ include: ['A.ts'], compress: true }); + assert.equal(result.source, 'repomix-cli'); assert.ok(result.content.includes('alpha')); + assert.ok(!result.content.includes('ONLY_B_CONTENT')); return result; + }); + await stage('empty selection remains zero files', async () => { + const result = await adapter.packWorkspace({ include: ['missing/**/*.ts'] }); + assert.equal(result.source, 'repomix-cli'); assert.equal(result.fileCount, 0); + return result; + }); + await stage('cache invalidates after a source edit', async () => { + const options = { include: ['A.ts'] }; + const first = await adapter.packWorkspace(options); + const cached = await adapter.packWorkspace(options); + assert.equal(cached.fromCache, true); assert.equal(cached.content, first.content); + const watcher = new WorkspaceWatch(); + let notify!: () => void; + const invalidated = new Promise(resolve => { notify = resolve; }); + watcher.start(root, () => { cache.invalidateFingerprint(root); notify(); }); + try { + assert.equal(watcher.getStatus().active, true); + await fs.appendFile(path.join(root, 'A.ts'), 'export const changedEvidence = 17;\n'); + await withTimeout(invalidated, 5000, 'real-repomix-file-watch'); + const changed = await adapter.packWorkspace(options); + assert.equal(changed.fromCache, false); assert.ok(changed.content.includes('changedEvidence')); + return { cacheHit: true, changed }; + } finally { await watcher.stop(); } + }); + await stage('explicit candidate set keeps builtin closed scope', async () => { + const before = children.length; + const result = await adapter.packWorkspace({ candidateFiles: ['A.ts'] }); + assert.equal(result.source, 'builtin-fallback'); assert.equal(children.length, before); + assert.ok(!result.content.includes('ONLY_B_CONTENT')); return result; + }); + await stage('cancel a started real CLI process and remove output', async () => { + const controller = new AbortController(); + const before = children.length; + const pending = adapter.packWorkspace({ include: ['B.ts'], outputFormat: 'xml', compress: true }, { signal: controller.signal }); + const rejected = assert.rejects(pending, /abort|cancel/i); + const entered = async () => { + while (children.length === before && !controller.signal.aborted) await new Promise(resolve => setTimeout(resolve, 10)); + }; + try { await withTimeout(entered(), 5000, 'real-repomix-entry'); } + finally { controller.abort(); } + await rejected; + assert.equal(adapter.activeProcessCount, 0); + const child = children[before]; + if (child.exitCode === null && child.signalCode === null) + await withTimeout(once(child, 'close'), 5000, 'real-repomix-close-event'); + assert.ok(child.exitCode !== null || child.signalCode !== null); + return { cancelledPid: child.pid }; + }); + await stage('real CLI startup timeout reports fallback and cleans process', async () => { + config.timeouts.repomixPackMs = 1; + const result = await adapter.packWorkspace({ include: ['B.ts'], outputFormat: 'plain', compress: true }); + assert.equal(result.source, 'builtin-fallback'); assert.equal(adapter.lastError?.reason, 'timeout'); + assert.equal(adapter.activeProcessCount, 0); + return { source: result.source, lastError: adapter.lastError }; + }); + report.success = true; +} catch (error) { report.error = String(error); process.exitCode = 1; } +finally { + try { await adapter.dispose(); } + catch (error) { report.cleanupError = String(error); report.success = false; process.exitCode = 1; } + cp.spawn = originalSpawn; syncBuiltinESMExports(); + report.children = children.map(child => ({ pid: child.pid, exited: child.exitCode !== null || child.signalCode !== null })); + report.temporaryOutputs = await fs.readdir(path.join(config.cacheDir, 'repomix_tmp')).catch(() => []); + if (report.children.some((child: any) => !child.exited) || report.temporaryOutputs.length) { + report.success = false; process.exitCode = 1; + } + const reportFile = path.join(root, 'report.json'); + await fs.writeFile(reportFile, JSON.stringify(report, null, 2)); + console.log(JSON.stringify({ reportFile, success: report.success, stages: report.stages.map(({ name, passed }: any) => ({ name, passed })), error: report.error })); +} diff --git a/scripts/verify-roslyn-gateway.mjs b/scripts/verify-roslyn-gateway.mjs new file mode 100644 index 0000000..0def7e8 --- /dev/null +++ b/scripts/verify-roslyn-gateway.mjs @@ -0,0 +1,254 @@ +/** + * 直接 Roslyn 的真实 stdio MCP 验收。只生成/求值 test-tmp 下两套 C# 项目,保留失败与进程证据。 + * 依赖项目内已批准 SDK 与已构建 Gateway/Code Host;不安装、不运行真实用户项目或目标应用。 + */ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const sdk = path.join(repo, '.deps/dotnet-10.0.303'); +const dotnet = path.join(sdk, 'dotnet.exe'); +const host = path.join(repo, 'tools/WinCode.Code.Host/bin/Release/net10.0/WinCode.Code.Host.dll'); +const env = { ...process.env, DOTNET_ROOT: sdk, DOTNET_HOST_PATH: dotnet, DOTNET_CLI_HOME: path.join(repo, '.deps/dotnet-cli-home'), + NUGET_PACKAGES: path.join(repo, '.deps/nuget-packages'), NUGET_HTTP_CACHE_PATH: path.join(repo, '.deps/nuget-http-cache'), + DOTNET_NOLOGO: '1', DOTNET_CLI_TELEMETRY_OPTOUT: '1' }; +const parent = path.join(repo, 'test-tmp/roslyn-gateway'); +await fs.mkdir(parent, { recursive: true }); +const root = await fs.mkdtemp(path.join(parent, 'run-')); +const report = { root, scenarios: [], processes: [], success: false, + limitations: ['Generated C# projects and a fresh local stdio client; not the currently configured Codex connection or a clean machine release test.'] }; +const library = 'namespace Demo;\npublic partial class Api { public static void Save(int x) {} public static void Save(string x) {} public static void Unused() {} }\npublic class Other { public static void Save(int x) {} }\n'; +const calls = tag => `using Demo;\n// 😀 中文 UTF-16 ${tag}\npublic class Use { public void Run() { Api.Save(1); Api.Save("x"); Other.Save(2); ${tag === 'A' ? 'Api.Save(3);' : ''} } }\n// Api.Save(777)\n`; +const project = 'net10.013.0falseEXTRA'; +const appProject = project.replace('EXTRA', ''); +let transport; +let client; +let stderr = ''; + +/** 执行限定时长的本地 SDK 命令;失败保留输出,不把失败当成缺包后自动安装。 */ +function dotnetRun(args) { + const result = spawnSync(dotnet, args, { cwd: repo, env, windowsHide: true, encoding: 'utf8', timeout: 120000 }); + assert.equal(result.status, 0, `${result.error ?? ''}\n${result.stdout}\n${result.stderr}`); + return result.stdout; +} + +/** 获取测试所有进程树;只拼接经正整数校验的 PID,记录创建时间以排除 PID 复用。 */ +function owned(pid) { + assert.ok(Number.isSafeInteger(pid) && pid > 0); + const command = `$all = @(Get-CimInstance Win32_Process); $ids = @(${pid}); do { $more = @($all | Where-Object { $_.ParentProcessId -in $ids -and $_.ProcessId -notin $ids }); $ids += @($more | ForEach-Object { $_.ProcessId }) } while ($more.Count -gt 0); @($all | Where-Object { $_.ProcessId -in $ids } | Select-Object ProcessId,ParentProcessId,CreationDate,Name,CommandLine) | ConvertTo-Json -Compress`; + const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', command], { encoding: 'utf8', windowsHide: true, timeout: 15000 }); + assert.equal(result.status, 0, result.stderr); + const value = JSON.parse(result.stdout || '[]'); + return Array.isArray(value) ? value : [value]; +} + +/** 每个进程按 PID/创建时间核对退出;不终止不属于本次测试的对象。 */ +function assertExited(processes) { + for (const process of processes) assert.ok(!owned(process.ProcessId).some(current => current.ProcessId === process.ProcessId && current.CreationDate === process.CreationDate), `Owned process survived: ${process.ProcessId}`); +} + +/** 工作区切换只关闭 Code Host 子树;Gateway 自己的控制台宿主应保持到 Gateway 退出。 */ +function codeProcesses() { + const all = owned(transport.pid); + const code = all.find(item => item.CommandLine?.includes(host)); + assert.ok(code, 'Owned Code Host is missing.'); + return owned(code.ProcessId); +} + +/** tools/call 使用真实 MCP 客户端;默认失败立即终止场景,故障测试显式读取错误响应。 */ +async function call(name, args = {}, failure = false) { + const response = await client.callTool({ name, arguments: args }, { timeout: 60000 }); + const data = JSON.parse(response.content[0].text); + if (!failure) assert.notEqual(response.isError, true, JSON.stringify(data)); + else assert.equal(response.isError, true, JSON.stringify(data)); + return data; +} + +/** 选择真实重载签名;测试不人工填 UTF-16 位置,必须通过公共符号搜索取得定位。 */ +async function integerTarget() { + const result = await call('wincode_find_code_symbol', { query: 'Save', kind: 'method' }); + assert.equal(result.source, 'roslyn'); + assert.equal(result.queryComplete, false); + assert.equal(result.semanticContext.freshness.status, 'checked'); + assert.equal(result.symbols.length, 3); + const target = result.symbols.find(symbol => symbol.signature === 'Demo.Api.Save(int)'); + assert.ok(target?.location, JSON.stringify(result)); + return target; +} + +/** 仅传回搜索结果里的身份;按实际源码字符串断言位置,避免自己重算同一实现作为真值。 */ +async function references(target, expected, expectedRoot) { + const result = await call('wincode_find_references', { symbolName: target.name, symbolLocation: target.location }); + assert.equal(result.source, 'roslyn'); + assert.equal(result.resolution, 'resolved'); + assert.equal(result.queryComplete, false); + assert.equal(result.totalReferences, expected); + for (const item of result.references) { + const source = await fs.readFile(path.join(expectedRoot, item.file), 'utf8'); + assert.equal(source.slice(item.start, item.start + item.length), 'Save'); + assert.equal(source.slice(item.start - 4, item.start), 'Api.'); + assert.ok(!source.slice(0, item.start).split('\n').at(-1).startsWith('//')); + } + return result; +} + +/** 等待自有 MSBuild 目标写入启动标记,使用有界轮询而非猜测固定启动延迟。 */ +async function markerReady(marker) { + const deadline = Date.now() + 12000; + while (true) { + try { await fs.access(marker); return; } catch {} + if (Date.now() > deadline) throw new Error('MSBuild blocking target did not start.'); + await new Promise(resolve => setTimeout(resolve, 50)); + } +} + +try { + console.log('[roslyn-gateway] build and generated fixtures'); + report.hostBuild = dotnetRun(['build', 'tools/WinCode.Code.Host', '-c', 'Release', '-p:RestoreLockedMode=true', '--nologo']); + for (const tag of ['A', 'B']) { + const workspace = path.join(root, tag); + for (const folder of ['Lib', 'App', '.cache']) await fs.mkdir(path.join(workspace, folder), { recursive: true }); + await fs.writeFile(path.join(workspace, 'Lib/Lib.csproj'), project.replace('EXTRA', '')); + await fs.writeFile(path.join(workspace, 'App/App.csproj'), appProject); + await fs.writeFile(path.join(workspace, 'Lib/Api.cs'), library); + await fs.writeFile(path.join(workspace, 'Lib/Partial.cs'), 'namespace Demo; public partial class Api { public int Value { get; set; } }'); + await fs.writeFile(path.join(workspace, 'App/Use.cs'), calls(tag)); + await fs.writeFile(path.join(workspace, 'Helper.ts'), 'export function localHelp() { return 3; }\n'); + dotnetRun(['restore', path.join(workspace, 'App/App.csproj'), '--nologo']); + } + const a = path.join(root, 'A'), b = path.join(root, 'B'); + const config = path.join(root, 'roslyn.json'); + await fs.writeFile(config, JSON.stringify({ enabled: true, allowProjectEvaluation: true, project: 'App/App.csproj', + configuration: 'Debug', targetFramework: 'net10.0', dotnetPath: dotnet, hostPath: host, loadTimeoutMs: 15000, queryTimeoutMs: 10000 })); + client = new Client({ name: 'roslyn-gateway-acceptance', version: '1' }); + transport = new StdioClientTransport({ command: process.execPath, args: [path.join(repo, 'dist/index.js'), '--workspace', a, '--roslyn-config', config], env, stderr: 'pipe' }); + await client.connect(transport); + transport.stderr?.on('data', chunk => { stderr = (stderr + chunk).slice(-16384); }); + const initial = await call('wincode_hello_world'); + assert.equal(initial.codeProvider, 'roslyn'); + assert.equal(initial.health.roslyn.processAlive, false); + assert.equal(initial.health.serena.handshakeOk, false); + report.scenarios.push('explicit production CLI selects Roslyn; hello does not load a project'); + const listed = await client.listTools(); + assert.equal(listed.tools.length, 15); + assert.ok(listed.tools.find(tool => tool.name === 'wincode_find_references').inputSchema.properties.symbolLocation); + report.scenarios.push('existing tools expose the validated optional symbolLocation contract'); + const target = await integerTarget(); + await references(target, 2, a); + const ambiguous = await call('wincode_find_references', { symbolName: 'Save' }); + assert.equal(ambiguous.resolution, 'ambiguous'); + assert.equal(ambiguous.candidateCount, 3); + assert.deepEqual(ambiguous.references, []); + report.scenarios.push('real MCP search selects an exact overload; simple-name ambiguity returns candidates'); + const type = await call('wincode_find_code_symbol', { query: 'Api', kind: 'class' }); + assert.equal(type.symbols.length, 1); + assert.equal(type.uniqueTypeMatch, true); + const scopedPartial = await call('wincode_find_references', { symbolName: 'Api', relativePath: 'Lib/Partial.cs' }); + assert.equal(scopedPartial.candidates.length, 1); + assert.equal(scopedPartial.candidates[0].location.file.replaceAll('\\', '/'), 'Lib/Partial.cs'); + const impact = await call('analyze_change_impact', { target: 'Api' }); + assert.equal(impact.source, 'roslyn'); + assert.equal(impact.queryComplete, false); + assert.equal(impact.riskLevel, 'UNKNOWN'); + assert.equal(impact.confidence, 'UNCERTAIN'); + assert.ok(impact.referencesCount > 0, JSON.stringify(impact)); + report.scenarios.push('partial declarations deduplicate; impact keeps real references and incomplete confidence'); + const context = await call('wincode_prepare_context', { task: 'Inspect Save', scopeFiles: ['Lib/Api.cs'], lineRanges: [{ file: 'Lib/Api.cs', startLine: 1, endLine: 3 }], maxTokens: 2000 }); + assert.ok(context.evidence.length > 0); + const textContext = await call('wincode_prepare_context', { task: 'Inspect localHelp', scopeFiles: ['Helper.ts'], symbol: 'localHelp', maxTokens: 2000 }); + assert.ok(textContext.evidence.some(item => item.snippet.includes('localHelp'))); + report.scenarios.push('explicit source context remains available alongside the semantic provider'); + const badLocation = { ...target.location, file: '../outside.cs' }; + assert.equal((await call('wincode_find_references', { symbolName: 'Save', symbolLocation: badLocation }, true)).errorCode, 'OUTSIDE_WORKSPACE'); + assert.equal((await call('wincode_find_references', { symbolName: 'Other', symbolLocation: target.location }, true)).errorCode, 'SYMBOL_MISMATCH'); + assert.equal((await call('wincode_find_references', { symbolName: 'Api/Save[0]' }, true)).errorCode, 'LEGACY_SYMBOL_ID'); + report.scenarios.push('outside location, mismatched name and legacy Serena identity are rejected'); + await fs.writeFile(path.join(a, 'App/Use.cs'), calls('A').replace('Api.Save(3);', '')); + const stale = await call('wincode_find_references', { symbolName: 'Save', symbolLocation: target.location }, true); + assert.ok(['SNAPSHOT_STALE', 'INPUTS_CHANGED'].includes(stale.errorCode)); + assert.equal(stale.references, undefined); + const edited = await integerTarget(); + assert.notEqual(edited.location.snapshotId, target.location.snapshotId); + await references(edited, 1, a); + report.scenarios.push('edit rejects old evidence; explicit new search reloads and returns changed references'); + const beforeSwitch = codeProcesses(); + report.beforeSwitch = { gatewayPid: transport.pid, processes: beforeSwitch }; + await call('workspace_open', { path: b }); + assertExited(beforeSwitch); + assert.equal((await call('wincode_find_references', { symbolName: 'Save', symbolLocation: edited.location }, true)).errorCode, 'SNAPSHOT_STALE'); + const inB = await integerTarget(); + await references(inB, 1, b); + await call('workspace_open', { path: a }); + const againA = await integerTarget(); + await references(againA, 1, a); + assert.notEqual(againA.location.snapshotId, edited.location.snapshotId); + assert.equal((await call('wincode_find_references', { symbolName: 'Save', symbolLocation: inB.location }, true)).errorCode, 'SNAPSHOT_STALE'); + report.scenarios.push('A to B to A closes the old Host and rejects identities from both prior sessions'); + + await call('workspace_open', { path: a }); + await fs.writeFile(path.join(a, 'App/App.csproj'), ' {}, 1000);\n"); + const marker = path.join(a, '.cache/block.started'); + const escape = value => value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<'); + const targetXml = ``; + for (const mode of ['cancel', 'crash', 'timeout']) { + console.log(`[roslyn-gateway] active MSBuild ${mode}`); + await call('workspace_open', { path: a }); + await fs.rm(marker, { force: true }); + await fs.writeFile(path.join(a, 'App/App.csproj'), appProject.replace('', targetXml + '')); + const controller = new AbortController(); + const pending = client.callTool({ name: 'wincode_find_code_symbol', arguments: { query: 'Api' } }, { timeout: 30000, signal: controller.signal }); + const settled = pending.then(value => ({ value }), error => ({ error: String(error) })); + await markerReady(marker); + const processes = codeProcesses(); + assert.ok(processes.some(item => item.CommandLine?.includes('BuildHost')), 'actual BuildHost must be observed during design-time work'); + assert.ok(processes.some(item => item.CommandLine?.includes(blocker)), 'blocking target child must be observed'); + report.processes.push({ mode, processes }); + if (mode === 'cancel') controller.abort(); + if (mode === 'crash') { + const hostProcess = processes.find(item => item.CommandLine?.includes(host)); + assert.ok(hostProcess); + process.kill(hostProcess.ProcessId, 'SIGKILL'); + } + const outcome = await settled; + if (mode === 'cancel') assert.ok(outcome.error); + else { + assert.equal(outcome.value?.isError, true, JSON.stringify(outcome)); + assert.equal(JSON.parse(outcome.value.content[0].text).errorCode, mode === 'timeout' ? 'HOST_TIMEOUT' : 'HOST_CRASHED'); + } + // 客户端取消会先结束本地等待;同根打开等待 Gateway 占用清理完成后,才应确认恢复。 + await call('workspace_open', { path: a }); + assertExited(processes); + await fs.writeFile(path.join(a, 'App/App.csproj'), appProject); + await references(await integerTarget(), 1, a); + report.scenarios.push(`${mode} during real MSBuild work releases observed Host, BuildHost and target descendants; explicit recovery succeeds`); + } + const final = await call('wincode_hello_world'); + assert.equal(final.health.serena.handshakeOk, false); + const processes = owned(transport.pid); + assert.ok(processes.every(item => !/python|serena/i.test(`${item.Name} ${item.CommandLine}`))); + await client.close(); + assertExited(processes); + report.scenarios.push('final client shutdown releases the Gateway and current Host without launching Serena or Python'); + report.success = true; +} catch (error) { report.failure = String(error); process.exitCode = 1; } +finally { + if (client) await client.close().catch(() => {}); + report.stderr = stderr; + await fs.writeFile(path.join(root, 'report.json'), JSON.stringify(report, null, 2)); + console.log(JSON.stringify({ success: report.success, scenarios: report.scenarios.length, failure: report.failure, report: path.join(root, 'report.json') })); +} diff --git a/scripts/verify-roslyn-host.mjs b/scripts/verify-roslyn-host.mjs new file mode 100644 index 0000000..1be215a --- /dev/null +++ b/scripts/verify-roslyn-host.mjs @@ -0,0 +1,378 @@ +/** + * 自有 Roslyn Host 的隔离验收入口:只写 test-tmp 下生成的两项目夹具。 + * 使用项目内 SDK/NuGet,验证语义结果、失败边界及进程退出;不启动 Gateway/Serena。 + * 返回非零退出码表示验收失败,详细结果和失败原因保留到夹具目录 report.json。 + */ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const localSdk = path.join(repo, '.deps/dotnet-10.0.303'); +await fs.access(path.join(localSdk, 'dotnet.exe')); +const dotnet = path.join(localSdk, 'dotnet.exe'); +const env = { ...process.env, DOTNET_ROOT: localSdk, DOTNET_HOST_PATH: dotnet, + DOTNET_CLI_HOME: path.join(repo, '.deps/dotnet-cli-home'), NUGET_PACKAGES: path.join(repo, '.deps/nuget-packages'), + NUGET_HTTP_CACHE_PATH: path.join(repo, '.deps/nuget-http-cache'), DOTNET_NOLOGO: '1', DOTNET_CLI_TELEMETRY_OPTOUT: '1' }; +const parent = path.join(repo, 'test-tmp/roslyn-host'); +await fs.mkdir(parent, { recursive: true }); +const root = await fs.mkdtemp(path.join(parent, 'fixture-')); +const report = { root, scenarios: [], metrics: [], limitations: ['Generated SDK C# fixture only; checkpoints cover tracked inputs, not arbitrary external target inputs or live Gateway migration.'] }; +const host = path.join(repo, 'tools/WinCode.Code.Host/bin/Release/net10.0/WinCode.Code.Host.dll'); +/** 执行有 180 秒上限的 dotnet 命令;失败包含构建输出,成功返回 stdout。 */ +function run(args, cwd = repo) { + const result = spawnSync(dotnet, args, { cwd, env, encoding: 'utf8', windowsHide: true, timeout: 180000, maxBuffer: 2 * 1024 * 1024 }); + if (result.error || result.status !== 0) throw new Error(`dotnet ${args[0]} failed: ${result.error ?? ''}\n${result.stdout}\n${result.stderr}`); + return result.stdout; +} +const code = { + 'Lib/Api.cs': 'namespace Demo;\npublic class Api {\n public static void Save(int x) {}\n public static void Save(string x) {}\n public static void Unused() {}\n}\npublic class Other { public static void Save(int x) {} }\n', + 'App/Use.cs': 'using Demo;\npublic class Use {\n public void Run() {\n Api.Save(1);\n Api.Save("x");\n Other.Save(2);\n Api.Save(3);\n }\n}\n', + 'App/Conditional.cs': '#if EXTRA\nclass Conditional { public void Run() { Demo.Api.Save(5); } }\n#endif\n', +}; +/** 生成固定 TFM/语言版本的测试项目;extra 仅来自本脚本内置 XML。 */ +const project = (extra = '') => `net10.013.0false${extra}`; +for (const directory of ['Lib', 'App']) await fs.mkdir(path.join(root, directory)); +for (const [file, content] of Object.entries(code)) await fs.writeFile(path.join(root, file), content); +await fs.writeFile(path.join(root, 'Lib/Lib.csproj'), project()); +await fs.writeFile(path.join(root, 'App/App.csproj'), project('')); +const args = [host, '--allow-project-evaluation', root, path.join(root, 'App/App.csproj'), 'Debug', 'net10.0']; +let child; +let exit; +/** + * 启动一个测试所有的 Host;next 按顺序取单行 JSON,exited 等待进程退出。 + * stderr 仅保留最后 16 Ki 字符。调用方必须在 finally 中关闭或回收 process。 + * 该驱动不是生产适配器;生产接入须另行处理并发、主动取消和崩溃恢复。 + */ +function startHost() { + const process = spawn(dotnet, args, { cwd: repo, env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); + const exited = new Promise(resolve => process.once('exit', (code, signal) => resolve({ code, signal }))); + let stderr = ''; + process.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-16384); }); + const queue = []; + const waiters = []; + createInterface({ input: process.stdout }).on('line', line => { + let value; + try { value = JSON.parse(line); } catch { value = { invalidProtocol: line }; } + const waiter = waiters.shift(); + if (waiter) waiter(value); else queue.push(value); + }); + /** 等待下一帧;超时移除自身等待项,避免下一帧错误地交给过期请求。 */ + const next = (milliseconds = 30000) => new Promise((resolve, reject) => { + if (queue.length) { resolve(queue.shift()); return; } + const accept = value => { clearTimeout(timer); resolve(value); }; + const timer = setTimeout(() => { + const index = waiters.indexOf(accept); + if (index >= 0) waiters.splice(index, 1); + reject(new Error(`Host response timeout: ${stderr}`)); + }, milliseconds); + waiters.push(accept); + }); + return { process, exited, next, stderr: () => stderr }; +} +/** + * 只读抓取给定 PID 及后代的身份;退出检查同时比对 CreationDate,避免 PID 复用误判。 + * PowerShell 命令只插入经过正整数校验的 PID,不插入路径或任意用户文本。 + */ +function ownedProcesses(pid) { + assert.ok(Number.isInteger(pid) && pid > 0); + const command = `$all = @(Get-CimInstance Win32_Process); $ids = @(${pid}); do { $more = @($all | Where-Object { $_.ParentProcessId -in $ids -and $_.ProcessId -notin $ids }); $ids += @($more | ForEach-Object { $_.ProcessId }) } while ($more.Count -gt 0); @($all | Where-Object { $_.ProcessId -in $ids } | Select-Object ProcessId,ParentProcessId,CreationDate,Name,CommandLine) | ConvertTo-Json -Compress`; + const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', command], { encoding: 'utf8', windowsHide: true, timeout: 20000 }); + assert.equal(result.status, 0, result.stderr); + const value = JSON.parse(result.stdout || '[]'); + return Array.isArray(value) ? value : [value]; +} +try { + // Only Host and generated fixture dependencies are restored, never a user's target application. + report.hostBuild = run(['build', 'tools/WinCode.Code.Host', '-c', 'Release', '-p:RestoreLockedMode=true', '--nologo']); + // 固定锁文件和包元数据随回执记录,便于后续核对依赖及声明的许可证。 + const dependencies = JSON.parse(await fs.readFile(path.join(repo, 'tools/WinCode.Code.Host/packages.lock.json'), 'utf8')).dependencies['net10.0']; + report.packages = await Promise.all(Object.entries(dependencies).map(async ([name, value]) => { + const metadata = await fs.readFile(path.join(env.NUGET_PACKAGES, name.toLowerCase(), value.resolved, `${name.toLowerCase()}.nuspec`), 'utf8'); + return { name, version: value.resolved, declaredLicense: metadata.match(/]*>(.*?)<\/license>/s)?.[1] ?? 'unavailable' }; + })); + report.sdkVersion = run(['--version']).trim(); + report.fixtureRestore = run(['restore', path.join(root, 'App/App.csproj'), '--nologo']); + const denied = spawnSync(dotnet, [host, root], { cwd: repo, env, encoding: 'utf8', windowsHide: true, timeout: 10000 }); + assert.equal(denied.status, 1); + assert.match(denied.stdout, /Explicit project evaluation permission required/); + report.scenarios.push('missing evaluation permission rejected before load'); + + const started = performance.now(); + const session = startHost(); + child = session.process; + exit = session.exited; + const next = session.next; + const duringLoad = ownedProcesses(child.pid); + const ready = await next(150000); + assert.equal(ready.type, 'ready', JSON.stringify(ready)); + assert.equal(ready.protocolVersion, 2); + assert.equal(ready.projects, 2); + assert.deepEqual(ready.loadDiagnostics, []); + assert.deepEqual(ready.compilationErrors, []); + assert.equal(ready.diskFreshnessVerified, false); + assert.equal(ready.freshness.status, 'checked'); + let activeSnapshot = ready.snapshot; + report.ready = ready; + report.metrics.push({ coldReadyMs: performance.now() - started }); + report.scenarios.push('two real MSBuild projects load without compiler errors'); + let count = 0; + /** 在指定库项目中按 UTF-16 偏移查引用;extra 用于构造受控失败样例。 */ + const query = async (position, extra = {}) => { + const id = `query-${++count}`; + child.stdin.write(JSON.stringify({ id, operation: 'references', snapshot: activeSnapshot, + project: 'Lib/Lib.csproj', file: 'Lib/Api.cs', position, ...extra }) + '\n'); + const response = await next(); + assert.equal(response.id, id); + return response; + }; + const source = code['Lib/Api.cs']; + const timedOut = await query(source.indexOf('Save(int'), { timeoutMs: 1 }); + assert.equal(timedOut.success, false, 'cold semantic operation should exceed the 1 ms test budget'); + assert.equal(timedOut.errorCode, 'CANCELLED'); + report.scenarios.push('1 ms cold-query budget cancels without losing the session'); + const integers = await query(source.indexOf('Save(int')); + assert.equal(integers.success, true, JSON.stringify(integers)); + assert.equal(integers.freshness.files, ready.freshness.files); + assert.equal(integers.freshness.fingerprint, ready.freshness.fingerprint); + assert.equal(integers.totalReferences, 2); + assert.deepEqual(integers.references.map(r => r.line).sort((a, b) => a - b), [4, 7]); + for (const reference of integers.references) { + assert.equal(reference.file.replaceAll('\\', '/'), 'App/Use.cs'); + assert.equal(reference.column, 7); + assert.equal(code['App/Use.cs'].slice(reference.start, reference.start + reference.length), 'Save'); + } + report.scenarios.push('integer overload resolves exact cross-project call spans and columns'); + const strings = await query(source.indexOf('Save(string')); + assert.equal(strings.success, true); + assert.deepEqual(strings.references.map(r => r.line), [5]); + report.scenarios.push('string overload excludes integer overload and same-name other type'); + const empty = await query(source.indexOf('Unused')); + assert.equal(empty.success, true); + assert.equal(empty.totalReferences, 0); + report.scenarios.push('valid symbol with zero references remains successful bounded evidence'); + const repeated = await query(source.indexOf('Save(int')); + assert.deepEqual(repeated.references, integers.references); + report.metrics.push({ firstQueryMs: integers.queryMs, warmQueryMs: repeated.queryMs, workingSetBytes: repeated.workingSetBytes }); + report.scenarios.push('warm query reuses snapshot and preserves exact evidence'); + const truncated = await query(source.indexOf('Save(int'), { limit: 1 }); + assert.equal(truncated.totalReferences, 2); + assert.equal(truncated.references.length, 1); + assert.equal(truncated.truncated, true); + assert.equal(truncated.queryComplete, false); + report.scenarios.push('output cap preserves total and marks incomplete'); + for (const [label, extra, errorCode] of [ + ['stale snapshot', { snapshot: 'stale' }, 'SNAPSHOT_STALE'], ['outside source', { file: '../outside.cs' }, 'OUTSIDE_WORKSPACE'], + ['invalid position', { position: -1 }, 'INVALID_ARGUMENT'], ['wrong project context', { project: 'App/App.csproj' }, 'INVALID_ARGUMENT'], + ['invalid time budget', { timeoutMs: 0 }, 'INVALID_ARGUMENT'], + ['fractional position', { position: 1.5 }, 'INVALID_ARGUMENT'], ['fractional time budget', { timeoutMs: 1.5 }, 'INVALID_ARGUMENT'], + ]) { + const rejected = await query(source.indexOf('Save(int'), extra); + assert.equal(rejected.success, false, label); + assert.equal(rejected.errorCode, errorCode, label); + report.scenarios.push(`${label} rejected`); + } + /** 主动重载应生成新身份;默认只用于预期成功的稳定夹具状态。 */ + const reload = async () => { + const id = `reload-${++count}`; + child.stdin.write(JSON.stringify({ id, operation: 'reload' }) + '\n'); + const result = await next(150000); + assert.equal(result.id, id); + assert.equal(result.type, 'ready', JSON.stringify(result)); + assert.notEqual(result.snapshot, activeSnapshot); + activeSnapshot = result.snapshot; + return result; + }; + /** 修改后立即发请求,不等待 watcher 防抖;失败必须没有旧引用载荷。 */ + const assertStale = async label => { + const result = await query(source.indexOf('Save(int')); + assert.equal(result.success, false, label); + assert.ok(['SNAPSHOT_STALE', 'INPUTS_CHANGED'].includes(result.errorCode), JSON.stringify(result)); + assert.equal(result.references, undefined); + report.scenarios.push(label); + }; + await fs.writeFile(path.join(root, 'App/Use.cs'), code['App/Use.cs'].replace('Api.Save(3)', 'Other.Save(3)')); + await assertStale('immediate query after source edit refuses old references'); + const firstSnapshot = activeSnapshot; + await reload(); + assert.equal((await query(source.indexOf('Save(int'))).totalReferences, 1); + assert.equal((await query(source.indexOf('Save(int'), { snapshot: firstSnapshot })).errorCode, 'SNAPSHOT_STALE'); + report.scenarios.push('reload reflects changed call and permanently expires old snapshot'); + + await fs.writeFile(path.join(root, 'App/Extra.cs'), 'class Extra { void Run() { Demo.Api.Save(9); } }'); + await assertStale('new source file invalidates the original reference file set'); + await reload(); + const added = await query(source.indexOf('Save(int')); + assert.equal(added.totalReferences, 2); + assert.ok(added.references.some(item => item.file.endsWith('Extra.cs'))); + report.scenarios.push('reloaded MSBuild Compile glob includes new call sites'); + + await fs.rename(path.join(root, 'App/Extra.cs'), path.join(root, 'App/Moved.cs')); + await assertStale('renamed file invalidates old locations'); + await reload(); + const renamed = await query(source.indexOf('Save(int')); + assert.ok(renamed.references.some(item => item.file.endsWith('Moved.cs'))); + assert.ok(renamed.references.every(item => !item.file.endsWith('Extra.cs'))); + await fs.unlink(path.join(root, 'App/Moved.cs')); + await assertStale('deleted file invalidates old references'); + await reload(); + assert.equal((await query(source.indexOf('Save(int'))).totalReferences, 1); + report.scenarios.push('rename and delete reloads return only current paths'); + + await fs.appendFile(path.join(root, 'App/obj/project.assets.json'), '\n'); + await assertStale('obj assets changes are tracked before another query'); + await reload(); + assert.equal((await query(source.indexOf('Save(int'))).totalReferences, 1); + await fs.writeFile(path.join(root, 'Directory.Build.props'), 'TRACE;EXTRA'); + await assertStale('Directory.Build.props change invalidates compiled conditions'); + await reload(); + const conditional = await query(source.indexOf('Save(int')); + assert.equal(conditional.totalReferences, 2); + assert.ok(conditional.references.some(item => item.file.endsWith('Conditional.cs'))); + report.scenarios.push('reload applies actual MSBuild preprocessor configuration'); + + const appProject = path.join(root, 'App/App.csproj'); + const originalProject = await fs.readFile(appProject, 'utf8'); + await fs.writeFile(appProject, originalProject.replace('', '')); + await assertStale('project Compile changes invalidate the loaded project graph'); + await reload(); + assert.equal((await query(source.indexOf('Save(int'))).totalReferences, 1); + report.scenarios.push('reload respects project file exclusions'); + + await fs.writeFile(appProject, ' [result.id, result])); + assert.equal(cancelled.get('cancel-control').cancellationRequested, true); + assert.equal(cancelled.get('cancel-target').errorCode, 'CANCELLED'); + assert.equal((await query(source.indexOf('Save(int'))).totalReferences, 2); + report.scenarios.push('explicit cancellation reaches queued or active work without closing the session'); + + // 单次突发同时检验身份冲突、排队截止和背压;每个输入都必须收到独立结果,不静默丢队列项。 + /** 构造当前夹具快照的引用请求,允许突发与 EOF 验收复用同一定位。 */ + const burstQuery = id => ({ id, operation: 'references', snapshot: activeSnapshot, + project: 'Lib/Lib.csproj', file: 'Lib/Api.cs', position: source.indexOf('Save(int') }); + // 到期的排队 reload 必须在触碰工作区前退出;若错误地到执行时才计时,会使后续旧身份查询失败。 + const burst = [burstQuery('burst-first'), burstQuery('burst-first'), + { id: 'burst-deadline', operation: 'reload', timeoutMs: 1 }, + ...Array.from({ length: 16 }, (_, index) => burstQuery(`burst-${index}`))]; + child.stdin.write(burst.map(request => JSON.stringify(request)).join('\n') + '\n'); + const burstResults = await Promise.all(burst.map(() => next())); + const duplicated = burstResults.filter(result => result.id === 'burst-first'); + assert.equal(duplicated.length, 2); + assert.equal(duplicated.filter(result => result.errorCode === 'DUPLICATE_REQUEST').length, 1); + assert.equal(duplicated.filter(result => result.success === true).length, 1); + report.scenarios.push('duplicate active id is rejected without cancelling its original request'); + assert.equal(burstResults.find(result => result.id === 'burst-deadline').errorCode, 'CANCELLED'); + report.scenarios.push('expired queued reload is cancelled before invalidating the valid snapshot'); + assert.ok(burstResults.some(result => result.errorCode === 'BUSY')); + for (const request of burst.slice(2)) assert.equal(burstResults.filter(result => result.id === request.id).length, 1); + for (const result of burstResults) { + if (result.success) assert.equal(result.totalReferences, 2); + else assert.ok(['CANCELLED', 'BUSY', 'DUPLICATE_REQUEST'].includes(result.errorCode)); + } + report.queue = { submitted: burst.length, completed: burstResults.filter(result => result.success).length, + rejectedBusy: burstResults.filter(result => result.errorCode === 'BUSY').length }; + report.scenarios.push('bounded queue reports backpressure and accounts for every submitted frame'); + child.stdin.write(JSON.stringify({ id: 'cancel-missing', operation: 'cancel', targetId: 'absent-request' }) + '\n'); + assert.equal((await next()).cancellationRequested, false); + assert.equal((await query(source.indexOf('Save(int'))).totalReferences, 2); + report.scenarios.push('cancelling an absent request reports no cancellation and preserves the session'); + + const excessive = path.join(root, 'oversized-input.bin'); + const handle = await fs.open(excessive, 'wx'); + try { await handle.truncate(33 * 1024 * 1024); } finally { await handle.close(); } + assert.equal((await query(source.indexOf('Save(int'))).errorCode, 'INPUT_BUDGET_EXCEEDED'); + await fs.unlink(excessive); + await reload(); + report.scenarios.push('input byte cap rejects oversized input without accepting a partial fingerprint'); + + await fs.writeFile(path.join(root, 'global.json'), JSON.stringify({ sdk: { version: '10.0.303', rollForward: 'disable' } })); + assert.equal((await query(source.indexOf('Save(int'))).errorCode, 'HOST_RESTART_REQUIRED'); + child.stdin.write(JSON.stringify({ id: 'sdk-reload', operation: 'reload' }) + '\n'); + assert.equal((await next()).errorCode, 'HOST_RESTART_REQUIRED'); + report.scenarios.push('SDK selection changes require a new process, not an in-process reload'); + const owned = [...new Map([...duringLoad, ...ownedProcesses(child.pid)].map(p => [`${p.ProcessId}/${p.CreationDate}`, p])).values()]; + report.ownedProcesses = owned; + report.buildHostObserved = owned.some(p => p.CommandLine?.includes('BuildHost')); + child.stdin.write(JSON.stringify({ id: 'stop', operation: 'shutdown' }) + '\n'); + assert.equal((await next()).id, 'stop'); + const stopped = await Promise.race([exit, new Promise((_, reject) => { const timer = setTimeout(() => reject(new Error('Host did not exit')), 10000); timer.unref(); })]); + assert.equal(stopped.code, 0); + report.scenarios.push('graceful shutdown exits successfully'); + for (const ownedProcess of owned) { + const remaining = ownedProcesses(ownedProcess.ProcessId).filter(p => p.ProcessId === ownedProcess.ProcessId && p.CreationDate === ownedProcess.CreationDate); + assert.deepEqual(remaining, [], 'owned process survived host disposal'); + } + report.scenarios.push('sampled owned processes are absent after shutdown; unobserved processes not claimed'); + // Design-time evaluation may create output directories without compiling an assembly. + /** 枚举夹具输出文件(不把空目录视为编译产物);ENOENT 表示还没有输出目录。 */ + const generatedFiles = async directory => { + const entries = await fs.readdir(directory, { withFileTypes: true }).catch(error => { if (error.code === 'ENOENT') return []; throw error; }); + return (await Promise.all(entries.map(entry => entry.isDirectory() ? generatedFiles(path.join(directory, entry.name)) : [path.join(directory, entry.name)]))).flat(); + }; + const outputs = (await Promise.all(['App', 'Lib'].map(project => generatedFiles(path.join(root, project, 'bin'))))).flat(); + assert.deepEqual(outputs, []); + report.scenarios.push('design-time output directories contain no compiled target files'); + const buildFiles = await generatedFiles(path.dirname(host)); + report.buildArtifacts = { files: buildFiles.length, bytes: (await Promise.all(buildFiles.map(async file => (await fs.stat(file)).size))).reduce((a, b) => a + b, 0) }; + report.stderr = session.stderr(); + + // A second generated project state cannot turn missing dependencies into complete evidence. + await fs.appendFile(path.join(root, 'App/Use.cs'), '\nclass Broken : UnavailablePackage.MissingBase {}\n'); + const broken = startHost(); + child = broken.process; + exit = broken.exited; + const brokenReady = await broken.next(150000); + assert.equal(brokenReady.type, 'ready'); + assert.ok(brokenReady.compilationErrors.some(error => error.includes('UnavailablePackage'))); + child.stdin.write(JSON.stringify({ id: 'missing-dependency', operation: 'references', snapshot: brokenReady.snapshot, + project: 'Lib/Lib.csproj', file: 'Lib/Api.cs', position: source.indexOf('Save(int') }) + '\n'); + const incomplete = await broken.next(); + assert.equal(incomplete.success, true); + assert.equal(incomplete.totalReferences, 2); + assert.equal(incomplete.queryComplete, false); + report.missingDependency = { diagnostics: brokenReady.compilationErrors, result: incomplete }; + report.scenarios.push('missing dependency retains useful references but marks incomplete'); + // EOF 必须取消并排空已接纳操作,再释放工作区;不能只验证空闲时退出。 + const closing = Array.from({ length: 3 }, (_, index) => ({ ...burstQuery(`eof-${index}`), snapshot: brokenReady.snapshot })); + child.stdin.end(closing.map(request => JSON.stringify(request)).join('\n') + '\n'); + const closingResults = await Promise.all(closing.map(() => broken.next())); + for (const request of closing) { + const result = closingResults.find(item => item.id === request.id); + assert.equal(result?.errorCode, 'CANCELLED'); + assert.equal(result?.references, undefined); + } + report.scenarios.push('stdin EOF cancels and drains accepted reference requests without old payloads'); + assert.equal((await Promise.race([exit, new Promise((_, reject) => { const timer = setTimeout(() => reject(new Error('EOF shutdown timeout')), 10000); timer.unref(); })])).code, 0); + report.scenarios.push('stdin EOF disposes the second workspace'); + report.success = true; +} catch (error) { + report.success = false; + report.failure = String(error); + process.exitCode = 1; +} finally { + if (child && child.exitCode === null) { + spawnSync('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true }); + await exit; + } + await fs.writeFile(path.join(root, 'report.json'), JSON.stringify(report, null, 2)); + console.log(JSON.stringify({ success: report.success, scenarios: report.scenarios.length, failure: report.failure, report: path.join(root, 'report.json') })); +} diff --git a/scripts/verify-serena-real.ts b/scripts/verify-serena-real.ts index ef0e9bb..48f4e46 100644 --- a/scripts/verify-serena-real.ts +++ b/scripts/verify-serena-real.ts @@ -5,6 +5,7 @@ import { SerenaAdapter } from '../src/Adapters/SerenaAdapter.js'; import { CacheManager } from '../src/Core/Cache.js'; import { getDefaultConfig } from '../src/Core/Config.js'; import { killProcessTree } from '../src/Core/ResourceManager.js'; +import { ToolRouter } from '../src/Core/ToolRouter.js'; // Explicit opt-in: use an already installed, isolated Serena command. Never install prerequisites here. const [command, ...prefixArgs] = process.argv.slice(2); @@ -119,6 +120,51 @@ try { assert.equal(inactive.getUpstreamStatus().projectActive, false); return { found, health: inactive.getUpstreamStatus() }; }); + await stage('real Router A-B-A switches rebind upstream and cached query evidence', async () => { + const workspaces = [path.join(root, 'switch-a'), path.join(root, 'switch-b')]; + for (const [index, directory] of workspaces.entries()) { + await fs.mkdir(path.join(directory, '.serena'), { recursive: true }); + await fs.copyFile('global.json', path.join(directory, 'global.json')); + await fs.writeFile(path.join(directory, '.serena/project.yml'), `project_name: switch-${index}\nlanguage_servers: [csharp]\nread_only: true\n`); + await fs.writeFile(path.join(directory, 'Fixture.csproj'), 'net10.0'); + await fs.writeFile(path.join(directory, `Unique${index}.cs`), 'public class Marker {}\n' + + [0, 1, 2].map(round => `public class Probe${round} {}\n`).join('')); + } + const routeConfig = getDefaultConfig(workspaces[0]); + routeConfig.adapters.serena.customCommand = command; + routeConfig.adapters.serena.customArgs = [...prefixArgs, 'start-mcp-server', '--project-from-cwd', + '--enable-web-dashboard', 'false', '--open-web-dashboard', 'false', '--enable-gui-log-window', 'false', '--log-level', 'WARNING']; + routeConfig.adapters.flaui.enabled = false; + routeConfig.adapters.repomix.useCli = false; + const router = new ToolRouter(routeConfig); + const results = [], ownedPids: number[] = []; + try { + await router.initialize(); + for (const [round, index] of [0, 1, 0].entries()) { + await router.openWorkspace(workspaces[index]); + await router.acquireRequestSlot(); + try { + const result = await router.findCodeSymbols('Marker'); + // Returning to A may legitimately reuse A's cache without a process. + // A fresh query additionally proves that the new connection binds A. + const fresh = await router.findCodeSymbols(`Probe${round}`); + const pid = (router.serena as any).serenaPid as number; + if (pid && !ownedPids.includes(pid)) ownedPids.push(pid); + assert.equal(result.source, 'serena-mcp'); assert.equal(result.queryComplete, true); + assert.deepEqual(result.symbols.map(symbol => [symbol.name, symbol.file]), [['Marker', `Unique${index}.cs`]]); + assert.equal(fresh.source, 'serena-mcp'); assert.equal(fresh.queryComplete, true); + assert.deepEqual(fresh.symbols.map(symbol => [symbol.name, symbol.file]), [[`Probe${round}`, `Unique${index}.cs`]]); + assert.equal(router.workspaceRecoveryState, null); + results.push({ workspace: workspaces[index], pid, result, fresh }); + } finally { router.endRequest(); } + } + assert.equal(ownedPids.length, 3); + return { results, ownedPids }; + } finally { + await router.dispose(); + for (const pid of ownedPids) assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' }); + } + }); assert.equal(await fs.readFile(path.join(root, 'Service.cs'), 'utf8'), source); report.passed = true; } catch (error) { report.error = String(error); process.exitCode = 1; } diff --git a/skills/wincode/SKILL.md b/skills/wincode/SKILL.md index ae5efe6..8859a4b 100644 --- a/skills/wincode/SKILL.md +++ b/skills/wincode/SKILL.md @@ -5,7 +5,9 @@ description: 使用 WinCode MCP 分析 Windows/.NET 工作区,或读取桌面 # WinCode -仓库手册版本:0.12.5。安装内容可用 `node scripts/sync-skill.mjs <安装目录绝对路径>` 核对;仅维护时执行,不在每个任务中例行检查。以当前连接实际 Schema 为准,手册版本不证明 MCP 已重连。 +发布基线:0.12.5;手册修订:2026-09-09(含本地 E1/E2 修复与可选直接 Roslyn MCP 接入,不代表新版本已发布)。安装内容可用 `node scripts/sync-skill.mjs <安装目录绝对路径>` 核对;仅维护时执行,不在每个任务中例行检查。以当前连接实际 Schema 为准,手册版本不证明 MCP 已重连。 + +默认代码路径仍使用 Serena;显式配置 Roslyn 的实例已通过相同 MCP 工具接入 WinCode.Code.Host。读取实际 codeProvider/source:Roslyn 搜索返回的 location 可作为引用工具的 symbolLocation;不要猜测定位、复用过期快照或把 Serena namePath 当作 Roslyn 身份。内部 position/snapshot/project 顶层参数及 reload/cancel 不是 MCP 工具字段。配置与维护验收边界见代码手册。 仅按当前任务读取对应手册,不预读全部文件: - 代码、上下文、引用、影响分析:[code](references/code.md)。 diff --git a/skills/wincode/references/code.md b/skills/wincode/references/code.md index a3832e6..3da23ff 100644 --- a/skills/wincode/references/code.md +++ b/skills/wincode/references/code.md @@ -2,6 +2,48 @@ 以下为 MCP 工具名和参数;以客户端实际 Schema 为准。 +## 后端与实验接口边界 + +默认 Gateway 使用 SerenaAdapter 或明确标记的本地文本降级;显式启用 Roslyn 的实例通过相同工具提供 C# 声明、引用和影响证据,不启动 Serena/Python,也不在失败后偷偷切回 Serena。`hello.codeProvider` 标明实例选择;`source` 按实际响应读取,不能根据仓库中存在 Host 推断当前连接已经更新。 + +Roslyn 调用顺序:用 wincode_find_code_symbol 搜索(query 最长 256 字符),根据 signature、file 和 location.project 选择具体声明;再把该项的 name 作为 symbolName、完整 location 对象作为 symbolLocation 传给 wincode_find_references。location 包含 snapshotId(32 位小写十六进制)、project/file(工作区内相对路径)和 position(非负零基 UTF-16)。不手工猜偏移;同名/重载返回候选,不能自动选第一项。简单名称查询在当前不完整范围下只返回候选,单候选也需明确定位;candidatesTruncated=true 时 candidateCount 可能缺省,不能当作全量计数。 + +编辑、重载或工作区切换会使 location 失效。SNAPSHOT_STALE/INPUTS_CHANGED 后,下一次显式符号搜索执行所需重载;失败请求不自动重放。若编辑后直接搜索,首个请求也可能报告过期,再显式搜索恢复。HOST_RESTART_REQUIRED 按诊断手册重新打开工作区。Serena 实例明确拒绝 symbolLocation;Serena 的 namePath/重载序号不能迁移为 Roslyn 身份。semanticContext 保留快照、输入检查点、排除生成器数和范围,queryComplete=false 时零引用仍不能证明可删除。 + +维护者可用启动参数 `--roslyn-config <配置 JSON 的绝对路径>` 显式选择;不从目标仓库自动发现执行配置。JSON 对应宿主 WinCodeConfig.adapters.roslyn,最多 16 KiB,示例路径须替换成已安装/已构建的实际文件: + +```json +{ + "enabled": true, + "allowProjectEvaluation": true, + "project": "App/App.csproj", + "configuration": "Debug", + "targetFramework": "net10.0", + "dotnetPath": "C:/dotnet/dotnet.exe", + "hostPath": "C:/WinCode/tools/WinCode.Code.Host/bin/Release/net10.0/WinCode.Code.Host.dll" +} +``` + +allowProjectEvaluation 表示允许 MSBuild 设计时求值执行项目 targets,须符合用户授权;不会自动 restore 或下载 SDK。project 是相对当前工作区的固定入口;A→B 切换后使用 B 中同一路径,缺失就报错,不猜其他项目。配置和 TFM 当前固定于实例,要改变它们需更新启动配置并重启 Gateway。dotnetPath/hostPath 必须为绝对普通文件,重解析路径不支持;子进程使用指定 dotnet 的安装根,不改系统环境。可选 loadTimeoutMs 为 1–120000(默认 120000),queryTimeoutMs 为 1–60000(默认 30000),不属于 MCP 请求参数。 + +维护验收使用 `npm run test:roslyn-host`(独立 Host)和 `npm run test:roslyn-gateway`(已构建 Gateway 的真实 stdio MCP)。要求已有项目内 SDK `.deps/dotnet-10.0.303`,会构建 Host、还原生成夹具并写入 test-tmp;Gateway 脚本还会在生成的 targets 中启动受控测试子进程,验证取消/崩溃/超时。它们不是日常工具不可用时的替代调用,不证明发布包或当前 Codex 连接已更新。环境变更须在用户授权范围内。 + +原型通过独立进程的 JSON 行协议 v2 工作,非 MCP tools/call:启动参数为 `--allow-project-evaluation ROOT PROJECT CONFIGURATION FRAMEWORK`;加载后 ready 帧给出 protocolVersion=2 和 snapshot。项目求值可能执行 targets,不自动 restore;本维护验收只使用获准的生成夹具。协议及启动方式以源码 `tools/WinCode.Code.Host/Program.cs` 注释为准,尚非稳定公共接口。 + +| 内部 operation | 请求与结果 | +| --- | --- | +| `symbols` | 必填 id、snapshot、query,可选 kind/file;最多返回 200 个声明,totalFound/truncated 说明截断,location 给出可用于引用的当前快照定位。超时与 references 相同 | +| `references` | 必填 id、snapshot、project、file、position;project/file 是工作区内路径,position 为零基 UTF-16 偏移。返回 line/column 一基,start/length 零基 UTF-16。timeoutMs 为 1–60000,默认 30000;limit 为 1–1000,默认 100,只约束返回条数 | +| `reload` | 必填 id;固定根、入口项目、配置和 TFM 内重新求值,成功返回新的 ready/snapshot,调用者须重新定位符号。timeoutMs 为 1–120000,默认 120000;开始重载后失败或取消不会恢复旧身份 | +| `cancel` | 必填 id、targetId;cancellationRequested 仅确认是否向活动目标发出了取消,目标仍有独立结果,不代表立即完成或回滚 | +| `shutdown` | 必填 id;停止接纳、取消并排空请求、释放工作区后才返回成功。stdin EOF 同样清理,但没有 shutdown 确认帧 | + +每帧还须包含 operation;id 为 1–128 字符且活动期间不可重复。队列最多等待 8 项,满时 BUSY;timeoutMs 从接纳起计算,包含排队,Host 本身执行协作取消。Gateway 超时/取消先等待目标收尾,超过 1 秒宽限才回收自有 Host 进程树;初次加载尚不能接收 cancel 时直接回收。Windows Host 在加载前绑定自有 Job,以覆盖普通子进程继承的退出行为;这不是沙盒,也不约束 targets 通过外部服务启动的进程。请求帧最多 65536 个 UTF-16 字符,Node 接收帧最多 1 Mi 字符,超长使通道失效。SDK/global.json、监听或资源释放故障可能要求新进程,不能循环 reload。 + +Host 监听变化并在查询前后比较输入内容指纹,变化时丢弃结果并要求显式 reload。freshness.status=checked 仅覆盖其声明的工作区文件、已加载文档/元数据及祖先常规配置;包括新增文件与 obj/assets,默认排除 bin/node_modules 等目录,但显式加载的输入仍检查。预算为最多 20000 个枚举条目、5000 个文件、总计 128 MiB、单文件 32 MiB;超过即失败,不接受截断快照。不支持重解析路径。 + +自定义 targets 的任意外部输入和整个磁盘原子快照尚未验证,所以仍保留 diskFreshnessVerified=false、externalCustomInputsVerified=false。queryComplete 当前为 false;排除的分析器/生成器、加载及编译诊断须保留,零引用不证明安全删除。普通 MCP 请求使用下方规范字段;snapshotId 仅出现在 symbolLocation/semanticContext 内,不单独作为顶层参数发送。未知字段可能被忽略,成功响应不证明新参数生效。TS/JS/Python 的限定文件文本取证仍走 prepare_context,不把 Roslyn 声明搜索当成多语言语义服务。 + ## 规范字段 兼容容忍模式允许额外字段,但会忽略它们,不能据“调用成功”判断参数已经生效。例如 `scopeFile`、`scope_files` 均不是 `scopeFiles`,`symbolName` 不能代替查符号工具的 `query`。未知字段不能补足缺失必填项;已知字段填错类型、空白必填值或违反范围规则仍会报错。下面列出的名称区分大小写,未列出的参数不应发送。 @@ -12,7 +54,7 @@ | `wincode_list_directory` | 无 | `path`: 非空字符串,默认 `.`;`maxDepth`: 整数 1–5;`maxEntries`: 整数 1–500;`maxOutputChars`: 整数 2048–32768;`includeIgnored`: 布尔值 | | `wincode_analyze_workspace` | 无 | `maxDepth`: 数字,默认 2 | | `wincode_find_code_symbol` | `query`: 非空字符串 | `kind`: 字符串,常用 `class/interface/method/function/type/enum`;此工具未声明文件范围参数,指定文件取证改用下面的 `scopeFiles` | -| `wincode_find_references` | `symbolName`: 非空字符串 | `relativePath`: 字符串,表示符号的**定义文件**,不表示只搜索该文件中的引用 | +| `wincode_find_references` | `symbolName`: 非空字符串 | `relativePath`: 定义文件相对路径;`symbolLocation`: Roslyn 搜索返回的 location 对象(snapshotId/project/file/position 均必填,路径各最长 4096);同时提供 relativePath 时必须与 location.file 一致 | | `analyze_change_impact` | `target`: 非空字符串 | 无 | | `wincode_plan_refactoring` | `target`、`goal`: 非空字符串 | 无 | | `wincode_safe_move_to_trash` | `filePath`: 工作区内相对路径字符串 | `reason`: 字符串;该工具实际移动文件,须符合用户授权 | @@ -83,6 +125,8 @@ lineRanges 为闭区间、1 起始行号,最多 8 个文件,每文件一个 metrics.selectedFiles 是选择数,packedFiles 是打包器实际处理数(片段模式为片段数),returnedFiles 是返回正文覆盖数;打包器缺少正文位置时为 null。relatedFiles.bodyStatus 表示 complete/partial/omitted/unknown;片段模式的 complete 仅表示该片段完整,不表示整个文件完整。小预算先裁辅助列表,metadataTruncated 提示列表可能不全。 +Repomix CLI 的 fileCount 使用独立运行摘要中的文件数,不从正文中的 File 标题估算;空包可以为 0。若已安装 CLI 的摘要格式不受支持或被配置静默隐藏,则明确降级为 builtin-fallback,并在适配器 lastError 记录原因。CLI 快照计数正确不代表其每个正文都有 WinCode 可用的位置映射。 + bodyStatusScope 明确该字段描述 displayed-snippet 或 packed-file。symbol 请求返回声明附近窗口,symbolCoverage=unknown;即使 bodyStatus=complete 也不能认定整个方法完整。若所需逻辑仍在后方,可使用该证据的 nextRequest 续读最多 80 行;补读从最终尾行之后开始,半截尾行会完整重读。它不推测方法结束位置、不证明调用链完整,fileLineCount 仅为读取时的行数;编辑后重新定位。EOF 不再建议补读,最大预算无法读取完整长行时转用文件读取工具。 2000 是首轮建议预算;证据不足再定向补充,确需文件正文才设 includeFullText=true。中文任务优先附上明确符号。startLine/endLine 是本次片段实际覆盖行,line 是其中的符号声明行;locationKind=file-start 只说明读到文件开头,evidenceInsufficient=false 不保证已取得回答问题所需的代码。完整模式的 packedContent 是正文,候选元数据不保证打包结果完整。 @@ -94,3 +138,7 @@ bodyStatusScope 明确该字段描述 displayed-snippet 或 packed-file。symbol 若已有影响报告,直接据此规划,不为获得通用清单再次调用 plan_refactoring。该工具仍会做影响分析;它返回的 evidence 保留歧义、降级和 UNKNOWN,不代表已经执行重构。 仅在用户授权移除文件时使用 wincode_safe_move_to_trash({filePath:"相对路径",reason:"原因"});它会实际移动文件。重构计划本身不执行修改。 + +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 响应时,本实现不保证自动恢复原目录映射。 + +回收站目标名含唯一标识,过长的原文件名展示部分会截短,以给元数据文件名预留空间;完整原路径保存在 originalPath 和成功写入的元数据中。恢复时使用这些路径,不从截短的目标名推断原文件名或扩展名。 diff --git a/skills/wincode/references/diagnostics.md b/skills/wincode/references/diagnostics.md index 731bf02..bac7af7 100644 --- a/skills/wincode/references/diagnostics.md +++ b/skills/wincode/references/diagnostics.md @@ -16,9 +16,9 @@ 仅遇到故障或用户要求时调用 wincode_hello_world({}) 查看适配器、工作区及 runtime;环境问题再用 wincode_diagnose_project({})。健康成功不证明 Serena 语义连接成功;watcher 停止、最近超时和清理错误如实报告,不自动安装依赖或循环重启。 -工具不可用:先确认客户端是否启用了 wincode MCP;已保存配置通常需重新加载客户端/会话。Skill 不负责注册 MCP。当前本机安装路径为 I:/WinCode,STDIO 启动配置: +工具不可用:先确认客户端是否启用了 wincode MCP;已保存配置通常需重新加载客户端/会话。Skill 不负责注册 MCP。安装路径取实际客户端配置,不沿用历史机器的 I:/WinCode。STDIO 配置结构(占位路径需替换): - 命令:node -- 独立参数:I:/WinCode/dist/index.js、--workspace、I:/WinCode +- 独立参数:/dist/index.js、--workspace、<目标工作区绝对路径> 不要把 codex mcp add 整条终端命令填入启动命令。不要重复注册或静默修改配置。VERSION_MISMATCH 可能表示新网关配了旧 Host,局部查询/状态要求 inspectionVersion=2;按授权重新构建发布。HOST_UNAVAILABLE 时检查已配置 Host 路径/发布产物;构建或环境变更按用户授权执行。 @@ -27,7 +27,7 @@ 需要手动检查时执行已有只读脚本: ```powershell -pwsh -NoProfile -File I:/WinCode/scripts/check-ui-audit.ps1 +pwsh -NoProfile -File "/scripts/check-ui-audit.ps1" ``` 仅用户明确需要桌面弹窗时加 -Desktop;不例行弹窗。日志只有 start 表示结果未知;本地日志不是防篡改证据。清理须获得授权、停止相关调用并保留用户需要的记录,不能为了恢复取证静默删除。 @@ -35,3 +35,17 @@ pwsh -NoProfile -File I:/WinCode/scripts/check-ui-audit.ps1 从 0.12.2 起,生产模式仅使用发布的 Release Host,缺失时明确不可用;`npm run dev`(`--development`)才允许 Debug/dotnet-run 回退。`customHostPath` 是显式配置覆盖,不是 MCP 请求字段。Host 响应的 `hostIdentity` 来自实际程序集,包含 version、informationalVersion、configuration 与 framework;旧 Host 未提供身份时不能推定版本一致。 仓内 `npm run check` 执行锁定构建、核心回归和生产 stdio,生成并校验 `dist/delivery-manifest.json`;`npm run check:desktop` 单独运行隔离桌面闭环。`npm run delivery:verify` 检查 Gateway、发布 Host 全部文件及四份受管手册的一致性,不启动 Host,也不验证另一个客户端实例或签名真实性。构建要求 Node 24(22 兼容)和 `global.json` 中锁定的 SDK;缺少环境时按授权安装,不自动修改环境。 + +WORKSPACE_RECOVERY_REQUIRED 表示切换中途失败后工作区一致性尚未确认。此时业务工具被拒绝;被动 hello 仍可读取 health.workspaceRecovery,status=recovery_required。先检查 recoveryAction:workspace_open 表示可按原任务指定路径重新打开,只有完整重置/初始化及 watcher 绑定成功才恢复请求;同一路径也执行完整恢复。restart_gateway 表示清理失败被当前实例保留,重新打开无法恢复;先检查 Gateway 自有资源的清理情况,再按客户端正常流程重启 Gateway,不自动重启或终止目标应用。永久失败后的 workspace_open 不再反复改变根或会话。不要只修改路径字段、反复重试业务请求或把旧适配器状态当成已切换成功。CANCELLED 若附带 workspaceRecovery,同样按其 recoveryAction 处理;切换变更前失败且状态未改变时仍保留旧工作区。 + +E4 统一错误表达尚未实施:当前可能收到 isError=true 的纯文本,也可能是 content 中的 JSON;不能要求所有失败都含 structuredContent、统一 recoveryAction 或 retryable。先保留 isError 和原始内容,只在实际存在时读取 errorCode、workspaceRecovery、trash outcome/实际位置。结构化字段缺失不等于成功,取消或失败也不代表副作用已回滚;部分完成不原样重试。JSON 文本与 structuredContent 同源的方案是后续迁移方向,不能套用到旧连接。 + +直接 Roslyn Host 与 UIA Host 是不同组件。新 Gateway 的 hello.codeProvider 和 health.roslyn 报告显式选择的提供方、已知观察、processAlive、snapshotId 及重载/重启/清理状态;hello 不启动 Roslyn 或执行项目,进程存活不等于当前磁盘语义已验证。ready 是内部握手帧,UIA 的 VERSION_MISMATCH、inspectionVersion 等不能套到 Code Host。当前 npm run check / delivery:verify 不替代 test:roslyn-host/test:roslyn-gateway,也不证明 Code Host 已纳入正式发布包。 + +Code Host 内部协议 v2 的失败包含 success=false、errorCode 和 error,且不附带旧引用。SNAPSHOT_STALE/INPUTS_CHANGED 要求等写入稳定后显式 reload,再用新身份定位;PROJECT_LOAD_FAILED 表示结构化 MSBuild 加载失败,先修复项目输入,再 reload,不能继续使用最后一次成功快照。源码的 compilationErrors 可随有用的部分引用返回,不能据此宣称完整。 + +Roslyn 的已知领域错误通过 MCP 的 isError=true 和 JSON 文本 success=false/errorCode/errorMessage 返回,不代表 E4 已覆盖所有工具。HOST_RESTART_REQUIRED(SDK/监听状态)应对当前路径执行 workspace_open,再显式搜索;同根打开也关闭旧 Host 后重新选择 SDK。清理失败则按 WORKSPACE_RECOVERY_REQUIRED 的 restart_gateway 处理,不能通过再次打开恢复。HOST_TIMEOUT/HOST_CRASHED 后旧定位不可用,下一次显式搜索才启动新 Host;不会重放失败引用。 + +INPUT_UNAVAILABLE/HOST_UNAVAILABLE 先检查明确的配置文件、SDK/Host/项目路径;HOST_PROTOCOL_ERROR 检查 Host 与 Gateway 的协议版本,不绕过校验。LEGACY_SYMBOL_ID 要求重新搜索 Roslyn 身份;UNSUPPORTED_SYMBOL_LOCATION 表示该实例使用 Serena;SYMBOL_MISMATCH 表示名称和定位不一致。INPUT_BUDGET_EXCEEDED 先缩小受支持范围,不能接受截断指纹。内部 BUSY 表示队列已满,DUPLICATE_REQUEST 要求新的 id;CANCELLED 是目标终止结果,取消确认不替代它。OUTSIDE_WORKSPACE/UNSUPPORTED_LINK 拒绝越界或链接路径,不放松校验来恢复。 + +维护接口变更时,同步检查 Gateway 工具定义、相应 references 手册、实际客户端 Schema 和已安装四份受管文件;更新源码手册后运行 skill:sync,再以 skill:check 校验。仍须单独确认 MCP 实例的版本/构建/Schema,不能用手册同步代替重连。公共接口尚未发布时,只记录实验边界,不提前把新参数加入 MCP 规范字段表。 diff --git a/src/Adapters/RepomixAdapter.ts b/src/Adapters/RepomixAdapter.ts index 7ba9d44..a1a0312 100644 --- a/src/Adapters/RepomixAdapter.ts +++ b/src/Adapters/RepomixAdapter.ts @@ -4,6 +4,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import crypto from 'node:crypto'; import { createRequire } from 'node:module'; +import { stripVTControlCharacters } from 'node:util'; import { IAdapter, AdapterHealth, AdapterLastError } from './IAdapter.js'; import { WinCodeConfig, getDefaultTimeouts } from '../Core/Config.js'; import { CacheManager } from '../Core/Cache.js'; @@ -208,7 +209,7 @@ export class RepomixAdapter implements IAdapter { // A disabled request must neither read a CLI snapshot nor join an enabled CLI pack. const allowCli = this.config.adapters.repomix.useCli; const policy = allowCli ? 'cli-enabled' : 'builtin-only'; - const cacheKey = `repomix_pack_v4_${policy}_${this.config.adapters.repomix.customCliPath ?? ''}_${JSON.stringify(options || {})}_${this.config.workspaceRoot}`; + const cacheKey = `repomix_pack_v5_${policy}_${this.config.adapters.repomix.customCliPath ?? ''}_${JSON.stringify(options || {})}_${this.config.workspaceRoot}`; const fingerprint = await this.cache.computeWorkspaceFingerprint(this.config.workspaceRoot); const cached = await this.cache.get(cacheKey, fingerprint); @@ -304,12 +305,14 @@ export class RepomixAdapter implements IAdapter { cwd: root, windowsHide: true, shell: false, - stdio: ['ignore', 'ignore', 'pipe'], + stdio: ['ignore', 'pipe', 'pipe'], }); this.trackProcess(proc); let stderr = ''; + let stdout = ''; + proc.stdout?.on('data', (d) => { stdout = (stdout + d.toString()).slice(-16384); }); proc.stderr?.on('data', (d) => { stderr = (stderr + d.toString()).slice(-4096); }); const packTimeoutMs = this.config.timeouts?.repomixPackMs ?? 30_000; @@ -349,13 +352,17 @@ export class RepomixAdapter implements IAdapter { const content = await fs.readFile(tempOutputFile, 'utf-8'); await fs.unlink(tempOutputFile).catch(() => {}); - // Count files from content headers - const fileMatches = content.match(/File: |; + private readonly lock = new Mutex(); + private client?: RoslynHostClient; + private snapshot?: string; + private reloadRequired = false; + private restartRequired = false; + private cleanupFailure?: GatewayRestartRequiredError; + private disposed = false; + private health?: AdapterHealth; + private observedAt: string | null = null; + + /** textDeclarations 只处理已提供正文,用于保留既有多语言文本能力,不调用任何 Serena 连接方法。 */ + constructor(private readonly config: WinCodeConfig, private readonly resources: ResourceManager, + private readonly textDeclarations: (content: string, file: string) => CodeSymbol[]) { + const options = config.adapters.roslyn; + if (options?.enabled !== true || options.allowProjectEvaluation !== true) throw new CodeQueryError('PROJECT_EVALUATION_NOT_ALLOWED', 'Explicit Roslyn project evaluation permission is required.'); + if (![options.configuration, options.targetFramework].every(value => typeof value === 'string' && value.trim().length > 0 && value.length <= 128)) + throw new CodeQueryError('INVALID_ARGUMENT', 'Explicit Configuration and TargetFramework are required.'); + for (const value of [options.dotnetPath, options.hostPath]) + if (typeof value !== 'string' || !path.isAbsolute(value)) throw new CodeQueryError('INVALID_ARGUMENT', 'Roslyn executable and Host paths must be absolute.'); + for (const [value, maximum] of [[options.loadTimeoutMs, 120000], [options.queryTimeoutMs, 60000]] as const) + if (value !== undefined && (!Number.isSafeInteger(value) || value < 1 || value > maximum)) throw new CodeQueryError('INVALID_ARGUMENT', 'Invalid Roslyn time budget.'); + this.options = Object.freeze({ ...options }); + this.localPath(options.project); + if (path.extname(options.project).toLowerCase() !== '.csproj') throw new CodeQueryError('INVALID_ARGUMENT', 'Roslyn entry must be a C# project.'); + resources.register('disposable', 'roslyn-adapter', () => this.dispose()); + } + + /** 由 Router 使用单独预算,包含一次启动/重载与当前查询;不沿用 Serena RPC 时间配置。 */ + get operationBudgetMs(): number { return (this.options.loadTimeoutMs ?? 120000) + (this.options.queryTimeoutMs ?? 30000); } + + /** 输入和响应路径都验证词法边界;Host 另检查重解析路径和实际文件读取。 */ + private localPath(file: string): string { + if (typeof file !== 'string' || !file.trim() || file.length > 4096 || path.isAbsolute(file)) throw new CodeQueryError('OUTSIDE_WORKSPACE', 'Expected an in-workspace relative path.'); + const full = path.resolve(this.config.workspaceRoot, file); + const relative = path.relative(this.config.workspaceRoot, full); + if (!relative || relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) throw new CodeQueryError('OUTSIDE_WORKSPACE', 'Path escapes the active workspace.'); + return full; + } + + /** 开始进程前验证普通文件及所有祖先,不允许通过链接把配置入口或运行程序替换到别处。 */ + private async regularFile(file: string): Promise { + try { + if (!(await fs.stat(file)).isFile()) throw new CodeQueryError('HOST_UNAVAILABLE', 'Configured input is not a regular file.'); + for (let current = file; ; current = path.dirname(current)) { + if ((await fs.lstat(current)).isSymbolicLink()) throw new CodeQueryError('UNSUPPORTED_LINK', 'Linked Roslyn paths are unsupported.'); + if (path.dirname(current) === current) break; + } + } catch (error) { throw error instanceof CodeQueryError ? error : new CodeQueryError('INPUT_UNAVAILABLE', String(error).slice(0, 2048)); } + } + + /** 校验内部失败码并更新恢复状态,不从异常文案猜测恢复方式。 */ + private accept(reply: HostReply): HostReply { + if (!reply.success) { + if (reply.errorCode === 'HOST_RESTART_REQUIRED') this.restartRequired = true; + if (['SNAPSHOT_STALE', 'INPUTS_CHANGED', 'PROJECT_LOAD_FAILED', 'INPUT_BUDGET_EXCEEDED'].includes(reply.errorCode ?? '')) this.reloadRequired = true; + const error = new CodeQueryError(reply.errorCode ?? 'HOST_PROTOCOL_ERROR', reply.error ?? 'Code Host request failed.'); + this.observe(false, error.message); + throw error; + } + this.observe(true, 'Loaded C# snapshot; evidence completeness remains bounded.'); + return reply; + } + + /** 只在显式搜索需要时按需启动;启动失败不自动重试,不执行 restore、安装或其他提供方。 */ + private async ready(operation?: OperationContext): Promise { + checkOperation(operation); + if (this.cleanupFailure) throw this.cleanupFailure; + if (this.disposed) throw new CodeQueryError('HOST_UNAVAILABLE', 'Roslyn adapter is disposed.'); + if (this.restartRequired) throw new CodeQueryError('HOST_RESTART_REQUIRED', 'Reopen the workspace to restart Code Host.'); + if (this.client && !this.client.active) await this.stopClient(); + if (!this.client) { + const project = this.localPath(this.options.project); + await Promise.all([this.regularFile(project), this.regularFile(this.options.dotnetPath), this.regularFile(this.options.hostPath)]); + checkOperation(operation); + this.client = new RoslynHostClient(this.options.dotnetPath, [this.options.hostPath, '--allow-project-evaluation', + this.config.workspaceRoot, project, this.options.configuration, this.options.targetFramework], this.config.workspaceRoot, this.resources); + try { + const reply = this.accept(await this.client.waitReady(this.options.loadTimeoutMs ?? 120000, operation)); + this.acceptReady(reply); + } catch (error) { await this.stopClient(true); throw error; } + } else if (this.reloadRequired) { + this.snapshot = undefined; + const reply = this.accept(await this.client.request({ operation: 'reload' }, this.options.loadTimeoutMs ?? 120000, operation)); + this.acceptReady(reply); + } + return this.client; + } + + /** v2/根配置握手不符立即拒绝;Windows 接入必须具有自有进程树关闭保障。 */ + private acceptReady(reply: HostReply): void { + if (reply.type !== 'ready' || reply.protocolVersion !== 2 || typeof reply.snapshot !== 'string' || !/^[a-f0-9]{32}$/.test(reply.snapshot) || + reply.configuration !== this.options.configuration || reply.framework !== this.options.targetFramework || + (process.platform === 'win32' && reply.processTreeGuard !== true)) + throw new CodeQueryError('HOST_PROTOCOL_ERROR', 'Code Host ready/configuration contract mismatch.'); + this.snapshot = reply.snapshot; + this.reloadRequired = false; + } + + /** 保留已知观察,不把 hello 当成主动加载或健康探针。 */ + private observe(available: boolean, details: string): void { + this.observedAt = new Date().toISOString(); + this.health = { available, source: available ? 'installed' : 'unavailable', details }; + } + + /** 被动状态同时表明是否需要重载/重启,不以活进程替代语义完整性。 */ + getKnownHealth() { + return { health: this.health, observedAt: this.observedAt, processAlive: Boolean(this.client?.active), + snapshotId: this.snapshot ?? null, reloadRequired: this.reloadRequired, restartRequired: this.restartRequired, + cleanupFailed: Boolean(this.cleanupFailure) }; + } + + /** 返回当前已知 Host 状态,无额外进程探测;该诊断不重新执行项目。 */ + async checkHealth(): Promise { + return { available: Boolean(this.client?.active && this.snapshot && !this.reloadRequired && !this.restartRequired && !this.cleanupFailure), + source: this.client?.active ? 'installed' : 'unavailable', details: this.cleanupFailure ? 'Gateway restart required after cleanup failure.' : + this.restartRequired ? 'Reopen workspace to restart Code Host.' : this.reloadRequired ? 'Search again to reload changed inputs.' : this.health?.details ?? 'Not loaded; an explicit symbol search loads the configured project.' }; + } + + /** 不完整性来自实际 Host 范围,不能把有精确位置的局部结果说成全局完备。 */ + private limitations(reply: HostReply): string[] { + if (reply.queryComplete !== false || !Array.isArray(reply.compilationErrors) || !Array.isArray(reply.loadDiagnostics) || !Number.isSafeInteger(reply.excludedAnalyzers)) + throw new CodeQueryError('HOST_PROTOCOL_ERROR', 'Missing Host completeness evidence.'); + return ['范围仅为当前入口加载的 C# 项目及单配置快照;不覆盖动态调用或仓外调用。', + `排除 ${reply.excludedAnalyzers} 个分析器/生成器引用,生成源码覆盖未证明。`, + '输入校验覆盖声明的文件集合,不保证任意外部 targets 输入或全磁盘原子一致。', + ...[...reply.loadDiagnostics, ...reply.compilationErrors].slice(0, 5).map(value => String(value).slice(0, 1024))]; + } + + /** 传递经校验的实际检查点,不把缺失的校验结果补写成 verified。 */ + private evidence(reply: HostReply): SemanticContext { + const freshness = reply.freshness as SemanticContext['freshness']; + if (reply.snapshot !== this.snapshot || reply.scope !== 'loaded-solution-snapshot' || reply.diskFreshnessVerified !== false || + !freshness || freshness.status !== 'checked' || freshness.externalCustomInputsVerified !== false || + typeof freshness.scope !== 'string' || typeof freshness.fingerprint !== 'string' || !/^[A-F0-9]{64}$/.test(freshness.fingerprint) || + !Number.isSafeInteger(freshness.files) || freshness.files < 0 || freshness.files > 5000 || + !Number.isSafeInteger(freshness.bytes) || freshness.bytes < 0 || freshness.bytes > 128 * 1024 * 1024) + throw new CodeQueryError('HOST_PROTOCOL_ERROR', 'Invalid semantic checkpoint evidence.'); + return { snapshotId: this.snapshot!, scope: 'loaded-solution-snapshot', diskFreshnessVerified: false, + excludedAnalyzers: reply.excludedAnalyzers as number, freshness }; + } + + /** 校验定位的坐标和范围;过期身份在启动或查询 Host 前拒绝。 */ + private validateLocation(location: SymbolLocation, current = true): void { + if (!location || typeof location.snapshotId !== 'string' || !/^[a-f0-9]{32}$/.test(location.snapshotId) || !Number.isSafeInteger(location.position) || location.position < 0) + throw new CodeQueryError('INVALID_ARGUMENT', 'Invalid symbolLocation.'); + this.localPath(location.project); this.localPath(location.file); + if (current && (!this.client?.active || location.snapshotId !== this.snapshot || this.reloadRequired || this.restartRequired)) + throw new CodeQueryError('SNAPSHOT_STALE', 'Symbol location expired; search again or reopen the workspace when restart is required.'); + } + + /** 验证当前 Host 返回的声明列表,禁止跨根或没有身份的候选进入公共响应。 */ + private symbols(reply: HostReply): CodeSymbol[] { + if (!Array.isArray(reply.symbols) || reply.symbols.length > 200) throw new CodeQueryError('HOST_PROTOCOL_ERROR', 'Invalid symbol list.'); + return reply.symbols.map((value: CodeSymbol) => { + if (!value || typeof value.name !== 'string' || !kinds.has(value.kind) || !Number.isSafeInteger(value.line) || value.line < 1 || !value.location) + throw new CodeQueryError('HOST_PROTOCOL_ERROR', 'Invalid symbol declaration.'); + this.validateLocation(value.location); + if (path.relative(this.localPath(value.file), this.localPath(value.location.file))) throw new CodeQueryError('HOST_PROTOCOL_ERROR', 'Declaration path mismatch.'); + return value; + }); + } + + /** 持有串行占用直到协议失败的进程清理结束;清理失败优先传播给 Router 的 E1 恢复门。 */ + private perform(operation: OperationContext | undefined, work: () => Promise): Promise { + return this.lock.runExclusive(async () => { + try { return await work(); } + catch (error) { + if (this.cleanupFailure) throw this.cleanupFailure; + if (this.client && (!this.client.active || (error instanceof CodeQueryError && error.errorCode === 'HOST_PROTOCOL_ERROR'))) + await this.stopClient(true); + throw error; + } + }, operation?.signal); + } + + /** 名称搜索不读语义缓存;过期时要求下一次显式搜索重载,不重放本次失败请求。 */ + async findSymbolsDetailed(query: string, kind?: string, relativePath?: string, operation?: OperationContext): Promise { + if (typeof query !== 'string' || !query.trim() || query.length > 256 || (kind !== undefined && (typeof kind !== 'string' || kind.length > 128))) + throw new CodeQueryError('INVALID_ARGUMENT', 'Roslyn query must contain 1–256 characters; kind must be a string of at most 128 characters.'); + if (relativePath) this.localPath(relativePath); + return this.perform(operation, async () => { + const client = await this.ready(operation); + const reply = this.accept(await client.request({ operation: 'symbols', snapshot: this.snapshot, query, + ...(kind ? { kind } : {}), ...(relativePath ? { file: relativePath } : {}) }, this.options.queryTimeoutMs ?? 30000, operation)); + const symbols = this.symbols(reply); + const limitations = this.limitations(reply); + if (!Number.isSafeInteger(reply.totalFound) || (reply.totalFound as number) < symbols.length || typeof reply.truncated !== 'boolean') + throw new CodeQueryError('HOST_PROTOCOL_ERROR', 'Invalid declaration coverage.'); + return { query, kindFilter: kind, symbols, totalFound: reply.totalFound as number, source: 'roslyn', analysisCompleteness: 'incomplete', + queryComplete: false, truncated: reply.truncated, limitations, semanticContext: this.evidence(reply), ...computeTypeMatchStats(symbols, query) }; + }); + } + + /** 旧数组调用仍可用;其消费者必须另行保留详细结果中的证据边界。 */ + async findSymbols(query: string, kind?: string, operation?: OperationContext): Promise { + return (await this.findSymbolsDetailed(query, kind, undefined, operation)).symbols; + } + + /** 已知文本片段沿用既有声明模式;该方法不扫描文件、不加载 Roslyn,也不调用 Serena。 */ + findSymbolsInContent(content: string, file: string): CodeSymbol[] { return this.textDeclarations(content, file); } + + /** 精确引用只能使用本次搜索得到的定位;简单名结果保留候选,绝不选择第一个重载。 */ + async findReferencesDetailed(symbolName: string, relativePath?: string, operation?: OperationContext, location?: SymbolLocation): Promise { + 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) { + const found = await this.findSymbolsDetailed(symbolName, undefined, relativePath, operation); + const candidates = found.symbols.filter(symbol => symbol.name === symbolName); + return { symbolName, references: [], totalReferences: 0, source: 'roslyn', analysisCompleteness: 'incomplete', queryComplete: false, + truncated: found.truncated, resolution: candidates.length > 1 ? 'ambiguous' : 'incomplete', candidates, candidateCount: found.truncated ? undefined : candidates.length, + candidatesTruncated: found.truncated, semanticContext: found.semanticContext, + limitations: [...found.limitations, '请从候选选择明确的 symbolLocation;未查询引用不能解释为零引用。'] }; + } + this.validateLocation(location); + if (relativePath && path.relative(this.localPath(relativePath), this.localPath(location.file))) throw new CodeQueryError('INVALID_ARGUMENT', 'relativePath and symbolLocation.file disagree.'); + 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)) + 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); + if (![item.line, item.column, item.length].every(value => Number.isSafeInteger(value) && (value as number) > 0) || + !Number.isSafeInteger(item.start) || (item.start as number) < 0 || typeof item.preview !== 'string') throw new CodeQueryError('HOST_PROTOCOL_ERROR', 'Invalid reference span.'); + return { ...item, symbolName, lineKind: 'reference' } as unknown as SymbolReference; + }); + if ((reply.totalReferences as number) < references.length || reply.snapshot !== location.snapshotId) throw new CodeQueryError('HOST_PROTOCOL_ERROR', 'Reference snapshot/coverage mismatch.'); + return { symbolName, symbolLocation: location, references, totalReferences: reply.totalReferences as number, source: 'roslyn', analysisCompleteness: 'incomplete', + queryComplete: false, truncated: reply.truncated, resolution: 'resolved', limitations: this.limitations(reply), semanticContext: this.evidence(reply) }; + }); + } + + /** 兼容已有内部数组接口;无定位时只返回已实际查询的引用,候选保留在详细接口。 */ + async findReferences(name: string, file?: string, operation?: OperationContext): Promise { + return (await this.findReferencesDetailed(name, file, operation)).references; + } + + /** 工作区切换或同根恢复先回收旧进程,失败永久保留并交给 E1 阻止业务请求。 */ + async resetConnection(): Promise { + await this.lock.runExclusive(async () => { + if (this.cleanupFailure) throw this.cleanupFailure; + await this.stopClient(); this.restartRequired = false; this.reloadRequired = false; + this.health = undefined; this.observedAt = null; + }); + } + + /** 关闭后的失败不可通过清空引用隐藏;后续 dispose/reset 必须重抛同一恢复要求。 */ + private async stopClient(force = false): Promise { + this.snapshot = undefined; + if (!this.client) return; + try { await this.client.close(force); this.client = undefined; } + catch (error) { this.cleanupFailure ??= new GatewayRestartRequiredError([error], 'Code Host cleanup failed; restart Gateway.'); throw this.cleanupFailure; } + } + + /** 仅释放自有 Host;不处置复用的文本解析器、Gateway 或目标应用。 */ + async dispose(): Promise { + this.disposed = true; + await this.resetConnection(); + } +} diff --git a/src/Adapters/RoslynHostClient.ts b/src/Adapters/RoslynHostClient.ts new file mode 100644 index 0000000..a5a83e8 --- /dev/null +++ b/src/Adapters/RoslynHostClient.ts @@ -0,0 +1,163 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import path from 'node:path'; +import { CodeQueryError } from '../Core/CodeQueries.js'; +import { checkOperation, type OperationContext } from '../Core/OperationContext.js'; +import { AbortError, ResourceManager, TimeoutError, killProcessTree, withTimeout } from '../Core/ResourceManager.js'; + +/** 已通过帧边界和基础信封校验的内部响应;业务字段仍须由适配器逐项校验。 */ +export type HostReply = Record & { success: boolean; id?: string | null; errorCode?: string; error?: string }; +interface Pending { resolve: (value: HostReply) => void; reject: (error: unknown) => void } + +/** + * 一个直接 Code Host 进程的 JSON 行通道。只管理本类 spawn 的进程,不接受外部 PID。 + * 取消先等待目标请求收尾,超过宽限才终止自有进程树;调用方在清理完成前不得释放请求占用。 + */ +export class RoslynHostClient { + readonly child: ChildProcessWithoutNullStreams; + private readonly pending = new Map(); + private readonly cancelIds = new Set(); + private readonly exited: Promise; + private readonly ready: Promise; + private closing = false; + private ended = false; + private closePromise?: Promise; + private failure?: Error; + private buffer = ''; + private stderr = ''; + + /** 调用方先验证路径/许可;参数始终通过 argv 传递,禁用 shell 和可见窗口。 */ + constructor(command: string, args: string[], cwd: string, resources: ResourceManager) { + this.ready = new Promise((resolve, reject) => this.pending.set('@ready', { resolve, reject })); + // 即使进程在调用 waitReady 前失败,也不会产生未处理的 Promise 拒绝。 + void this.ready.catch(() => {}); + this.child = spawn(command, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, shell: false, + // 只固定本子进程的 SDK 安装根;避免继承的 DOTNET_HOST_PATH 将 MSBuild 引向另一套 dotnet。 + env: { ...process.env, DOTNET_HOST_PATH: command, DOTNET_ROOT: path.dirname(command) }, + detached: process.platform !== 'win32' }); + resources.registerProcess('roslyn', this.child); + this.child.stdout.setEncoding('utf8'); + this.child.stdout.on('data', (text: string) => this.read(text)); + this.child.stderr.setEncoding('utf8'); + this.child.stderr.on('data', (text: string) => { this.stderr = (this.stderr + text).slice(-8192); }); + this.child.stdin.on('error', error => this.fail(new CodeQueryError('HOST_UNAVAILABLE', error.message))); + this.child.on('error', error => this.fail(new CodeQueryError('HOST_UNAVAILABLE', error.message))); + this.exited = new Promise(resolve => this.child.once('close', code => { + this.ended = true; + this.fail(new CodeQueryError('HOST_CRASHED', `Code Host exited (${code}); search again to restart. ${this.stderr}`)); + resolve(code); + })); + } + + /** 仅返回已知进程状态,不启动探测或执行项目。 */ + get active(): boolean { return !this.ended && !this.closing && !this.failure; } + + /** 进程/协议失败只结算一次;清理由等待该失败的操作或资源所有者完成。 */ + private fail(error: Error): void { + this.failure ??= error; + for (const item of this.pending.values()) item.reject(this.failure); + this.pending.clear(); + } + + /** 输出最大 1 Mi UTF-16 字符/帧;未知 id、非法 JSON 或信封会使整个通道失效。 */ + private read(text: string): void { + if (this.failure) return; + this.buffer += text; + try { + while (true) { + const newline = this.buffer.indexOf('\n'); + if ((newline < 0 ? this.buffer.length : newline) > 1048576) throw new Error('Host response frame exceeds 1 Mi characters.'); + if (newline < 0) return; + const frame = JSON.parse(this.buffer.slice(0, newline)); + this.buffer = this.buffer.slice(newline + 1); + if (!frame || typeof frame !== 'object' || typeof frame.success !== 'boolean') throw new Error('Invalid Host envelope.'); + if (typeof frame.id === 'string' && this.cancelIds.delete(frame.id)) continue; + const key = frame.id === null && this.pending.has('@ready') ? '@ready' : frame.id; + const item = this.pending.get(key); + if (!item) throw new Error('Unexpected Host response id.'); + this.pending.delete(key); + item.resolve(frame); + } + } catch (error) { this.fail(new CodeQueryError('HOST_PROTOCOL_ERROR', String(error))); } + } + + /** 发送一帧并注册关联;同步写入失败也结算对应请求,不能留下悬空等待。 */ + private send(request: Record): { id: string; result: Promise } { + const id = randomUUID(); + const result = new Promise((resolve, reject) => { + if (this.failure || this.ended) { reject(this.failure ?? new CodeQueryError('HOST_UNAVAILABLE', 'Host is closed.')); return; } + this.pending.set(id, { resolve, reject }); + const frame = JSON.stringify({ ...request, id }); + if (frame.length > 65536) { this.pending.delete(id); reject(new CodeQueryError('INVALID_ARGUMENT', 'Host request exceeds frame budget.')); return; } + try { this.child.stdin.write(frame + '\n'); } + catch (error) { this.pending.delete(id); reject(error); } + }); + return { id, result }; + } + + /** 等待操作或取消/截止;所有计时器与 AbortSignal 监听在结算时释放。 */ + private async wait(result: Promise, budget: number, operation?: OperationContext): Promise { + checkOperation(operation); + const duration = Math.max(1, Math.min(budget, operation?.deadline === undefined ? budget : operation.deadline - Date.now())); + let abort: (() => void) | undefined; + const cancelled = new Promise((_, reject) => { + abort = () => reject(new AbortError()); + operation?.signal?.addEventListener('abort', abort, { once: true }); + if (operation?.signal?.aborted) abort(); + }); + try { return await withTimeout(Promise.race([result, cancelled]), duration, 'roslyn'); } + finally { if (abort) operation?.signal?.removeEventListener('abort', abort); } + } + + /** 初次加载前 Host 尚不读取 cancel;取消或超时直接回收自有进程,不自动重试启动。 */ + async waitReady(budget: number, operation?: OperationContext): Promise { + try { return await this.wait(this.ready, budget, operation); } + catch (error) { await this.close(true); throw error instanceof TimeoutError ? new CodeQueryError('HOST_TIMEOUT', error.message) : error; } + } + + /** 请求失败不自动重放;取消后确认目标结束,超宽限则关闭整条连接并等待进程退出。 */ + async request(request: Record, budget: number, operation?: OperationContext): Promise { + checkOperation(operation); + if (this.closing) throw new CodeQueryError('HOST_UNAVAILABLE', 'Code Host is closing.'); + const sent = this.send({ ...request, timeoutMs: Math.max(1, Math.min(budget, operation?.deadline === undefined ? budget : operation.deadline - Date.now())) }); + try { return await this.wait(sent.result, budget, operation); } + catch (error) { + if ((error instanceof AbortError || error instanceof TimeoutError) && this.active) { + const id = randomUUID(); + this.cancelIds.add(id); + this.child.stdin.write(JSON.stringify({ id, operation: 'cancel', targetId: sent.id }) + '\n'); + try { await withTimeout(sent.result, 1000, 'roslyn-cancel'); } + catch { await this.close(true); } + } else if (this.failure) await this.close(true); + throw error instanceof TimeoutError ? new CodeQueryError('HOST_TIMEOUT', error.message) : error; + } + } + + /** 正常关闭先请求 shutdown;协议失败/硬截止直接回收,重复调用保留同一清理结果。 */ + close(force = false): Promise { + if (this.closePromise) return this.closePromise; + this.closing = true; + this.closePromise = this.closeOnce(force); + return this.closePromise; + } + + /** 即使优雅关闭没有回复,也尝试终止;最终必须等到实际进程退出。 */ + private async closeOnce(force: boolean): Promise { + let cleanupFailure: unknown; + if (!this.ended && !force && !this.failure) { + try { + const reply = await withTimeout(this.send({ operation: 'shutdown' }).result, 1000, 'roslyn-close'); + if (!reply.success) throw new Error('Host cleanup failed.'); + const code = await withTimeout(this.exited, 1000, 'roslyn-exit'); + if (code !== 0) throw new Error(`Host cleanup exited ${code}.`); + return; + } catch (error) { + // 超时可由已验证的硬回收完成;明确的关闭/协议失败仍须向 E1 保留,不能仅因 PID 消失而隐藏。 + if (!(error instanceof TimeoutError)) cleanupFailure = error; + } + } + if (!this.ended) await killProcessTree(this.child); + await withTimeout(this.exited, 2500, 'roslyn-exit'); + if (cleanupFailure) throw cleanupFailure; + } +} diff --git a/src/Adapters/SerenaAdapter.ts b/src/Adapters/SerenaAdapter.ts index 80c0ad5..aacd063 100644 --- a/src/Adapters/SerenaAdapter.ts +++ b/src/Adapters/SerenaAdapter.ts @@ -9,6 +9,7 @@ import { WINCODE_VERSION, WinCodeConfig, WinCodeTimeouts, getDefaultTimeouts } f import { CacheManager } from '../Core/Cache.js'; import { ResourceManager, + GatewayRestartRequiredError, TimeoutError, killProcessTree, toExternalOpFailure, @@ -1046,7 +1047,7 @@ export class SerenaAdapter implements IAdapter { try { await this.closeClientAndTransport(client, transport); } catch (error) { failures.push(error); } if (failures.length) { this.recordError('error', new Error('Serena cleanup failed; the reset result remains failed.'), false); - throw new AggregateError(failures, 'Serena reset failed.'); + throw new GatewayRestartRequiredError(failures, 'Serena reset failed; restart the Gateway after checking cleanup.'); } } finally { this.disposing = false; diff --git a/src/CompositeTools/ImpactAnalyzer.ts b/src/CompositeTools/ImpactAnalyzer.ts index e9166ca..4d1896c 100644 --- a/src/CompositeTools/ImpactAnalyzer.ts +++ b/src/CompositeTools/ImpactAnalyzer.ts @@ -8,6 +8,7 @@ import { computeTypeMatchStats, FindSymbolsResult, FindReferencesResult, + type CodeSource, } from '../Core/CodeQueries.js'; import { WinCodeConfig } from '../Core/Config.js'; @@ -28,7 +29,7 @@ export interface ImpactReport { riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' | 'UNKNOWN'; riskReason: string; confidence: 'HIGH' | 'MEDIUM' | 'UNCERTAIN'; - source: 'serena-mcp' | 'serena-adapter-fallback' | 'unknown'; + source: CodeSource | 'unknown'; analysisCompleteness: 'semantic' | 'degraded' | 'unindexed' | 'incomplete'; limitations: string[]; uniqueResolution: boolean; @@ -46,7 +47,7 @@ export interface ImpactReport { } interface QueryAssessment { - source: 'serena-mcp' | 'serena-adapter-fallback' | 'unknown'; + source: CodeSource | 'unknown'; queryComplete: boolean; queryError?: string; unique: boolean; @@ -205,12 +206,12 @@ export class ImpactAnalyzer { } let refs: SymbolReference[] = []; - if (assessment.unique && assessment.queryComplete && !assessment.truncated && + // 精确 Roslyn 定位允许收集局部引用;风险/置信度仍保留 queryComplete=false 的 UNKNOWN 限制。 + if (assessment.unique && (assessment.queryComplete || matchedSymbol?.location) && !assessment.truncated && typeof this.serena.findReferencesDetailed === 'function') { - const refRes: FindReferencesResult = await this.serena.findReferencesDetailed( - matchedSymbol?.namePath ?? symbolName, - matchedSymbol?.file, operation - ); + const refRes: FindReferencesResult = matchedSymbol?.location ? await this.serena.findReferencesDetailed( + matchedSymbol.name, matchedSymbol.file, operation, matchedSymbol.location + ) : await this.serena.findReferencesDetailed(matchedSymbol?.namePath ?? symbolName, matchedSymbol?.file, operation); refs = refRes.references || []; if (refRes.source) assessment.source = refRes.source; if (refRes.queryComplete === false) { @@ -393,7 +394,7 @@ export class ImpactAnalyzer { ): ImpactReport['analysisCompleteness'] { if (!declared) return 'unindexed'; if (!assessment.queryComplete || assessment.truncated) return 'incomplete'; - if (assessment.source === 'serena-mcp') return 'semantic'; + if (assessment.source === 'serena-mcp' || assessment.source === 'roslyn') return 'semantic'; return 'degraded'; } @@ -418,8 +419,8 @@ export class ImpactAnalyzer { const msg = '未找到引用不得直接解释为“无影响”或“低风险”,也不得视为可安全删除。'; if (!seen.has(msg)) out.push(msg); } - if (assessment.source === 'serena-mcp' && (!assessment.queryComplete || assessment.truncated)) { - const msg = 'Serena 已返回结果,但查询不完整或可能截断,可信度不能只看供应方。'; + if ((assessment.source === 'serena-mcp' || assessment.source === 'roslyn') && (!assessment.queryComplete || assessment.truncated)) { + const msg = '语义提供方已返回结果,但查询不完整或可能截断,可信度不能只看供应方。'; if (!seen.has(msg)) out.push(msg); } return out; @@ -527,7 +528,7 @@ export class ImpactAnalyzer { if (!assessment.unique || !assessment.queryComplete || assessment.truncated || referencesCount === 0) { return 'UNCERTAIN'; } - if (assessment.source === 'serena-mcp') { + if (assessment.source === 'serena-mcp' || assessment.source === 'roslyn') { return 'HIGH'; } return 'MEDIUM'; diff --git a/src/CompositeTools/ProjectDiagnostics.ts b/src/CompositeTools/ProjectDiagnostics.ts index ead397b..1edfe68 100644 --- a/src/CompositeTools/ProjectDiagnostics.ts +++ b/src/CompositeTools/ProjectDiagnostics.ts @@ -1,10 +1,10 @@ -import { exec } from 'node:child_process'; +import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { WorkspaceManager, ProjectIdentity } from '../Core/Workspace.js'; import { WinCodeConfig } from '../Core/Config.js'; import { AdapterHealthQuery } from '../Core/AdapterStatus.js'; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); export interface DiagnosticItem { category: 'Environment' | 'Project' | 'Dependencies' | 'Windows'; @@ -52,7 +52,8 @@ export class ProjectDiagnostics { // Check .NET SDK availability — presence is not semantic analysis capability try { - const { stdout } = await execAsync('dotnet --version', { + const executable = this.config.adapters.roslyn?.enabled ? this.config.adapters.roslyn.dotnetPath : 'dotnet'; + const { stdout } = await execFileAsync(executable, ['--version'], { windowsHide: true, timeout: this.config.timeouts?.dotnetMs ?? 5000, }); @@ -73,7 +74,10 @@ export class ProjectDiagnostics { if (this.serena) { const health = await this.serena.checkHealth(); const up = health.upstream; - if (up?.mode === 'connected' && up.semanticQueryUsable) { + if (this.config.adapters.roslyn?.enabled) { + items.push({ category: 'Dependencies', status: health.available ? 'PASS' : 'WARN', + message: `Direct Roslyn: ${health.details ?? 'state unavailable'}. Query completeness must be checked separately.` }); + } else if (up?.mode === 'connected' && up.semanticQueryUsable) { items.push({ category: 'Dependencies', status: 'PASS', diff --git a/src/Core/CodeQueries.ts b/src/Core/CodeQueries.ts index 6ce4808..3f98cca 100644 --- a/src/Core/CodeQueries.ts +++ b/src/Core/CodeQueries.ts @@ -1,4 +1,29 @@ import type { OperationContext } from './OperationContext.js'; + +/** 语义来源与查询完整性独立;新增来源不能自动提高影响分析置信度。 */ +export type CodeSource = 'serena-mcp' | 'serena-adapter-fallback' | 'roslyn'; + +/** 当前 Host 快照内的精确定位;position 为零基 UTF-16,编辑、重载或切换后不可复用。 */ +export interface SymbolLocation { + snapshotId: string; + project: string; + file: string; + position: number; +} + +/** 直接语义查询实际采用的快照与输入校验范围;该对象不代表全磁盘或生成代码覆盖。 */ +export interface SemanticContext { + snapshotId: string; + scope: 'loaded-solution-snapshot'; + diskFreshnessVerified: false; + excludedAnalyzers: number; + freshness: { status: 'checked'; scope: string; fingerprint: string; files: number; bytes: number; externalCustomInputsVerified: false }; +} + +/** 代码查询的领域失败;Gateway 保留稳定错误码,不对所有工具实施新的错误信封。 */ +export class CodeQueryError extends Error { + constructor(readonly errorCode: string, message: string) { super(message); this.name = 'CodeQueryError'; } +} export const SERENA_DEGRADED_LIMITATIONS: string[] = [ '本地正则扫描仅作为文本检索降级方案,不保证符号身份、重载区分、跨文件引用完整性或安全重命名。', '本地正则扫描无法替代完整 Roslyn/TypeScript LSP 语义层面的跨文件重命名与重载解析。', @@ -14,6 +39,9 @@ export interface CodeSymbol { containerName?: string; /** Exact upstream identity, including overload indices; name is only a display label. */ namePath?: string; + /** Roslyn 返回的当前快照定位;不使用 Serena namePath 或重载序号推导它。 */ + location?: SymbolLocation; + column?: number; } export interface SymbolReference { @@ -22,14 +50,19 @@ export interface SymbolReference { line: number; preview: string; lineKind?: 'reference' | 'containing-symbol'; + column?: number; + start?: number; + length?: number; + project?: string; } export interface FindSymbolsResult { + semanticContext?: SemanticContext; query: string; kindFilter?: string; totalFound: number; symbols: CodeSymbol[]; - source: 'serena-mcp' | 'serena-adapter-fallback'; + source: CodeSource; analysisCompleteness: 'semantic' | 'degraded' | 'incomplete'; limitations: string[]; queryComplete: boolean; @@ -40,10 +73,11 @@ export interface FindSymbolsResult { } export interface FindReferencesResult { + semanticContext?: SemanticContext; symbolName: string; totalReferences: number; references: SymbolReference[]; - source: 'serena-mcp' | 'serena-adapter-fallback'; + source: CodeSource; analysisCompleteness: 'semantic' | 'degraded' | 'incomplete'; limitations: string[]; queryComplete: boolean; @@ -54,6 +88,7 @@ export interface FindReferencesResult { candidateCount?: number; candidatesTruncated?: boolean; target?: { namePath: string; relativePath: string }; + symbolLocation?: SymbolLocation; } const TYPE_KINDS = new Set(['class', 'interface', 'struct', 'enum']); @@ -66,7 +101,7 @@ export function computeTypeMatchStats( (s) => TYPE_KINDS.has((s.kind || '').toLowerCase()) && s.name === query ); const identities = new Set(typeMatches.map((s) => JSON.stringify([ - (s.file || '').replace(/\\/g, '/'), s.namePath ?? [s.containerName, s.name, s.line], + (s.file || '').replace(/\\/g, '/'), s.location ?? s.namePath ?? [s.containerName, s.name, s.line], ]))); const typeMatchCount = identities.size; return { @@ -83,7 +118,7 @@ export interface CodeSymbolQuery { export interface CodeReferenceQuery extends CodeSymbolQuery { findReferences(symbolName: string, relativePath?: string, operation?: OperationContext): Promise; - findReferencesDetailed?(symbolName: string, relativePath?: string, operation?: OperationContext): Promise; + findReferencesDetailed?(symbolName: string, relativePath?: string, operation?: OperationContext, location?: SymbolLocation): Promise; } export interface ContextCodeQuery extends CodeSymbolQuery { diff --git a/src/Core/Config.ts b/src/Core/Config.ts index ddb9d38..4a358ee 100644 --- a/src/Core/Config.ts +++ b/src/Core/Config.ts @@ -31,6 +31,21 @@ export interface WinCodeCacheLimits { fingerprintMemoMs: number; } +/** 显式选择直接 Roslyn;入口与单配置随当前工作区解释,不会自动 restore 或回退到 Serena。 */ +export interface RoslynConfig { + enabled: boolean; + allowProjectEvaluation: boolean; + /** 工作区内入口 csproj 的相对路径;工作区切换后使用新根中的同一路径。 */ + project: string; + configuration: string; + targetFramework: string; + /** 已安装可执行文件与已构建 Host 的绝对路径;不运行下载器或 shell 包装器。 */ + dotnetPath: string; + hostPath: string; + loadTimeoutMs?: number; + queryTimeoutMs?: number; +} + export interface WinCodeConfig { workspaceRoot: string; cacheDir: string; @@ -39,6 +54,7 @@ export interface WinCodeConfig { timeouts: WinCodeTimeouts; cacheLimits: WinCodeCacheLimits; adapters: { + roslyn?: RoslynConfig; repomix: { useCli: boolean; /** Absolute installed JavaScript CLI entry (.js/.cjs/.mjs), never a shell wrapper. */ diff --git a/src/Core/ResourceManager.ts b/src/Core/ResourceManager.ts index a7f2a0d..3fa1ec2 100644 --- a/src/Core/ResourceManager.ts +++ b/src/Core/ResourceManager.ts @@ -97,6 +97,14 @@ export class AbortError extends Error { } } +/** A retained cleanup failure cannot be recovered by reusing the same runtime. */ +export class GatewayRestartRequiredError extends AggregateError { + constructor(errors: unknown[], message: string) { + super(errors, message); + this.name = 'GatewayRestartRequiredError'; + } +} + /** * Serializes a critical section. Callers queue; there is no OS thread pool. * Cancels queue waiting. Once fn starts, it owns cooperative cancellation and cleanup; diff --git a/src/Core/ToolRouter.ts b/src/Core/ToolRouter.ts index c082e44..5a77468 100644 --- a/src/Core/ToolRouter.ts +++ b/src/Core/ToolRouter.ts @@ -5,6 +5,8 @@ import { WorkspaceManager, WorkspaceOpenOptions, WorkspaceDirectoryOptions } fro import { ContextManager, PreparedContextOptions } from './Context.js'; import { RepomixAdapter } from '../Adapters/RepomixAdapter.js'; import { SerenaAdapter } from '../Adapters/SerenaAdapter.js'; +import { RoslynAdapter } from '../Adapters/RoslynAdapter.js'; +import { CodeQueryError, type SymbolLocation } from './CodeQueries.js'; import { FlaUiAdapter } from '../Adapters/FlaUiAdapter.js'; import { UiInspectRequest, UiInspectResult } from './UiContracts.js'; import { reviewUi, UiReviewResult } from '../CompositeTools/UiReview.js'; @@ -13,16 +15,36 @@ import { ImpactAnalyzer } from '../CompositeTools/ImpactAnalyzer.js'; import { RefactorAssistant } from '../CompositeTools/RefactorAssistant.js'; import { ProjectDiagnostics } from '../CompositeTools/ProjectDiagnostics.js'; import { ExtensionManager } from '../Extensions/ExtensionManager.js'; -import { Mutex, ResourceManager, AbortError, TimeoutError } from './ResourceManager.js'; +import { Mutex, ResourceManager, AbortError, TimeoutError, GatewayRestartRequiredError } from './ResourceManager.js'; import { SessionManager, WorkspaceSession } from './SessionManager.js'; import { WorkspaceWatch } from './WorkspaceWatch.js'; import { AdapterLastError } from './AdapterStatus.js'; import { type OperationContext, checkOperation } from './OperationContext.js'; +export interface WorkspaceRecovery { + activeWorkspace: string; + attemptedWorkspace: string; + phase: string; + message: string; + recoveryAction: 'workspace_open' | 'restart_gateway'; +} + +export class WorkspaceRecoveryRequiredError extends Error { + constructor(readonly recovery: WorkspaceRecovery) { + super(recovery.recoveryAction === 'restart_gateway' + ? 'Workspace cleanup could not be confirmed. Check Gateway-owned resource cleanup and restart the Gateway; workspace_open cannot recover this instance.' + : 'Workspace consistency is unconfirmed. Call workspace_open to complete recovery.'); + this.name = 'WorkspaceRecoveryRequiredError'; + } +} + export interface RuntimeHealth { + codeProvider: 'serena' | 'roslyn'; + roslyn?: ReturnType; resourceCleanup: ReturnType; version: string; - status: 'online' | 'shutting_down'; + status: 'online' | 'shutting_down' | 'recovery_required'; + workspaceRecovery: WorkspaceRecovery | null; uptimeMs: number; startedAt: string; activeWorkspace: string | null; @@ -66,6 +88,7 @@ export class ToolRouter { context: ContextManager; repomix: RepomixAdapter; serena: SerenaAdapter; + roslyn?: RoslynAdapter; flaui: FlaUiAdapter; architecture: ArchitectureAnalyzer; impact: ImpactAnalyzer; @@ -83,18 +106,28 @@ export class ToolRouter { private pruneTimer: ReturnType | null = null; private readonly watch = new WorkspaceWatch(); private watchRegistered = false; + private workspaceRecovery: WorkspaceRecovery | null = null; private readonly codeOperations = new Set(); private async runCode(signal: AbortSignal | undefined, work: (operation: OperationContext) => Promise): Promise { const controller = new AbortController(); const cancel = () => controller.abort(); - const budget = this.config.timeouts.serenaConnectMs + this.config.timeouts.serenaCallMs + this.config.timeouts.fileScanMs; + const budget = (this.roslyn?.operationBudgetMs ?? (this.config.timeouts.serenaConnectMs + this.config.timeouts.serenaCallMs)) + this.config.timeouts.fileScanMs; const operation = { signal: controller.signal, deadline: Date.now() + budget }; const timer = setTimeout(() => controller.abort(new TimeoutError('operation', budget)), budget); this.codeOperations.add(controller); signal?.addEventListener('abort', cancel, { once: true }); if (signal?.aborted || this.shuttingDown) cancel(); try { checkOperation(operation); const result = await work(operation); checkOperation(operation); return result; } + catch (error) { + // 查询清理失败同样会留下不可信的自有 Host 状态;按 E1 阻止后续业务,不能只返回一次错误。 + if (this.roslyn && error instanceof GatewayRestartRequiredError) { + this.workspaceRecovery = { activeWorkspace: this.config.workspaceRoot, attemptedWorkspace: this.config.workspaceRoot, + phase: 'roslyn-cleanup', message: error.message.slice(0, 1024), recoveryAction: 'restart_gateway' }; + throw new WorkspaceRecoveryRequiredError({ ...this.workspaceRecovery }); + } + throw error; + } finally { clearTimeout(timer); signal?.removeEventListener('abort', cancel); this.codeOperations.delete(controller); } } @@ -111,12 +144,14 @@ export class ToolRouter { this.workspace = new WorkspaceManager(config); this.repomix = new RepomixAdapter(config, this.cache, this.resources); this.serena = new SerenaAdapter(config, this.cache, this.resources); + if (config.adapters.roslyn?.enabled) + this.roslyn = new RoslynAdapter(config, this.resources, (content, file) => this.serena.findSymbolsInContent(content, file)); this.flaui = new FlaUiAdapter(config, this.resources); - this.context = new ContextManager(config, this.workspace, this.repomix, this.serena); - this.architecture = new ArchitectureAnalyzer(this.workspace, this.serena); - this.impact = new ImpactAnalyzer(this.serena, this.config); - this.refactor = new RefactorAssistant(this.workspace, this.serena, this.impact); - this.diagnostics = new ProjectDiagnostics(this.workspace, this.config, this.serena); + this.context = new ContextManager(config, this.workspace, this.repomix, this.code); + this.architecture = new ArchitectureAnalyzer(this.workspace, this.code); + this.impact = new ImpactAnalyzer(this.code, this.config); + this.refactor = new RefactorAssistant(this.workspace, this.code, this.impact); + this.diagnostics = new ProjectDiagnostics(this.workspace, this.config, this.code); this.extensions = new ExtensionManager(config); } @@ -124,6 +159,9 @@ export class ToolRouter { return this.shuttingDown; } + /** 提供方在构造时显式选择;Roslyn 失败不触发 Serena RPC。 */ + private get code(): SerenaAdapter | RoslynAdapter { return this.roslyn ?? this.serena; } + get inFlightRequests(): number { return this.inFlight; } @@ -132,12 +170,19 @@ export class ToolRouter { return this.switchingPromise !== null; } + get workspaceRecoveryState(): WorkspaceRecovery | null { + return this.workspaceRecovery ? { ...this.workspaceRecovery } : null; + } + findCodeSymbols(query: string, kind?: string, signal?: AbortSignal) { - return this.runCode(signal, operation => this.serena.findSymbolsDetailed(query, kind, undefined, operation)); + return this.runCode(signal, operation => this.code.findSymbolsDetailed(query, kind, undefined, operation)); } - findCodeReferences(symbolName: string, relativePath?: string, signal?: AbortSignal) { - return this.runCode(signal, operation => this.serena.findReferencesDetailed(symbolName, relativePath, operation)); + /** 精确位置只属于 Roslyn;旧提供方收到该字段必须明确拒绝,不能忽略后再猜符号。 */ + findCodeReferences(symbolName: string, relativePath?: string, signal?: AbortSignal, location?: SymbolLocation) { + if (location && !this.roslyn) throw new CodeQueryError('UNSUPPORTED_SYMBOL_LOCATION', 'This instance uses Serena; Roslyn symbolLocation is unsupported.'); + return this.runCode(signal, operation => this.roslyn ? this.roslyn.findReferencesDetailed(symbolName, relativePath, operation, location) : + this.serena.findReferencesDetailed(symbolName, relativePath, operation)); } prepareContext(options: PreparedContextOptions, signal?: AbortSignal) { @@ -172,7 +217,7 @@ export class ToolRouter { return this.workspace.listDirectory(options); } - async acquireRequestSlot(signal?: AbortSignal): Promise { + async acquireRequestSlot(signal?: AbortSignal, allowDuringRecovery = false): Promise { if (this.shuttingDown) throw new Error('WinCode is shutting down; tool call rejected.'); if (signal?.aborted) throw new AbortError('The tool call was cancelled.'); while (this.switchingPromise) { @@ -197,6 +242,7 @@ export class ToolRouter { if (signal?.aborted) throw new AbortError('The tool call was cancelled.'); } if (this.shuttingDown) throw new Error('WinCode is shutting down; tool call rejected.'); + if (this.workspaceRecovery && !allowDuringRecovery) throw new WorkspaceRecoveryRequiredError({ ...this.workspaceRecovery }); this.beginRequest(); } @@ -223,7 +269,7 @@ export class ToolRouter { this.session.open(this.config.workspaceRoot, this.cache.currentNamespace); await this.cache.initialize(); await this.repomix.initialize(); - await this.serena.initialize(); + if (!this.roslyn) await this.serena.initialize(); await this.flaui.initialize(); await this.extensions.initializeAll(); const fp = await this.cache.computeWorkspaceFingerprint(this.config.workspaceRoot); @@ -240,16 +286,24 @@ export class ToolRouter { } private async bindWatch(workspaceRoot: string): Promise { - await this.watch.stop(); + try { await this.watch.stop(); } + catch (error) { throw new GatewayRestartRequiredError([error], 'Workspace watcher cleanup failed; restart the Gateway after checking cleanup.'); } this.watch.start(workspaceRoot, () => { this.cache.noteFilesystemChange(workspaceRoot); }); + this.assertWatchBound(workspaceRoot); if (!this.watchRegistered) { this.resources.register('disposable', 'workspace-watch', () => this.watch.stop()); this.watchRegistered = true; } } + private assertWatchBound(workspaceRoot: string): void { + const status = this.watch.getStatus(); + if (!status.active || status.root !== path.resolve(workspaceRoot)) + throw new Error(`Workspace watcher binding failed: ${status.lastError?.message ?? 'no active watcher for the requested workspace'}`); + } + /** * Switch the active workspace. Serialized so two MCP calls cannot interleave * Serena dispose/connect and cache namespace changes. @@ -259,6 +313,8 @@ export class ToolRouter { if (this.shuttingDown) { throw new Error('WinCode is shutting down; workspace_open rejected.'); } + if (this.workspaceRecovery?.recoveryAction === 'restart_gateway') + throw new WorkspaceRecoveryRequiredError({ ...this.workspaceRecovery }); if (!this.switchingPromise) { this.switchingPromise = new Promise((resolve) => { @@ -266,6 +322,9 @@ export class ToolRouter { }); } + const previousRoot = this.config.workspaceRoot; + let rootPrepared = false; + let phase = 'drain'; try { // Wait for existing in-flight queries on the old workspace to settle before re-binding const drainTimeout = this.config.timeouts?.shutdownMs ?? 8_000; @@ -277,22 +336,28 @@ export class ToolRouter { } const resolved = path.resolve(targetPath); - const previousRoot = this.config.workspaceRoot; const sameWorkspace = - Boolean(previousRoot) && path.resolve(previousRoot) === resolved && Boolean(this.session.current); + !this.workspaceRecovery && this.watch.getStatus().active && Boolean(previousRoot) && path.resolve(previousRoot) === resolved && Boolean(this.session.current); + phase = 'fingerprint'; const fp = await this.cache.computeWorkspaceFingerprint(resolved, { fresh: true }); checkOperation({ signal }); + phase = 'workspace'; const result = await this.workspace.openWorkspace(targetPath, options); + rootPrepared = true; + checkOperation({ signal }); if (sameWorkspace) { + phase = 'refresh'; + // 同根 workspace_open 是显式恢复入口;停止旧 Host 后由下一次搜索按新 SDK/输入加载。 + if (this.roslyn) { phase = 'roslyn-reset'; await this.roslyn.resetConnection(); } const previousFp = this.session.current?.fingerprint ?? null; this.session.touch(); this.session.setFingerprint(fp); if (previousFp && previousFp !== fp) { this.cache.invalidateFingerprint(resolved); this.cache.setNamespace(this.config.workspaceRoot); - this.serena.markProjectStale(); + if (!this.roslyn) this.serena.markProjectStale(); } return result; } @@ -300,18 +365,47 @@ export class ToolRouter { // Keep the process cache directory; isolate by namespace so we do not // write `.cache/wincode` into every opened repo, and so project A // symbols cannot be read as project B. + phase = 'cache'; this.cache.invalidateFingerprint(previousRoot); this.cache.setNamespace(this.config.workspaceRoot); + phase = 'session'; this.session.open(this.config.workspaceRoot, this.cache.currentNamespace); this.session.setFingerprint(fp); + phase = 'watch'; await this.bindWatch(this.config.workspaceRoot); + checkOperation({ signal }); + phase = 'repomix-dispose'; await this.repomix.dispose(); - await this.serena.resetConnection(); + checkOperation({ signal }); + phase = this.roslyn ? 'roslyn-reset' : 'serena-reset'; + await (this.roslyn ? this.roslyn.resetConnection() : this.serena.resetConnection()); + checkOperation({ signal }); + phase = 'repomix-initialize'; await this.repomix.initialize(); - await this.serena.initialize(); + checkOperation({ signal }); + phase = 'serena-initialize'; + if (!this.roslyn) await this.serena.initialize(); + checkOperation({ signal }); + phase = 'composites'; this.bindCompositeTools(); + checkOperation({ signal }); + phase = 'watch-confirmation'; + this.assertWatchBound(this.config.workspaceRoot); + // Publish readiness only after every participant has completed rebinding. + this.workspaceRecovery = null; return result; + } catch (error) { + if (rootPrepared || this.config.workspaceRoot !== previousRoot || this.workspaceRecovery) { + this.workspaceRecovery = { + activeWorkspace: this.config.workspaceRoot, attemptedWorkspace: path.resolve(targetPath), + phase, message: (error instanceof Error ? error.message : String(error)).slice(0, 1024), + recoveryAction: error instanceof GatewayRestartRequiredError ? 'restart_gateway' : 'workspace_open', + }; + if (!signal?.aborted && !(error instanceof AbortError)) + throw new WorkspaceRecoveryRequiredError({ ...this.workspaceRecovery }); + } + throw error; } finally { const resolve = this.resolveSwitching; this.switchingPromise = null; @@ -322,11 +416,11 @@ export class ToolRouter { } private bindCompositeTools(): void { - this.context = new ContextManager(this.config, this.workspace, this.repomix, this.serena); - this.architecture = new ArchitectureAnalyzer(this.workspace, this.serena); - this.impact = new ImpactAnalyzer(this.serena, this.config); - this.refactor = new RefactorAssistant(this.workspace, this.serena, this.impact); - this.diagnostics = new ProjectDiagnostics(this.workspace, this.config, this.serena); + this.context = new ContextManager(this.config, this.workspace, this.repomix, this.code); + this.architecture = new ArchitectureAnalyzer(this.workspace, this.code); + this.impact = new ImpactAnalyzer(this.code, this.config); + this.refactor = new RefactorAssistant(this.workspace, this.code, this.impact); + this.diagnostics = new ProjectDiagnostics(this.workspace, this.config, this.code); } async waitForIdle(timeoutMs: number, signal?: AbortSignal): Promise { @@ -357,7 +451,10 @@ export class ToolRouter { return { version: WINCODE_VERSION, - status: this.shuttingDown ? 'shutting_down' : 'online', + codeProvider: this.roslyn ? 'roslyn' : 'serena', + ...(this.roslyn ? { roslyn: this.roslyn.getKnownHealth() } : {}), + status: this.shuttingDown ? 'shutting_down' : this.workspaceRecovery ? 'recovery_required' : 'online', + workspaceRecovery: this.workspaceRecovery ? { ...this.workspaceRecovery } : null, uptimeMs: Date.now() - this.startedAt, startedAt: new Date(this.startedAt).toISOString(), activeWorkspace: this.config.workspaceRoot, @@ -450,6 +547,7 @@ export class ToolRouter { () => this.watch.stop(), () => this.repomix.dispose(), () => this.serena.dispose(), + () => this.roslyn?.dispose(), () => this.flaui.dispose(), () => this.extensions.disposeAll(), () => this.cache.flush(), diff --git a/src/Core/Workspace.ts b/src/Core/Workspace.ts index d89593b..e494880 100644 --- a/src/Core/Workspace.ts +++ b/src/Core/Workspace.ts @@ -3,9 +3,26 @@ import path from 'node:path'; import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import { WinCodeConfig } from './Config.js'; +import { randomUUID } from 'node:crypto'; const execAsync = promisify(exec); +export interface TrashMoveResult { + success: boolean; + trashPath: string; + message: string; + outcome: 'completed' | 'not_moved' | 'partial'; + failureStage?: 'validation' | 'prepare' | 'move' | 'metadata'; + errorCode?: 'TRASH_NOT_MOVED' | 'TRASH_METADATA_FAILED'; + originalPath?: string; + metadataPath?: string; +} + +export function invalidTrashResult(message: string): TrashMoveResult { + return { success: false, trashPath: '', message, outcome: 'not_moved', + failureStage: 'validation', errorCode: 'TRASH_NOT_MOVED' }; +} + export interface ProjectIdentity { name: string; type: 'dotnet' | 'node' | 'python' | 'rust' | 'go' | 'general'; @@ -940,45 +957,56 @@ 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<{ success: boolean; trashPath: string; message: string }> { + async moveToTrash(relativeFilePath: string, reason?: string): Promise { try { validateTrashPath(relativeFilePath, this.root, this.config.trashDir); } catch (error) { - return { success: false, trashPath: '', message: error instanceof Error ? error.message : String(error) }; + return invalidTrashResult(error instanceof Error ? error.message : String(error)); } const targetPath = path.resolve(this.root, relativeFilePath); - // 5. Realpath boundary check: resolve symlinks and Windows junctions to prevent escaping via links - const realRoot = await this.getRealPath(this.root); - const realTarget = await this.getRealPath(targetPath); - const realTrash = await this.getRealPath(this.config.trashDir); - - if (!this.isPathInside(realRoot, realTarget)) { - return { - success: false, - trashPath: '', - message: `Failed to move file to trash: Target path "${relativeFilePath}" resolves outside the workspace via symlink or junction.`, - }; - } + let failureStage: NonNullable = 'validation'; + let destinationPath = ''; + let moved = false; + try { + // Resolve symlinks and Windows junctions before any move. + const realRoot = await this.getRealPath(this.root); + const realTarget = await this.getRealPath(targetPath); + const realTrash = await this.getRealPath(this.config.trashDir); - if (this.isPathInsideOrEqual(realTrash, realTarget)) { - return { - success: false, - trashPath: '', - message: 'Failed to move file to trash: Cannot move items from or within the trash directory.', - }; - } + if (!this.isPathInside(realRoot, realTarget)) { + return invalidTrashResult(`Failed to move file to trash: Target path "${relativeFilePath}" resolves outside the workspace via symlink or junction.`); + } - // All checks passed without side-effects -> proceed to file operations - await fs.mkdir(this.config.trashDir, { recursive: true }); + if (this.isPathInsideOrEqual(realTrash, realTarget)) { + return invalidTrashResult('Failed to move file to trash: Cannot move items from or within the trash directory.'); + } - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const fileName = path.basename(targetPath); - const trashFileName = `${timestamp}_${fileName}`; - const destinationPath = path.join(this.config.trashDir, trashFileName); + failureStage = 'prepare'; + await fs.mkdir(this.config.trashDir, { recursive: true }); + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const fileName = path.basename(targetPath); + const prefix = `${timestamp}_${randomUUID()}_`; + // Bound both the payload name and its metadata sibling. UTF-8 bytes also + // bound UTF-16 units on Windows; iterate code points to avoid splitting them. + const nameBudget = 255 - Buffer.byteLength(prefix + '.meta.json'); + let displayName = ''; + let nameBytes = 0; + for (const character of fileName) { + const bytes = Buffer.byteLength(character); + if (nameBytes + bytes > nameBudget) break; + displayName += character; + nameBytes += bytes; + } + displayName = displayName.replace(/[. ]+$/, '') || 'file'; + const trashFileName = prefix + displayName; + destinationPath = path.join(this.config.trashDir, trashFileName); - try { + failureStage = 'move'; await fs.rename(targetPath, destinationPath); + moved = true; + failureStage = 'metadata'; const metaPath = path.join(this.config.trashDir, `${trashFileName}.meta.json`); await fs.writeFile( metaPath, @@ -995,14 +1023,21 @@ export class WorkspaceManager { return { success: true, + outcome: 'completed', originalPath: targetPath, metadataPath: metaPath, trashPath: destinationPath, message: `File safely moved to trash: ${path.relative(this.root, destinationPath)}`, }; - } catch (err: any) { + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); return { success: false, - trashPath: destinationPath, - message: `Failed to move file to trash: ${err?.message || String(err)}`, + outcome: moved ? 'partial' : 'not_moved', failureStage, + errorCode: moved ? 'TRASH_METADATA_FAILED' : 'TRASH_NOT_MOVED', + originalPath: targetPath, trashPath: moved ? destinationPath : '', + ...(moved ? { metadataPath: `${destinationPath}.meta.json` } : {}), + message: moved + ? `File was moved to ${destinationPath}, but metadata was not completed: ${detail}. Preserve this path; do not retry the move or assume it was rolled back.` + : `Failed to move file to trash: ${detail}`, }; } } diff --git a/src/Gateway/CodeTools.ts b/src/Gateway/CodeTools.ts index 85d7b93..38ef52a 100644 --- a/src/Gateway/CodeTools.ts +++ b/src/Gateway/CodeTools.ts @@ -1,6 +1,7 @@ import { defineTool, jsonResult } from './ToolDefinition.js'; import { contextResponse } from './ContextResponse.js'; import { validateContextScope, type PreparedContextOptions } from '../Core/Context.js'; +import type { SymbolLocation } from '../Core/CodeQueries.js'; export const CODE_TOOLS = [ defineTool({ @@ -79,7 +80,7 @@ export const CODE_TOOLS = [ }), defineTool<{ query: string; kind?: string }>({ name: 'wincode_find_code_symbol', - description: 'Locates code symbols with signatures and line numbers. Uses Serena when handshake and project activation succeed; otherwise local text scan. Result includes source, queryComplete, uniqueTypeMatch, and limitations.', + description: 'Locates code declarations with signatures and positions using the configured provider. Direct Roslyn returns snapshot-bound location objects for exact reference selection; old locations expire after edits/reloads/switches. Inspect source, queryComplete, truncation and limitations.', inputSchema: { type: 'object', additionalProperties: true, properties: { @@ -97,9 +98,9 @@ export const CODE_TOOLS = [ }, { execute: async (args, { router, signal }) => jsonResult(await router.findCodeSymbols(args.query, args.kind, signal), true), }), - defineTool<{ symbolName: string; relativePath?: string }>({ + defineTool<{ symbolName: string; relativePath?: string; symbolLocation?: SymbolLocation }>({ name: 'wincode_find_references', - description: 'Finds all call sites and usages of a specified symbol across the repository. Uses Serena semantic references when available; degrades to local text retrieval with explicit limitations annotation (text retrieval does not guarantee symbol identity or cross-file reference completeness).', + 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: { type: 'object', additionalProperties: true, properties: { @@ -111,15 +112,27 @@ export const CODE_TOOLS = [ type: 'string', description: 'Defining file relative to the workspace. Pair it with the full namePath for precise references; omitted paths are resolved only from a complete unique semantic candidate.', }, + symbolLocation: { + type: 'object', additionalProperties: true, required: ['snapshotId', 'project', 'file', 'position'], + properties: { + snapshotId: { type: 'string', pattern: '^[a-f0-9]{32}$' }, + project: { type: 'string', minLength: 1, maxLength: 4096 }, + file: { type: 'string', minLength: 1, maxLength: 4096 }, + position: { type: 'integer', minimum: 0 }, + }, + description: 'Copy the location returned by the current Roslyn symbol search. project/file are workspace-relative; position is a zero-based UTF-16 offset. This is not a durable ID. Serena instances reject this field.', + }, }, required: ['symbolName'], }, }, { - execute: async (args, { router, signal }) => jsonResult(await router.findCodeReferences(args.symbolName, args.relativePath, signal), true), + 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), }), defineTool<{ target: string }>({ name: 'analyze_change_impact', - description: 'Estimates change blast radius from uniquely resolved symbols. Confidence depends on unique resolution and query completeness, not on source=serena-mcp alone. Zero references yield UNKNOWN, never safe-to-delete.', + description: 'Estimates change blast radius from uniquely resolved symbols. Confidence depends on unique resolution and query completeness, not on provider alone. Zero or incomplete references yield UNKNOWN, never safe-to-delete.', inputSchema: { type: 'object', additionalProperties: true, properties: { diff --git a/src/Gateway/McpServer.ts b/src/Gateway/McpServer.ts index cd6cfe2..98648de 100644 --- a/src/Gateway/McpServer.ts +++ b/src/Gateway/McpServer.ts @@ -1,8 +1,9 @@ import { Server } from '@modelcontextprotocol/server'; import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import { ToolRouter } from '../Core/ToolRouter.js'; +import { ToolRouter, WorkspaceRecoveryRequiredError } from '../Core/ToolRouter.js'; import { WINCODE_VERSION } from '../Core/Config.js'; import { AbortError } from '../Core/ResourceManager.js'; +import { CodeQueryError } from '../Core/CodeQueries.js'; import { ToolRegistry } from './ToolRegistry.js'; import { jsonResult, type ToolExecutionContext } from './ToolDefinition.js'; @@ -39,15 +40,21 @@ export class WinCodeMcpServer { let acquired = false; try { if (!definition!.switchesWorkspace) { - await this.router.acquireRequestSlot(signal); + await this.router.acquireRequestSlot(signal, definition!.allowDuringWorkspaceRecovery); acquired = true; } return await definition!.execute(args, context); } catch (error) { if (error instanceof AbortError || (error instanceof Error && error.name === 'AbortError') || signal?.aborted) { return jsonResult({ schemaVersion: '1.0', protocolVersion: '1.0', success: false, - errorCode: 'CANCELLED', errorMessage: 'Tool call was cancelled.' }, true, true); + errorCode: 'CANCELLED', errorMessage: 'Tool call was cancelled.', + ...(this.router.workspaceRecoveryState ? { workspaceRecovery: this.router.workspaceRecoveryState } : {}) }, true, true); } + if (error instanceof WorkspaceRecoveryRequiredError) + return jsonResult({ success: false, errorCode: 'WORKSPACE_RECOVERY_REQUIRED', + errorMessage: error.message, workspaceRecovery: error.recovery }, true, true); + if (error instanceof CodeQueryError) + return jsonResult({ success: false, errorCode: error.errorCode, errorMessage: error.message }, true, true); return { content: [{ type: 'text' as const, text: `Tool Execution Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true }; } finally { if (acquired) this.router.endRequest(); diff --git a/src/Gateway/ToolDefinition.ts b/src/Gateway/ToolDefinition.ts index ea1547d..fa79ab4 100644 --- a/src/Gateway/ToolDefinition.ts +++ b/src/Gateway/ToolDefinition.ts @@ -12,6 +12,7 @@ export interface ToolDefinition { tool: Tool; aliases?: Array<{ name: string; listed: boolean; description?: string }>; switchesWorkspace?: boolean; + allowDuringWorkspaceRecovery?: boolean; invalidArguments?: (message: string) => CallToolResult; validate?: (args: Record, context: ToolExecutionContext) => void; execute: (args: Record, context: ToolExecutionContext) => Promise; diff --git a/src/Gateway/WorkspaceTools.ts b/src/Gateway/WorkspaceTools.ts index 3704e1f..a6d8e50 100644 --- a/src/Gateway/WorkspaceTools.ts +++ b/src/Gateway/WorkspaceTools.ts @@ -1,5 +1,5 @@ import { defineTool, jsonResult } from './ToolDefinition.js'; -import { validateWorkspaceDirectoryOptions, validateTrashPath, type WorkspaceOpenOptions, type WorkspaceDirectoryOptions } from '../Core/Workspace.js'; +import { validateWorkspaceDirectoryOptions, validateTrashPath, invalidTrashResult, type WorkspaceOpenOptions, type WorkspaceDirectoryOptions } from '../Core/Workspace.js'; import { RUNTIME_IDENTITY } from '../Core/RuntimeIdentity.js'; import { WINCODE_VERSION } from '../Core/Config.js'; import { contractHash } from './ContractHash.js'; @@ -63,6 +63,7 @@ export const WORKSPACE_TOOLS = [ }, }, }, { + allowDuringWorkspaceRecovery: true, validate: (args, context) => { if (args.toolName !== undefined && !context.tools.some(tool => tool.name === args.toolName)) throw new Error(`Tool is not registered in this instance: ${args.toolName}`); @@ -77,7 +78,9 @@ export const WORKSPACE_TOOLS = [ ...(selectedTool ? { tool: { name: selectedTool.name, inputSchema: selectedTool.inputSchema, schemaHash: contractHash(selectedTool.inputSchema) } } : {}) }, platform: process.platform, workspace: router.config.workspaceRoot, timestamp: new Date().toISOString(), health, + codeProvider: health.codeProvider, adapters: { + ...(health.roslyn ? { roslyn: health.roslyn } : {}), serena: { available: true, source: health.serena.handshakeOk ? 'installed' : 'fallback', details: `commandFound=${health.serena.commandFound}; handshakeOk=${health.serena.handshakeOk}; projectActive=${health.serena.projectActive === null ? 'unprobed' : health.serena.projectActive}; semanticQueryUsable=${health.serena.semanticQueryUsable}; mode=${health.serena.mode}`, upstream: { commandFound: health.serena.commandFound, handshakeOk: health.serena.handshakeOk, @@ -105,7 +108,7 @@ export const WORKSPACE_TOOLS = [ }), defineTool>({ name: 'wincode_diagnose_project', - description: 'Diagnoses project health, Windows/.NET SDK readiness, solution files, Serena/Repomix status, and a lightweight runtime snapshot (uptime, cache, child processes). dotnet --version is not semantic analysis.', + description: 'Diagnoses project health, Windows/.NET SDK readiness, solution files, configured code provider/Repomix status, and a lightweight runtime snapshot. dotnet --version or a live Host does not prove complete semantic evidence.', inputSchema: { type: 'object', additionalProperties: true, properties: {}, @@ -131,7 +134,7 @@ export const WORKSPACE_TOOLS = [ required: ['filePath'], }, }, { - invalidArguments: message => jsonResult({ success: false, trashPath: '', message }, true, 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); diff --git a/src/index.ts b/src/index.ts index e9b87cb..3de2741 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,8 @@ import { getDefaultConfig, WINCODE_VERSION } from './Core/Config.js'; import { ToolRouter } from './Core/ToolRouter.js'; import { WinCodeMcpServer } from './Gateway/McpServer.js'; +import fs from 'node:fs/promises'; +import path from 'node:path'; async function main() { let workspaceRoot = process.cwd(); @@ -17,6 +19,21 @@ async function main() { } const config = getDefaultConfig(workspaceRoot); + // 此文件是用户显式选择的启动配置,不从目标仓库自动发现或接受 MCP 参数指定执行程序。 + const roslynIndex = args.indexOf('--roslyn-config'); + if (roslynIndex >= 0) { + const file = args[roslynIndex + 1]; + if (!file || !path.isAbsolute(file)) throw new Error('--roslyn-config requires an absolute JSON file path.'); + const handle = await fs.open(file, 'r'); + try { + const buffer = Buffer.alloc(16385); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + if (bytesRead > 16384) throw new Error('Roslyn configuration exceeds 16 KiB.'); + const options = JSON.parse(buffer.subarray(0, bytesRead).toString('utf8')); + if (options?.enabled !== true) throw new Error('Explicit Roslyn configuration must set enabled=true.'); + config.adapters.roslyn = options; + } finally { await handle.close(); } + } if (args.includes('--development')) config.adapters.flaui.hostMode = 'development'; const router = new ToolRouter(config); const server = new WinCodeMcpServer(router); diff --git a/tests/failure-recovery.test.ts b/tests/failure-recovery.test.ts new file mode 100644 index 0000000..52ed464 --- /dev/null +++ b/tests/failure-recovery.test.ts @@ -0,0 +1,254 @@ +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import nativeFs from 'node:fs'; +import { EventEmitter } from 'node:events'; +import path from 'node:path'; +import os from 'node:os'; +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { ToolRouter, WorkspaceRecoveryRequiredError } from '../src/Core/ToolRouter.js'; +import { getDefaultConfig } from '../src/Core/Config.js'; +import { WinCodeMcpServer } from '../src/Gateway/McpServer.js'; + +async function fixture(run: (router: ToolRouter, a: string, b: string, client: Client) => Promise, expectedCleanupFailure = false) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-recovery-')); + const a = path.join(root, 'a'), b = path.join(root, 'b'); + await fs.mkdir(a); await fs.mkdir(b); + await fs.writeFile(path.join(a, 'OnlyA.cs'), 'class OnlyA {}'); + await fs.writeFile(path.join(b, 'OnlyB.cs'), 'class OnlyB {}'); + const config = getDefaultConfig(a); + config.adapters.serena.enabled = false; config.adapters.flaui.enabled = false; + config.adapters.repomix.useCli = false; + const router = new ToolRouter(config); + const server = new WinCodeMcpServer(router); + const client = new Client({ name: 'recovery-fixture', version: '1' }); + const [left, right] = InMemoryTransport.createLinkedPair(); + try { + await router.initialize(); + await Promise.all([client.connect(left), (server as any).server.connect(right)]); + await run(router, a, b, client); + } finally { + await client.close(); + if (expectedCleanupFailure) await assert.rejects(server.stop(), /Gateway shutdown failed/); + else await server.stop(); + assert.equal(router.inFlightRequests, 0); + assert.equal((await router.getRuntimeHealth()).workspaceWatch.active, false); + const relative = path.relative(os.tmpdir(), root); + assert.ok(relative.startsWith('wincode-recovery-') && !relative.includes(path.sep)); + await fs.rm(root, { recursive: true, force: true }); + } +} + +const body = (result: any) => JSON.parse(result.content[0].text); + +it('internal Serena close failure requests Gateway restart instead of repeating an unrecoverable reset', async () => fixture(async (router, _a, b, client) => { + let closes = 0; + (router.serena as any).serenaClient = { + close: async () => { closes++; if (closes === 1) throw new Error('fixture transient client close failure'); }, + }; + const failed = await client.callTool({ name: 'workspace_open', arguments: { path: b } }); + assert.equal(failed.isError, true); + assert.equal(body(failed).workspaceRecovery.recoveryAction, 'restart_gateway'); + assert.match(body(failed).errorMessage, /restart the Gateway/); + const sessionId = router.session.current?.id; + for (let attempt = 0; attempt < 2; attempt++) { + const repeated = await client.callTool({ name: 'workspace_open', arguments: { path: b } }); + assert.equal(body(repeated).workspaceRecovery.recoveryAction, 'restart_gateway'); + assert.equal(router.session.current?.id, sessionId, 'permanent failure must not mutate the session again'); + } + assert.equal(closes, 1); + await assert.rejects(router.acquireRequestSlot(), WorkspaceRecoveryRequiredError); + const health = body(await client.callTool({ name: 'wincode_hello_world', arguments: {} })).health; + assert.equal(health.workspaceRecovery.recoveryAction, 'restart_gateway'); +}, true)); + +it('native watcher creation failure blocks queries and a later open recreates the watcher', async t => fixture(async (router, _a, b) => { + const failed = t.mock.method(nativeFs, 'watch', () => { throw new Error('fixture native watch creation failure'); }); + try { await assert.rejects(router.openWorkspace(b), WorkspaceRecoveryRequiredError); } + finally { failed.mock.restore(); } + assert.equal(router.workspaceRecoveryState?.recoveryAction, 'workspace_open'); + assert.equal((await router.getRuntimeHealth()).workspaceWatch.active, false); + await assert.rejects(router.acquireRequestSlot(), WorkspaceRecoveryRequiredError); + await router.openWorkspace(b); + assert.equal(router.workspaceRecoveryState, null); + assert.equal((await router.getRuntimeHealth()).workspaceWatch.active, true); + assert.equal((await router.getRuntimeHealth()).workspaceWatch.root, b); +})); + +it('a retained native watcher close failure requires restart and is not advertised as reopenable', async t => { + const native = new EventEmitter() as nativeFs.FSWatcher; + native.close = () => { throw new Error('fixture native close failure'); }; + const mock = t.mock.method(nativeFs, 'watch', () => native); + try { + await fixture(async (router, _a, b) => { + await assert.rejects(router.openWorkspace(b), WorkspaceRecoveryRequiredError); + assert.equal(router.workspaceRecoveryState?.recoveryAction, 'restart_gateway'); + const sessionId = router.session.current?.id; + await assert.rejects(router.openWorkspace(b), WorkspaceRecoveryRequiredError); + assert.equal(router.session.current?.id, sessionId); + }, true); + } finally { mock.mock.restore(); } +}); + +it('watcher failure during adapter initialization cannot commit a successful workspace switch', async t => fixture(async (router, _a, b) => { + const original = nativeFs.watch; + let targetWatch: nativeFs.FSWatcher | undefined; + t.mock.method(nativeFs, 'watch', (...args: Parameters) => { targetWatch = original(...args); return targetWatch; }); + const initialize = router.serena.initialize.bind(router.serena); + const fault = t.mock.method(router.serena, 'initialize', async () => { + await initialize(); + targetWatch!.emit('error', new Error('fixture asynchronous watch failure')); + }); + try { await assert.rejects(router.openWorkspace(b), WorkspaceRecoveryRequiredError); } + finally { fault.mock.restore(); } + assert.equal(router.workspaceRecoveryState?.phase, 'watch-confirmation'); + await assert.rejects(router.acquireRequestSlot(), WorkspaceRecoveryRequiredError); + await router.openWorkspace(b); + assert.equal(router.workspaceRecoveryState, null); +})); + +it('invalid target preserves the old workspace and still admits requests', async () => fixture(async (router, a, b) => { + const before = router.session.current?.id; + await assert.rejects(router.openWorkspace(path.join(b, 'missing')), /Invalid workspace/); + assert.equal(router.config.workspaceRoot, a); + assert.equal(router.session.current?.id, before); + assert.equal(router.workspaceRecoveryState, null); + await router.acquireRequestSlot(); router.endRequest(); +})); + +for (const stage of ['namespace', 'session', 'watch', 'dispose', 'reset', 'initialize', 'serena', 'composites']) { + it(`failure at ${stage} blocks queries; same-root recovery performs a full rebind`, async t => fixture(async (router, a, b, client) => { + await router.cache.set('isolation', 'A'); + const targets: Record = { + namespace: [router.cache, 'setNamespace'], session: [router.session, 'open'], + watch: [router, 'bindWatch'], dispose: [router.repomix, 'dispose'], + reset: [router.serena, 'resetConnection'], initialize: [router.repomix, 'initialize'], + serena: [router.serena, 'initialize'], composites: [router, 'bindCompositeTools'], + }; + const [target, method] = targets[stage]; + const fault = t.mock.method(target, method, () => { throw new Error(`fixture:${stage}`); }); + try { await assert.rejects(router.openWorkspace(b), WorkspaceRecoveryRequiredError); } + finally { fault.mock.restore(); } + assert.equal(router.config.workspaceRoot, b); + await assert.rejects(router.acquireRequestSlot(), WorkspaceRecoveryRequiredError); + assert.equal(router.inFlightRequests, 0); + const query = await client.callTool({ name: 'wincode_list_directory', arguments: {} }); + assert.equal(query.isError, true); + assert.equal(body(query).errorCode, 'WORKSPACE_RECOVERY_REQUIRED'); + const hello = await client.callTool({ name: 'wincode_hello_world', arguments: {} }); + assert.notEqual(hello.isError, true); + assert.equal(body(hello).status, 'recovery_required'); + assert.equal(body(hello).health.workspaceRecovery.recoveryAction, 'workspace_open'); + // An invalid recovery attempt must never reopen admission. + await assert.rejects(router.openWorkspace(path.join(b, 'missing'))); + await assert.rejects(router.acquireRequestSlot(), WorkspaceRecoveryRequiredError); + const resets = t.mock.method(router.serena, 'resetConnection', router.serena.resetConnection.bind(router.serena)); + const opened = await client.callTool({ name: 'wincode_workspace_open', arguments: { path: b } }); + assert.notEqual(opened.isError, true); + // Router resets explicitly; Serena.initialize also disposes its prior connection. + assert.equal(resets.mock.callCount(), 2); + assert.equal(router.workspaceRecoveryState, null); + const health = await router.getRuntimeHealth(); + assert.equal(health.session?.workspaceRoot, b); + assert.equal(health.workspaceWatch.root, b); + assert.equal(health.session?.cacheNamespace, router.cache.currentNamespace); + assert.equal(await router.cache.get('isolation'), null); + const listing = body(await client.callTool({ name: 'wincode_list_directory', arguments: {} })); + assert.ok(listing.entries.some((entry: any) => entry.path === 'OnlyB.cs')); + assert.ok(!listing.entries.some((entry: any) => entry.path === 'OnlyA.cs')); + await router.openWorkspace(a); + })); +} + +it('cancellation after root preparation rejects queued queries until recovery', async t => fixture(async (router, a, b) => { + const controller = new AbortController(); + const original = router.workspace.openWorkspace.bind(router.workspace); + let entered!: () => void, release!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + const proceed = new Promise(resolve => { release = resolve; }); + const fault = t.mock.method(router.workspace, 'openWorkspace', async (...args: Parameters) => { + const result = await original(...args); entered(); await proceed; return result; + }); + const switching = assert.rejects(router.openWorkspace(b, {}, controller.signal), /cancel|abort/i); + await started; + const query = assert.rejects(router.acquireRequestSlot(), WorkspaceRecoveryRequiredError); + controller.abort(); release(); + try { await Promise.all([switching, query]); } finally { fault.mock.restore(); } + assert.equal(router.config.workspaceRoot, b); + assert.equal(router.workspaceRecoveryState?.phase, 'workspace'); + await router.openWorkspace(a); + await router.acquireRequestSlot(); router.endRequest(); +})); + +it('metadata failure returns partial with durable location; retry and restart preserve moved bytes', async t => fixture(async (router, a, _b, client) => { + const original = fs.writeFile; + const fault = t.mock.method(fs, 'writeFile', async (...args: Parameters) => { + if (String(args[0]).endsWith('.meta.json')) throw new Error('fixture metadata failure'); + return original(...args); + }); + let result: any; + try { + const response = await client.callTool({ name: 'wincode_safe_move_to_trash', arguments: { filePath: 'OnlyA.cs' } }); + assert.equal(response.isError, true); result = body(response); + } finally { fault.mock.restore(); } + assert.equal(result.success, false); assert.equal(result.outcome, 'partial'); + assert.equal(result.errorCode, 'TRASH_METADATA_FAILED'); + assert.equal(result.failureStage, 'metadata'); + assert.equal(result.originalPath, path.join(a, 'OnlyA.cs')); + await assert.rejects(fs.stat(result.originalPath), { code: 'ENOENT' }); + assert.equal(await fs.readFile(result.trashPath, 'utf8'), 'class OnlyA {}'); + const retry = await router.moveToTrash('OnlyA.cs'); + assert.equal(retry.outcome, 'not_moved'); assert.equal(retry.trashPath, ''); + assert.equal(await fs.readFile(result.trashPath, 'utf8'), 'class OnlyA {}'); + // Simulate a fresh manager without relying on any in-memory recovery state. + const { WorkspaceManager } = await import('../src/Core/Workspace.js'); + const restarted = new WorkspaceManager(getDefaultConfig(a)); + const repeated = await restarted.moveToTrash('OnlyA.cs'); + assert.equal(repeated.outcome, 'not_moved'); + assert.equal(await fs.readFile(result.trashPath, 'utf8'), 'class OnlyA {}'); +})); + +for (const stage of ['prepare', 'move'] as const) { + it(`trash ${stage} failure leaves source intact and does not advertise a destination`, async t => fixture(async (router, a) => { + const method = stage === 'prepare' ? 'mkdir' : 'rename'; + const fault = t.mock.method(fs, method, async () => { throw new Error(`fixture ${stage}`); }); + let result; + try { result = await router.moveToTrash('OnlyA.cs'); } finally { fault.mock.restore(); } + assert.equal(result.outcome, 'not_moved'); assert.equal(result.failureStage, stage); + assert.equal(result.trashPath, ''); + assert.equal(await fs.readFile(path.join(a, 'OnlyA.cs'), 'utf8'), 'class OnlyA {}'); + const retry = await router.moveToTrash('OnlyA.cs'); + assert.equal(retry.outcome, 'completed'); assert.equal(retry.success, true); + assert.equal(JSON.parse(await fs.readFile(retry.metadataPath!, 'utf8')).originalPath, path.join(a, 'OnlyA.cs')); + })); +} + +it('same-named files moved in the same timestamp retain separate contents and metadata', async t => fixture(async (router, a) => { + await fs.mkdir(path.join(a, 'nested')); + await fs.writeFile(path.join(a, 'nested', 'OnlyA.cs'), 'second file'); + t.mock.method(Date.prototype, 'toISOString', () => '2026-09-09T00:00:00.000Z'); + const first = await router.moveToTrash('OnlyA.cs'); + const second = await router.moveToTrash('nested/OnlyA.cs'); + assert.equal(first.outcome, 'completed'); assert.equal(second.outcome, 'completed'); + assert.notEqual(first.trashPath, second.trashPath); + assert.equal(await fs.readFile(first.trashPath, 'utf8'), 'class OnlyA {}'); + assert.equal(await fs.readFile(second.trashPath, 'utf8'), 'second file'); + assert.equal(JSON.parse(await fs.readFile(second.metadataPath!, 'utf8')).originalPath, path.join(a, 'nested', 'OnlyA.cs')); +})); + +it('long ASCII and Unicode names retain complete payload and metadata without name overflow', async () => fixture(async (router, a) => { + const names = [183, 184, 190, 193, 194, 200, 220, 255].map(length => 'x'.repeat(length - 4) + '.txt'); + // UTF-8 and UTF-16 limits differ: avoid splitting Unicode code points when shortening. + names.push('汉'.repeat(80) + '.txt', '😀'.repeat(60) + '.txt'); + for (const [index, name] of names.entries()) { + const source = path.join(a, name); + const content = `original content ${index}`; + await fs.writeFile(source, content); + const result = await router.moveToTrash(name); + assert.equal(result.outcome, 'completed', `${name.length}: ${result.message}`); + assert.ok(Buffer.byteLength(path.basename(result.metadataPath!)) <= 255); + assert.equal(await fs.readFile(result.trashPath, 'utf8'), content); + assert.equal(JSON.parse(await fs.readFile(result.metadataPath!, 'utf8')).originalPath, source); + await assert.rejects(fs.stat(source), { code: 'ENOENT' }); + } +})); diff --git a/tests/repomix-disabled.test.ts b/tests/repomix-disabled.test.ts index 003f573..bc9103d 100644 --- a/tests/repomix-disabled.test.ts +++ b/tests/repomix-disabled.test.ts @@ -85,6 +85,7 @@ if (args.includes('--version')) { console.log('fixture-1'); process.exit(0); } fs.writeFileSync(path.join(process.cwd(), 'started.json'), JSON.stringify({pid:process.pid,args,cwd:process.cwd()})); if (args.includes('--compress')) { setInterval(() => {}, 1000); } else fs.writeFileSync(args[args.indexOf('-o') + 1], JSON.stringify({args,cwd:process.cwd()})); +console.log(' Total Files: 1 files'); `); try { await run(adapter, config, root); } finally { @@ -119,6 +120,31 @@ it('discovers installed package bin metadata without invoking npm or PATH wrappe assert.equal((await adapter.packWorkspace()).source, 'repomix-cli'); })); +for (const count of [0, 2, 1234]) { + it(`CLI summary reports ${count} files independently of body headers`, async () => realCliFixture(async (adapter, config) => { + await fs.writeFile(config.adapters.repomix.customCliPath!, ` +const fs = require('node:fs'); +const args = process.argv.slice(2); +if (args.includes('--version')) { console.log('fixture-1'); process.exit(0); } +fs.writeFileSync(args[args.indexOf('-o') + 1], 'File: fake\\n## File: fake\\n'); +console.log(' Total Files: ${count.toLocaleString('en-US')} files'); +`); + await adapter.initialize(); + const result = await adapter.packWorkspace(); + assert.equal(result.source, 'repomix-cli'); + assert.equal(result.fileCount, count); + })); +} + +it('missing CLI count degrades instead of inventing a packed file', async () => realCliFixture(async (adapter, config) => { + const script = await fs.readFile(config.adapters.repomix.customCliPath!, 'utf8'); + await fs.writeFile(config.adapters.repomix.customCliPath!, script.replace("console.log(' Total Files: 1 files');", '')); + await adapter.initialize(); + const result = await adapter.packWorkspace(); + assert.equal(result.source, 'builtin-fallback'); + assert.match(adapter.lastError?.message ?? '', /file-count summary/); +})); + for (const invalid of ['missing.cjs', 'wrapper.cmd', 'relative.cjs']) { it(`invalid explicit CLI ${invalid} falls back without a process`, async () => realCliFixture(async (adapter, config, root) => { await fs.writeFile(path.join(root, 'wrapper.cmd'), '@echo should-never-run'); diff --git a/tests/roslyn-contracts.test.ts b/tests/roslyn-contracts.test.ts new file mode 100644 index 0000000..0377bff --- /dev/null +++ b/tests/roslyn-contracts.test.ts @@ -0,0 +1,108 @@ +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { RoslynHostClient } from '../src/Adapters/RoslynHostClient.js'; +import { RoslynAdapter } from '../src/Adapters/RoslynAdapter.js'; +import { 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'; + +/** 只产生自有 Node 协议夹具;finally 先清理进程,再删除已验证的临时根。 */ +async function processFixture(source: string, run: (client: RoslynHostClient, resources: ResourceManager) => Promise): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-roslyn-rpc-')); + const resources = new ResourceManager(); + let client: RoslynHostClient | undefined; + try { + const file = path.join(root, 'host.cjs'); + await fs.writeFile(file, source); + client = new RoslynHostClient(process.execPath, [file], root, resources); + await run(client, resources); + } finally { + await client?.close(true).catch(() => {}); + await resources.dispose(); + assert.equal(resources.childProcessCount(), 0); + assert.ok(path.relative(os.tmpdir(), root).startsWith('wincode-roslyn-rpc-')); + await fs.rm(root, { recursive: true, force: true }); + } +} + +it('rejects missing project-evaluation permission and unbounded options before any process is registered', async () => { + const config = getDefaultConfig(process.cwd()); + const resources = new ResourceManager(); + config.adapters.roslyn = { enabled: true, allowProjectEvaluation: false, project: 'App.csproj', configuration: 'Debug', + targetFramework: 'net10.0', dotnetPath: process.execPath, hostPath: path.resolve('host.dll') }; + assert.throws(() => new RoslynAdapter(config, resources, () => []), (error: unknown) => error instanceof CodeQueryError && error.errorCode === 'PROJECT_EVALUATION_NOT_ALLOWED'); + config.adapters.roslyn.allowProjectEvaluation = true; + config.adapters.roslyn.loadTimeoutMs = Infinity; + assert.throws(() => new RoslynAdapter(config, resources, () => []), /Invalid Roslyn time budget/); + config.adapters.roslyn.loadTimeoutMs = 1000; + config.adapters.roslyn.project = '../outside.csproj'; + assert.throws(() => new RoslynAdapter(config, resources, () => []), /escapes/); + assert.deepEqual(resources.list(), []); + await resources.dispose(); +}); + +for (const [label, output] of [ + ['invalid JSON', 'not-json\n'], + ['unsolicited id', JSON.stringify({ id: 'unrequested', success: true }) + '\n'], + ['oversized frame', 'x'.repeat(1048577)], +] as const) { + it(`closes an owned Host after ${label}, without accepting a ready snapshot`, async () => processFixture( + `process.stdout.write(${JSON.stringify(output)}); setInterval(() => {}, 1000);`, async (client) => { + await assert.rejects(client.waitReady(5000), (error: unknown) => error instanceof CodeQueryError && error.errorCode === 'HOST_PROTOCOL_ERROR'); + assert.equal(client.active, false); + assert.ok(client.child.exitCode !== null || client.child.signalCode !== null); + })); +} + +it('timeout waits for cancellation grace then hard-reaps the unresponsive owned process', async () => processFixture( + `console.log(JSON.stringify({ id:null, success:true })); process.stdin.resume(); setInterval(() => {}, 1000);`, async (client) => { + await client.waitReady(5000); + await assert.rejects(client.request({ operation: 'symbols' }, 20), (error: unknown) => error instanceof CodeQueryError && error.errorCode === 'HOST_TIMEOUT'); + assert.equal(client.active, false); + assert.ok(client.child.exitCode !== null || client.child.signalCode !== null); + })); + +it('explicit shutdown failure remains a rejected cleanup result after process exit', async () => processFixture( + `console.log(JSON.stringify({ id:null, success:true })); require('node:readline').createInterface({input:process.stdin}).on('line', line => { const r=JSON.parse(line); console.log(JSON.stringify({id:r.id,success:false,errorCode:'HOST_RESTART_REQUIRED'})); process.exit(1); });`, async (client) => { + await client.waitReady(5000); + const first = client.close(); + await assert.rejects(first); + assert.equal(client.close(), first); + await assert.rejects(client.close()); + })); + +it('Roslyn cleanup failure enters sticky E1 recovery and never starts Serena or mutates another root', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-roslyn-recovery-')); + const a = path.join(root, 'a'), b = path.join(root, 'b'); + await fs.mkdir(a); await fs.mkdir(b); + const config = getDefaultConfig(a); + config.adapters.flaui.enabled = false; config.adapters.repomix.useCli = false; + config.adapters.roslyn = { enabled: true, allowProjectEvaluation: true, project: 'App.csproj', configuration: 'Debug', + targetFramework: 'net10.0', dotnetPath: process.execPath, hostPath: path.join(root, 'host.dll') }; + const router = new ToolRouter(config); + let closes = 0; + router.serena.initialize = async () => { throw new Error('Serena must not initialize'); }; + router.serena.findSymbolsDetailed = async () => { throw new Error('Serena must not query'); }; + try { + await router.initialize(); + (router.roslyn as any).client = { close: async () => { closes++; throw new Error('injected cleanup failure'); } }; + await assert.rejects(router.openWorkspace(b), WorkspaceRecoveryRequiredError); + assert.equal(router.workspaceRecoveryState?.recoveryAction, 'restart_gateway'); + await assert.rejects(router.openWorkspace(a), WorkspaceRecoveryRequiredError); + assert.equal(config.workspaceRoot, b); + assert.equal(closes, 1); + await assert.rejects(router.acquireRequestSlot(), WorkspaceRecoveryRequiredError); + const health = await router.getRuntimeHealth(); + assert.equal(health.codeProvider, 'roslyn'); + assert.equal(health.roslyn?.cleanupFailed, true); + } finally { + await assert.rejects(router.dispose()); + assert.equal(router.resources.childProcessCount(), 0); + assert.ok(path.relative(os.tmpdir(), root).startsWith('wincode-roslyn-recovery-')); + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/stage1-cleanup.test.ts b/tests/stage1-cleanup.test.ts index b59b521..1b84b3d 100644 --- a/tests/stage1-cleanup.test.ts +++ b/tests/stage1-cleanup.test.ts @@ -173,7 +173,6 @@ it('shutdown waits for the active switch and rejects its queued queries', async const events: string[] = []; router.workspace.openWorkspace = async () => { entered(); await blocked; return {} as any; }; router.cache.computeWorkspaceFingerprint = async () => 'fixture'; - (router as any).bindWatch = () => {}; router.repomix.dispose = async () => { events.push('dispose'); }; router.repomix.initialize = async () => { events.push('initialize'); }; router.serena.resetConnection = async () => {}; diff --git a/tests/tool-contracts.test.ts b/tests/tool-contracts.test.ts index eb7639b..81669c3 100644 --- a/tests/tool-contracts.test.ts +++ b/tests/tool-contracts.test.ts @@ -205,7 +205,7 @@ it('rejects declared enum, range and nested type violations before admission', a it('validates directory and trash lexical scope before admission while preserving trash errors', async () => fixture(async (client, router, admissions) => { let calls = 0; router.listDirectory = async () => { calls++; return {} as any; }; - router.moveToTrash = async () => { calls++; return { success: true, trashPath: '', message: '' }; }; + router.moveToTrash = async () => { calls++; return { success: true, trashPath: '', message: '', outcome: 'completed' }; }; for (const requested of ['../outside', 'src/../inside']) { const result = await client.callTool({ name: 'wincode_list_directory', arguments: { path: requested } }); assert.equal(result.isError, true, requested); diff --git a/tools/WinCode.Code.Host/OwnedProcessJob.cs b/tools/WinCode.Code.Host/OwnedProcessJob.cs new file mode 100644 index 0000000..67b8909 --- /dev/null +++ b/tools/WinCode.Code.Host/OwnedProcessJob.cs @@ -0,0 +1,71 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +/// +/// Host 在启动 MSBuild 前加入仅属于自己的 Windows Job;正常退出或被强制终止时回收其后代。 +/// 只管理通过普通进程创建继承的后代,不是执行沙盒,也不限制 targets 借助外部服务启动进程。 +/// +internal static class OwnedProcessJob +{ + // 句柄不可继承且由静态字段保活;不能在 Host 返回关闭确认前 Dispose,否则会终止自身。 + // 进程终止时 Windows 关闭最后一个句柄,KILL_ON_JOB_CLOSE 清理该 Job 中的进程。 + private static SafeFileHandle? lifetime; + + /// 一次性绑定本进程;无法建立所有权边界时在任何项目求值前失败。 + public static void Attach() + { + if (!OperatingSystem.IsWindows() || lifetime != null) return; + var job = CreateJobObjectW(IntPtr.Zero, null); + if (job.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateJobObject failed."); + var limits = new ExtendedLimits { Basic = new BasicLimits { Flags = 0x2000 } }; + try + { + if (!SetInformationJobObject(job, 9, ref limits, (uint)Marshal.SizeOf()) || + !AssignProcessToJobObject(job, GetCurrentProcess())) + throw new Win32Exception(Marshal.GetLastWin32Error(), "Unable to protect owned process tree."); + lifetime = job; + } + catch { job.Dispose(); throw; } + } + + /// Win32 JOBOBJECT_BASIC_LIMIT_INFORMATION;指针尺寸字段使用 UIntPtr 保持平台布局。 + [StructLayout(LayoutKind.Sequential)] + private struct BasicLimits + { + public long ProcessTime, JobTime; + public uint Flags; + public UIntPtr MinimumWorkingSet, MaximumWorkingSet; + public uint ActiveProcesses; + public UIntPtr Affinity; + public uint PriorityClass, SchedulingClass; + } + + /// Win32 IO_COUNTERS;扩展限制结构必须保留全部六个 64 位计数器。 + [StructLayout(LayoutKind.Sequential)] + private struct IoCounters { public ulong ReadOperations, WriteOperations, OtherOperations, ReadBytes, WriteBytes, OtherBytes; } + + /// Win32 JOBOBJECT_EXTENDED_LIMIT_INFORMATION;仅设置关闭时终止标志,不设资源配额。 + [StructLayout(LayoutKind.Sequential)] + private struct ExtendedLimits + { + public BasicLimits Basic; + public IoCounters Io; + public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory; + } + + /// 创建匿名、不可继承的 Job 句柄。 + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateJobObjectW(IntPtr attributes, string? name); + /// 写入 Job 扩展限制,信息类别 9 对应 ExtendedLimitInformation。 + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetInformationJobObject(SafeFileHandle job, int infoClass, ref ExtendedLimits limits, uint length); + /// 把本 Host 关联到 Job;后续普通子进程默认继承该关联。 + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AssignProcessToJobObject(SafeFileHandle job, IntPtr process); + /// 取得当前进程的伪句柄,无需 CloseHandle。 + [DllImport("kernel32.dll")] + private static extern IntPtr GetCurrentProcess(); +} diff --git a/tools/WinCode.Code.Host/Program.cs b/tools/WinCode.Code.Host/Program.cs new file mode 100644 index 0000000..6430b8f --- /dev/null +++ b/tools/WinCode.Code.Host/Program.cs @@ -0,0 +1,174 @@ +using System.Collections.Concurrent; +using System.Text; +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Build.Locator; + +/// +/// 实验性 C# Host:直接调用 Roslyn,内部协议 v2,尚未注册到 Gateway。 +/// MSBuild targets 是获准执行的项目代码,本进程不是执行沙盒,也不自动 restore。 +/// +internal static class Program +{ + private static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + private static readonly object OutputLock = new(); + + /// 一个已接纳请求拥有一个取消源;排队、执行和回收全过程共用其身份。 + private sealed record Pending(string Id, JsonElement Request, CancellationTokenSource Cancellation); + + /// 验证显式求值许可、固定工作区和配置;按入口项目目录选择 SDK 后启动会话。 + /// --allow-project-evaluation ROOT PROJECT CONFIGURATION FRAMEWORK。 + /// 正常退出 0;启动、协议流或资源释放失败 1。 + private static async Task Main(string[] args) + { + Console.InputEncoding = Console.OutputEncoding = new UTF8Encoding(false); + try + { + if (args.Length != 5 || args[0] != "--allow-project-evaluation") + throw new ArgumentException("Explicit project evaluation permission required: --allow-project-evaluation ROOT PROJECT CONFIGURATION FRAMEWORK"); + var root = Path.GetFullPath(args[1]); + var project = WorkspaceInputs.Inside(root, args[2]); + if (!Path.GetExtension(project).Equals(".csproj", StringComparison.OrdinalIgnoreCase) || !File.Exists(project)) + throw new ArgumentException("A C# project is required."); + if (string.IsNullOrWhiteSpace(args[3]) || string.IsNullOrWhiteSpace(args[4])) throw new ArgumentException("Explicit configuration and framework required."); + // MSBuild 定位先于 JIT 加载 Workspace;CWD 仅在这个自有进程内改变。 + Directory.SetCurrentDirectory(Path.GetDirectoryName(project)!); + OwnedProcessJob.Attach(); + MSBuildLocator.RegisterDefaults(); + return await RunAsync(root, project, args[3], args[4]); + } + catch (Exception error) { WriteFailure(null, error, "hostError"); return 1; } + } + + /// stdout 仅写完整单行 JSON;主读循环的取消确认与工作线程响应串行写入。 + private static void Write(object value) + { + lock (OutputLock) Console.WriteLine(JsonSerializer.Serialize(value, Json)); + } + + /// 按已知异常类型分类,不用自然语言推断恢复。错误响应不携带旧引用。 + private static void WriteFailure(string? id, Exception error, string type = "result") => Write(new { + id, type, success = false, errorCode = error switch { + HostFailure failure => failure.Code, + OperationCanceledException => "CANCELLED", + ArgumentException or JsonException or FormatException or InvalidOperationException or KeyNotFoundException => "INVALID_ARGUMENT", + _ => "QUERY_FAILED" + }, error = error.Message + }); + + /// + /// 持有一个固定根的会话。工作队列最多 8 条,语义操作串行执行;输入线程可立即取消。 + /// shutdown/EOF 停止接纳并取消现有请求,等待它们结束及资源释放后才确认关闭。 + /// + /// + /// v2 请求:references 必填 id/operation/snapshot/project/file/position; + /// symbols 必填 id/operation/snapshot/query,可选 kind/file;最多返回 200 个声明与当前快照定位。 + /// position 是零基 UTF-16 偏移,返回 line/column 一基,start/length 零基 UTF-16。 + /// reload 只需 id/operation,成功返回新的 ready/snapshot,调用者必须重新定位。 + /// cancel 使用 id/operation/targetId,确认仅说明取消已发出,目标请求仍有独立结果。 + /// symbols/references 的 timeoutMs 默认 30000、上限 60000;reload 默认/上限 120000,均至少 1,包含排队时间。 + /// limit 为 1–1000、默认 100,只约束返回量。协作取消不等于进程级硬截止。 + /// + private static async Task RunAsync(string root, string project, string configuration, string framework) + { + var session = new WorkspaceSession(root, project, configuration, framework); + var queue = Channel.CreateBounded(new BoundedChannelOptions(8) { SingleReader = true, SingleWriter = true }); + var requests = new ConcurrentDictionary(); + using var stopping = new CancellationTokenSource(); + string? shutdownId = null; + Task worker = Task.CompletedTask; + try + { + using (var initialDeadline = new CancellationTokenSource(120000)) + Write(await session.ReloadAsync(null, initialDeadline.Token)); + worker = Task.Run(async () => { + await foreach (var pending in queue.Reader.ReadAllAsync()) + { + try + { + pending.Cancellation.Token.ThrowIfCancellationRequested(); + var operation = pending.Request.GetProperty("operation").GetString(); + var response = operation switch { + "reload" => await session.ReloadAsync(pending.Id, pending.Cancellation.Token), + "references" => await session.ReferencesAsync(pending.Request, pending.Cancellation.Token), + "symbols" => await session.SymbolsAsync(pending.Request, pending.Cancellation.Token), + _ => throw new ArgumentException("Unknown operation.") + }; + pending.Cancellation.Token.ThrowIfCancellationRequested(); + Write(response); + } + catch (Exception error) { WriteFailure(pending.Id, error); } + finally + { + requests.TryRemove(pending.Id, out _); + pending.Cancellation.Dispose(); + } + } + }); + while (true) + { + var line = ReadFrame(); + if (line == null) break; + string? id = null; + try + { + using var json = JsonDocument.Parse(line); + var request = json.RootElement; + id = request.GetProperty("id").GetString(); + if (string.IsNullOrWhiteSpace(id) || id.Length > 128) throw new ArgumentException("id must contain 1–128 characters."); + if (requests.ContainsKey(id)) throw new HostFailure("DUPLICATE_REQUEST", "Request id is already active."); + var operation = request.GetProperty("operation").GetString(); + if (operation == "shutdown") { shutdownId = id; break; } + if (operation == "cancel") + { + var targetId = request.GetProperty("targetId").GetString() ?? throw new ArgumentException("targetId required."); + var cancelled = requests.TryGetValue(targetId, out var cancellation); + if (cancelled) try { cancellation!.Cancel(); } catch (ObjectDisposedException) { cancelled = false; } + Write(new { id, success = true, targetId, cancellationRequested = cancelled }); + continue; + } + if (operation is not ("references" or "symbols" or "reload")) throw new ArgumentException("Unknown operation."); + var maximum = operation == "reload" ? 120000 : 60000; + var duration = request.TryGetProperty("timeoutMs", out var value) ? value.GetInt32() : operation == "reload" ? 120000 : 30000; + if (duration < 1 || duration > maximum) throw new ArgumentException("Invalid timeout."); + var source = CancellationTokenSource.CreateLinkedTokenSource(stopping.Token); + source.CancelAfter(duration); + requests[id] = source; + if (!queue.Writer.TryWrite(new(id, request.Clone(), source))) + { + requests.TryRemove(id, out _); + source.Dispose(); + throw new HostFailure("BUSY", "Host queue is full."); + } + } + catch (Exception error) { WriteFailure(id, error); } + } + } + finally + { + try { stopping.Cancel(); } + finally + { + queue.Writer.TryComplete(); + try { await worker; } + finally { session.Dispose(); } + } + } + if (shutdownId != null) Write(new { id = shutdownId, success = true }); + return 0; + } + + /// 读取最多 65536 个 UTF-16 字符;超长帧终止会话,避免继续解析失去边界的数据。 + private static string? ReadFrame() + { + var buffer = new StringBuilder(); + while (true) + { + var ch = Console.Read(); + if (ch == -1) return buffer.Length == 0 ? null : buffer.ToString(); + if (ch == '\n') return buffer.ToString(); + if (buffer.Length >= 65536) throw new ArgumentException("Request frame exceeds 64 Ki characters."); + buffer.Append((char)ch); + } + } +} diff --git a/tools/WinCode.Code.Host/WinCode.Code.Host.csproj b/tools/WinCode.Code.Host/WinCode.Code.Host.csproj new file mode 100644 index 0000000..1c95875 --- /dev/null +++ b/tools/WinCode.Code.Host/WinCode.Code.Host.csproj @@ -0,0 +1,15 @@ + + + Exe + net10.0 + enable + enable + true + + + + + + + + diff --git a/tools/WinCode.Code.Host/WorkspaceInputs.cs b/tools/WinCode.Code.Host/WorkspaceInputs.cs new file mode 100644 index 0000000..03ba7c4 --- /dev/null +++ b/tools/WinCode.Code.Host/WorkspaceInputs.cs @@ -0,0 +1,113 @@ +using System.Security.Cryptography; +using System.Text; + +/// 携带稳定错误码的内部协议失败;恢复动作由调用方处理,不自动重放请求。 +internal sealed class HostFailure(string code, string message) : Exception(message) +{ + public string Code { get; } = code; +} + +/// +/// 有界输入清单:跟踪工作区文件(包含 obj)、已加载文档/元数据,以及祖先常规配置。 +/// 排除目录只影响默认枚举,显式加载的文件仍加入校验。不能发现任意自定义 target 的隐式外部输入。 +/// +internal sealed record WorkspaceInputs(string Fingerprint, IReadOnlyDictionary Files, long Bytes) +{ + public int FileCount { get; init; } = Files.Count; + private static readonly HashSet IgnoredDirectories = new(StringComparer.OrdinalIgnoreCase) + { ".git", "node_modules", ".deps", "bin", "dist", "build", ".cache", ".vs", ".packages", "test-tmp", "trash" }; + private static readonly string[] AncestorNames = ["global.json", "Directory.Build.props", "Directory.Build.targets", "Directory.Packages.props", "NuGet.Config"]; + internal const int MaxEntries = 20000; + internal const int MaxFiles = 5000; + internal const long MaxBytes = 128L * 1024 * 1024; + + /// 规范化并验证请求源码路径;根外路径或链接路径立即失败。 + public static string Inside(string root, string requested) + { + var full = Path.GetFullPath(requested, root); + var relative = Path.GetRelativePath(root, full); + if (relative == ".." || relative.StartsWith(".." + Path.DirectorySeparatorChar) || Path.IsPathRooted(relative)) + throw new HostFailure("OUTSIDE_WORKSPACE", "Path outside workspace."); + RejectLinks(full); + return full; + } + + /// 拒绝现存祖先路径中的重解析点;普通路径不存在由读取阶段按失败处理。 + private static void RejectLinks(string full) + { + for (string? current = full; current != null; current = Path.GetDirectoryName(current)) + if ((File.Exists(current) || Directory.Exists(current)) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + throw new HostFailure("UNSUPPORTED_LINK", "Reparse paths are not supported."); + } + + /// 仅用于过滤监听噪声;obj 不排除,未知路径事件应由调用方标记失效。 + public static bool IsIgnored(string root, string full) => + Path.GetRelativePath(root, full).Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Any(IgnoredDirectories.Contains); + + /// + /// 读取输入内容并计算 SHA-256。超出条目、文件或字节预算直接失败,不生成截断的有效快照。 + /// extraFiles 来自实际加载的文档和 PortableExecutableReference,可包含授权的 SDK/包元数据。 + /// + public static async Task CaptureAsync(string root, IEnumerable extraFiles, CancellationToken token) + { + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + var pending = new Stack(); + pending.Push(root); + var entries = 0; + while (pending.TryPop(out var directory)) + { + foreach (var entry in Directory.EnumerateFileSystemEntries(directory)) + { + token.ThrowIfCancellationRequested(); + if (++entries > MaxEntries) throw new HostFailure("INPUT_BUDGET_EXCEEDED", "Too many workspace entries."); + var attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.Directory) != 0 && IgnoredDirectories.Contains(Path.GetFileName(entry))) continue; + if ((attributes & FileAttributes.ReparsePoint) != 0) throw new HostFailure("UNSUPPORTED_LINK", "Linked workspace input."); + if ((attributes & FileAttributes.Directory) != 0) pending.Push(entry); + else paths.Add(entry); + } + } + foreach (var extra in extraFiles) paths.Add(Path.GetFullPath(extra)); + for (var parent = Directory.GetParent(root); parent != null; parent = parent.Parent) + foreach (var name in AncestorNames) + { + var file = Path.Combine(parent.FullName, name); + if (File.Exists(file)) paths.Add(file); + } + if (paths.Count > MaxFiles) throw new HostFailure("INPUT_BUDGET_EXCEEDED", "Too many input files."); + var files = new Dictionary(StringComparer.OrdinalIgnoreCase); + long bytes = 0; + using var aggregate = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + foreach (var file in paths.Order(StringComparer.OrdinalIgnoreCase)) + { + token.ThrowIfCancellationRequested(); + RejectLinks(file); + var length = new FileInfo(file).Length; + if (length > 32L * 1024 * 1024 || length > MaxBytes - bytes) + throw new HostFailure("INPUT_BUDGET_EXCEEDED", "Input byte budget exceeded."); + // 读前长度不阻止文件随后增长,因此流读取也受同一上限约束。 + using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 8192, true); + using var contents = new MemoryStream(); + var buffer = new byte[8192]; + int read; + while ((read = await stream.ReadAsync(buffer, token)) != 0) + { + bytes += read; + if (bytes > MaxBytes || contents.Length + read > 32L * 1024 * 1024) + throw new HostFailure("INPUT_BUDGET_EXCEEDED", "Input grew beyond byte budget."); + contents.Write(buffer, 0, read); + } + var data = contents.ToArray(); + files.Add(file, data); + aggregate.AppendData(Encoding.UTF8.GetBytes(file.ToUpperInvariant() + "\0")); + aggregate.AppendData(SHA256.HashData(data)); + } + return new(Convert.ToHexString(aggregate.GetHashAndReset()), files, bytes); + } + + /// 取得已有 global.json 的内容签名;SDK 已在进程内绑定,变化后必须重启 Host。 + public string SdkSelection => string.Join(";", Files.Where(pair => Path.GetFileName(pair.Key).Equals("global.json", StringComparison.OrdinalIgnoreCase)) + .OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase) + .Select(pair => pair.Key + ":" + Convert.ToHexString(SHA256.HashData(pair.Value)))); +} diff --git a/tools/WinCode.Code.Host/WorkspaceSession.cs b/tools/WinCode.Code.Host/WorkspaceSession.cs new file mode 100644 index 0000000..b27145f --- /dev/null +++ b/tools/WinCode.Code.Host/WorkspaceSession.cs @@ -0,0 +1,317 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.FindSymbols; +using Microsoft.CodeAnalysis.MSBuild; +using Microsoft.CodeAnalysis.Text; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +/// +/// 一个工作区的串行语义会话;加载、重载和引用操作不能并发修改本对象。 +/// 监听只提供变化提示,查询前后仍检查内容指纹。加载失败后保留失效状态,禁止返回旧证据。 +/// +internal sealed class WorkspaceSession : IDisposable +{ + private readonly string root, projectPath, configuration, framework; + private readonly FileSystemWatcher watcher; + private MSBuildWorkspace? workspace; + private Solution? solution; + private WorkspaceInputs? inputs; + private string[] extraFiles = []; + private string? sdkSelection; + private string? snapshot; + private volatile bool invalidated = true; + private volatile string? watchError; + private Exception? cleanupFailure; + private long generation; + private long configurationGeneration; + private int excludedAnalyzers; + private string[] loadDiagnostics = [], compilationErrors = []; + + /// 绑定固定根及配置并启动监听;不在构造时执行 MSBuild,求值由 ReloadAsync 显式启动。 + public WorkspaceSession(string root, string projectPath, string configuration, string framework) + { + this.root = root; this.projectPath = projectPath; this.configuration = configuration; this.framework = framework; + watcher = new(root) { IncludeSubdirectories = true, NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite | NotifyFilters.Size }; + watcher.Changed += (_, e) => Changed(e.FullPath); + watcher.Created += (_, e) => Changed(e.FullPath); + watcher.Deleted += (_, e) => Changed(e.FullPath); + watcher.Renamed += (_, e) => { Changed(e.OldFullPath); Changed(e.FullPath); }; + watcher.Error += (_, e) => { watchError = e.GetException().Message; Interlocked.Increment(ref generation); }; + watcher.EnableRaisingEvents = true; + } + + /// 立即推进变更代次,不防抖;重载仅由显式请求执行,避免每个保存事件都运行 targets。 + private void Changed(string file) + { + if (!WorkspaceInputs.IsIgnored(root, file)) + { + Interlocked.Increment(ref generation); + if (Path.GetExtension(file).ToLowerInvariant() is ".csproj" or ".props" or ".targets" or ".json" or ".config") + Interlocked.Increment(ref configurationGeneration); + } + } + + /// 监听或清理失败不可通过重新使用旧会话恢复,要求所属 Host 重启。 + private void CheckHealth() + { + if (watchError != null || cleanupFailure != null) + throw new HostFailure("HOST_RESTART_REQUIRED", watchError ?? cleanupFailure!.Message); + } + + /// 校验窗口内事件代次没有变化;散列期间有写入则拒绝这个不稳定检查点。 + private async Task CaptureAsync(CancellationToken token, bool checkEvents = true) + { + CheckHealth(); + var before = Interlocked.Read(ref generation); + var captured = await WorkspaceInputs.CaptureAsync(root, extraFiles, token); + CheckHealth(); + if (checkEvents && before != Interlocked.Read(ref generation)) + throw new HostFailure("INPUTS_CHANGED", "Inputs changed while reading; reload after writes finish."); + if (sdkSelection != null && captured.SdkSelection != sdkSelection) + throw new HostFailure("HOST_RESTART_REQUIRED", "global.json changed; restart Host to select MSBuild again."); + return captured; + } + + /// + /// 废弃旧快照后重新加载。至多两次尝试,用于首次设计时生成文件/发现元数据的稳定化。 + /// 取消、文件变化或加载失败都不恢复旧身份。SDK 选择变化需要新进程,不能本进程热切换。 + /// + public async Task ReloadAsync(string? id, CancellationToken token) + { + invalidated = true; + CheckHealth(); + ReleaseWorkspace(); + // 删除的旧文档必须使查询失效,但不能阻止新项目模型重新发现当前输入集合。 + extraFiles = extraFiles.Where(File.Exists).ToArray(); + var clock = Stopwatch.StartNew(); + for (var attempt = 0; attempt < 2; attempt++) + { + token.ThrowIfCancellationRequested(); + // 设计时构建会触碰 obj 缓存;加载阶段按内容比较,另单独拒绝配置求值期间的配置写入。 + var configurationBefore = Interlocked.Read(ref configurationGeneration); + var before = await CaptureAsync(token, checkEvents: false); + sdkSelection ??= before.SdkSelection; + workspace = MSBuildWorkspace.Create(new Dictionary { + ["Configuration"] = configuration, ["TargetFramework"] = framework, + ["RunAnalyzers"] = "false", ["RunAnalyzersDuringBuild"] = "false" + }); + try + { + await workspace.OpenProjectAsync(projectPath, cancellationToken: token); + // OpenProjectAsync 可能返回部分项目而不抛异常;结构化加载失败不能伪装成 ready。 + ReadLoadDiagnostics(); + var candidate = workspace.CurrentSolution; + excludedAnalyzers = candidate.Projects.Sum(p => p.AnalyzerReferences.Count); + var required = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var project in candidate.Projects.ToArray()) + { + required.Add(WorkspaceInputs.Inside(root, project.FilePath!)); + foreach (var document in project.Documents) required.Add(WorkspaceInputs.Inside(root, document.FilePath!)); + foreach (var metadata in project.MetadataReferences.OfType()) + if (metadata.FilePath != null) required.Add(metadata.FilePath); + candidate = candidate.WithProjectAnalyzerReferences(project.Id, []); + } + extraFiles = required.ToArray(); + var captured = await CaptureAsync(token, checkEvents: false); + if (before.Fingerprint != captured.Fingerprint) + { + ReleaseWorkspace(); + if (attempt == 0) continue; + throw new HostFailure("INPUTS_CHANGED", "Inputs did not stabilize during load."); + } + // 显式固定每份文档文本,防止 FileTextLoader 在首次查询时才读取更新后的磁盘文件。 + foreach (var document in candidate.Projects.SelectMany(p => p.Documents).ToArray()) + { + using var content = new MemoryStream(captured.Files[document.FilePath!], false); + candidate = candidate.WithDocumentText(document.Id, SourceText.From(content, Encoding.UTF8, throwIfBinaryDetected: true)); + } + var errors = new List(); + foreach (var project in candidate.Projects) + { + var compilation = await project.GetCompilationAsync(token); + errors.AddRange(compilation!.GetDiagnostics(token).Where(d => d.Severity == DiagnosticSeverity.Error).Take(20).Select(d => d.ToString())); + } + var after = await CaptureAsync(token, checkEvents: false); + if (captured.Fingerprint != after.Fingerprint || configurationBefore != Interlocked.Read(ref configurationGeneration)) + throw new HostFailure("INPUTS_CHANGED", "Inputs changed during compilation."); + var completedDiagnostics = ReadLoadDiagnostics(); + solution = candidate; + // 稳定快照只保留摘要,文档已固定;不长期保留 SDK/包程序集的大块字节数组。 + inputs = after with { Files = new Dictionary() }; + snapshot = Guid.NewGuid().ToString("N"); + loadDiagnostics = completedDiagnostics; + compilationErrors = errors.ToArray(); + invalidated = false; + return new { id, type = "ready", success = true, protocolVersion = 2, snapshot, + projects = candidate.ProjectIds.Count, configuration, framework, loadMs = clock.ElapsedMilliseconds, + loadDiagnostics, compilationErrors, excludedAnalyzers, scope = "loaded-solution-snapshot", + processTreeGuard = OperatingSystem.IsWindows(), diskFreshnessVerified = false, freshness = Freshness(after) }; + } + catch { ReleaseWorkspace(); throw; } + } + throw new HostFailure("INPUTS_CHANGED", "Reload required."); + } + + /// 按 Roslyn 的诊断类别拒绝项目加载失败;源码编译错误由 compilationErrors 单独保留。 + private string[] ReadLoadDiagnostics() + { + var diagnostics = workspace!.Diagnostics.ToArray(); + var failures = diagnostics.Where(d => d.Kind == WorkspaceDiagnosticKind.Failure).Take(20).ToArray(); + if (failures.Length > 0) + throw new HostFailure("PROJECT_LOAD_FAILED", string.Join(Environment.NewLine, failures.Select(d => d.ToString()))); + return diagnostics.Select(d => d.ToString()).ToArray(); + } + + /// 描述检查的明确范围;自定义 targets 的任意外部输入、环境与整个磁盘不在保证范围内。 + private static object Freshness(WorkspaceInputs value) => new { + status = "checked", scope = "workspace-files-loaded-metadata-and-ancestor-config", + fingerprint = value.Fingerprint, files = value.FileCount, bytes = value.Bytes, + externalCustomInputsVerified = false + }; + + /// 比较当前输入与已加载模型;任何读取失败或变化都会使该身份永久失效,直到显式 reload。 + private async Task EnsureCurrentAsync(string requestedSnapshot, CancellationToken token) + { + if (invalidated || snapshot != requestedSnapshot || inputs == null || solution == null) + throw new HostFailure("SNAPSHOT_STALE", "Snapshot expired; reload and relocate the symbol."); + try + { + var current = await CaptureAsync(token); + if (current.Fingerprint != inputs.Fingerprint) + throw new HostFailure("SNAPSHOT_STALE", "Workspace inputs changed; reload and relocate the symbol."); + } + catch (OperationCanceledException) { throw; } + catch (IOException error) { invalidated = true; throw new HostFailure("SNAPSHOT_STALE", "Tracked input unavailable: " + error.Message); } + catch { invalidated = true; throw; } + } + + /// + /// 在固定编译上下文中检索声明,返回可直接用于引用的声明标识符偏移。 + /// partial 声明按同项目 ISymbol 去重;不同项目仍保留独立身份。截断与生成器缺口不隐瞒。 + /// + public async Task SymbolsAsync(JsonElement request, CancellationToken token) + { + var requestedSnapshot = request.GetProperty("snapshot").GetString()!; + await EnsureCurrentAsync(requestedSnapshot, token); + var query = request.GetProperty("query").GetString(); + if (string.IsNullOrWhiteSpace(query) || query.Length > 256) throw new HostFailure("INVALID_ARGUMENT", "Query must contain 1–256 characters."); + var kind = request.TryGetProperty("kind", out var filter) ? filter.GetString() : null; + var scopeFile = request.TryGetProperty("file", out var file) ? WorkspaceInputs.Inside(root, file.GetString()!) : null; + var symbols = new List(); + var totalFound = 0; + foreach (var project in solution!.Projects.OrderBy(p => p.FilePath, StringComparer.OrdinalIgnoreCase)) + { + var seen = new HashSet(SymbolEqualityComparer.Default); + foreach (var document in project.Documents.OrderBy(d => d.FilePath, StringComparer.OrdinalIgnoreCase)) + { + if (scopeFile != null && !string.Equals(scopeFile, document.FilePath, StringComparison.OrdinalIgnoreCase)) continue; + var syntax = await document.GetSyntaxRootAsync(token); + var model = await document.GetSemanticModelAsync(token); + foreach (var node in syntax!.DescendantNodes().OfType()) + { + token.ThrowIfCancellationRequested(); + if (node is not (BaseTypeDeclarationSyntax or DelegateDeclarationSyntax or MethodDeclarationSyntax or ConstructorDeclarationSyntax or PropertyDeclarationSyntax)) continue; + var originalDeclaration = model!.GetDeclaredSymbol(node, token); + var declared = originalDeclaration; + if (declared is IMethodSymbol method) declared = method.PartialDefinitionPart ?? method; + if (declared == null || !declared.Name.Contains(query, StringComparison.OrdinalIgnoreCase) || !seen.Add(declared)) continue; + var declaredKind = DeclarationKind(declared); + if (declaredKind == null || (!string.IsNullOrEmpty(kind) && kind != declaredKind && !(kind == "type" && declared is INamedTypeSymbol))) continue; + // 限定 partial 所在文件时使用该声明的源位置;不能偷偷改成另一个文件里的首个声明。 + var location = scopeFile == null ? declared.Locations.FirstOrDefault(l => l.IsInSource) : + originalDeclaration!.Locations.FirstOrDefault(l => l.IsInSource && string.Equals(l.SourceTree?.FilePath, scopeFile, StringComparison.OrdinalIgnoreCase)); + if (location == null) continue; + var position = location.GetLineSpan().StartLinePosition; + totalFound++; + if (symbols.Count == 200) continue; + symbols.Add(new { name = declared.Name, kind = declaredKind, + file = Path.GetRelativePath(root, location.SourceTree!.FilePath), line = position.Line + 1, column = position.Character + 1, + signature = declared.ToDisplayString(), containerName = declared.ContainingType?.ToDisplayString(), + location = new { snapshotId = snapshot, project = Path.GetRelativePath(root, project.FilePath!), + file = Path.GetRelativePath(root, location.SourceTree.FilePath), position = location.SourceSpan.Start } }); + } + } + } + await EnsureCurrentAsync(requestedSnapshot, token); + return new { id = request.GetProperty("id").GetString(), success = true, snapshot, symbols, totalFound, + truncated = totalFound > symbols.Count, queryComplete = false, loadDiagnostics, compilationErrors, excludedAnalyzers, + scope = "loaded-solution-snapshot", diskFreshnessVerified = false, freshness = Freshness(inputs!) }; + } + + /// 映射当前公共声明类别;没有支持的成员不伪装成方法或类型。 + private static string? DeclarationKind(ISymbol symbol) => symbol switch { + INamedTypeSymbol type => type.TypeKind switch { TypeKind.Class => "class", TypeKind.Interface => "interface", TypeKind.Struct => "struct", TypeKind.Enum => "enum", TypeKind.Delegate => "type", _ => null }, + IMethodSymbol => "method", IPropertySymbol => "property", _ => null + }; + + /// + /// 在指定项目的文档中以零基 UTF-16 偏移定位符号,返回一基行列和原始 span。 + /// 查询前后都验证输入;结束时变更则丢弃计算结果,绝不附带旧引用作为成功响应。 + /// + public async Task ReferencesAsync(JsonElement request, CancellationToken token) + { + var clock = Stopwatch.StartNew(); + var requestedSnapshot = request.GetProperty("snapshot").GetString()!; + await EnsureCurrentAsync(requestedSnapshot, token); + var requestedProject = WorkspaceInputs.Inside(root, request.GetProperty("project").GetString()!); + var file = WorkspaceInputs.Inside(root, request.GetProperty("file").GetString()!); + var project = solution!.Projects.SingleOrDefault(p => string.Equals(p.FilePath, requestedProject, StringComparison.OrdinalIgnoreCase)) + ?? throw new HostFailure("INVALID_ARGUMENT", "Project is not in the loaded snapshot."); + var document = project.Documents.SingleOrDefault(d => string.Equals(d.FilePath, file, StringComparison.OrdinalIgnoreCase)) + ?? throw new HostFailure("INVALID_ARGUMENT", "Document is not in the selected project."); + var position = request.GetProperty("position").GetInt32(); + var limit = request.TryGetProperty("limit", out var value) ? value.GetInt32() : 100; + if (limit < 1 || limit > 1000) throw new HostFailure("INVALID_ARGUMENT", "Invalid limit."); + var text = await document.GetTextAsync(token); + if (position < 0 || position >= text.Length) throw new HostFailure("INVALID_ARGUMENT", "Invalid UTF-16 position."); + var symbol = await SymbolFinder.FindSymbolAtPositionAsync(document, position, token) + ?? throw new HostFailure("SYMBOL_NOT_FOUND", "No symbol at position."); + if (request.TryGetProperty("symbolName", out var expected) && symbol.Name != expected.GetString()) + throw new HostFailure("SYMBOL_MISMATCH", "Location does not identify the requested symbol; search again."); + var found = await SymbolFinder.FindReferencesAsync(symbol, solution, token); + var locations = found.SelectMany(r => r.Locations).Where(r => r.Location.IsInSource) + .DistinctBy(r => (r.Document.Id, r.Location.SourceSpan)).ToArray(); + var references = new List(); + foreach (var location in locations.Take(limit)) + { + var source = await location.Document.GetTextAsync(token); + var span = location.Location.SourceSpan; + var lineSpan = source.Lines.GetLinePositionSpan(span); + var preview = source.Lines[lineSpan.Start.Line].ToString(); + references.Add(new { project = Path.GetRelativePath(root, location.Document.Project.FilePath!), + file = Path.GetRelativePath(root, location.Document.FilePath!), start = span.Start, length = span.Length, + line = lineSpan.Start.Line + 1, column = lineSpan.Start.Character + 1, preview = preview[..Math.Min(300, preview.Length)] }); + } + await EnsureCurrentAsync(requestedSnapshot, token); + return new { id = request.GetProperty("id").GetString(), success = true, snapshot, symbol = symbol.ToDisplayString(), references, + totalReferences = locations.Length, truncated = locations.Length > limit, + queryComplete = false, loadDiagnostics, compilationErrors, excludedAnalyzers, + scope = "loaded-solution-snapshot", diskFreshnessVerified = false, freshness = Freshness(inputs!), + queryMs = clock.ElapsedMilliseconds, workingSetBytes = Environment.WorkingSet }; + } + + /// 释放当前 MSBuildWorkspace;失败留存,禁止后续重载伪装成清理成功。 + private void ReleaseWorkspace() + { + var previous = workspace; + workspace = null; + solution = null; + inputs = null; + if (previous == null) return; + try { previous.Dispose(); } + catch (Exception error) { cleanupFailure = error; throw new HostFailure("HOST_RESTART_REQUIRED", error.Message); } + } + + /// 关闭监听与工作区;调用方必须先排空当前操作。失败向 Host 退出状态传播。 + public void Dispose() + { + invalidated = true; + try { watcher.Dispose(); } + catch (Exception error) { cleanupFailure ??= error; } + finally { ReleaseWorkspace(); } + if (cleanupFailure != null) throw new HostFailure("HOST_RESTART_REQUIRED", cleanupFailure.Message); + } +} diff --git a/tools/WinCode.Code.Host/packages.lock.json b/tools/WinCode.Code.Host/packages.lock.json new file mode 100644 index 0000000..cdc090d --- /dev/null +++ b/tools/WinCode.Code.Host/packages.lock.json @@ -0,0 +1,188 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Microsoft.Build.Framework": { + "type": "Direct", + "requested": "[17.11.48, )", + "resolved": "17.11.48", + "contentHash": "C3WIMt2wBl4++NX3jSEpTq5KXBhvAV154R4JrYHkfy9JSBcXWiL0mkgpspk5xSdOj+fS/uz7zluIy6bMM1fkkQ==" + }, + "Microsoft.Build.Locator": { + "type": "Direct", + "requested": "[1.11.2, )", + "resolved": "1.11.2", + "contentHash": "tY+/S54G29CGsbL3slVu4vqtpciwVnb3fKOmrhgzEQmu/VziFaWmD/E1e/2KH7cDucuycGSkWsSXndBs5Uawow==" + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "D2zqK/k16fto0yMz0hcXMTkzOxEwMDJyA1mu/KXF9Befwz4zub3MpHQD8FeRxJtVSSsC3dQFYBw7zu7r/pfO7g==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.CSharp": "[5.9.0]", + "Microsoft.CodeAnalysis.Common": "[5.9.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.9.0]", + "System.Composition": "10.0.1" + } + }, + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "BBux6hhD4wXt4lYN2oaY+jV4PnIj5plyqohijgSZtxJQqsvsldPEdii2q9lGrJbNjKEALnldDHz1rmGKG0PeeA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.11.48", + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.9.0]", + "Microsoft.Extensions.DependencyInjection": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1", + "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", + "System.Composition": "10.0.1" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "5.9.0-1.26328.17", + "contentHash": "HP9NNk8ZjOSI2hgOyXnQg+kv7/X837Vr2nAlXiGAtqtYnYKjRRa1UmQFr8KFs5ynGYKqfbb8zB9APoWjiAGdMg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "1A6jz50NG4nOEW8tX5+h+MyHqjWL0mPGwrUdwu+OlTfyknLo0GfxSqj4zEks8uVUdHdo9v8Ir9dHxACf8iYNEA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]", + "System.Composition": "10.0.1" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "zerXV0GAR9LCSXoSIApbWn+Dq1/T+6vbXMHGduq1LoVQRHT0BXsGQEau0jeLUBUcsoF/NaUT8ADPu8b+eNcIyg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "oIy8fQxxbUsSrrOvgBqlVgOeCtDmrcynnTG+FQufcUWBrwyPfwlUkCDB2vaiBeYPyT+20u9/HeuHeBf+H4F/8g==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "9ItMpMLFZFJFqCuHLLbR3LiA4ahA8dMtYuXpXl2YamSDWZhYS9BruPprkftY0tYi2bQ0slNrixdFm+4kpz1g5w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "YkmyiPIWAXVb+lPIrM0LE5bbtLOJkCiRTFiHpkVOvhI7uTvCfoOHLEN0LcsY56GpSD7NqX3gJNpsaDe87/B3zg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "G6VVwywpJI4XIobetGHwg7wDOYC2L2XBYdtskxLaKF/Ynb5QBwLl7Q//wxAR2aVCLkMpoQrjSP9VoORkyddsNQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "DO8XrJkp5x4PddDuc/CH37yDBCs9BYN6ijlKyR3vMb55BP1Vwh90vOX8bNfnKxr5B2qEI3D8bvbY1fFbDveDHQ==" + }, + "Microsoft.VisualStudio.SolutionPersistence": { + "type": "Transitive", + "resolved": "1.0.52", + "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" + }, + "System.Composition": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "DJkqaWQfho/ReTzKcZD3zJJ6K4GcS154k+T0UCPMBNIOZ2U/lNpyiiWZ6Etw0onWyTH1K+yhICsdmwA5xy2aPQ==", + "dependencies": { + "System.Composition.AttributedModel": "10.0.1", + "System.Composition.Convention": "10.0.1", + "System.Composition.Hosting": "10.0.1", + "System.Composition.Runtime": "10.0.1", + "System.Composition.TypedParts": "10.0.1" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "mRxYvpCVPAeuLEk0c0kxWJVjbW1/HUoxCgYotOj9eDeQiYcTDOMdCQApsTrHYMN3pHBA8WoF00KGolG632Etaw==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "dYynUByfVBzYDheNPGxS8UN8AvG/4tXf/coSs1odHOyoh4etv1kad/FrLWLMq4f8NO49NV20Xu+0/y613woTUA==", + "dependencies": { + "System.Composition.AttributedModel": "10.0.1" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "4UGmyBdKWEN1nkqspJlji/nV7XIVm6KGlOC2So0mtM/gKvaNgLz+tUkcbY+6Zpr7dr6ohX1S5yl0RLID5otRHw==", + "dependencies": { + "System.Composition.Runtime": "10.0.1" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "TVfys1gnUIhmXuYfFzyez0fOkDyELe9UwlxYeVlq6FmqmWmt1ouF0OQJ+6ozkHbkaop7uBUaXw7Qb+/o0m+nMg==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "koSfjkdQZfgQr3SyiSIBboDn+GiR0vZ3x9Uek9FJbXK0w5AiATV8KrnMEP8B8OAlO+Y3zQf0CPCNzwH+VIYDKg==", + "dependencies": { + "System.Composition.AttributedModel": "10.0.1", + "System.Composition.Hosting": "10.0.1", + "System.Composition.Runtime": "10.0.1" + } + } + } + } +} \ No newline at end of file