diff --git a/CHANGELOG.md b/CHANGELOG.md index f88ae66..84a4b6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## 0.14.0 (unreleased) +- Resolve Git from launch-time installation paths outside the workspace and use absolute argv-based execution. Disable executable fsmonitor configuration, require Git 2.36+, recognize linked worktrees and report unknown status when Git fails. +- Reject linked cache/trash paths and recheck mutation boundaries. Manage only versioned WinCode JSON entries and reserved overflow filenames; preserve legacy/unrecognized files instead of pruning arbitrary JSON or text. Keep partial trash outcomes when metadata writing fails after a move. +- Route architecture discovery through bounded workspace readers, reject outside project descriptors, bound graph parsing and directory enumeration, and propagate cancellation through actual reads. Architecture depth is now an integer from 1 to 5; reports expose incomplete scans and a 32768-character serialized output cap. +- Remove overlapping whitespace matching in local C# declarations and reject normalized declaration lines above 16384 characters as incomplete. Add isolated regression coverage for the previously blocking input and a real MCP heartbeat. + - Validate parent/child creation times in acceptance process-tree snapshots, rejecting stale parent-PID edges after Windows PID reuse. Keep strict exit checks for the real observed Code Host descendants. - Synchronize bilingual setup/cache guidance, architecture/data flow, managed Skill manuals, native Host guidance and remaining roadmap with the verified 0.14.0 behavior. Separate healthy confirmation from recovery and current implementation from planned project binding/admission limits. diff --git a/README.md b/README.md index 67dd8eb..8da16fa 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,10 @@ The 2026-09-08 check of the current Codex connection against TavernDesk source p `wincode_analyze_change_impact` is an alias of `analyze_change_impact`. Detailed workflows: [code intelligence](skills/wincode/references/code.md), [UI inspection](skills/wincode/references/ui.md), [diagnostics](skills/wincode/references/diagnostics.md). +Architecture analysis accepts integer depths 1–5 and returns `scanComplete`, `omissions` and output truncation evidence. Discovery examines at most 2000 entries; the tree preview examines 500. The graph reads at most 16 project descriptors, 64 KiB per file and 256 KiB total, and examines at most 2000 entry-point directory entries. The complete report is capped at 32768 UTF-16 characters. Outside-workspace projects are omitted; this tool does not evaluate MSBuild. + +Git probes use a detected absolute installation path outside the workspace, require Git 2.36 or later and disable executable fsmonitor configuration. Missing or failed Git status is `unknown`, with no assertion that the tree is clean. Cache/trash writes reject existing symlinks and junctions in their paths. Cache cleanup manages versioned WinCode JSON and reserved overflow names; legacy/unrecognized files remain untouched and are outside the managed quota. These checks do not provide an atomic sandbox against concurrent filesystem replacement. + ### Architecture and resource control See the [architecture, data-flow and verification-gate guide](WinCode-架构与数据流说明.md) for the current component boundaries, request sequences, storage lifecycle and delivery checks (Chinese). @@ -394,6 +398,10 @@ npm run delivery:verify `wincode_analyze_change_impact` 是 `analyze_change_impact` 的别名。详细参数与工作流请参考对应手册:[代码分析](skills/wincode/references/code.md)、[UI 取证](skills/wincode/references/ui.md)、[系统诊断](skills/wincode/references/diagnostics.md)。 +架构分析只接受整数深度 1–5,返回 `scanComplete`、`omissions` 和输出截断证据。项目发现最多检查 2000 个目录项,树预览最多 500 项;依赖图最多读取 16 个项目描述文件、每文件 64 KiB、合计 256 KiB,入口文件搜索合计最多检查 2000 项。整份报告最多 32768 个 UTF-16 字符。工作区外项目会省略,本工具不求值 MSBuild。 + +Git 探测从工作区外的安装位置取得绝对可执行路径,要求 Git 2.36 及以上,并禁用可执行的 fsmonitor 配置;缺失或查询失败明确为 `unknown`,不报告干净。缓存和回收写入拒绝路径中已有的符号链接/junction。缓存仅管理带版本标记的 WinCode JSON 与保留命名的 overflow;旧版及无法识别的文件保留,不计入受管配额。这些校验不提供对抗并发路径替换的原子沙盒保证。 + ### 架构设计与资源管控 ```text 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 8bf62b2..d72f710 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,8 +1,8 @@ # WinCode 下一轮工程化迭代计划书 -更新:2026-09-10 14:01(北京时间)。工作区 D:/CODEX PROJECT/WinCode MCP;修复分支 codex/runtime-cache-continuity 基于 origin/main dda0203(已合并 #34),保留上一轮增量并完成本轮四类验收缺陷修复。 +更新:2026-09-10(北京时间)。工作区 D:/CODEX PROJECT/WinCode MCP;本地修复分支 codex/architecture-boundaries 基于 main 7d53fda(已合并 #35)。本轮优先落实架构审查第 1–2 批:执行/磁盘边界、Git 状态、本地正则和有界结构扫描。实际验证回执持续追加至工作日志。 -本版依据当前代码、多实例诊断和 GitHub 一手实现维护。用户随后已明确要求按计划实施;N1–N3 的推荐方向已确认,不重复申请方向审批。本轮先交付可独立验证的 N2 热态保留与 N3 的互斥取消基础,固定根迁移和完整准入容量尚未实现。已完成的 M0–M4、N2 从待办移除,历史证据保留在 [工作日志](docs/codex_worklog.md) 与 [CHANGELOG](CHANGELOG.md)。真实客户端配置和对外发布另按有效授权执行。 +本版依据当前代码、多实例诊断和 GitHub 一手实现维护。用户已明确要求按设计开始修复;N1–N3 的推荐方向继续有效,不重复申请方向审批。此前 N2 热态保留与 N3 的互斥取消基础已完成,固定根迁移和完整准入容量尚未实现。已完成的 M0–M4、N2 从待办移除,历史证据保留在 [工作日志](docs/codex_worklog.md) 与 [CHANGELOG](CHANGELOG.md)。真实客户端配置和对外发布另按有效授权执行。 ## 1. 最终推荐 @@ -26,7 +26,7 @@ 已从开发待办移除:本机交付重建、原生 owner guard、UIA 启动探测延后、可逆手动释放、最小 WinForms 托盘/安全管道、状态过期与注册拒绝反馈、原生源码与交付绑定,以及已经运行的双实例托盘/Roslyn 贯通。对应核心 360/360、桌面 35/35 和后续专项回执见 [11:32 稳定性记录](docs/codex_worklog.md#2026-09-10-1132--0140-稳定性收尾工作流连续性状态可信度与原生交付北京时间)。这些是已有回归基线,不是下一轮重新建设任务。 -N2 已取得此前核心 364/364;本轮完整核心 373/373、桌面 35/35、真实 Roslyn MCP 22 场景和 E4 错误契约 16 场景证据;同根十次重复打开、四次并发确认保持实际 Host PID/snapshot,SDK 变化仍要求新进程,冷加载与热重载损坏、取消/崩溃/超时分别验证。互斥队列取消会立即删除等待节点,执行中的清理仍持锁,但这还不是完整有界准入。当前交付身份与补充桌面/多实例回执统一记录在工作日志;不能据此声称正在运行的旧消费者已更新。 +N2 曾取得核心 364/364,#35 历史基线为核心 373/373、桌面 35/35、真实 Roslyn MCP 22 场景和 E4 错误契约 16 场景。本轮第 1–2 批架构修复的最终核心为 385/385,真实 Roslyn 22 场景与 E4 16 场景重新通过,桌面未重跑。同根十次重复打开、四次并发确认保持实际 Host PID/snapshot,SDK 变化仍要求新进程,冷加载与热重载损坏、取消/崩溃/超时分别验证。互斥队列取消会立即删除等待节点,执行中的清理仍持锁,但这还不是完整有界准入。具体交付身份和历次回执统一记录在工作日志;不能据此声称正在运行的旧消费者已更新。 | 现象 | 当前证据等级 | 下一步 | | --- | --- | --- | @@ -35,7 +35,8 @@ N2 已取得此前核心 364/364;本轮完整核心 373/373、桌面 35/35、 | drain 监听器超过默认数量 | **已定位至测试客户端 SDK**;任务结束后监听器为 0,Gateway 没有同类警告 | 保留诊断;不抬高阈值或更换依赖冒充修复 | | 独立实例共享同项目 cacheDir 的写入/清理 | 已复现并修复 peer prune 后悬空 overflow 命中;两进程回归通过 | N4 保留其他写入/退出交错与源码/UI 边界,不重复建设已通过用例 | | 一个原生托盘专项没有生成 UI 回执 | **既有未定位失败**;旧脚本未保留退出码,超时只是推测,随后三次未复现 | N5 利用已补诊断复查,不能写成已修复 | -| 实际 Codex 的最新构建/Roslyn 消费闭环、其他软件接入、Node 22/远端 CI、长期大项目资源趋势 | **仍未完成或范围不足** | N5 保留并逐项完成,不从旧计划中误删 | +| PR #35 最终提交的 Node 22/24 与 CodeQL | 已通过并合并;属于 5d1ae37/7d53fda 历史基线 | 后续本地增量仍须取得自己的 CI,不沿用旧提交结果 | +| 实际 Codex 的最新构建/Roslyn 消费闭环、其他软件接入、长期大项目资源趋势 | **仍未完成或范围不足** | N5 保留并逐项完成,不从旧计划中误删 | 诊断依据:[三实例八场景](test-tmp/multi-agent/run-9uve7M/report.json)、[第二轮堆栈和保留的测试假设失败](test-tmp/multi-agent/run-ffxcyO/report.json)、[修正后帧与托盘容量边界](test-tmp/multi-agent/run-PxHPQo/report.json)、[十轮混合负载](test-tmp/mixed-load/run-HT12AB/report.json)。success 表示诊断场景完成,不能解释为未发现问题。SDK 超大输入的正确行为是报错并关闭通道,第二轮最初期待继续读取是测试假设错误。 @@ -57,6 +58,8 @@ N2 已取得此前核心 364/364;本轮完整核心 373/373、桌面 35/35、 剩余顺序:N1 → N3 → N4 → N5。N2 已先完成独立实现和验收;N1 改变跨项目公共行为时,仍须迁移已有切换及故障注入测试,不能以删掉故障覆盖换取通过。具体版本号在实施时确定,不按旧 M0–M5 的建议版本重发已有功能。 +本轮第 1–2 批修复不等于完成 N1/N3。已收敛的静态路径检查没有解决恶意并发替换的原子性;协作取消没有证明能强制中断永久挂起的 OS I/O。A9 的 ResourceManager 旧清理列表与已注销进程身份交错纳入下一批生产清理回归;目前只有拦截 OS 动作的复现,尚未修改产品清理实现,不能报告真实 PID 复用误杀已经发生或已修复。 + ### N1:连接固定项目,阻止任务串线 目标:从根本上去除同一连接“最后一次 workspace_open 决定全部后续调用”的隐式切换。 diff --git "a/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" "b/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" index cc5261c..2d2aa77 100644 --- "a/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" +++ "b/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" @@ -1,6 +1,6 @@ # WinCode 架构、数据流与检查关口 -**源码契约:0.14.0;基于 #34 合并基线及 PR #35 的缓存/工作连续性修复,更新日期:2026-09-10(北京时间)。具体提交、测试与合并状态见工作记录和 GitHub PR。** +**源码契约:0.14.0;基于已合并 PR #35 的 main 7d53fda,本地架构边界修复位于 codex/architecture-boundaries,更新日期:2026-09-10(北京时间)。具体提交、测试与合并状态见工作记录和 GitHub PR。** 本说明描述当前源码中已实现的结构。GitHub 分支保护的历史只读核查日期为 2026-09-08;本轮核对 PR 检查状态,不把它等同重新审计全部保护设置。历史实测结果见[工作记录](docs/codex_worklog.md)。源码版本、磁盘构建和客户端当前连接是三个不同对象,不能互相替代。 @@ -185,6 +185,10 @@ flowchart TB 源码、磁盘状态和多个调用之间不存在数据库式快照事务;Watcher/fingerprint/TTL 也不能保证每次观察均与外部写入同步。工作区 fingerprint 只作有界变更提示,watcher 不能成为唯一新鲜度依据。跨进程清理后,内存和磁盘读取都复核 overflow 是否存在,缺失即重建;这不是跨调用租约,引用未来仍可能过期,需要重新请求。 +本轮磁盘边界收敛:`GitClient` 从启动环境的工作区外安装位置解析绝对 Git,使用 argv、禁止 shell,并要求支持布尔 fsmonitor 配置的 Git 2.36+;查询强制关闭 fsmonitor。linked worktree 与 Git 管理子目录由 Git 判断;状态失败返回 unknown,不推断 clean。`FileSystemBoundary` 检查路径及实际目标;Cache 初始化/维护/overflow 和 trash 写入拒绝路径中的链接,Cache 同时固定已打开目录的文件系统身份。版本化 JSON 名称与头标记约束清理所有权;旧版/无法识别的文件保留、重算,不计入受管磁盘配额。预先存在的 junction 已有回归,并发恶意替换的原子隔离没有实现。 + +架构概览不再另走无总量限制的旧树/项目读取:共用 ProjectDiscovery、WorkspaceBrowser 和 OperationContext。发现上限 2000 项、树 500 项;图上限 16 个项目/64 KiB 单文件/256 KiB 合计、入口枚举 2000 项,返回完整性与遗漏;整份报告上限 32768 UTF-16 字符。取消后的读取在实际返回并关闭句柄后结束归属,不靠外层超时提前释放。文本声明先规范化空白并拒绝超过 16384 字符的规范化单行,避免原有重叠可选空白匹配;不是完整语法分析器。 + ### 5.2 工作区切换 健康同根确认:`工作区互斥锁 → 读取概览/刷新提示 → 保留 Host、快照、watcher 和 session`。它不设置切换屏障、不等待普通查询排空;取消只读确认不会制造恢复门。 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 1ad4f01..789f8a1 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-10 13:58(北京时间)。当前修复分支 codex/runtime-cache-continuity 基于 origin/main dda0203(已合并 #34 的 0.14.0 基线),保留上一轮未提交增量并修复本轮验收缺陷。本文件只列未完成工作。完整推荐与验收标准见 [下一轮工程化迭代计划书](WinCode-下一轮工程化迭代计划书.md),历史实施及失败见 [工作日志](docs/codex_worklog.md)。 +更新:2026-09-10(北京时间)。本地修复分支 codex/architecture-boundaries 基于 main 7d53fda(已合并 #35 的 0.14.0 基线);本轮优先修执行/磁盘边界、Git 状态、声明正则和有界架构扫描。本文件只列未完成工作。完整推荐与验收标准见 [下一轮工程化迭代计划书](WinCode-下一轮工程化迭代计划书.md),历史实施及失败见 [工作日志](docs/codex_worklog.md)。 ## 最终推荐 @@ -14,7 +14,7 @@ 实际消费者最新构建/Roslyn 接入、既有未定位原生验收失败、共享存储竞争、长期资源趋势等仍保留为待验,不能随已完成功能删除。 -本轮移除 N2:健康同路径重复打开保留 Host/snapshot,Windows 大小写/分隔符别名保持身份;已知 SDK 重启要求和清理失败门禁继续生效。此前核心 364/364;本轮完整核心 373/373、桌面 35/35、真实 Roslyn 22 场景、E4 16 场景通过。N3 的物理取消/FIFO 基础已实现,但统一容量与过载尚未完成;具体回执和测试前提修正保留在工作日志。 +此前已移除 N2:健康同路径重复打开保留 Host/snapshot,Windows 大小写/分隔符别名保持身份;已知 SDK 重启要求和清理失败门禁继续生效。#35 历史基线为核心 373/373、桌面 35/35、真实 Roslyn 22 场景、E4 16 场景。本轮架构第 1–2 批修复最终通过核心 385/385、真实 Roslyn 22 和 E4 16 场景,桌面未重跑。N3 的物理取消/FIFO 基础已实现,但统一容量与过载尚未完成;具体回执和测试前提修正保留在工作日志。 补充验收修复:实际内容绑定的上下文缓存、按内容复用解析的有界文本扫描、缺失附件命中重建,以及同根确认取消/慢查询边界已通过新增 8 项运行回归及 1 项 PID 复用观察器回归。真实 Roslyn 取消后 PID/snapshot 保留;不把这些结果称作 N1 或完整 N3 已完成。 @@ -24,11 +24,14 @@ | --- | --- | --- | | N1 | 连接固定工作区,错误目标在副作用前拒绝 | 同一连接打开 B 不改变已绑定 A 的根、Host、快照、watcher、cache/trash;独立 B 连接继续正确 | | N3 | 有界受理、FIFO 等待、取消归还、明确过载 | 建议从每实例 32 个未完成业务请求、4 个状态槽及 64 KiB 参数预算实测;正常突发顺畅,超载不无限积压、不重启、不自动重放 | +| A9 | 清理快照与已注销进程身份交错 | 旧清理列表不盲目终止已失去归属的 PID;区分模拟 OS 拦截与真实进程证据 | | N4 | 同项目多实例的共享存储/源码变更边界 | 实际缓存/打包读写、prune/overflow 与退出交错无错误正文;源码变化后旧定位失效;UIA 窗口隔离另验 | | N5 | 实际客户端闭环、残余失败和交付验收 | 明确项目配置与实际 build/provider/schema;至少两种目标软件接入;旧失败保留根因状态,Node 22/远端 CI 与长期观测按实际证据报告 | N2 已先完成;接下来实施 N1 时保留已有热态与故障恢复覆盖。N3 参数只是起始建议,需按现有合法请求和相同任务对照调整。N4 已复现并修复 peer prune 后的悬空附件命中,新增双进程回归;剩余源码/UI 竞争与其他存储交错继续验证,不预先引入跨进程锁或按每次启动复制全部持久缓存。 +PR #35 最终提交的 Node 22/24/CodeQL 已通过并合并,属于历史基线;本轮本地增量的远端 CI 与实际消费者仍待验证。静态链接拒绝不提供对抗并发路径替换的原子保证,扫描预算/协作取消不保证能强制中断永久挂起的 OS I/O。 + ## 必须保留的边界 - Windows 11 x64 是开发/测试基准;其他系统、Windows 或依赖版本不保证同样效果,macOS/Linux 用户 fork 适配。 diff --git a/docs/codex_worklog.md b/docs/codex_worklog.md index be2ce86..b7ff7f0 100644 --- a/docs/codex_worklog.md +++ b/docs/codex_worklog.md @@ -981,3 +981,19 @@ - 新增确定性 PID 复用/缺少创建身份回归,专项现为 9/9。修改受管文档后的 [核心 372/372](../test-tmp/check/2026-09-10T06-10-43-228Z-core/report.json)通过;加上 PID 回归后的最终 [完整核心 373/373](../test-tmp/check/2026-09-10T06-14-00-192Z-core/report.json)通过类型、构建、原生交付、stdio 和清单校验。修正观察器后的 [真实 Roslyn Gateway 22 场景](../test-tmp/roslyn-gateway/run-qoTd5O/report.json)通过。新增规则依旧严格断言真实子进程退出,不把只读过滤回放当成新的清理证据。 - 全项目 14 份 Markdown 已核对,93 个本地链接、UTF-8、代码围栏通过(历史工作日志只核对新增段,不静默改写旧失败)。文档检查初次因 Git 对中文文件名引用导致路径解析失败,改用 git ls-files -z 取得真实文件名后通过,属于检查脚本路径处理错误。 - 用户补充确认:修复观察器之后,必须在最新提交上完整运行 Node 22 专项链,若真实 Code Host/BuildHost/cmd/blocker 仍存活才继续查产品生命周期。在该链与全部必需检查通过前,PR #35 保持未合并;不沿用 bcd2fe5 的部分 CI 结果。 + +## 2026-09-10 17:00 — 架构审查第 1–2 批修复(北京时间) + +- 用户要求“按照你的设计开始进行迭代or修复”。在已合并 #35 的 main `7d53fdac3017560dbecb138eee0f8bb6f6361af3` 上建立 `codex/architecture-boundaries`,先落实执行/磁盘边界和默认读取可用性。此前 #35 最终提交 `5d1ae37` 的 Node 22/24/CodeQL 已通过并合并,属于历史基线;本轮本地修改尚未提交、推送或应用到实际消费者。 +- A1/B1/B2:新增 `GitClient`,从启动时工作区外安装候选解析真实绝对路径,使用 execFile/argv,禁用 shell、可执行 fsmonitor 和可重定向 Git 工作目录的环境变量。要求 Git 2.36+;旧版把 false 当 hook 路径的行为依据 [Git 官方配置文档](https://git-scm.com/docs/git-config#Documentation/git-config.txt-corefsmonitor),不自动安装或升级。状态由 Git 自身查询,支持 linked worktree;查询不可用/失败为 unknown,缺少 isClean,不把异常报告为干净。生成仓库的 git.cmd 反例和真实 worktree/损坏配置回归通过;fsmonitor 先用直接 Git 阳性对照确认会生成标记,再确认 Workspace/Fingerprint 两条生产路径都不生成标记。 +- A2/A3:新增共用 `FileSystemBoundary`。Cache 初始化、写入、维护和 Repomix overflow 都拒绝 cacheDir/祖先中的链接或 junction,并校验已打开目录的 dev/ino 身份;trash 目标实际位置必须在工作区内,在 mkdir、rename 和 metadata 前复查。JSON 文件名及格式头采用 wincode-v1 所有权标记;清理只处理本版本可识别条目和保留命名的 overflow。旧版/无法识别的 JSON、临时文件及无关文本保留,不计入受管配额,不做迁移。保留移动后元数据失败的 partial、实际位置与既有恢复门。 +- A5/A8:旧 identifyProject 归并到有界 ProjectDiscovery;架构目录树和图入口枚举共用 WorkspaceBrowser。项目描述符检查词法/实际根边界;不另读 .sln 外部引用,不求值 MSBuild。发现最多 2000 项、树最多 500 项;图最多 16 个项目文件、单文件 64 KiB、合计 256 KiB,入口枚举合计最多 2000 项。maxDepth 限整数 1–5,整份格式化 JSON 上限 32768 UTF-16 字符,返回 scanComplete/omissions/outputOmissions。Router、Context、结构扫描与目录浏览传递实际取消/deadline;取消后在底层读取返回和句柄关闭前继续持有活动请求资格。 +- A4:TextDeclarations 折叠屏蔽后的空白,移除 C# 重叠可选空白匹配;规范化单行超过 16384 字符返回词法不确定/不完整。独立 Node 子进程受外部 5 秒截止约束,并通过真实 MCP 查询与心跳,避免用主线程计时器证明主线程未阻塞。原 2084 字符反例不再超时;最终全量负载中,2048 空格解析约 1.05 ms,65536 空格约 12.56 ms,20 ms 心跳在约 23.86 ms 响应。这是合成输入的实测,不是普遍性能保证。 +- 正式新增 [12 项边界回归](../tests/architecture-safety.test.ts)和[独立声明探针](../tests/fixtures/declaration-budget-probe.mjs),纳入 package.json 的完整 inventory。最初 6 个缺陷场景在修复前均失败;第一批与既有相关测试 67/67 通过。首次完整检查 [382/384](../test-tmp/check/2026-09-10T08-48-35-105Z-core/report.json)保留两项失败:旧“非 Git”夹具实际位于 Git 仓库内;旧 overflow 夹具把任意 txt 当受管附件。依 [rev-parse 官方行为](https://git-scm.com/docs/git-rev-parse)改为真正独立的非 Git 目录;附件改由生产 writeOverflow 创建。未放宽非 Git、过期删除、容量驱逐或内存引用保护断言。 +- fsmonitor 阳性夹具首次未生成标记,保留 [失败回执](../test-tmp/architecture-boundaries-recheck.log)。对照 [Git 实现](https://github.com/git/git/blob/master/fsmonitor.c)的 shell 调用以及本地 stderr,确认带空格的 hook 配置未引用,实际尝试启动 D:/CODEX;修正生成夹具的命令引用后,[25/25 相关复测](../test-tmp/architecture-boundaries-recheck-final.log)通过。没有更改真实仓库/全局 Git 配置。 +- 作者反证自审发现“紧凑 JSON 已符合预算,格式化 MCP 文本仍超限”:137 个生成项目声明返回 32847 字符,见[修前失败](../test-tmp/architecture-output-budget-before.log)。按实际格式化序列化计数后为 32238,见[修后回执](../test-tmp/architecture-output-budget-after.log),新增端到端预算回归。不用仅检查内部对象大小代替客户端实际响应。 +- 最终 [npm run check](../test-tmp/check/2026-09-10T08-58-28-915Z-core/report.json)通过:Windows x64 / Node 24.19.0,**385/385,0 fail、0 skip**;包含类型检查、Gateway 构建、既有原生组件锁定还原/发布、完整回归、新 stdio 实例、交付清单核验。[E4 16 场景](../test-tmp/error-contracts/run-WnFBU1/report.json)通过,包括真实 partial trash 及恢复行为。最终 buildId=`ce1d898b90951d0419797ab316dfe7172527d939b49c9638b7d7a80935452352`,Schema=`304d4030ad9e3ba8ad55159273a7b9b892bd8f6366980d3b2f9ce2d75a0fe2a9`,delivery contentId=`ddbb283087dcb1e69f632eae7e9cc8db9263b231565a8cf2bb45ec3f87c54d78`、matched=true。revision 仍是基线,未提交增量由 sourceHash/artifactHash 标识。 +- 同步 README 中英双语、CHANGELOG、架构说明、计划/路线图及仓库 Skill 源文件;本机安装副本和真实 MCP 连接未更新。剩余 N1 固定项目、完整 N3 准入、A9 生产清理身份、N4/N5 实际消费者/交错/长期样本继续保留。静态链接与目录身份复查不构成对抗并发替换的原子沙盒;合作取消不保证强制中断永久挂起的 OS I/O;overflow 返回后没有永久租约。自动释放仍关闭,默认 local-text 与显式 Roslyn 求值授权不变。 +- 17:03 补充验证:[真实 Roslyn MCP 22 场景](../test-tmp/roslyn-gateway/run-NDrihh/report.json)全部通过,包括 Host 热态/精确身份、实际 MSBuild 取消/崩溃/超时后的已观测 Host/BuildHost/后代清理及恢复、最终 Gateway 退出。只生成/求值 test-tmp C# 夹具并使用既有 SDK;不是当前 Codex 连接或干净机器验收。其后 `delivery:verify` 仍 matched=true、contentId 不变;本轮未改原生/UIA/托盘生产代码,未重复桌面验收,历史未定位桌面失败继续保留。当前代码、预算、Schema 与手册经作者自审,未进行独立模型或人工审核。 + +- 用户随后明确要求先提交目前版本并合并。发布范围为本轮第 1–2 批修复和对应测试/文档;发布前交付清单再次 matched=true,远端 main 仍为 7d53fda。沿用相同生产源码的 385/385、真实 Roslyn 22 和 E4 16 回执;新提交的必需 CI 完成后才合并,真实消费者配置不在本次发布范围内。 diff --git a/package.json b/package.json index f20a0a1..ebe2666 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "build": "node scripts/build.mjs", "start": "node dist/index.js", "dev": "tsx src/index.ts --development", - "test": "tsx --test tests/core-cache.test.ts tests/workspace-files.test.ts tests/text-symbols.test.ts tests/context-packing.test.ts tests/composite-tools.test.ts tests/mcp-stdio.test.ts tests/stability-lifecycle.test.ts tests/cache-budgets.test.ts tests/process-failures.test.ts tests/request-concurrency.test.ts tests/evidence-confidence.test.ts tests/watch-invalidation.test.ts tests/resource-cleanup.test.ts tests/stage1-cleanup.test.ts tests/ui-hardening.test.ts tests/ui-source-review.test.ts tests/v071-acceptance.test.ts tests/ui-background.test.ts tests/ui-audit.test.ts tests/ui-query.test.ts tests/context-efficiency.test.ts tests/agent-efficiency-benchmark.test.ts tests/workspace-summary.test.ts tests/runtime-identity.test.ts tests/runtime-contract.test.ts tests/context-coverage.test.ts tests/semantic-identity.test.ts tests/ui-code-candidates.test.ts tests/skill-sync.test.ts tests/local-text.test.ts tests/repomix-disabled.test.ts tests/workspace-watch-close.test.ts tests/workspace-lifecycle.test.ts tests/failure-recovery.test.ts tests/tool-contracts.test.ts tests/architecture-boundaries.test.ts tests/lifecycle-cancellation.test.ts tests/delivery-contract.test.ts tests/roslyn-contracts.test.ts tests/gateway-exit.test.ts tests/owner-process-guard.test.ts tests/manual-release.test.ts tests/tray-client.test.ts tests/runtime-cache-regressions.test.ts", + "test": "tsx --test tests/core-cache.test.ts tests/workspace-files.test.ts tests/text-symbols.test.ts tests/context-packing.test.ts tests/composite-tools.test.ts tests/mcp-stdio.test.ts tests/stability-lifecycle.test.ts tests/cache-budgets.test.ts tests/process-failures.test.ts tests/request-concurrency.test.ts tests/evidence-confidence.test.ts tests/watch-invalidation.test.ts tests/resource-cleanup.test.ts tests/stage1-cleanup.test.ts tests/ui-hardening.test.ts tests/ui-source-review.test.ts tests/v071-acceptance.test.ts tests/ui-background.test.ts tests/ui-audit.test.ts tests/ui-query.test.ts tests/context-efficiency.test.ts tests/agent-efficiency-benchmark.test.ts tests/workspace-summary.test.ts tests/runtime-identity.test.ts tests/runtime-contract.test.ts tests/context-coverage.test.ts tests/semantic-identity.test.ts tests/ui-code-candidates.test.ts tests/skill-sync.test.ts tests/local-text.test.ts tests/repomix-disabled.test.ts tests/workspace-watch-close.test.ts tests/workspace-lifecycle.test.ts tests/failure-recovery.test.ts tests/tool-contracts.test.ts tests/architecture-boundaries.test.ts tests/lifecycle-cancellation.test.ts tests/delivery-contract.test.ts tests/roslyn-contracts.test.ts tests/gateway-exit.test.ts tests/owner-process-guard.test.ts tests/manual-release.test.ts tests/tray-client.test.ts tests/runtime-cache-regressions.test.ts tests/architecture-safety.test.ts", "test:verify": "tsx tests/verify.ts", "benchmark:agent": "tsx scripts/benchmark-agent-efficiency.ts", "test:benchmark": "tsx --test tests/agent-efficiency-benchmark.test.ts", diff --git a/skills/wincode/references/code.md b/skills/wincode/references/code.md index 28fbbf6..5badf21 100644 --- a/skills/wincode/references/code.md +++ b/skills/wincode/references/code.md @@ -63,7 +63,7 @@ Host 监听变化并在查询前后比较内容指纹,变化时丢弃结果并 | --- | --- | --- | | `workspace_open` | `path`: 非空字符串,最长 4096 | `includeTree`: 布尔值;`maxOutputChars`: 整数 2048–32768,默认 8000 | | `wincode_list_directory` | 无 | `path`: 非空字符串,最长 4096,默认 `.`;`maxDepth`: 整数 1–5,默认 1;`maxEntries`: 整数 1–500,默认 100;`maxOutputChars`: 整数 2048–32768,默认 8000;`includeIgnored`: 布尔值,默认 false | -| `wincode_analyze_workspace` | 无 | `maxDepth`: 数字,默认 2 | +| `wincode_analyze_workspace` | 无 | `maxDepth`: 整数 1–5,默认 2;整份 JSON 最多 32768 个 UTF-16 字符 | | `wincode_find_code_symbol` | `query`: 非空字符串 | `kind`: 字符串,按下述提供方支持范围使用;Roslyn 的 query 最长 256、kind 最长 128。此工具未声明文件范围参数,指定文件取证改用下面的 `scopeFiles` | | `wincode_find_references` | `symbolName`: 非空字符串 | `relativePath`: 定义文件相对路径(Roslyn 用于限定候选,local-text 不据此缩小引用扫描);`symbolLocation`: Roslyn 搜索返回的 location 对象(snapshotId/project/file/position 均必填,路径各最长 4096);同时提供 relativePath 时必须与 location.file 一致 | | `analyze_change_impact` | `target`: 非空字符串 | `symbolLocation`: 搜索返回的完整定位;提供时 target 必须是该符号的简单名称 | @@ -125,6 +125,10 @@ lineRanges 查看最终 coverage.allRequestedCovered、completeLines 和 details workspace_open 默认返回项目摘要和最多 8 个入口,整份 JSON 默认不超过 8000 个 UTF-16 字符;不生成目录树或统计全仓大小。检查 projectScanComplete,null 统计不等于零。需要目录时用 wincode_list_directory 指定窄路径,查看 scanComplete/truncated/omissions。includeTree:true 可显式取得有界兼容树,不能当成完整仓库清单。maxOutputChars 为 2048–32768;目录 maxDepth 为 1–5,maxEntries 为 1–500。需要生成目录时显式 includeIgnored:true,但不能越过工作区边界。 +架构分析共用上述有界发现/浏览器:发现最多 2000 项,树预览最多 500 项;图最多 16 个项目文件、每文件 64 KiB、合计 256 KiB,入口目录枚举合计最多 2000 项。`scanComplete=false`、`omissions` 与 `outputOmissions` 表示未取得完整结构,不能将空引用解释成已证明不存在依赖。普通项目识别与图读取都拒绝根外项目描述符;取消沿实际读取循环传播,单次底层 I/O 仍可能延迟取消。 + +本地声明解析先折叠屏蔽后的空白,超过 16384 字符的规范化单行返回 lexical-uncertainty/incomplete;复杂声明仍可能省略。Git 查询需要 2.36+,从工作区外的绝对安装路径执行并禁用 fsmonitor;`git.status=unknown` 或缺少 `isClean` 不等于干净。linked worktree 及 Git 管理的子目录由 Git 本身识别。 + ```json {"task":"查明保存失败原因","scopeFiles":["src/Service.cs"],"includeFullText":false,"maxTokens":2000} ``` diff --git a/skills/wincode/references/diagnostics.md b/skills/wincode/references/diagnostics.md index 5562115..a57ca23 100644 --- a/skills/wincode/references/diagnostics.md +++ b/skills/wincode/references/diagnostics.md @@ -59,6 +59,8 @@ INPUT_UNAVAILABLE/HOST_UNAVAILABLE 先检查明确的配置文件、SDK/Host/项 ## 源码缓存与附件(0.14.0) +缓存初始化、写入与清理拒绝 cacheDir 及其祖先中的符号链接/junction;已打开缓存目录被替换时拒绝后续磁盘变更。新条目采用 `wincode-v1_` 文件名和格式标记,清理只处理可识别的本版本条目及保留命名的 overflow。旧版 JSON、无法识别的文件和无关附件保留,不计入受管配额;不会自动迁移或删除整个旧目录。回收站目标必须实际位于工作区内,移动后元数据失败仍返回 partial 和实际文件位置。这些检查不等于原生句柄级原子路径隔离。 + 本地文本查询每次重新枚举有界输入,声明解析按实际内容复用;内置上下文打包按实际选中文件的内容复用。工作区 fingerprint 和 watcher 只是变更提示,不能证明完整源码身份。旧连接仍返回过时正文时,先核对运行 buildId,再由客户端正常重连,不以 git add 或反复释放 Host 代替升级。overflow 在命中时复核存在性,缺失则重建;它没有永久租约,后续读取失败可重新请求上下文,不全局删除其他实例的缓存。 ## 连接关闭(0.13.2) diff --git a/src/Adapters/RepomixAdapter.ts b/src/Adapters/RepomixAdapter.ts index 324aac5..d79e6cd 100644 --- a/src/Adapters/RepomixAdapter.ts +++ b/src/Adapters/RepomixAdapter.ts @@ -506,18 +506,7 @@ export class RepomixAdapter implements IAdapter { const bytes = this.cache.estimateBytes(result.content); if (bytes <= limit) return result; - const overflowDir = path.join(this.config.cacheDir, 'overflow'); - await fs.mkdir(overflowDir, { recursive: true }); - const uniqueId = crypto.randomUUID().slice(0, 8); - const overflowPath = path.join(overflowDir, `pack_${Date.now()}_${uniqueId}.txt`); - const tmpPath = `${overflowPath}.tmp.${uniqueId}`; - try { - await fs.writeFile(tmpPath, result.content, 'utf-8'); - await fs.rename(tmpPath, overflowPath); - } catch (err) { - await fs.unlink(tmpPath).catch(() => {}); - throw err; - } + const overflowPath = await this.cache.writeOverflow(result.content); const previewChars = Math.min(result.content.length, 2_000); return { ...result, diff --git a/src/CompositeTools/ArchitectureAnalyzer.ts b/src/CompositeTools/ArchitectureAnalyzer.ts index bbf0497..b12ffa5 100644 --- a/src/CompositeTools/ArchitectureAnalyzer.ts +++ b/src/CompositeTools/ArchitectureAnalyzer.ts @@ -1,5 +1,7 @@ import { WorkspaceManager, ProjectIdentity, WorkspaceTreeItem } from '../Core/Workspace.js'; import { DotNetProjectGraph, loadDotNetProjectGraph } from '../Core/DotNetGraph.js'; +import { boundedInteger } from '../Core/WorkspaceContracts.js'; +import { checkOperation, type OperationContext } from '../Core/OperationContext.js'; export interface ArchitectureReport { projectName: string; @@ -16,6 +18,11 @@ export interface ArchitectureReport { recommendedAgentFocus: string; projectGraph: DotNetProjectGraph | null; projectSummaries: Array<{ file: string; kind: string; evidence: string[] }>; + scanComplete: boolean; + truncated: boolean; + omissions: Array<{ path: string; reason: string }>; + outputOmissions: string[]; + limits: { maxDepth: number; maxOutputChars: number; maxTreeEntries: number }; } /** Directory folder names are hints. For .NET, prefer projectGraph from sln/csproj files. */ @@ -26,9 +33,11 @@ export class ArchitectureAnalyzer { this.workspace = workspace; } - async analyze(maxDepth = 2): Promise { - const identity: ProjectIdentity = await this.workspace.identifyProject(); - const tree: WorkspaceTreeItem = await this.workspace.getDirectoryTree(maxDepth); + async analyze(maxDepth = 2, operation: OperationContext = { deadline: Date.now() + 20_000 }): Promise { + boundedInteger(maxDepth, 2, 1, 5, 'maxDepth'); + checkOperation(operation); + const identity: ProjectIdentity = await this.workspace.identifyProject(operation); + const tree: WorkspaceTreeItem = await this.workspace.getDirectoryTree(maxDepth, operation); const layers = [ { @@ -89,7 +98,8 @@ export class ArchitectureAnalyzer { projectGraph = await loadDotNetProjectGraph( this.workspace.root, identity.solutionFiles, - identity.projectFiles + identity.projectFiles, + operation ); } @@ -130,7 +140,7 @@ export class ArchitectureAnalyzer { 'Non-.NET workspace: directory hints only. Do not treat folder names as verified architecture layers.'; } - return { + const report: ArchitectureReport = { projectName: identity.name, projectTypes: identity.frameworks, isWindowsDotNet: identity.isDotNet, @@ -141,6 +151,25 @@ export class ArchitectureAnalyzer { recommendedAgentFocus, projectGraph, projectSummaries, + scanComplete: identity.scanComplete !== false && tree.scanComplete !== false && projectGraph?.scanComplete !== false, + truncated: false, + omissions: [...(identity.discovery?.omissions ?? []), ...(tree.omittedDirectories ?? []), ...(projectGraph?.omissions ?? [])].slice(0, 24), + outputOmissions: [], + limits: { maxDepth, maxOutputChars: 32768, maxTreeEntries: 500 }, }; + const fits = () => JSON.stringify(report, null, 2).length <= report.limits.maxOutputChars; + // Bound the complete serialized report, including repeated summaries and dependency descriptions. + for (const field of ['projectSummaries', 'projectGraph', 'recommendedAgentFocus', 'layers', 'keyEntryPoints', 'projects', 'solutions', 'projectTypes', 'omissions'] as const) { + if (fits()) break; + report.truncated = true; + report.scanComplete = false; + report.outputOmissions.push(field); + if (field === 'projectGraph') report.projectGraph = null; + else if (field === 'recommendedAgentFocus') report.recommendedAgentFocus = 'Report exceeded its output budget; inspect narrower paths.'; + else report[field] = []; + } + if (!fits()) throw new Error('Architecture report identity exceeds the output budget.'); + checkOperation(operation); + return report; } } diff --git a/src/Core/Cache.ts b/src/Core/Cache.ts index 283907a..d5a88bb 100644 --- a/src/Core/Cache.ts +++ b/src/Core/Cache.ts @@ -4,8 +4,15 @@ import crypto from 'node:crypto'; import { getDefaultCacheLimits, WinCodeCacheLimits } from './Config.js'; import { WorkspaceFingerprint } from './WorkspaceFingerprint.js'; +import { assertLinkFreePath } from './FileSystemBoundary.js'; + +const CACHE_FILE = /^wincode-v1_[A-Za-z0-9_-]{0,32}_[a-f0-9]{16}\.json$/; +const CACHE_TEMP = /^wincode-v1_[A-Za-z0-9_-]{0,32}_[a-f0-9]{16}\.json\.tmp\.\d+\.[a-f0-9]{8}$/; +const OVERFLOW_FILE = /^pack_\d+_[a-f0-9]{8}\.txt(?:\.tmp\.[a-f0-9]{8})?$/; +const CACHE_HEADER = '{"format":"wincode-cache-v1",'; export interface CacheEntry { + format?: 'wincode-cache-v1'; timestamp: number; ttlMs?: number; fingerprint?: string; @@ -39,6 +46,7 @@ export class CacheManager { private memoryBytes = 0; private namespace = ''; private writeChain: Promise = Promise.resolve(); + private diskIdentity: string | null = null; constructor( cacheDir: string, @@ -46,7 +54,7 @@ export class CacheManager { maxDiskEntries = 500, limits?: Partial ) { - this.cacheDir = cacheDir; + this.cacheDir = path.resolve(cacheDir); const defaults = getDefaultCacheLimits(); this.maxMemoryEntries = limits?.maxMemoryEntries ?? maxMemoryEntries; this.maxDiskEntries = limits?.maxDiskEntries ?? maxDiskEntries; @@ -90,23 +98,77 @@ export class CacheManager { } async initialize(): Promise { - try { - await fs.mkdir(this.cacheDir, { recursive: true }); - await this.pruneDiskCache(); - } catch { - // Ignore if directory already exists + await this.assertDiskBoundary('', true); + await this.pruneDiskCache(); + } + + private async assertDiskBoundary(child = '', create = false): Promise { + await assertLinkFreePath(this.cacheDir); + if (create) await fs.mkdir(this.cacheDir, { recursive: true }); + await assertLinkFreePath(this.cacheDir); + const stat = await fs.lstat(this.cacheDir, { bigint: true }); + if (!stat.isDirectory()) throw new Error('Cache root is not a directory.'); + const identity = `${stat.dev}:${stat.ino}`; + if (this.diskIdentity !== null && this.diskIdentity !== identity) throw new Error('Cache root identity changed; reopen the workspace.'); + this.diskIdentity = identity; + if (child) { + const target = path.join(this.cacheDir, child); + await assertLinkFreePath(target); + if (create) await fs.mkdir(target, { recursive: true }); + await assertLinkFreePath(target); } } + private async unlinkOwned(file: string): Promise { + const parent = path.dirname(file); + const overflow = parent === path.join(this.cacheDir, 'overflow'); + if (parent !== this.cacheDir && !overflow) return; + if (!(overflow ? OVERFLOW_FILE.test(path.basename(file)) : CACHE_FILE.test(path.basename(file)) || CACHE_TEMP.test(path.basename(file)))) return; + await this.assertDiskBoundary(overflow ? 'overflow' : ''); + const stat = await fs.lstat(file).catch((error: NodeJS.ErrnoException) => { if (error.code === 'ENOENT') return null; throw error; }); + if (stat?.isFile() && !stat.isSymbolicLink() && (overflow || await this.hasCacheHeader(file))) await fs.unlink(file); + } + + private async hasCacheHeader(file: string): Promise { + const handle = await fs.open(file, 'r'); + try { + const header = Buffer.alloc(CACHE_HEADER.length); + const { bytesRead } = await handle.read(header, 0, header.length, 0); + return bytesRead === header.length && header.toString('utf8') === CACHE_HEADER; + } finally { await handle.close(); } + } + + private async assertReplaceable(file: string): Promise { + await assertLinkFreePath(file); + const stat = await fs.lstat(file).catch((error: NodeJS.ErrnoException) => { if (error.code === 'ENOENT') return null; throw error; }); + if (stat && (!stat.isFile() || !await this.hasCacheHeader(file))) throw new Error('Cache destination contains an unowned file.'); + } + + async writeOverflow(content: string): Promise { + await this.assertDiskBoundary('overflow', true); + const unique = crypto.randomUUID().slice(0, 8); + const target = path.join(this.cacheDir, 'overflow', `pack_${Date.now()}_${unique}.txt`); + const temp = `${target}.tmp.${unique}`; + try { + await fs.writeFile(temp, content, { encoding: 'utf8', flag: 'wx' }); + await this.assertDiskBoundary('overflow'); + await fs.rename(temp, target); + return target; + } catch (error) { await this.unlinkOwned(temp).catch(() => {}); throw error; } + } + /** * Point this manager at a new directory after workspace_open. * Memory is always dropped; the previous disk tree is left for the OS/prune. */ async rebind(newCacheDir: string): Promise { + await assertLinkFreePath(newCacheDir); + await this.flush(); this.memoryCache.clear(); this.memoryBytes = 0; this.workspaceFingerprint.reset(); - this.cacheDir = newCacheDir; + this.cacheDir = path.resolve(newCacheDir); + this.diskIdentity = null; await this.initialize(); } @@ -118,7 +180,7 @@ export class CacheManager { const namespaced = this.namespacedKey(key); const hash = crypto.createHash('sha256').update(namespaced).digest('hex').substring(0, 16); const safeKey = namespaced.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 32); - return path.join(this.cacheDir, `${safeKey}_${hash}.json`); + return path.join(this.cacheDir, `wincode-v1_${safeKey}_${hash}.json`); } estimateBytes(data: unknown): number { @@ -153,7 +215,12 @@ export class CacheManager { if (typeof file !== 'string') return false; const relative = path.relative(path.join(this.cacheDir, 'overflow'), file); if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return false; - return fs.stat(file).then(stat => stat.isFile(), () => false); + if (!OVERFLOW_FILE.test(path.basename(file))) return false; + try { + await this.assertDiskBoundary('overflow'); + const stat = await fs.lstat(file); + return stat.isFile() && !stat.isSymbolicLink(); + } catch { return false; } } /** @@ -181,17 +248,20 @@ export class CacheManager { const filePath = this.getCacheFilePath(key); try { - const stat = await fs.stat(filePath); + await this.assertDiskBoundary(); + const stat = await fs.lstat(filePath); + if (!stat.isFile() || stat.isSymbolicLink()) return null; if (stat.size > this.maxEntryBytes) { - await fs.unlink(filePath).catch(() => {}); + await this.unlinkOwned(filePath); return null; } const content = await fs.readFile(filePath, 'utf-8'); const entry: CacheEntry = JSON.parse(content); + if (entry.format !== 'wincode-cache-v1' || !Number.isFinite(entry.timestamp) || !Object.hasOwn(entry, 'data')) return null; if (entry.ttlMs && now - entry.timestamp > entry.ttlMs) { - await fs.unlink(filePath).catch(() => {}); + await this.unlinkOwned(filePath); const p = (entry.data as any)?.overflowPath; if (typeof p === 'string') { await this.removeOverflow(p); @@ -223,6 +293,7 @@ export class CacheManager { async set(key: string, data: T, options?: { ttlMs?: number; fingerprint?: string }): Promise { const byteSize = this.estimateBytes(data); const entry: CacheEntry = { + format: 'wincode-cache-v1', timestamp: Date.now(), ttlMs: options?.ttlMs, fingerprint: options?.fingerprint, @@ -239,7 +310,7 @@ export class CacheManager { // A rejected replacement must invalidate the old value, not silently resurrect it. this.deleteMemory(memKey); const stalePath = this.getCacheFilePath(key); - await this.enqueueWrite(() => fs.unlink(stalePath).catch(() => {})); + await this.enqueueWrite(() => this.unlinkOwned(stalePath).catch(() => {})); return; } @@ -248,8 +319,11 @@ export class CacheManager { const filePath = targetFilePath; const tmpPath = `${filePath}.tmp.${Date.now()}.${crypto.randomUUID().slice(0, 8)}`; try { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(tmpPath, JSON.stringify(entry), 'utf-8'); + await this.assertDiskBoundary('', true); + await this.assertReplaceable(filePath); + await fs.writeFile(tmpPath, JSON.stringify(entry), { encoding: 'utf8', flag: 'wx' }); + await this.assertDiskBoundary(); + await this.assertReplaceable(filePath); await fs.rename(tmpPath, filePath); this.writeCount++; if (this.writeCount % 20 === 0) { @@ -257,7 +331,7 @@ export class CacheManager { this.pruneExpiredMemory(); } } catch (err) { - await fs.unlink(tmpPath).catch(() => {}); + await this.unlinkOwned(tmpPath).catch(() => {}); console.warn(`[CacheManager] Failed to write cache to ${filePath}:`, err); } }); @@ -327,7 +401,7 @@ export class CacheManager { private async removeOverflow(file: string): Promise { const target = path.resolve(file); const overflowDir = path.resolve(this.cacheDir, 'overflow'); - if (path.dirname(target) !== overflowDir) return; + if (path.dirname(target) !== overflowDir || !OVERFLOW_FILE.test(path.basename(target))) return; // Invalidate readers before deleting their backing snapshot. for (const [key, entry] of this.memoryCache) { @@ -335,6 +409,7 @@ export class CacheManager { if (typeof ref === 'string' && path.resolve(ref) === target) this.deleteMemory(key); } try { + await this.assertDiskBoundary('overflow'); const dirStat = await fs.lstat(overflowDir); if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) return; const realCache = await fs.realpath(this.cacheDir); @@ -342,7 +417,7 @@ export class CacheManager { if (path.relative(realCache, realOverflow) !== 'overflow') return; const stat = await fs.lstat(target); if (!stat.isFile() || stat.isSymbolicLink()) return; - await fs.unlink(target); + await this.unlinkOwned(target); } catch { // Missing or inaccessible snapshots are safe to leave for a later prune. } @@ -354,18 +429,19 @@ export class CacheManager { } private async pruneDiskCacheOnce(options?: { orphanGraceMs?: number }): Promise { + await this.assertDiskBoundary(); try { const files = await fs.readdir(this.cacheDir); - const jsonFiles = files.filter((f) => f.endsWith('.json')); + const jsonFiles = files.filter((f) => CACHE_FILE.test(f)); const now = Date.now(); // 1. Clean up orphaned .tmp files older than 30s - const tmpFiles = files.filter((f) => f.includes('.tmp.')); + const tmpFiles = files.filter((f) => CACHE_TEMP.test(f)); for (const tf of tmpFiles) { try { const s = await fs.stat(path.join(this.cacheDir, tf)); if (now - s.mtimeMs > 30_000) { - await fs.unlink(path.join(this.cacheDir, tf)).catch(() => {}); + await this.unlinkOwned(path.join(this.cacheDir, tf)); } } catch {} } @@ -385,14 +461,17 @@ export class CacheManager { for (const f of jsonFiles) { const jsonPath = path.join(this.cacheDir, f); try { - const stat = await fs.stat(jsonPath); + await this.assertDiskBoundary(); + const stat = await fs.lstat(jsonPath); + if (!stat.isFile() || stat.isSymbolicLink()) continue; // Reject oversized JSON before parsing; overflow orphans are reconciled below. if (stat.size > this.maxEntryBytes) { - await fs.unlink(jsonPath).catch(() => {}); + await this.unlinkOwned(jsonPath); continue; } const content = await fs.readFile(jsonPath, 'utf-8'); const entry: CacheEntry = JSON.parse(content); + if (entry.format !== 'wincode-cache-v1' || !Number.isFinite(entry.timestamp) || !Object.hasOwn(entry, 'data')) continue; const overflowPath = typeof entry?.data?.overflowPath === 'string' @@ -401,7 +480,7 @@ export class CacheManager { // Check TTL expiration if (entry.ttlMs && now - entry.timestamp > entry.ttlMs) { - await fs.unlink(jsonPath).catch(() => {}); + await this.unlinkOwned(jsonPath); if (overflowPath) { await this.removeOverflow(overflowPath); } @@ -410,7 +489,7 @@ export class CacheManager { // Account for overflow file size in entry disk footprint let overflowSize = 0; - if (overflowPath) { + if (overflowPath && await this.backingFileExists(entry.data)) { try { const os = await fs.stat(overflowPath); overflowSize = os.size; @@ -428,8 +507,7 @@ export class CacheManager { totalBytes: stat.size + overflowSize, }); } catch { - // Corrupted or unreadable JSON file - await fs.unlink(jsonPath).catch(() => {}); + // Unreadable or unrecognizable files have no proven ownership; preserve them. } } @@ -444,7 +522,7 @@ export class CacheManager { (validEntries.length - survivingIndex > this.maxDiskEntries || totalDiskBytes > this.maxDiskBytes) ) { const victim = validEntries[survivingIndex]; - await fs.unlink(victim.jsonPath).catch(() => {}); + await this.unlinkOwned(victim.jsonPath); if (victim.overflowPath) { await this.removeOverflow(victim.overflowPath); } @@ -476,8 +554,10 @@ export class CacheManager { const overflowDir = path.join(this.cacheDir, 'overflow'); const orphanGraceMs = options?.orphanGraceMs ?? 120_000; // 2 minutes grace period try { + await this.assertDiskBoundary('overflow'); const overflowFiles = await fs.readdir(overflowDir); for (const of of overflowFiles) { + if (!OVERFLOW_FILE.test(of)) continue; const fullPath = path.resolve(overflowDir, of); if (of.includes('.tmp.')) { const s = await fs.stat(fullPath).catch(() => null); @@ -507,9 +587,10 @@ export class CacheManager { let diskEntries = 0; let estimatedDiskBytes = 0; try { + await this.assertDiskBoundary(); const files = await fs.readdir(this.cacheDir); for (const f of files) { - if (!f.endsWith('.json')) continue; + if (!CACHE_FILE.test(f)) continue; diskEntries++; try { const s = await fs.stat(path.join(this.cacheDir, f)); @@ -519,8 +600,10 @@ export class CacheManager { } } const overflowDir = path.join(this.cacheDir, 'overflow'); + await this.assertDiskBoundary('overflow'); const overflowFiles = await fs.readdir(overflowDir).catch(() => []); for (const of of overflowFiles) { + if (!OVERFLOW_FILE.test(of)) continue; diskEntries++; try { const s = await fs.stat(path.join(overflowDir, of)); @@ -554,14 +637,16 @@ export class CacheManager { } private async clearDisk(): Promise { + await this.assertDiskBoundary(); try { const files = await fs.readdir(this.cacheDir); for (const file of files) { - if (file.endsWith('.json') || file.includes('.tmp.')) { - await fs.unlink(path.join(this.cacheDir, file)).catch(() => {}); + if (CACHE_FILE.test(file) || CACHE_TEMP.test(file)) { + await this.unlinkOwned(path.join(this.cacheDir, file)); } } const overflowDir = path.join(this.cacheDir, 'overflow'); + await this.assertDiskBoundary('overflow'); const overflowFiles = await fs.readdir(overflowDir).catch(() => []); for (const of of overflowFiles) { await this.removeOverflow(path.join(overflowDir, of)); diff --git a/src/Core/Context.ts b/src/Core/Context.ts index a73b697..a7be361 100644 --- a/src/Core/Context.ts +++ b/src/Core/Context.ts @@ -212,7 +212,7 @@ export class ContextManager { const budgetTokens = Math.max(512, opts.maxTokens ?? 8000); const maxChars = budgetTokens * 4; - const identity: ProjectIdentity = await this.workspace.identifyProject(); + const identity: ProjectIdentity = await this.workspace.identifyProject(operation); const keywords = scope || ranges.size ? [] : this.extractKeywords(task); const collected = await this.collectSymbolEvidence(opts, scope, keywords, operation); const { limitations, fileIssues, keySymbols, sourceContents } = collected; diff --git a/src/Core/DotNetGraph.ts b/src/Core/DotNetGraph.ts index c67643e..bc78481 100644 --- a/src/Core/DotNetGraph.ts +++ b/src/Core/DotNetGraph.ts @@ -1,10 +1,10 @@ -/** - * File-derived .NET solution graph: .sln project list, csproj ProjectReference, - * TFM / WPF-WinUI-WinForms flags, and conventional entry files. - * This is not an architecture judgment and does not use Roslyn. - */ +/** File-derived .NET declarations; never evaluates MSBuild or imports outside projects. */ import fs from 'node:fs/promises'; import path from 'node:path'; +import { resolveWorkspaceFile } from './FileSystemBoundary.js'; +import { checkOperation, rethrowOperationError, type OperationContext } from './OperationContext.js'; +import { listDirectory } from './WorkspaceBrowser.js'; +import { isWorkspacePathInside } from './WorkspaceContracts.js'; export interface DotNetProjectNode { name: string; @@ -24,139 +24,112 @@ export interface DotNetProjectGraph { solutions: string[]; projects: DotNetProjectNode[]; edges: { from: string; to: string }[]; + scanComplete: boolean; + omissions: Array<{ path: string; reason: string }>; + omittedCount: number; + descriptorBytesRead: number; + visitedEntries: number; + limits: { maxProjects: number; maxDescriptorBytes: number; maxFileBytes: number; maxEntries: number }; } -const ENTRY_NAMES = new Set([ - 'program.cs', - 'app.xaml.cs', - 'startup.cs', - 'main.cs', -]); +const ENTRY_NAMES = new Set(['program.cs', 'app.xaml.cs', 'startup.cs', 'main.cs']); export async function loadDotNetProjectGraph( - workspaceRoot: string, - solutionFiles: string[], - projectFiles: string[] + workspaceRoot: string, solutionFiles: string[], projectFiles: string[], + operation: OperationContext = { deadline: Date.now() + 20_000 }, ): Promise { - const solutions = [...solutionFiles]; - const discovered = new Map(); - - for (const proj of projectFiles) { - const rel = proj.replace(/\\/g, '/'); - const name = path.basename(rel, path.extname(rel)); - discovered.set(rel.toLowerCase(), rel); - if (!discovered.has(name.toLowerCase())) { - discovered.set(name.toLowerCase(), rel); - } - } - - for (const sln of solutionFiles) { + checkOperation(operation); + const graph: DotNetProjectGraph = { + solutions: solutionFiles.slice(0, 64), projects: [], edges: [], scanComplete: true, omissions: [], omittedCount: 0, + descriptorBytesRead: 0, visitedEntries: 0, + limits: { maxProjects: 16, maxDescriptorBytes: 262144, maxFileBytes: 65536, maxEntries: 2000 }, + }; + const omit = (file: string, reason: string) => { + graph.scanComplete = false; + graph.omittedCount++; + if (graph.omissions.length < 24) graph.omissions.push({ path: file, reason }); + }; + // ProjectDiscovery already parses .sln/.slnx entries under the common workspace boundary. + const unique = [...new Set(projectFiles.map(file => file.replace(/\\/g, '/')))]; + if (unique.length > graph.limits.maxProjects) omit('.', 'project-budget'); + if (solutionFiles.length > graph.solutions.length) omit('.', 'solution-budget'); + for (const rel of unique.slice(0, graph.limits.maxProjects)) { + checkOperation(operation); + if (graph.descriptorBytesRead >= graph.limits.maxDescriptorBytes) { omit(rel, 'descriptor-budget'); break; } + let content: string; + let handle: Awaited> | undefined; try { - const slnContent = await fs.readFile(path.join(workspaceRoot, sln), 'utf-8'); - const projectRegex = - /Project\("\{[A-Za-z0-9-]+\}"\)\s*=\s*"([^"]+)",\s*"([^"]+\.csproj)"/gi; - let match: RegExpExecArray | null; - while ((match = projectRegex.exec(slnContent)) !== null) { - const rel = match[2].replace(/\\/g, '/'); - discovered.set(rel.toLowerCase(), rel); - discovered.set(match[1].toLowerCase(), rel); - } - } catch { - // Ignore unreadable solution files - } - } - - const uniqueRels = Array.from(new Set(Array.from(discovered.values()))); - const projects: DotNetProjectNode[] = []; - - for (const rel of uniqueRels) { - const node = await parseCsproj(workspaceRoot, rel); - if (node) { - projects.push(node); + const full = await resolveWorkspaceFile(workspaceRoot, rel); + handle = await fs.open(full, 'r'); + const stat = await handle.stat(); + if (!stat.isFile()) { omit(rel, 'not-a-file'); continue; } + const capacity = Math.min(graph.limits.maxFileBytes, graph.limits.maxDescriptorBytes - graph.descriptorBytesRead); + if (stat.size > capacity) { omit(rel, 'descriptor-too-large'); continue; } + const buffer = Buffer.alloc(capacity); + const { bytesRead } = await handle.read(buffer, 0, capacity, 0); + graph.descriptorBytesRead += bytesRead; + checkOperation(operation); + if (bytesRead !== stat.size || (await handle.stat()).size !== stat.size) { omit(rel, 'descriptor-changed'); continue; } + content = buffer.subarray(0, bytesRead).toString('utf8'); + } catch (error) { + rethrowOperationError(error, operation); + omit(rel, 'descriptor-unreadable-or-outside-workspace'); + continue; + } finally { await handle?.close(); } + + const field = (value?: string) => { + if (value && value.length > 512) { omit(rel, 'metadata-field-budget'); return undefined; } + return value?.trim(); + }; + const sdk = field(content.match(/]*Sdk="([^"]+)"/i)?.[1]); + const refs: string[] = []; + for (const match of content.matchAll(/= 16) { omit(rel, 'reference-budget'); break; } + const full = path.resolve(workspaceRoot, path.dirname(rel), match[1].replace(/\\/g, '/')); + if (!isWorkspacePathInside(workspaceRoot, full)) { omit(rel, 'reference-outside-workspace'); continue; } + refs.push(path.relative(workspaceRoot, full).replace(/\\/g, '/')); } + const entryPoints: string[] = []; + if (graph.visitedEntries < graph.limits.maxEntries) { + try { + const listing = await listDirectory(workspaceRoot, { + path: path.dirname(rel), maxDepth: 4, + maxEntries: Math.min(500, graph.limits.maxEntries - graph.visitedEntries), maxOutputChars: 32768, + }, operation); + graph.visitedEntries += listing.visitedEntries; + if (!listing.scanComplete || listing.truncated) omit(rel, 'entry-point-scan-incomplete'); + for (const entry of listing.entries) { + if (entry.type !== 'file' || !ENTRY_NAMES.has(path.posix.basename(entry.path).toLowerCase())) continue; + if (entryPoints.length >= 8) { omit(rel, 'entry-point-result-budget'); break; } + entryPoints.push(entry.path); + } + } catch (error) { + rethrowOperationError(error, operation); + omit(rel, 'entry-point-directory-unreadable'); + } + } else omit(rel, 'entry-point-entry-budget'); + graph.projects.push({ + name: path.basename(rel, path.extname(rel)), relativePath: rel, sdk, + targetFramework: field((content.match(/([^<]+)<\/TargetFramework>/i) || + content.match(/([^<]+)<\/TargetFrameworks>/i))?.[1]), + outputType: field(content.match(/([^<]+)<\/OutputType>/i)?.[1]), + isWpf: /\s*true\s*<\/UseWPF>/i.test(content), + isWinUi: /\s*true\s*<\/UseWinUI>/i.test(content), + isWinForms: /\s*true\s*<\/UseWindowsForms>/i.test(content), + isWeb: (sdk ?? '').toLowerCase().includes('web'), projectReferences: refs, entryPoints, + }); } - - const byRel = new Map(projects.map((p) => [p.relativePath.replace(/\\/g, '/').toLowerCase(), p])); - const byName = new Map(projects.map((p) => [p.name.toLowerCase(), p])); - const edges: { from: string; to: string }[] = []; - - for (const project of projects) { - const resolvedRefs: string[] = []; + const byRel = new Map(graph.projects.map(project => [project.relativePath.toLowerCase(), project])); + for (const project of graph.projects) { + const names: string[] = []; for (const ref of project.projectReferences) { - const target = - byRel.get(ref.replace(/\\/g, '/').toLowerCase()) || byName.get(path.basename(ref, '.csproj').toLowerCase()); - if (target) { - resolvedRefs.push(target.name); - edges.push({ from: project.name, to: target.name }); - } else { - resolvedRefs.push(path.basename(ref, '.csproj')); - } + const target = byRel.get(ref.toLowerCase()); + if (target) graph.edges.push({ from: project.name, to: target.name }); + names.push(target?.name ?? path.basename(ref, path.extname(ref))); } - project.projectReferences = Array.from(new Set(resolvedRefs)); + project.projectReferences = [...new Set(names)]; } - - return { solutions, projects, edges }; -} - -async function parseCsproj(workspaceRoot: string, relativePath: string): Promise { - const fullPath = path.isAbsolute(relativePath) ? relativePath : path.join(workspaceRoot, relativePath); - let content: string; - try { - content = await fs.readFile(fullPath, 'utf-8'); - } catch { - return null; - } - - const rel = path.relative(workspaceRoot, fullPath).replace(/\\/g, '/'); - const name = path.basename(rel, path.extname(rel)); - const sdkMatch = content.match(/]*Sdk="([^"]+)"/i); - const tfMatch = - content.match(/([^<]+)<\/TargetFramework>/i) || - content.match(/([^<]+)<\/TargetFrameworks>/i); - const outputMatch = content.match(/([^<]+)<\/OutputType>/i); - - const projectDir = path.dirname(fullPath); - const rawRefs: string[] = []; - const refRegex = /\s*true\s*<\/UseWPF>/i.test(content), - isWinUi: /\s*true\s*<\/UseWinUI>/i.test(content), - isWinForms: /\s*true\s*<\/UseWindowsForms>/i.test(content), - isWeb: (sdkMatch?.[1] || '').toLowerCase().includes('web'), - projectReferences: rawRefs, - entryPoints: await findEntryPoints(workspaceRoot, projectDir), - }; -} - -async function findEntryPoints(workspaceRoot: string, projectDir: string): Promise { - const found: string[] = []; - - const walk = async (dir: string, depth: number) => { - if (depth > 3 || found.length >= 8) return; - const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); - for (const entry of entries) { - if (found.length >= 8) break; - if (entry.name === 'bin' || entry.name === 'obj' || entry.name.startsWith('.')) continue; - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - await walk(full, depth + 1); - } else if (entry.isFile() && ENTRY_NAMES.has(entry.name.toLowerCase())) { - found.push(path.relative(workspaceRoot, full).replace(/\\/g, '/')); - } - } - }; - - await walk(projectDir, 0); - return found; + checkOperation(operation); + return graph; } diff --git a/src/Core/FileSystemBoundary.ts b/src/Core/FileSystemBoundary.ts new file mode 100644 index 0000000..4aa0274 --- /dev/null +++ b/src/Core/FileSystemBoundary.ts @@ -0,0 +1,31 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { isWorkspacePathInside } from './WorkspaceContracts.js'; + +/** Reject existing links in a writable path, including ancestors and Windows junctions. + * Recheck immediately before mutations; this is not an atomic OS-handle sandbox. */ +export async function assertLinkFreePath(target: string): Promise { + const resolved = path.resolve(target); + let current = path.parse(resolved).root; + for (const part of resolved.slice(current.length).split(path.sep).filter(Boolean)) { + current = path.join(current, part); + try { + const stat = await fs.lstat(current); + if (stat.isSymbolicLink()) throw new Error(`Unsafe filesystem path: link or junction at ${current}`); + if (current !== resolved && !stat.isDirectory()) throw new Error(`Unsafe filesystem path: ancestor is not a directory at ${current}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; + } + } +} + +/** Check both spelling and physical destination before accessing a workspace file. */ +export async function resolveWorkspaceFile(root: string, relative: string): Promise { + const full = path.resolve(root, relative.replace(/\\/g, '/')); + if (!isWorkspacePathInside(root, full)) throw new Error('File is outside the workspace.'); + const realRoot = await fs.realpath(root); + const real = await fs.realpath(full); + if (!isWorkspacePathInside(realRoot, real)) throw new Error('File resolves outside the workspace.'); + return real; +} diff --git a/src/Core/GitClient.ts b/src/Core/GitClient.ts new file mode 100644 index 0000000..6750ecd --- /dev/null +++ b/src/Core/GitClient.ts @@ -0,0 +1,48 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { isWorkspacePathInside } from './WorkspaceContracts.js'; + +const execFileAsync = promisify(execFile); +const supportedVersions = new Set(); // At most the fixed launch-time candidate list below. +// Capture the launch environment once, never search a queried repository's cwd. +const candidates = [ + ...[process.env.ProgramFiles, process.env['ProgramFiles(x86)'], process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Programs')] + .filter((value): value is string => !!value).map(base => path.join(base, 'Git', 'cmd', 'git.exe')), + ...(process.env.PATH ?? '').split(path.delimiter).filter(dir => path.isAbsolute(dir)) + .map(dir => path.join(dir, process.platform === 'win32' ? 'git.exe' : 'git')), +]; + +export async function resolveGitExecutable(root: string): Promise { + const realRoot = await fs.realpath(root); + for (const candidate of candidates) { + if (isWorkspacePathInside(root, candidate, true)) continue; + try { + const real = await fs.realpath(candidate); + if (isWorkspacePathInside(realRoot, real, true)) continue; + if ((await fs.stat(real)).isFile()) return real; + } catch { /* Try another launch-time installation candidate. */ } + } + throw Object.assign(new Error('No trusted Git executable is available outside the workspace.'), { code: 'GIT_UNAVAILABLE' }); +} + +export async function runGit(root: string, args: string[], timeout: number): Promise<{ stdout: string }> { + const executable = await resolveGitExecutable(root); + if (!supportedVersions.has(executable)) { + const version = await execFileAsync(executable, ['--version'], { + cwd: path.dirname(executable), windowsHide: true, shell: false, timeout, maxBuffer: 4096, encoding: 'utf8', + }); + const match = /^git version (\d+)\.(\d+)/.exec(version.stdout); + // Older Git can interpret the boolean "false" as an executable fsmonitor hook path. + if (!match || Number(match[1]) < 2 || (Number(match[1]) === 2 && Number(match[2]) < 36)) + throw Object.assign(new Error('Git 2.36 or later is required for safe read-only probes.'), { code: 'GIT_UNAVAILABLE' }); + supportedVersions.add(executable); + } + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => + !/^(?:GIT_(?:DIR|WORK_TREE|COMMON_DIR|INDEX_FILE|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|CONFIG_COUNT|CONFIG_KEY_\d+|CONFIG_VALUE_\d+))$/i.test(key))); + return execFileAsync(executable, ['--no-pager', '--no-optional-locks', '-c', 'core.fsmonitor=false', ...args], { + cwd: root, windowsHide: true, shell: false, timeout, maxBuffer: 1024 * 1024, encoding: 'utf8', + env: { ...env, LC_ALL: 'C', LANG: 'C', GIT_TERMINAL_PROMPT: '0' }, + }); +} diff --git a/src/Core/ProjectDiscovery.ts b/src/Core/ProjectDiscovery.ts index 5af7269..272563f 100644 --- a/src/Core/ProjectDiscovery.ts +++ b/src/Core/ProjectDiscovery.ts @@ -1,165 +1,22 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import { checkOperation, rethrowOperationError, type OperationContext } from './OperationContext.js'; import { isWorkspacePathInside, validateWorkspaceDirectoryOptions, type ProjectIdentity, type WorkspaceMetadata, type WorkspaceTreeItem, type WorkspaceDirectoryOptions, type WorkspaceDirectoryResult } from './WorkspaceContracts.js'; import { DEFAULT_IGNORES, directoryOmission, previewOmission } from './WorkspaceBrowser.js'; const isInsideOrEqual = (parent: string, target: string) => isWorkspacePathInside(parent, target, true); /** 识别现有项目声明;不求值构建脚本。 */ -export async function identifyProject(root: string): Promise { - const frameworks: string[] = []; - const solutionFiles: string[] = []; - const projectFiles: string[] = []; - const packageManagers: string[] = []; - let hasGit = false; - let targetFramework: string | undefined; - - try { - const entries = await fs.readdir(root, { withFileTypes: true }); - - for (const entry of entries) { - const lower = entry.name.toLowerCase(); - if (entry.name === '.git') hasGit = true; - if (lower.endsWith('.sln') || lower.endsWith('.slnx')) { - solutionFiles.push(entry.name); - frameworks.push('.NET Solution'); - } - if (lower.endsWith('.csproj')) { - projectFiles.push(entry.name); - frameworks.push('C# / .NET'); - } - if (entry.name === 'package.json') { - packageManagers.push('npm/node'); - frameworks.push('Node.js / TypeScript'); - } - if (entry.name === 'requirements.txt' || entry.name === 'pyproject.toml') { - packageManagers.push('pip/python'); - frameworks.push('Python'); - } - if (entry.name === 'Cargo.toml') { - frameworks.push('Rust'); - } - if (entry.name === 'go.mod') { - frameworks.push('Go'); - } - } - - // If solution file exists, parse projects defined inside it - if (solutionFiles.length > 0) { - for (const sln of solutionFiles) { - try { - const slnContent = await fs.readFile(path.join(root, sln), 'utf-8'); - const projectRegex = /Project\("\{[A-Za-z0-9-]+\}"\)\s*=\s*"([^"]+)",\s*"([^"]+\.csproj)"/g; - let match; - while ((match = projectRegex.exec(slnContent)) !== null) { - const projRelPath = match[2].replace(/\\/g, '/'); - if (!projectFiles.includes(projRelPath)) { - projectFiles.push(projRelPath); - } - } - } catch { - // Ignore sln read errors - } - } - } - - // Deep search for .csproj files in subdirectories up to 3 levels if not in solution - if (projectFiles.length === 0) { - const scanCsproj = async (dir: string, depth: number) => { - if (depth > 3) return; - const subEntries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); - for (const sub of subEntries) { - if (DEFAULT_IGNORES.has(sub.name)) continue; - const full = path.join(dir, sub.name); - if (sub.isDirectory()) { - await scanCsproj(full, depth + 1); - } else if (sub.name.toLowerCase().endsWith('.csproj')) { - projectFiles.push(path.relative(root, full)); - } - } - }; - await scanCsproj(root, 0); - } - - // Inspect csproj files for WPF/WinUI/TargetFramework - for (const proj of projectFiles.slice(0, 5)) { - try { - const fullProj = path.isAbsolute(proj) ? proj : path.join(root, proj); - const projContent = await fs.readFile(fullProj, 'utf-8'); - const tfMatch = projContent.match(/([^<]+)<\/TargetFramework>/i); - if (tfMatch && !targetFramework) { - targetFramework = tfMatch[1].trim(); - } - if (projContent.includes('true') && !frameworks.includes('WPF')) { - frameworks.push('WPF'); - } - if (projContent.includes('true') && !frameworks.includes('WinUI')) { - frameworks.push('WinUI'); - } - if (projContent.includes('true') && !frameworks.includes('WinForms')) { - frameworks.push('WinForms'); - } - } catch { - // Ignore - } - } - - // Check Directory.Build.props if targetFramework not yet found - if (!targetFramework) { - try { - const propsContent = await fs.readFile(path.join(root, 'Directory.Build.props'), 'utf-8'); - const tfMatch = propsContent.match(/([^<]+)<\/TargetFramework>/i); - if (tfMatch) targetFramework = tfMatch[1].trim(); - } catch { - // Ignore - } - } - } catch (err) { - console.warn('[WorkspaceManager] Error identifying project:', err); - } - - const isDotNet = solutionFiles.length > 0 || projectFiles.length > 0; - let type: 'dotnet' | 'node' | 'python' | 'rust' | 'go' | 'general' = 'general'; - let language = 'Unknown'; - - if (isDotNet) { - type = 'dotnet'; - language = 'C#'; - if (!packageManagers.includes('NuGet')) packageManagers.push('NuGet'); - if (targetFramework && !frameworks.includes(targetFramework)) frameworks.push(targetFramework); - } else if (packageManagers.includes('npm/node')) { - type = 'node'; - language = 'TypeScript'; - } else if (packageManagers.includes('pip/python')) { - type = 'python'; - language = 'Python'; - } else if (frameworks.includes('Rust')) { - type = 'rust'; - language = 'Rust'; - } else if (frameworks.includes('Go')) { - type = 'go'; - language = 'Go'; - } - - return { - name: path.basename(root), - type, - language, - primarySolution: solutionFiles[0] || null, - frameworks: Array.from(new Set(frameworks)), - isDotNet, - solutionFiles, - projectFiles, - hasGit, - packageManagers, - targetFramework, - }; +export async function identifyProject(root: string, operation?: OperationContext): Promise { + const result = await discoverProject(root, operation); + return { ...result.identity, scanComplete: result.complete, discovery: result.discovery }; } /** 有界项目发现;保留原有深度、文件数和读取预算。 */ -export async function discoverProject(root: string): Promise<{ +export async function discoverProject(root: string, operation: OperationContext = { deadline: Date.now() + 20_000 }): Promise<{ identity: ProjectIdentity; complete: boolean; entryPoints: string[]; discovery: NonNullable; }> { + checkOperation(operation); const discovery: NonNullable = { visitedEntries: 0, descriptorBytesRead: 0, maxEntries: 2000, maxDepth: 3, maxDescriptorBytes: 262144, ignoredDirectoryCount: 0, omissions: [], omittedCount: 0, @@ -192,6 +49,7 @@ export async function discoverProject(root: string): Promise<{ else if (!projects.has(normalized)) omit('.', 'project-list-limit'); }; const readDescriptor = async (rel: string): Promise => { + checkOperation(operation); if (descriptorReads >= 16 || discovery.descriptorBytesRead >= discovery.maxDescriptorBytes) { omit(rel, 'descriptor-budget'); return null; } @@ -207,10 +65,12 @@ export async function discoverProject(root: string): Promise<{ const capacity = Math.min(65536, discovery.maxDescriptorBytes - discovery.descriptorBytesRead); const buffer = Buffer.alloc(capacity); const { bytesRead } = await handle.read(buffer, 0, capacity, 0); + checkOperation(operation); discovery.descriptorBytesRead += bytesRead; if (stat.size > bytesRead) omit(rel, 'descriptor-truncated'); return buffer.subarray(0, bytesRead).toString('utf8'); - } catch { + } catch (error) { + rethrowOperationError(error, operation); omit(rel, 'descriptor-unreadable'); return null; } finally { await handle?.close(); } }; @@ -221,13 +81,16 @@ export async function discoverProject(root: string): Promise<{ } const queue = [{ full: root, depth: 0 }]; while (queue.length && discovery.visitedEntries < discovery.maxEntries) { + checkOperation(operation); const current = queue.shift()!; try { const real = await fs.realpath(current.full); if (!isInsideOrEqual(realRoot, real)) { omit(relative(current.full), 'external-link'); continue; } const directory = await fs.opendir(real); try { while (discovery.visitedEntries < discovery.maxEntries) { + checkOperation(operation); const entry = await directory.read(); + checkOperation(operation); if (!entry) break; discovery.visitedEntries++; const full = path.join(current.full, entry.name); @@ -251,7 +114,8 @@ export async function discoverProject(root: string): Promise<{ } } } finally { await directory.close(); } if (discovery.visitedEntries >= discovery.maxEntries) omit(relative(current.full), 'entry-budget'); - } catch { + } catch (error) { + rethrowOperationError(error, operation); if (current.depth === 0) throw new Error('Workspace root could not be read during project discovery.'); omit(relative(current.full), 'directory-unreadable'); } @@ -321,6 +185,7 @@ export async function discoverProject(root: string): Promise<{ projectFiles: Array.from(projects), hasGit: await fs.lstat(path.join(root, '.git')).then(() => true).catch(() => false), packageManagers: Array.from(packageManagers), targetFramework, }; + checkOperation(operation); return { identity, complete, discovery, entryPoints: Array.from(new Set([...Array.from(solutions).slice(0, 1), ...manifests, ...entryPoints, ...projects])).slice(0, 8), diff --git a/src/Core/TextDeclarations.ts b/src/Core/TextDeclarations.ts index 2364076..e1fbebe 100644 --- a/src/Core/TextDeclarations.ts +++ b/src/Core/TextDeclarations.ts @@ -4,7 +4,7 @@ import type { CodeSymbol } from './CodeQueries.js'; export class TextLexicalError extends Error { readonly code = 'TEXT_LEXICAL_UNCERTAINTY'; - constructor() { super('Cannot reliably delimit comments/literals; no declarations returned for this file.'); } + constructor(message = 'Cannot reliably delimit comments/literals; no declarations returned for this file.') { super(message); } } /** 解析已提供的正文,不读取磁盘、不启动进程;正则匹配不提供语义身份或完整引用保证。 */ @@ -18,39 +18,49 @@ export function parseTextDeclarations(content: string, relPath: string, ext: str for (let i = 0; i < lines.length; i++) { checkpoint(); const line = lines[i]; - const trimmed = line.trim(); + // Collapse masked whitespace once; overlapping optional whitespace patterns must not backtrack over it. + const trimmed = line.trim().replace(/\s+/g, ' '); + if (trimmed.length > 16384) throw new TextLexicalError('Declaration line exceeds the 16384 character parsing budget; no declarations returned for this file.'); const lineNum = i + 1; // C# symbol patterns if (ext === '.cs') { - const classMatch = trimmed.match(/(?:public|private|protected|internal)?\s*(?:static|abstract|sealed|partial)?\s*class\s+([A-Za-z0-9_]+)/); + const classMatch = trimmed.match(/\bclass ([A-Za-z0-9_]+)/); if (classMatch) { symbols.push({ name: classMatch[1], kind: 'class', file: relPath, line: lineNum, signature: originalLines[i].trim() }); continue; } - const structMatch = trimmed.match(/(?:public|private|protected|internal)?\s*(?:readonly|ref)?\s*struct\s+([A-Za-z0-9_]+)/); + const structMatch = trimmed.match(/\bstruct ([A-Za-z0-9_]+)/); if (structMatch) { symbols.push({ name: structMatch[1], kind: 'struct', file: relPath, line: lineNum, signature: originalLines[i].trim() }); continue; } - const enumMatch = trimmed.match(/(?:public|private|protected|internal)?\s*enum\s+([A-Za-z0-9_]+)/); + const enumMatch = trimmed.match(/\benum ([A-Za-z0-9_]+)/); if (enumMatch) { symbols.push({ name: enumMatch[1], kind: 'enum', file: relPath, line: lineNum, signature: originalLines[i].trim() }); continue; } - const interfaceMatch = trimmed.match(/(?:public|private|protected|internal)?\s*interface\s+([A-Za-z0-9_]+)/); + const interfaceMatch = trimmed.match(/\binterface ([A-Za-z0-9_]+)/); if (interfaceMatch) { symbols.push({ name: interfaceMatch[1], kind: 'interface', file: relPath, line: lineNum, signature: originalLines[i].trim() }); continue; } - const methodMatch = trimmed.match(/(?:public|private|protected|internal)\s+(?:async\s+)?(?:static\s+|virtual\s+|override\s+|sealed\s+)?([A-Za-z0-9_<>?, \[\]]+)\s+([A-Za-z0-9_]+)\s*\(/); - if (methodMatch && !['if', 'for', 'while', 'switch', 'using', 'catch'].includes(methodMatch[2])) { - symbols.push({ name: methodMatch[2], kind: 'method', file: relPath, line: lineNum, signature: originalLines[i].trim() }); - continue; + const visibility = /\b(?:public|private|protected|internal) /.exec(trimmed); + const open = visibility ? trimmed.indexOf('(', visibility.index + visibility[0].length) : -1; + if (visibility && open >= 0) { + const header = trimmed.slice(visibility.index + visibility[0].length, open).trim(); + const tokens = header.split(' '); + const name = tokens.pop() ?? ''; + const returnType = tokens.join(' ').replace(/^(?:(?:async|static|virtual|override|sealed) )+/, ''); + if (/^[A-Za-z0-9_]+$/.test(name) && returnType && /^[A-Za-z0-9_<>?, \[\]]+$/.test(returnType) && + !['if', 'for', 'while', 'switch', 'using', 'catch'].includes(name)) { + symbols.push({ name, kind: 'method', file: relPath, line: lineNum, signature: originalLines[i].trim() }); + continue; + } } } @@ -86,7 +96,7 @@ export function parseTextDeclarations(content: string, relPath: string, ext: str continue; } - const arrowFuncMatch = trimmed.match(/(?:export\s+)?const\s+([A-Za-z0-9_]+)\s*=\s*(?:async\s*)?\([^)]*\)\s*(?::\s*[^=]+)?\s*=>/); + const arrowFuncMatch = trimmed.match(/^(?:export\s+)?const\s+([A-Za-z0-9_]+)\s*=\s*(?:async\s*)?\([^)]*\)\s*(?::\s*[^=]+)?\s*=>/); if (arrowFuncMatch) { symbols.push({ name: arrowFuncMatch[1], kind: 'function', file: relPath, line: lineNum, signature: originalLines[i].trim() }); continue; diff --git a/src/Core/ToolRouter.ts b/src/Core/ToolRouter.ts index 9bafb3c..369e0cc 100644 --- a/src/Core/ToolRouter.ts +++ b/src/Core/ToolRouter.ts @@ -239,8 +239,8 @@ export class ToolRouter { return this.runCode(signal, operation => this.context.prepareContext(options, operation)); } - analyzeWorkspace(maxDepth?: number) { - return this.architecture.analyze(maxDepth); + analyzeWorkspace(maxDepth?: number, signal?: AbortSignal) { + return this.runCode(signal, operation => this.architecture.analyze(maxDepth, operation)); } analyzeChangeImpact(target: string, signal?: AbortSignal, location?: SymbolLocation) { @@ -265,8 +265,8 @@ export class ToolRouter { return this.workspace.moveToTrash(filePath, reason); } - listDirectory(options: WorkspaceDirectoryOptions = {}) { - return this.workspace.listDirectory(options); + listDirectory(options: WorkspaceDirectoryOptions = {}, signal?: AbortSignal) { + return this.runCode(signal, operation => this.workspace.listDirectory(options, operation)); } async acquireRequestSlot(signal?: AbortSignal, allowDuringRecovery = false): Promise { diff --git a/src/Core/Workspace.ts b/src/Core/Workspace.ts index 6293e1d..8ecd601 100644 --- a/src/Core/Workspace.ts +++ b/src/Core/Workspace.ts @@ -5,13 +5,12 @@ import { isWorkspacePathInside, validateTrashPath, type TrashMoveResult, type Pr export { type TrashMoveResult, invalidTrashResult, type ProjectIdentity, type WorkspaceTreeItem, type WorkspaceGitStatus, type WorkspaceMetadata, type WorkspaceOpenOptions, type WorkspaceDirectoryOptions, type WorkspaceDirectoryResult, type WorkspaceOpenResult, validateWorkspaceDirectoryOptions, validateTrashPath } from './WorkspaceContracts.js'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { exec } from 'node:child_process'; -import { promisify } from 'node:util'; +import { runGit } from './GitClient.js'; +import { assertLinkFreePath } from './FileSystemBoundary.js'; +import type { OperationContext } from './OperationContext.js'; import { WinCodeConfig } from './Config.js'; import { randomUUID } from 'node:crypto'; -const execAsync = promisify(exec); - export class WorkspaceManager { private config: WinCodeConfig; @@ -53,7 +52,8 @@ export class WorkspaceManager { try { const real = await fs.realpath(current); return remainingSegments.length > 0 ? path.join(real, ...remainingSegments) : real; - } catch { + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; const parent = path.dirname(current); if (parent === current) { return targetPath; @@ -86,84 +86,42 @@ export class WorkspaceManager { /** * Identifies the project types, especially Windows / .NET ecosystems */ - async identifyProject(): Promise { - return identifyProject(this.root); + async identifyProject(operation?: OperationContext): Promise { + return identifyProject(this.root, operation); } /** * Retrieves Git status safely for the current workspace */ async getGitStatus(): Promise { - const gitDir = path.join(this.root, '.git'); - const isGit = await fs.stat(gitDir).then((s) => s.isDirectory()).catch(() => false); - if (!isGit) { - return { isGit: false }; - } - - let branch = 'unknown'; - let isClean = true; - let headCommit: string | undefined; - let remoteUrl: string | undefined; - const gitTimeout = this.config.timeouts?.gitMs ?? 5000; - try { - const { stdout: bOut } = await execAsync('git rev-parse --abbrev-ref HEAD', { - cwd: this.root, - windowsHide: true, - timeout: gitTimeout, - }); - branch = bOut.trim(); - } catch { - // Fallback: read .git/HEAD when git CLI is missing, hung, or timed out - try { - const headContent = await fs.readFile(path.join(gitDir, 'HEAD'), 'utf-8'); - const match = headContent.match(/ref:\s*refs\/heads\/([^\r\n]+)/); - if (match) branch = match[1]; - } catch {} + if ((await runGit(this.root, ['rev-parse', '--is-inside-work-tree'], gitTimeout)).stdout.trim() !== 'true') return { isGit: false }; + } catch (error) { + const failure = error as NodeJS.ErrnoException & { stderr?: string }; + if (failure.stderr?.startsWith('fatal: not a git repository')) return { isGit: false }; + return { isGit: null, status: 'unknown', errorCode: failure.code === 'GIT_UNAVAILABLE' ? 'GIT_UNAVAILABLE' : 'GIT_QUERY_FAILED' }; } - - try { - const { stdout: cOut } = await execAsync('git rev-parse --short HEAD', { - cwd: this.root, - windowsHide: true, - timeout: gitTimeout, - }); - headCommit = cOut.trim(); - } catch {} - - try { - const { stdout: sOut } = await execAsync('git status --porcelain', { - cwd: this.root, - windowsHide: true, - timeout: gitTimeout, - }); - isClean = sOut.trim().length === 0; - } catch {} - - try { - const { stdout: rOut } = await execAsync('git remote get-url origin', { - cwd: this.root, - windowsHide: true, - timeout: gitTimeout, - }); - remoteUrl = rOut.trim(); - } catch {} - - return { - isGit: true, - branch, - isClean, - headCommit, - remoteUrl, + const [branch, head, status, remote] = await Promise.allSettled([ + runGit(this.root, ['rev-parse', '--abbrev-ref', 'HEAD'], gitTimeout), + runGit(this.root, ['rev-parse', '--short', 'HEAD'], gitTimeout), + runGit(this.root, ['status', '--porcelain'], gitTimeout), + runGit(this.root, ['remote', 'get-url', 'origin'], gitTimeout), + ]); + const clean = status.status === 'fulfilled' ? status.value.stdout.trim().length === 0 : undefined; + return { isGit: true, + ...(branch.status === 'fulfilled' ? { branch: branch.value.stdout.trim() } : {}), + ...(head.status === 'fulfilled' ? { headCommit: head.value.stdout.trim() } : {}), + ...(remote.status === 'fulfilled' ? { remoteUrl: remote.value.stdout.trim() } : {}), + ...(clean === undefined ? { status: 'unknown', errorCode: 'GIT_QUERY_FAILED' } : { isClean: clean, status: clean ? 'clean' : 'dirty' }), }; } /** * Scans the workspace directory tree up to maxDepth and collects summary statistics */ - async getDirectoryTree(maxDepth = 3): Promise { - return getDirectoryTree(this.root, maxDepth); + async getDirectoryTree(maxDepth = 3, operation?: OperationContext): Promise { + return getDirectoryTree(this.root, maxDepth, operation); } /** @@ -182,8 +140,8 @@ export class WorkspaceManager { } /** Directory browsing is stateless and bounds enumeration as well as final serialization. */ - async listDirectory(options: WorkspaceDirectoryOptions = {}): Promise { - return listDirectory(this.root, options); + async listDirectory(options: WorkspaceDirectoryOptions = {}, operation?: OperationContext): Promise { + return listDirectory(this.root, options, operation); } /** @@ -312,8 +270,13 @@ export class WorkspaceManager { return invalidTrashResult('Failed to move file to trash: Cannot move items from or within the trash directory.'); } + if (!this.isPathInside(realRoot, realTrash)) return invalidTrashResult('Failed to move file to trash: Trash destination resolves outside the workspace.'); + await assertLinkFreePath(this.config.trashDir); + failureStage = 'prepare'; await fs.mkdir(this.config.trashDir, { recursive: true }); + await assertLinkFreePath(this.config.trashDir); + if (await fs.realpath(this.config.trashDir) !== realTrash) throw new Error('Trash destination changed during preparation.'); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const fileName = path.basename(targetPath); @@ -334,11 +297,14 @@ export class WorkspaceManager { destinationPath = path.join(this.config.trashDir, trashFileName); failureStage = 'move'; + await assertLinkFreePath(this.config.trashDir); + if (await this.getRealPath(targetPath) !== realTarget) throw new Error('Trash source changed during preparation.'); await fs.rename(targetPath, destinationPath); moved = true; failureStage = 'metadata'; const metaPath = path.join(this.config.trashDir, `${trashFileName}.meta.json`); + await assertLinkFreePath(this.config.trashDir); await fs.writeFile( metaPath, JSON.stringify( diff --git a/src/Core/WorkspaceBrowser.ts b/src/Core/WorkspaceBrowser.ts index fbf8601..a50699b 100644 --- a/src/Core/WorkspaceBrowser.ts +++ b/src/Core/WorkspaceBrowser.ts @@ -1,5 +1,6 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import { checkOperation, rethrowOperationError, type OperationContext } from './OperationContext.js'; import { isWorkspacePathInside, validateWorkspaceDirectoryOptions, type ProjectIdentity, type WorkspaceMetadata, type WorkspaceTreeItem, type WorkspaceDirectoryOptions, type WorkspaceDirectoryResult } from './WorkspaceContracts.js'; const isInsideOrEqual = (parent: string, target: string) => isWorkspacePathInside(parent, target, true); @@ -41,58 +42,24 @@ export async function previewOmission(dir: string): Promise { } /** 生成既有深度范围的目录树。 */ -export async function getDirectoryTree(root: string, maxDepth = 3): Promise { - const scan = async (dirPath: string, currentDepth: number): Promise => { - const name = path.basename(dirPath); - const relativePath = path.relative(root, dirPath) || '.'; - const item: WorkspaceTreeItem = { - name, - path: dirPath, - relativePath, - type: 'directory', - children: [], - }; - - if (currentDepth >= maxDepth) { - return item; - } - - try { - const entries = await fs.readdir(dirPath, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - if (!entry.isDirectory() && DEFAULT_IGNORES.has(entry.name)) continue; - - if (entry.isDirectory()) { - const reason = await directoryOmission(fullPath); - if (reason) { - (item.omittedDirectories ??= []).push({ path: path.relative(root, fullPath).replace(/\\/g, '/'), reason }); - continue; - } - item.children?.push(await scan(fullPath, currentDepth + 1)); - } else if (entry.isFile()) { - const stat = await fs.stat(fullPath).catch(() => null); - item.children?.push({ - name: entry.name, - path: fullPath, - relativePath: path.relative(root, fullPath), - type: 'file', - size: stat?.size, - }); - } - } - } catch { - // Skip inaccessible dirs - } - - return item; - }; - - return scan(root, 0); +export async function getDirectoryTree(root: string, maxDepth = 3, operation?: OperationContext): Promise { + const listing = await listDirectory(root, { maxDepth, maxEntries: 500, maxOutputChars: 32768 }, operation); + const tree: WorkspaceTreeItem = { name: path.basename(root), path: root, relativePath: '.', type: 'directory', children: [], + omittedDirectories: listing.omissions, scanComplete: listing.scanComplete && !listing.truncated, + truncated: listing.truncated, visitedEntries: listing.visitedEntries }; + const nodes = new Map([['.', tree]]); + for (const entry of listing.entries) { + const item: WorkspaceTreeItem = { name: path.posix.basename(entry.path), path: path.resolve(root, entry.path), + relativePath: entry.path, type: entry.type, ...(entry.type === 'directory' ? { children: [] } : {}) }; + nodes.get(path.posix.dirname(entry.path))?.children?.push(item); + if (entry.type === 'directory') nodes.set(entry.path, item); + } + return tree; } /** 验证路径后有界浏览目录,保留遗漏与截断证据。 */ -export async function listDirectory(root: string, options: WorkspaceDirectoryOptions = {}): Promise { +export async function listDirectory(root: string, options: WorkspaceDirectoryOptions = {}, operation: OperationContext = { deadline: Date.now() + 20_000 }): Promise { + checkOperation(operation); const { full, maxDepth, maxEntries, maxOutputChars } = validateWorkspaceDirectoryOptions(options, root); const realRoot = await fs.realpath(root); const realTarget = await fs.realpath(full); @@ -113,13 +80,16 @@ export async function listDirectory(root: string, options: WorkspaceDirectoryOpt // includeIgnored controls filtering of child directories, not access to that path. const queue = [{ full, depth: 0 }]; while (queue.length && result.visitedEntries < maxEntries) { + checkOperation(operation); const current = queue.shift()!; try { const real = await fs.realpath(current.full); if (!isInsideOrEqual(realRoot, real)) { omit(relative(current.full), 'external-link'); continue; } const directory = await fs.opendir(real); try { while (result.visitedEntries < maxEntries) { + checkOperation(operation); const entry = await directory.read(); + checkOperation(operation); if (!entry) break; result.visitedEntries++; const entryFull = path.join(current.full, entry.name); @@ -137,12 +107,14 @@ export async function listDirectory(root: string, options: WorkspaceDirectoryOpt } } } finally { await directory.close(); } if (result.visitedEntries >= maxEntries) omit(relative(current.full), 'entry-budget'); - } catch { + } catch (error) { + rethrowOperationError(error, operation); if (current.depth === 0) throw new Error('Requested directory could not be read.'); omit(relative(current.full), 'directory-unreadable'); } } if (queue.length) omit(result.path, 'entry-budget'); + checkOperation(operation); return fitDirectory(result); } diff --git a/src/Core/WorkspaceContracts.ts b/src/Core/WorkspaceContracts.ts index d52970a..d395579 100644 --- a/src/Core/WorkspaceContracts.ts +++ b/src/Core/WorkspaceContracts.ts @@ -29,6 +29,8 @@ export interface ProjectIdentity { hasGit: boolean; packageManagers: string[]; targetFramework?: string; + scanComplete?: boolean; + discovery?: WorkspaceMetadata['projectDiscovery']; } export interface WorkspaceTreeItem { @@ -39,14 +41,19 @@ export interface WorkspaceTreeItem { size?: number; children?: WorkspaceTreeItem[]; omittedDirectories?: Array<{ path: string; reason: string }>; + scanComplete?: boolean; + truncated?: boolean; + visitedEntries?: number; } export interface WorkspaceGitStatus { - isGit: boolean; + isGit: boolean | null; branch?: string; isClean?: boolean; headCommit?: string; remoteUrl?: string; + status?: 'clean' | 'dirty' | 'unknown'; + errorCode?: 'GIT_UNAVAILABLE' | 'GIT_QUERY_FAILED'; } export interface WorkspaceMetadata { diff --git a/src/Core/WorkspaceFingerprint.ts b/src/Core/WorkspaceFingerprint.ts index 7300c83..8967342 100644 --- a/src/Core/WorkspaceFingerprint.ts +++ b/src/Core/WorkspaceFingerprint.ts @@ -1,9 +1,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import crypto from 'node:crypto'; -import { exec } from 'node:child_process'; -import { promisify } from 'node:util'; -const execAsync = promisify(exec); +import { runGit } from './GitClient.js'; /** 文本缓存的有界变更提示及短时复用;它不是 Roslyn 编译输入快照,也不证明全仓内容相同。 */ export class WorkspaceFingerprint { @@ -104,10 +102,10 @@ export class WorkspaceFingerprint { if (hasGit) { try { const [headRes, statusRes] = await Promise.all([ - execAsync('git rev-parse HEAD', { cwd: resolvedRoot, windowsHide: true, timeout: 3000 }).catch( + runGit(resolvedRoot, ['rev-parse', 'HEAD'], 3000).catch( () => ({ stdout: '' }) ), - execAsync('git status --porcelain=v1 -z', { cwd: resolvedRoot, windowsHide: true, timeout: 5000 }), + runGit(resolvedRoot, ['status', '--porcelain=v1', '-z'], 5000), ]); const headCommit = headRes.stdout.trim(); const gitStatusRaw = statusRes.stdout; diff --git a/src/Gateway/WorkspaceTools.ts b/src/Gateway/WorkspaceTools.ts index e349192..8a36850 100644 --- a/src/Gateway/WorkspaceTools.ts +++ b/src/Gateway/WorkspaceTools.ts @@ -45,7 +45,7 @@ export const WORKSPACE_TOOLS = [ }, }, { validate: (args, { router }) => { validateWorkspaceDirectoryOptions(args, router.config.workspaceRoot); }, - execute: async (args, { router }) => jsonResult(await router.listDirectory(args)), + execute: async (args, { router, signal }) => jsonResult(await router.listDirectory(args, signal)), }), defineTool<{ greeting?: string; toolName?: string }>({ name: 'wincode_hello_world', @@ -95,13 +95,13 @@ export const WORKSPACE_TOOLS = [ type: 'object', additionalProperties: true, properties: { maxDepth: { - type: 'number', - description: 'Maximum directory tree depth to inspect (default 2)', + type: 'integer', minimum: 1, maximum: 5, default: 2, + description: 'Maximum directory tree depth to inspect (1-5; default 2). Entry, descriptor and response budgets also apply.', }, }, }, }, { - execute: async (args, { router }) => jsonResult(await router.analyzeWorkspace(args.maxDepth), true), + execute: async (args, { router, signal }) => jsonResult(await router.analyzeWorkspace(args.maxDepth, signal), true), }), defineTool>({ name: 'wincode_diagnose_project', diff --git a/tests/architecture-safety.test.ts b/tests/architecture-safety.test.ts new file mode 100644 index 0000000..73f61c3 --- /dev/null +++ b/tests/architecture-safety.test.ts @@ -0,0 +1,224 @@ +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { WorkspaceManager } from '../src/Core/Workspace.js'; +import { WorkspaceFingerprint } from '../src/Core/WorkspaceFingerprint.js'; +import { CacheManager } from '../src/Core/Cache.js'; +import { getDefaultConfig } from '../src/Core/Config.js'; +import { ArchitectureAnalyzer } from '../src/CompositeTools/ArchitectureAnalyzer.js'; +import { loadDotNetProjectGraph } from '../src/Core/DotNetGraph.js'; +import { ToolRouter } from '../src/Core/ToolRouter.js'; +import { WinCodeMcpServer } from '../src/Gateway/McpServer.js'; +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; + +async function fixture(run: (root: string) => Promise) { + const parent = path.resolve('test-tmp'); + await fs.mkdir(parent, { recursive: true }); + const root = await fs.mkdtemp(path.join(parent, 'architecture-safety-')); + try { await run(root); } + finally { + const relative = path.relative(await fs.realpath(parent), await fs.realpath(root)); + assert(relative && !relative.startsWith('..') && !path.isAbsolute(relative)); + await fs.rm(root, { recursive: true, force: true }); + } +} + +it('workspace Git probes never execute a repository-local git.cmd', { skip: process.platform !== 'win32' }, async () => fixture(async root => { + await fs.mkdir(path.join(root, '.git')); + const marker = path.join(root, 'executed.txt'); + await fs.writeFile(path.join(root, 'git.cmd'), '@echo off\r\n>>"%~dp0executed.txt" echo UNSAFE\r\nexit /b 0\r\n'); + await new WorkspaceManager(getDefaultConfig(root)).getGitStatus(); + await new WorkspaceFingerprint(0).computeWorkspaceFingerprint(root); + assert.equal(await fs.stat(marker).then(() => true, () => false), false); +})); + +it('cache initialization rejects a junction before touching unrelated files', async () => fixture(async root => { + const outside = path.join(root, 'outside'); + await fs.mkdir(outside); + const marker = path.join(outside, 'ordinary-unrelated.json'); + await fs.writeFile(marker, 'not a cache'); + const cacheDir = path.join(root, 'cache'); + await fs.symlink(outside, cacheDir, process.platform === 'win32' ? 'junction' : 'dir'); + const failure = await new CacheManager(cacheDir).initialize().then(() => null, error => error); + assert.equal(await fs.readFile(marker, 'utf8').catch(() => 'MISSING'), 'not a cache'); + assert.ok(failure, 'an unsafe configured cache must not initialize successfully'); +})); + +it('cache pruning and clearing preserve unowned JSON, temporary and overflow files', async () => fixture(async root => { + const cache = new CacheManager(root, 4, 0); + await cache.initialize(); + await fs.mkdir(path.join(root, 'overflow')); + const files = ['unrelated.json', 'unrelated.tmp.1', 'overflow/unrelated.txt', 'wincode-v1_unowned_0123456789abcdef.json']; + for (const file of files) { + await fs.writeFile(path.join(root, file), 'ordinary content'); + await fs.utimes(path.join(root, file), new Date(0), new Date(0)); + } + await cache.set('own', 'value'); + await cache.pruneDiskCache({ orphanGraceMs: 0 }); + await cache.clear(); + for (const file of files) assert.equal(await fs.readFile(path.join(root, file), 'utf8').catch(() => 'MISSING'), 'ordinary content'); +})); + +it('cache mutations reject a replaced root and an overflow junction', async () => fixture(async root => { + const cacheDir = path.join(root, 'cache'), outside = path.join(root, 'outside'); + const cache = new CacheManager(cacheDir); + await cache.initialize(); + await cache.set('entry', 'owned'); + await fs.mkdir(outside); + await fs.symlink(outside, path.join(cacheDir, 'overflow'), process.platform === 'win32' ? 'junction' : 'dir'); + await assert.rejects(cache.writeOverflow('never outside'), /link|junction/i); + assert.deepEqual(await fs.readdir(outside), []); + // Both paths are verified generated children of the fixture before the move. + const moved = path.join(root, 'old-cache'); + assert.equal(path.dirname(await fs.realpath(cacheDir)), await fs.realpath(root)); + assert.equal(path.dirname(moved), root); + await fs.rename(cacheDir, moved); + await fs.mkdir(cacheDir); + await fs.writeFile(path.join(cacheDir, 'keep.json'), 'preserve replacement'); + await assert.rejects(cache.pruneDiskCache(), /identity changed/); + await assert.rejects(cache.clear(), /identity changed/); + assert.equal(await fs.readFile(path.join(cacheDir, 'keep.json'), 'utf8'), 'preserve replacement'); +})); + +it('declaration parsing and the real MCP heartbeat survive the blocking whitespace counterexample', async t => fixture(async root => { + const stdout = execFileSync(process.execPath, ['--import', 'tsx', 'tests/fixtures/declaration-budget-probe.mjs', root], + { encoding: 'utf8', timeout: 5000, windowsHide: true, maxBuffer: 32768, stdio: 'pipe' }); + const result = JSON.parse(stdout.trim().split(/\r?\n/).at(-1)!); + assert.deepEqual(result.samples.map((sample: any) => sample.size), [128, 2048, 16384, 65536]); + assert.ok(result.heartbeatMs < 1000); + t.diagnostic(JSON.stringify(result)); +})); + +it('architecture shares bounded streaming discovery and rejects depth values before scanning', async t => fixture(async root => { + const workspace = new WorkspaceManager(getDefaultConfig(root)); + const analyzer = new ArchitectureAnalyzer(workspace); + let reads = 0, closes = 0; + t.mock.method(fs, 'opendir', async () => ({ + read: async () => { reads++; return { name: `source-${reads}.cs`, isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }; }, + close: async () => { closes++; }, + }) as any); + for (const depth of [-1, 0, 1.5, 6, Infinity, NaN]) await assert.rejects(analyzer.analyze(depth), /maxDepth/); + assert.equal(reads, 0); + const report = await analyzer.analyze(); + assert.equal(reads, 2500, 'discovery and directory view each stop at their declared entry cap'); + assert.equal(closes, 2); + assert.equal(report.scanComplete, false); + assert.ok(report.omissions.some(item => item.reason === 'entry-budget')); + assert.ok(JSON.stringify(report).length <= 32768); +})); + +it('project graph enforces descriptor and project budgets without reading oversized bodies', async () => fixture(async root => { + const project = 'Exe'; + const files: string[] = []; + for (let i = 0; i < 20; i++) { const name = `Project${i}.csproj`; files.push(name); await fs.writeFile(path.join(root, name), project); } + const graph = await loadDotNetProjectGraph(root, [], files); + assert.equal(graph.projects.length, 16); + assert.equal(graph.scanComplete, false); + assert.ok(graph.omissions.some(item => item.reason === 'project-budget')); + await fs.writeFile(path.join(root, 'Large.csproj'), 'x'.repeat(65537)); + const large = await loadDotNetProjectGraph(root, [], ['Large.csproj']); + assert.equal(large.descriptorBytesRead, 0); + assert.equal(large.projects.length, 0); + assert.ok(large.omissions.some(item => item.reason === 'descriptor-too-large')); +})); + +it('MCP architecture cancellation retains ownership until a pending read finishes', async t => fixture(async root => { + const config = getDefaultConfig(root); config.adapters.flaui.enabled = false; config.adapters.repomix.useCli = false; + const router = new ToolRouter(config), server = new WinCodeMcpServer(router); + const client = new Client({ name: 'architecture-cancel', version: '1' }); + const [a, b] = InMemoryTransport.createLinkedPair(); + let release!: () => void, entered!: () => void, closed = 0; + const gate = new Promise(resolve => { release = resolve; }); + const entry = new Promise(resolve => { entered = resolve; }); + t.mock.method(fs, 'opendir', async () => ({ read: async () => { entered(); await gate; return null; }, close: async () => { closed++; } }) as any); + try { + await Promise.all([client.connect(a), (server as any).server.connect(b)]); + const controller = new AbortController(); + const call = client.callTool({ name: 'wincode_analyze_workspace', arguments: {} }, { signal: controller.signal }).catch(error => error); + await entry; controller.abort(); await call; + await new Promise(resolve => setImmediate(resolve)); + assert.equal((router as any).codeOperations.size, 1); + const hello = await client.callTool({ name: 'wincode_hello_world', arguments: {} }); + assert.notEqual(hello.isError, true); + release(); + for (let i = 0; i < 50 && (router as any).codeOperations.size; i++) await new Promise(resolve => setTimeout(resolve, 10)); + assert.equal((router as any).codeOperations.size, 0); + assert.equal(closed, 1); + assert.equal((router as any).inFlight, 0); + } finally { release(); await client.close(); await server.stop(); } +})); + +it('the actual MCP architecture text, including formatting, stays within the report budget', async t => fixture(async root => { + const config = getDefaultConfig(root); config.adapters.flaui.enabled = false; config.adapters.repomix.useCli = false; + const router = new ToolRouter(config), server = new WinCodeMcpServer(router); + const client = new Client({ name: 'architecture-output-budget', version: '1' }); + const [a, b] = InMemoryTransport.createLinkedPair(); + try { + await Promise.all([client.connect(a), (server as any).server.connect(b)]); + for (const count of [100, 128, 137, 160]) { + const projects = Array.from({ length: count }, (_, i) => `P${i.toString().padStart(3, '0')}${'x'.repeat(170)}.csproj`); + await fs.writeFile(path.join(root, 'Fixture.slnx'), `${projects.map(file => ``).join('')}`); + const response: any = await client.callTool({ name: 'wincode_analyze_workspace', arguments: {} }); + assert.notEqual(response.isError, true); + const text = response.content[0].text; + t.diagnostic(JSON.stringify({ count, textChars: text.length, compactChars: JSON.stringify(JSON.parse(text)).length })); + assert.ok(text.length <= 32768, `${count} declarations returned ${text.length} characters`); + assert.equal(JSON.parse(text).scanComplete, false, 'unreadable project bodies must remain incomplete'); + } + } finally { await client.close(); await server.stop(); } +})); + +it('trash rejects an external destination junction without moving the source', async () => fixture(async root => { + const project = path.join(root, 'project'), outside = path.join(root, 'outside'); + await fs.mkdir(project); await fs.mkdir(outside); + await fs.writeFile(path.join(project, 'keep.txt'), 'preserve'); + await fs.symlink(outside, path.join(project, 'trash'), process.platform === 'win32' ? 'junction' : 'dir'); + const result = await new WorkspaceManager(getDefaultConfig(project)).moveToTrash('keep.txt'); + assert.equal(result.success, false); + assert.equal(result.outcome, 'not_moved'); + assert.equal(await fs.readFile(path.join(project, 'keep.txt'), 'utf8'), 'preserve'); + assert.deepEqual(await fs.readdir(outside), []); +})); + +it('Git status recognizes linked worktrees and never reports clean after Git failure', async () => fixture(async root => { + const main = path.join(root, 'main'), linked = path.join(root, 'linked'), hooks = path.join(root, 'hooks'); + await fs.mkdir(main); await fs.mkdir(hooks); + // Resolve the test tool outside the generated repository; never resolve it in fixture cwd. + const executable = process.platform === 'win32' + ? execFileSync(path.join(process.env.SystemRoot!, 'System32', 'where.exe'), ['git.exe'], { encoding: 'utf8', windowsHide: true }).trim().split(/\r?\n/)[0] + : '/usr/bin/git'; + const git = (...args: string[]) => execFileSync(executable, ['-c', `core.hooksPath=${hooks}`, '-c', 'commit.gpgsign=false', ...args], + { cwd: main, windowsHide: true, timeout: 5000, stdio: 'pipe' }); + git('init'); await fs.writeFile(path.join(main, 'fixture.txt'), 'initial'); git('add', 'fixture.txt'); + git('-c', 'user.name=WinCode Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'fixture'); + git('worktree', 'add', '--detach', linked, 'HEAD'); + assert.equal((await new WorkspaceManager(getDefaultConfig(linked)).getGitStatus()).isGit, true); + await fs.writeFile(path.join(main, 'fixture.txt'), 'changed'); + const workspace = new WorkspaceManager(getDefaultConfig(main)); + assert.equal((await workspace.getGitStatus()).isClean, false); + const monitor = path.join(main, 'fixture-fsmonitor'); + await fs.writeFile(monitor, '#!/bin/sh\nprintf called > fsmonitor-ran.txt\nexit 1\n', { mode: 0o755 }); + git('config', 'core.fsmonitor', "'" + monitor.replace(/\\/g, '/').replace(/'/g, "'\\''") + "'"); + git('status', '--porcelain'); + const marker = path.join(main, 'fsmonitor-ran.txt'); + assert.equal(await fs.readFile(marker, 'utf8'), 'called', 'positive control must execute the fixture monitor'); + await fs.unlink(marker); + await workspace.getGitStatus(); + await new WorkspaceFingerprint(0).computeWorkspaceFingerprint(main); + assert.equal(await fs.stat(marker).then(() => true, () => false), false, 'WinCode read-only probes must disable executable fsmonitor configuration'); + await fs.writeFile(path.join(main, '.git', 'config'), '[invalid config'); + assert.notEqual((await workspace.getGitStatus()).isClean, true); +})); + +it('architecture analysis does not read external solution projects', async () => fixture(async root => { + const project = path.join(root, 'project'), outside = path.join(root, 'outside'); + await fs.mkdir(project); await fs.mkdir(outside); + await fs.writeFile(path.join(outside, 'Outside.csproj'), 'OUTSIDE_SECRET'); + await fs.writeFile(path.join(outside, 'Program.cs'), 'class Outside {}'); + await fs.writeFile(path.join(project, 'App.sln'), 'Project("{11111111-1111-1111-1111-111111111111}") = "Outside", "../outside/Outside.csproj", "{22222222-2222-2222-2222-222222222222}"\nEndProject'); + const report = await new ArchitectureAnalyzer(new WorkspaceManager(getDefaultConfig(project))).analyze(); + assert.doesNotMatch(JSON.stringify(report), /OUTSIDE_SECRET|\.\.\/outside\/Program\.cs/); + assert.equal(report.projectGraph?.projects.length, 0); +})); diff --git a/tests/fixtures/declaration-budget-probe.mjs b/tests/fixtures/declaration-budget-probe.mjs new file mode 100644 index 0000000..f70e469 --- /dev/null +++ b/tests/fixtures/declaration-budget-probe.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { parseTextDeclarations, TextLexicalError } from '../../src/Core/TextDeclarations.ts'; +import { getDefaultConfig } from '../../src/Core/Config.ts'; +import { ToolRouter } from '../../src/Core/ToolRouter.ts'; +import { WinCodeMcpServer } from '../../src/Gateway/McpServer.ts'; + +const samples = []; +for (const size of [128, 2048, 16384, 65536]) { + const source = 'class Fixture {\n public int ' + ' '.repeat(size) + 'Field;\n}'; + const started = performance.now(); + const symbols = parseTextDeclarations(source, 'Fixture.cs', '.cs'); + samples.push({ size, elapsedMs: performance.now() - started }); + assert.equal(symbols.filter(symbol => symbol.name === 'Fixture').length, 1); + assert.equal(symbols.filter(symbol => symbol.kind === 'method').length, 0); +} +const methods = parseTextDeclarations('public static async Task> Fetch (int x) {}', 'Fixture.cs', '.cs'); +assert.equal(methods[0]?.name, 'Fetch'); +assert.throws(() => parseTextDeclarations('public int ' + 'x'.repeat(17000) + ';', 'Fixture.cs', '.cs'), TextLexicalError); + +// The parent imposes an OS child deadline: a blocked event loop cannot pass via its own timer. +const root = path.resolve(process.argv[2]); +await fs.writeFile(path.join(root, 'Fixture.cs'), 'class Fixture {\n public int ' + ' '.repeat(2048) + 'Field;\n}'); +const config = getDefaultConfig(root); +config.adapters.flaui.enabled = false; +config.adapters.repomix.useCli = false; +config.timeouts.fileScanMs = 250; +const router = new ToolRouter(config), server = new WinCodeMcpServer(router); +const client = new Client({ name: 'declaration-budget-probe', version: '1' }); +const [a, b] = InMemoryTransport.createLinkedPair(); +try { + await Promise.all([client.connect(a), server.server.connect(b)]); + const started = performance.now(); + const heartbeat = new Promise(resolve => setTimeout(() => resolve(performance.now() - started), 20)); + const result = await client.callTool({ name: 'wincode_find_code_symbol', arguments: { query: 'Fixture' } }); + assert.notEqual(result.isError, true, JSON.stringify(result)); + assert.match(JSON.stringify(result), /Fixture/); + const heartbeatMs = await heartbeat; + assert(heartbeatMs < 1000, 'heartbeat was blocked by declaration parsing'); + console.log(JSON.stringify({ samples, heartbeatMs, gatewayElapsedMs: performance.now() - started })); +} finally { await client.close(); await server.stop(); } diff --git a/tests/process-failures.test.ts b/tests/process-failures.test.ts index db6d38a..a5251a2 100644 --- a/tests/process-failures.test.ts +++ b/tests/process-failures.test.ts @@ -2,6 +2,7 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert'; import fs from 'node:fs/promises'; import path from 'node:path'; +import os from 'node:os'; import { execFile } from 'node:child_process'; import { pathToFileURL } from 'node:url'; @@ -66,12 +67,17 @@ describe('process-failures', () => { }); it('git-less workspace is reported, not thrown', async () => { - const tmp = path.join(testCacheDir, 'nongit_ws'); - await fs.mkdir(tmp, { recursive: true }); - await fs.writeFile(path.join(tmp, 'readme.txt'), 'x'); - const ws = new WorkspaceManager(getDefaultConfig(tmp)); - const git = await ws.getGitStatus(); - assert.strictEqual(git.isGit, false); + // A directory inside this repository is still Git-controlled even without its own .git. + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-nongit-')); + try { + await fs.writeFile(path.join(tmp, 'readme.txt'), 'x'); + const ws = new WorkspaceManager(getDefaultConfig(tmp)); + const git = await ws.getGitStatus(); + assert.strictEqual(git.isGit, false); + } finally { + assert.strictEqual(path.dirname(await fs.realpath(tmp)), await fs.realpath(os.tmpdir())); + await fs.rm(tmp, { recursive: true, force: true }); + } }); it('malformed workspace path fails with a structured error', async () => { diff --git a/tests/resource-cleanup.test.ts b/tests/resource-cleanup.test.ts index f276d9f..c00b3a9 100644 --- a/tests/resource-cleanup.test.ts +++ b/tests/resource-cleanup.test.ts @@ -128,8 +128,7 @@ describe('resource-cleanup', () => { await fs.mkdir(overflowDir, { recursive: true }); // 1. Fresh orphan within grace period: must NOT be deleted - const freshOrphan = path.resolve(overflowDir, 'fresh_orphan.txt'); - await fs.writeFile(freshOrphan, 'X'.repeat(200)); + const freshOrphan = await cache.writeOverflow('X'.repeat(200)); await cache.pruneDiskCache({ orphanGraceMs: 60_000 }); assert.strictEqual(await fs.stat(freshOrphan).then(() => true).catch(() => false), true, 'Fresh orphan within grace period must be kept'); @@ -138,17 +137,15 @@ describe('resource-cleanup', () => { assert.strictEqual(await fs.stat(freshOrphan).then(() => true).catch(() => false), false, 'Expired orphan must be reaped'); // 3. Overflow size counted in totalDiskBytes: large overflow causes eviction of oldest entry - const oldOverflow = path.resolve(overflowDir, 'old_overflow.txt'); - await fs.writeFile(oldOverflow, 'A'.repeat(8_000)); + const oldOverflow = await cache.writeOverflow('A'.repeat(8_000)); await cache.set('item_old', { overflowPath: oldOverflow, tag: 'old' }); await new Promise((r) => setTimeout(r, 30)); - const newOverflow = path.resolve(overflowDir, 'new_overflow.txt'); - await fs.writeFile(newOverflow, 'B'.repeat(8_000)); + const newOverflow = await cache.writeOverflow('B'.repeat(8_000)); await cache.set('item_new', { overflowPath: newOverflow, tag: 'new' }); - // Total overflow is 16,000 bytes > maxDiskBytes (15,000). Prune must evict item_old and delete old_overflow.txt! + // Total overflow is 16,000 bytes > maxDiskBytes (15,000). Prune must evict item_old and its owned attachment. await cache.pruneDiskCache(); const oldOverflowExists = await fs.stat(oldOverflow).then(() => true).catch(() => false); @@ -158,8 +155,7 @@ describe('resource-cleanup', () => { assert.strictEqual(newOverflowExists, true, 'New overflow must be retained within capacity'); // 4. Memory-cached overflow file is protected even if not in disk JSON - const memOverflow = path.resolve(overflowDir, 'mem_overflow.txt'); - await fs.writeFile(memOverflow, 'C'.repeat(500)); + const memOverflow = await cache.writeOverflow('C'.repeat(500)); (cache as any).memoryCache.set('mem_only', { timestamp: Date.now(), data: { overflowPath: memOverflow }, diff --git a/tests/stage1-cleanup.test.ts b/tests/stage1-cleanup.test.ts index aedb922..2a31e57 100644 --- a/tests/stage1-cleanup.test.ts +++ b/tests/stage1-cleanup.test.ts @@ -122,7 +122,7 @@ it('capacity eviction invalidates the memory snapshot as well as its file', asyn const cache = new CacheManager(root, 50, 50, { maxDiskBytes: 1000 }); await cache.initialize(); await fs.mkdir(path.join(root, 'overflow')); - const snapshot = path.join(root, 'overflow', 'pack.txt'); + const snapshot = path.join(root, 'overflow', 'pack_1_12345678.txt'); await fs.writeFile(snapshot, 'x'.repeat(2000)); await cache.set('pack', { overflowPath: snapshot }); await cache.pruneDiskCache(); diff --git a/tests/tool-contracts.test.ts b/tests/tool-contracts.test.ts index 7eebc56..4daa4f1 100644 --- a/tests/tool-contracts.test.ts +++ b/tests/tool-contracts.test.ts @@ -57,9 +57,9 @@ const examples: Record> = { const expectedCalls: Record = { workspace_open: { method: 'openWorkspace', args: ['example', { includeTree: undefined, maxOutputChars: undefined }, ''] }, wincode_workspace_open: { method: 'openWorkspace', args: ['example', { includeTree: undefined, maxOutputChars: undefined }, ''] }, - wincode_list_directory: { method: 'listDirectory', args: [{ path: '.', maxDepth: 1 }] }, + wincode_list_directory: { method: 'listDirectory', args: [{ path: '.', maxDepth: 1 }, ''] }, wincode_hello_world: { method: 'getRuntimeHealth', args: [] }, - wincode_analyze_workspace: { method: 'analyzeWorkspace', args: [2] }, + wincode_analyze_workspace: { method: 'analyzeWorkspace', args: [2, ''] }, wincode_prepare_context: { method: 'prepareContext', args: [{ task: 'Inspect Target', scopeFiles: ['Target.ts'] }, ''] }, wincode_find_code_symbol: { method: 'findCodeSymbols', args: ['Target', 'class', ''] }, wincode_find_references: { method: 'findCodeReferences', args: ['Target', 'Target.ts', ''] },