@feng0531/dsh-code-review 0.75.0 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,9 +2,17 @@
2
2
 
3
3
  本文件介绍对使用者有意义的变化。安装与参数见 [README](README.md),本次发布正文见 [发布说明](RELEASE-NOTES.md)。历史提交和安装包保留在 [GitHub Releases](https://github.com/f-e-n-g-0531/dsh-code-review/releases);本文不把开发日志当作功能介绍。
4
4
 
5
+ ## 0.76.0 — 独立显式 SVN 标准关键字上下文
6
+
7
+ 未启用autoContext时可显式选择标准关键字正常文件;原始展开正文/hash/行号保持,BASE收缩与未展开正文独立核验,全部核验正文计入原显式来源预算。预览只披露映射元数据,自定义/固定宽度和不完整展开拒绝,自动依赖和调用方关键字仍未开放。
8
+
9
+ ## 0.75.1 — 修复 SVN 根级上下文祖先校验遗漏
10
+
11
+ 根目录直属上下文不再跳过根目录属性检查,节点指纹绑定祖先身份。仅mergeinfo变化仍可用但使旧快照过期;不安全根属性明确拒绝。保持根目录mergeinfo预览和路径保护,只读离线,不新增支持模式。513项串行全量回归、7项根级组合、宿主/隔离安装和四矩阵CI通过;原始Actions/npm/Release包146,408字节逐字节一致,双哈希/清单/冻结正文核验通过。
12
+
5
13
  ## 0.75.0 — SVN 手动上下文离线来源校验
6
14
 
7
- 未启用autoContext时,未变更显式上下文也校验SVN节点/属性/URL及祖先身份并末尾复核,保留EOL元数据。已选变更复用原快照,不读取排除或删除来源。不安全来源明确拒绝,不再仅凭本地文件位置读取。只读离线,显式来源共享20名额/1MiB/30秒预算;其他SVN缺口继续。
15
+ 未启用autoContext时,未变更显式上下文也校验SVN节点/属性/URL及祖先身份并末尾复核,保留EOL元数据。已选变更复用原快照,不读取排除或删除来源。不安全来源明确拒绝,不再仅凭本地文件位置读取。只读离线,显式来源共享20名额/1MiB/30秒预算;其他SVN缺口继续。512项全量串行回归、16项采集组合、实际宿主/隔离安装和四矩阵CI通过;原始Actions/npm/Release包145,650字节逐字节一致,双哈希、清单及冻结正文通过。
8
16
 
9
17
  ## 0.74.0 — SVN 本地依赖/调用方换行信息与正常属性修复
10
18
 
@@ -32,7 +32,9 @@
32
32
 
33
33
  ## 当前验证结论
34
34
 
35
- - 最近完整回归:511项通过;v0.74.0 本地导航安全专项11项通过。
35
+ - 最近完整回归:513项通过;v0.75.1 根级安全/优先预览组合7项通过。
36
+ - v0.75.1 已发布根级上下文祖先检查BUG修复,所有节点指纹绑定祖先属性,mergeinfo变化使旧身份过期,不安全根属性拒绝;宿主/隔离安装和四矩阵CI通过,原始Actions/npm/Release包146,408字节逐字节一致,双哈希/清单/冻结正文通过。纯BUG递增Z;全面SVN缺口继续。
37
+ - v0.75.0 已发布独立显式SVN工作副本上下文节点/属性/URL身份核验与EOL元数据,末尾复核且离线受限,拒绝不安全来源;宿主/隔离安装与四矩阵CI通过,原始Actions/npm/Release包145,650字节逐字节一致,双哈希/清单/冻结说明通过。关键字、特殊状态与分支来源等缺口继续。
36
38
  - v0.74.0 已发布离线自动依赖/显式导航入口/命中调用方EOL元数据与normal属性候选漏选修复,关键字/special/非正常状态仍检查;宿主/隔离安装与四矩阵CI通过,原始Actions/npm/Release包144,494字节逐字节一致,双哈希/清单/冻结正文通过。独立无autoContext显式本地属性映射、关键字与其余SVN缺口继续。
37
39
  - v0.73.0 已发布固定历史上下文逐侧EOL来源元数据,显式/旧侧/依赖/调用方保持原身份,预览不含正文,篡改映射拒绝;宿主、隔离安装与四矩阵CI通过,原始Actions/npm/Release 143,452字节逐字节一致,双哈希/清单/冻结说明通过。工作副本关键字上下文及其余SVN缺口继续。
38
40
  - v0.72.0 已发布可操作使用指南与固定历史变更文件逐侧EOL元数据,缺失/目录侧明确null,原来源身份/正文保持;实际宿主、隔离安装与四矩阵CI通过;原始Actions/npm/Release包142,623字节逐字节一致,双哈希/清单/冻结发布说明通过。历史上下文EOL、关键字上下文、未提交替换和跨URL等仍未闭环。
package/DEVELOPMENT.md CHANGED
@@ -27,6 +27,8 @@
27
27
 
28
28
  ## 正式发布
29
29
 
30
+ 每次改动完成并验证通过后都发布正式版本,不止于提交推送。版本格式为 `vX.Y.Z`:X是大版本(重大或不兼容升级),Y是小版本/新增功能,Z是BUG修复;纯修复递增Z,不一律递增Y。已发布标签不移动,发布不自动安装、升级或重启宿主。
31
+
30
32
  1. 修改版本号与锁文件;更新 [变更记录](CHANGELOG.md) 和 [发布说明](RELEASE-NOTES.md)。
31
33
  2. 完成测试、差异检查和独立安装验证后,提交、推送,并创建对应 vX.Y.Z 标签。标签一旦进入发布流程就不再移动。
32
34
  3. 等待标签 CI 的 Ubuntu/Windows × Node.js 22/24 四组测试全部成功。
package/README.md CHANGED
@@ -156,7 +156,7 @@ dsh plugin add https://github.com/f-e-n-g-0531/dsh-code-review.git
156
156
 
157
157
  这些文件和目录需要实际存在,路径相对于所选仓库:
158
158
 
159
- - `contextPaths`:手动加入辅助文件,不代表该文件本身完成了主审。SVN工作副本的未变更文件须通过离线正常节点/属性及来源检查;关键字、特殊或不可确认来源会明确拒绝,已选择的可审查变更复用快照。
159
+ - `contextPaths`:手动加入辅助文件,不代表该文件本身完成了主审。SVN工作副本的未变更文件须通过离线正常节点/属性及来源检查;标准关键字仅在未启用autoContext的独立显式选择下核验后支持;自定义/固定宽度关键字、特殊或不可确认来源仍明确拒绝,已选择的可审查变更复用快照。
160
160
  - `autoContext:true`:自动补充最多三层文本依赖,不是全仓扫描或编译器分析。
161
161
  - `callerScopePaths`:在明确指定的非根目录中寻找字面引用。预览可能扫描该目录内不匹配的候选正文;只有捕获的匹配上下文随执行发送模型。不保证找到全部调用方。
162
162
  - `rulePaths`:最多四个 UTF-8 Markdown 或文本规则文件,不能同时列为上下文。
package/RELEASE-NOTES.md CHANGED
@@ -1,14 +1,14 @@
1
- # v0.75.0 发布说明
1
+ # v0.76.0 发布说明
2
2
 
3
- ## SVN 手动上下文的离线来源校验
3
+ ## SVN 标准关键字文件可作为独立手动上下文
4
4
 
5
- 工作副本审查中,单独使用 `contextPaths`(未启用自动依赖)加入未变更文件时,现在也会核对SVN节点、仓库URL、属性与祖先身份,并在采集末尾复核。正常EOL属性与原始正文一起保留,预览披露换行元数据。
5
+ 工作副本审查时,未启用autoContext的 `contextPaths` 可以显式选择带标准 `svn:keywords` 的正常文件。插件保留原始展开正文,并在本地核验BASE关键字收缩结果与未展开仓库正文一致。
6
6
 
7
- - 不再仅凭文件位于本地目录就当作安全SVN上下文。未跟踪/排除变更、关键字和特殊节点等不可确认来源明确拒绝。
8
- - 已选入且可审查的变更文件继续复用原快照正文和工作侧EOL元数据,不恢复被排除或删除的文件。
9
- - 显式未变更来源共享20个名额、1MiB正文与30秒采集及身份复核预算;只读本地元数据,不连接远端。
10
- - 原文、原始字节哈希和行号保持,属性变化要求重新预览;不以换行映射证明行为等价。
7
+ - 预览披露原文本/视图哈希、收缩数量和行保持映射,不显示正文、不调用审查模型。
8
+ - 模型仍读取原始展开源码与原行号,不把关键字收缩当作行为等价证明。
9
+ - BASE展开与未展开核验正文都计入显式上下文原1MiB来源预算,30秒采集/身份复核限制不变,只读离线。
10
+ - 自定义配置、固定宽度和未闭合展开仍拒绝。自动依赖与限定调用方的关键字读取没有放开;不支持的组合会明确报错。
11
11
 
12
- 如果以前把不受支持的节点当作手动上下文加入,本版可能明确报错,而不是继续读取。工作副本关键字上下文、未提交替换、有效继承mergeinfo和跨URL比较仍未完成。
12
+ 用法:先预览 `{"contextPaths":["src/version.h"]}`,检查上下文关键字元数据与模型目的地后再确认执行。不要同时启用autoContext来绕过该模式边界。
13
13
 
14
- 操作示例见 [README](README.md),状态与证据见 [SVN支持矩阵](SVN-SUPPORT-MATRIX.md)。
14
+ 操作流程见 [README](README.md),其余SVN缺口见 [支持矩阵](SVN-SUPPORT-MATRIX.md)。
@@ -31,11 +31,29 @@
31
31
  | 历史两侧上下文与调用方 | v0.63.0 已发布显式 contextPaths 固定双侧来源及目标 rulePaths,UUID/URL/peg/operative revision/原字节与正文 hash 验证,禁止脏工作树与排除项回流。477 项串行回归、宿主、隔离安装、四矩阵 CI 与原包/npm/Release 127,355 字节、双哈希/清单/发布说明通过。v0.64.0 已发布 oldContextPaths 旧侧独有正文、SVN 来源专用校验、预览披露与选中删除身份复用;479 项串行回归、宿主、隔离安装、四矩阵 CI 与原包/npm/Release 127,879 字节、双哈希/清单/发布说明通过;本地开发了注入固定版本 lookup/read 能力的三层历史依赖导航核心,逐侧共享512探测/20候选/1MiB来源与上下文名额;排除项、旧侧限定和规则隔离测试通过。v0.68.0 已发布三层逐侧历史 autoContext,固定父目录有界清单 lookup,清单成功证明缺失、访问失败直接传播,真实旧/新版本链和脏工作树隔离通过。496 项串行回归、宿主、隔离安装、四矩阵 CI 与原包/npm/Release 134,056 字节、双哈希/清单/发布说明通过;本地新增逐侧固定 revision 调用方核心能力接口:共享256索引/128扫描/1MiB正文/512侧探测,命中侧才捕获正文,规则/变更排除与old-only限制保留,同侧命名调用声明导航测试通过;本地已接入固定 SVN 显式目录递归清单/同侧正文、统一30秒/512命令/元数据/正文预算,caller 与依赖共享精确探测及来源上限,真实集成专项13项通过;v0.69.0 已发布历史限定调用方和autoContext组合;502项串行全量回归、组合模型输入隔离、宿主/隔离安装、四矩阵 CI 通过。原包/npm/Release 136,030 字节、双哈希/清单/发布说明通过;字面发现仍不证明完整调用覆盖 | 第六批:逐侧固定版本,不能回退至工作副本 |
32
32
  | 真正 r7314→r7315 样本 | 用户未提供隔离副本路径;本地类似复现不是该样本验收 | 获得路径后只读生成预览,不自动发送模型 |
33
33
 
34
- ## 下一批:独立显式本地上下文(v0.75.0 待发布)
34
+ ## 下一批:独立显式标准关键字上下文(v0.76.0 待发布)
35
+
36
+ - 仅 `captureSvnExplicitContext` 显式开启节点标准关键字能力,默认调用方/依赖安全检查仍拒绝keywords;正常状态、URL/UUID/祖先指纹核验保留。
37
+ - 原始工作正文保留;BASE展开收缩与cat --ignore-keywords核验,两个核验正文计入同一1MiB来源/30秒预算。预览contextKeywordMappings不含正文,模型使用原展开原行号。
38
+ - `test/svn-explicit-keywords.test.mjs` 离线真实复现通过:原文/映射、BASE来源计费、预览0发送、模型原文、status不变和稳定快照;自定义/固定宽度拒绝,默认节点与autoContext组合继续拒绝。与显式/依赖组合9项通过,514项串行全量回归、9项显式/依赖专项、宿主/隔离安装通过,尚未发布。
39
+ - 自动关键字依赖/调用方、特殊状态和分支来源仍未闭环。
40
+
41
+ ## BUG修复:根级上下文祖先检查(v0.75.1 已发布)
42
+
43
+ 冻结提交 `55b796432d1847e337f08f66f1b1be356dac3eff`;标签CI `38710236827`,唯一发布运行 `38711005747`,原工件 `11960911950`。原始Actions/npm/Release包146,408字节逐字节一致;SHA-256 `77ff46e66cc61191a8df4a829b9f540139fde3557199812b2086f0865433c00c`,SHA-512/清单/冻结发布说明通过。
44
+
45
+ - 新真实离线复现证明:根目录直属文件在根mergeinfo变化后节点指纹不变,`svnCallerNode` 跳过了父目录为根的检查。修复前测试明确失败。
46
+ - `svnCallerAncestors` 支持严格根目录scope,避免重复根节点;`svnCallerNode` 始终核验父目录并把祖先指纹纳入节点身份。普通相对路径限制不放宽。
47
+ - `test/svn-root-context-safety.test.mjs` 修复后通过:根mergeinfo改变指纹、已捕获上下文verify拒绝、其他根属性与externals明确阻断;与优先根预览/mergeinfo祖先/显式上下文组合7项通过。513项串行全量回归、7项根级安全/优先预览组合与宿主/隔离安装、四矩阵标签及发布CI通过,正式发布工件核验完成。
48
+ - 本批纯BUG修复使用v0.75.1,不当作新增能力;其余SVN缺口保留。
49
+
50
+ ## 独立显式本地上下文(v0.75.0 已发布)
51
+
52
+ 冻结提交 `d08cb354709a2fb2051a7769b853cb98ae1619cd`;标签CI `38706343061`,唯一发布运行 `38707025423`,原工件 `11958951726`。原始Actions/npm/Release包145,650字节逐字节一致;SHA-256 `7d251aa209a7430c8b72a018472cc6d5d2e1e24ea9282623f19b5fd58896917e`,SHA-512/清单/冻结发布正文核验通过。
35
53
 
36
54
  - `src/svn-explicit-context.mjs` 在无autoContext模式核验未变更显式SVN节点与属性/URL/祖先身份,末尾复核;20名额/1MiB正文/30秒含核验,离线只读。
37
55
  - `snapshot.mjs` 复用显式捕获正文,已选可审查变更复用工作侧EOL;排除/删除/关键字/special等不回流。预览沿用contextEolMappings披露,不显示原文。
38
- - `test/svn-explicit-context.test.mjs` 真实离线复现:CRLF元数据、稳定身份、0预览发送、status不变、关键字拒绝、未跟踪排除、属性变化及取消拒绝;与既有SVN采集组合16项通过。512项串行全量回归、16项既有采集组合及最终3项安全组合、实际宿主/隔离安装通过,尚未发布。
56
+ - `test/svn-explicit-context.test.mjs` 真实离线复现:CRLF元数据、稳定身份、0预览发送、status不变、关键字拒绝、未跟踪排除、属性变化及取消拒绝;与既有SVN采集组合16项通过。512项串行全量回归、16项既有采集组合及最终3项安全组合、实际宿主/隔离安装、四矩阵标签及发布CI通过,正式发布工件核验完成。
39
57
  - 工作副本关键字、特殊状态、跨URL与有效继承mergeinfo仍未完成。
40
58
 
41
59
  ## 本地导航 EOL 与正常属性候选(v0.74.0 已发布)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-code-review",
3
- "version": "0.75.0",
3
+ "version": "0.76.0",
4
4
  "private": false,
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
package/src/service.mjs CHANGED
@@ -49,7 +49,7 @@ export function createReviewService(llm, { capture = captureSnapshot, now = Date
49
49
  while (previews.size >= maxPreviews) previews.delete(previews.keys().next().value);
50
50
  const previewId = randomUUID();
51
51
  previews.set(previewId, { ...current, target, options, rounds, snapshotId: snapshot.id, expires: now() + ttlMs });
52
- return { ...(options.businessRequirement!==undefined?{businessRequirement:{text:options.businessRequirement,hash:hash(options.businessRequirement),bytes:Buffer.byteLength(options.businessRequirement)}}:{}), previewId, plan, snapshotId: snapshot.id, repositoryRoot: snapshot.root, vcs: snapshot.vcs, ...(snapshot.history ? { history: snapshot.history } : {}), ...(snapshot.mergeSources ? {mergeSources:snapshot.mergeSources} : {}), model: current.route, modelSendingEnabled: allowModelSending, files: snapshot.files.map(f => ({ path: f.path, eligibility: f.eligibility, reason: f.reason, ...(f.eolMapping?{eolMapping:f.eolMapping,textNotice:f.textNotice}:{}), ...(f.keywordMapping?{keywordMapping:f.keywordMapping,textNotice:f.textNotice}:{}), ...(f.rawStatus==='replaced'&&snapshot.history?.vcs==='svn'?{oldKind:f.oldKind,newKind:f.newKind,nodeNotice:f.nodeNotice}:{}), ...(f.blockCode ? {blockCode:f.blockCode} : {}), ...(f.copySource ? {copySource:f.copySource,copyNotice:f.copyNotice} : {}) })), contextPaths: snapshot.context.map(c => c.path), ...(snapshot.context.some(c=>c.eolMapping)?{contextEolMappings:snapshot.context.filter(c=>c.eolMapping).map(c=>({path:c.path,eolMapping:c.eolMapping}))}:{}), ...(snapshot.history?.vcs==='svn'?{contextSources:snapshot.context.map(c=>({path:c.path,...(c.svnSource?{new:c.svnSource}:{}),...(c.oldSvnSource?{old:c.oldSvnSource}:{})}))}:{}), ...(options.oldContextPaths !== undefined ? {oldContextPaths:options.oldContextPaths,oldContextSources:snapshot.context.filter(c=>c.oldOnly).map(c=>({path:c.path,side:'context-old',revision:c.oldRevision,...(c.oldSvnSource?{svnSource:c.oldSvnSource}:{blobOid:c.oldBlobOid}),hash:c.oldHash}))} : {}), ...(snapshot.callerDiscovery ? {callerDiscovery:snapshot.callerDiscovery} : {}), ...(snapshot.autoContext ? { autoContext: snapshot.autoContext } : {}), rules: (snapshot.rules ?? []).map(({ path, hash }) => ({ path, hash })), notice: (snapshot.history?.vcs==='svn'&&options.autoContext===true?'SVN历史autoContext为精确依赖定位读取固定版本父目录有界清单,包含同目录非匹配名字元数据;仅匹配捕获正文发送模型,不扫描全仓,不用工作树替代历史版本。':'') + (options.oldContextPaths !== undefined ? 'oldContextPaths仅为baseline旧侧正文,无目标侧替代;选中变更复用原old身份,旧侧不能证明新侧行为。' : '') + (snapshot.callerDiscovery ? 'callerScopePaths已授权预览读取限定目录内tracked候选正文,包括不匹配文件;非匹配正文不发送模型,匹配捕获上下文会发送。仅字面引用导航,不是函数调用或完整调用覆盖。' : '') + (enableRiskPlanning ? '执行按plan阈值决定风险假设规划,小变更可跳过独立规划但仍主审并复核;计划不是证据,所有阶段共享原预算。' : '') + '执行会向上述模型提供方发送可审查文件两侧内容、预览列出的显式/自动上下文、所选项目规则全文及预览业务需求文本。模型可在该快照范围内多轮只读检索,不读取范围外文件;候选生成后会进行证据与反证复核,两阶段各最多3轮检索,共享每文件120秒和总模型调用100次预算;复核不能证明缺陷成立。plan.reviewRounds为独立审查工作量,非模型reasoning effort;后续轮不带首轮风险计划,所有轮共享原超时与预算。plan仅预检初始输入,ready不代表已审查;minimumCalls不含后续检索/复核/动态业务综合;多文件可先进行业务分组,分组失败保留原组并记录限制,动态综合数执行前未知,实际预算可能不足。请先向用户展示范围,获得确认后执行。' };
52
+ return { ...(options.businessRequirement!==undefined?{businessRequirement:{text:options.businessRequirement,hash:hash(options.businessRequirement),bytes:Buffer.byteLength(options.businessRequirement)}}:{}), previewId, plan, snapshotId: snapshot.id, repositoryRoot: snapshot.root, vcs: snapshot.vcs, ...(snapshot.history ? { history: snapshot.history } : {}), ...(snapshot.mergeSources ? {mergeSources:snapshot.mergeSources} : {}), model: current.route, modelSendingEnabled: allowModelSending, files: snapshot.files.map(f => ({ path: f.path, eligibility: f.eligibility, reason: f.reason, ...(f.eolMapping?{eolMapping:f.eolMapping,textNotice:f.textNotice}:{}), ...(f.keywordMapping?{keywordMapping:f.keywordMapping,textNotice:f.textNotice}:{}), ...(f.rawStatus==='replaced'&&snapshot.history?.vcs==='svn'?{oldKind:f.oldKind,newKind:f.newKind,nodeNotice:f.nodeNotice}:{}), ...(f.blockCode ? {blockCode:f.blockCode} : {}), ...(f.copySource ? {copySource:f.copySource,copyNotice:f.copyNotice} : {}) })), contextPaths: snapshot.context.map(c => c.path), ...(snapshot.context.some(c=>c.keywordMapping)?{contextKeywordMappings:snapshot.context.filter(c=>c.keywordMapping).map(c=>({path:c.path,keywordMapping:c.keywordMapping}))}:{}), ...(snapshot.context.some(c=>c.eolMapping)?{contextEolMappings:snapshot.context.filter(c=>c.eolMapping).map(c=>({path:c.path,eolMapping:c.eolMapping}))}:{}), ...(snapshot.history?.vcs==='svn'?{contextSources:snapshot.context.map(c=>({path:c.path,...(c.svnSource?{new:c.svnSource}:{}),...(c.oldSvnSource?{old:c.oldSvnSource}:{})}))}:{}), ...(options.oldContextPaths !== undefined ? {oldContextPaths:options.oldContextPaths,oldContextSources:snapshot.context.filter(c=>c.oldOnly).map(c=>({path:c.path,side:'context-old',revision:c.oldRevision,...(c.oldSvnSource?{svnSource:c.oldSvnSource}:{blobOid:c.oldBlobOid}),hash:c.oldHash}))} : {}), ...(snapshot.callerDiscovery ? {callerDiscovery:snapshot.callerDiscovery} : {}), ...(snapshot.autoContext ? { autoContext: snapshot.autoContext } : {}), rules: (snapshot.rules ?? []).map(({ path, hash }) => ({ path, hash })), notice: (snapshot.history?.vcs==='svn'&&options.autoContext===true?'SVN历史autoContext为精确依赖定位读取固定版本父目录有界清单,包含同目录非匹配名字元数据;仅匹配捕获正文发送模型,不扫描全仓,不用工作树替代历史版本。':'') + (options.oldContextPaths !== undefined ? 'oldContextPaths仅为baseline旧侧正文,无目标侧替代;选中变更复用原old身份,旧侧不能证明新侧行为。' : '') + (snapshot.callerDiscovery ? 'callerScopePaths已授权预览读取限定目录内tracked候选正文,包括不匹配文件;非匹配正文不发送模型,匹配捕获上下文会发送。仅字面引用导航,不是函数调用或完整调用覆盖。' : '') + (enableRiskPlanning ? '执行按plan阈值决定风险假设规划,小变更可跳过独立规划但仍主审并复核;计划不是证据,所有阶段共享原预算。' : '') + '执行会向上述模型提供方发送可审查文件两侧内容、预览列出的显式/自动上下文、所选项目规则全文及预览业务需求文本。模型可在该快照范围内多轮只读检索,不读取范围外文件;候选生成后会进行证据与反证复核,两阶段各最多3轮检索,共享每文件120秒和总模型调用100次预算;复核不能证明缺陷成立。plan.reviewRounds为独立审查工作量,非模型reasoning effort;后续轮不带首轮风险计划,所有轮共享原超时与预算。plan仅预检初始输入,ready不代表已审查;minimumCalls不含后续检索/复核/动态业务综合;多文件可先进行业务分组,分组失败保留原组并记录限制,动态综合数执行前未知,实际预算可能不足。请先向用户展示范围,获得确认后执行。' };
53
53
  } finally { busy = false; }
54
54
  },
55
55
  async execute(args, exec) {
@@ -6,6 +6,6 @@ import path from 'node:path';
6
6
  import {svnCallerAncestors} from './svn-caller-safety.mjs';
7
7
  const canonical=v=>Array.isArray(v)?v.map(canonical):v&&typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,canonical(v[k])])):v;
8
8
  // Body authorization requires regular local node and offline SVN identity/properties.
9
- export async function svnCallerNode(root,name,options={}){relativePath(name);const parent=path.posix.dirname(name);if(parent!=='.')await svnCallerAncestors(root,parent,options);const stat=await lstat(path.join(root,name));if(stat.isSymbolicLink()||!stat.isFile())throw new Error('SVN caller source is not a regular file');const run=async args=>xml(await checked('svn',['--non-interactive',...args,'--',name+'@'],{...options,cwd:root,maxBytes:64*1024}));const entries=(await run(['status','--xml','--verbose','--ignore-externals','--depth','empty'])).status?.target?.flatMap(t=>t.entry??[])??[];if(entries.length!==1||entries[0]['@_path'].replaceAll('\\','/')!==name)throw new Error('SVN caller source status missing');const s=entries[0]['wc-status'];if(s?.['@_item']!=='normal'||!['none','normal'].includes(s['@_props'])||['@_switched','@_copied','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true'))throw new Error('Unsafe SVN caller source state');const info=(await run(['info','--xml'])).info?.entry?.[0];const rootInfo=xml(await checked('svn',['--non-interactive','info','--xml','--','.@'],{...options,cwd:root,maxBytes:64*1024})).info?.entry?.[0];if(info?.['@_kind']!=='file'||info['wc-info']?.['copy-from-url']||info.repository?.uuid!==rootInfo.repository?.uuid||info['wc-info']?.['wcroot-abspath']!==rootInfo['wc-info']?.['wcroot-abspath'])throw new Error('Unsafe SVN caller source identity');const expected=rootInfo.url.replace(/\/$/,'')+'/'+name.split('/').map(encodeURIComponent).join('/');if(info.url!==expected)throw new Error('Unsafe SVN caller switched source URL');const props=(await run(['proplist','--xml','--verbose'])).properties?.target?.[0]?.property??[];if(props.some(p=>['svn:special','svn:keywords','svn:externals'].includes(p['@_name'])))throw new Error('Unsafe SVN caller source properties');const eol=props.filter(p=>p['@_name']==='svn:eol-style');if(eol.length>1||eol.some(p=>p['@_encoding']||!['native','LF','CRLF','CR'].includes(p['#text'])))throw new Error('Unsafe SVN caller EOL properties');return {...(eol.length?{eolStyle:eol[0]['#text']}:{}),revision:s['@_revision'],fingerprint:hash(JSON.stringify(canonical({status:entries[0],info,props})))};}
9
+ export async function svnCallerNode(root,name,options={}){relativePath(name);const parent=path.posix.dirname(name);const ancestors=await svnCallerAncestors(root,parent,options);const stat=await lstat(path.join(root,name));if(stat.isSymbolicLink()||!stat.isFile())throw new Error('SVN caller source is not a regular file');const run=async args=>xml(await checked('svn',['--non-interactive',...args,'--',name+'@'],{...options,cwd:root,maxBytes:64*1024}));const entries=(await run(['status','--xml','--verbose','--ignore-externals','--depth','empty'])).status?.target?.flatMap(t=>t.entry??[])??[];if(entries.length!==1||entries[0]['@_path'].replaceAll('\\','/')!==name)throw new Error('SVN caller source status missing');const s=entries[0]['wc-status'];if(s?.['@_item']!=='normal'||!['none','normal'].includes(s['@_props'])||['@_switched','@_copied','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true'))throw new Error('Unsafe SVN caller source state');const info=(await run(['info','--xml'])).info?.entry?.[0];const rootInfo=xml(await checked('svn',['--non-interactive','info','--xml','--','.@'],{...options,cwd:root,maxBytes:64*1024})).info?.entry?.[0];if(info?.['@_kind']!=='file'||info['wc-info']?.['copy-from-url']||info.repository?.uuid!==rootInfo.repository?.uuid||info['wc-info']?.['wcroot-abspath']!==rootInfo['wc-info']?.['wcroot-abspath'])throw new Error('Unsafe SVN caller source identity');const expected=rootInfo.url.replace(/\/$/,'')+'/'+name.split('/').map(encodeURIComponent).join('/');if(info.url!==expected)throw new Error('Unsafe SVN caller switched source URL');const props=(await run(['proplist','--xml','--verbose'])).properties?.target?.[0]?.property??[];if(props.some(p=>['svn:special','svn:externals',...(options.allowExplicitKeywords===true?[]:['svn:keywords'])].includes(p['@_name'])))throw new Error('Unsafe SVN caller source properties');const eol=props.filter(p=>p['@_name']==='svn:eol-style');if(eol.length>1||eol.some(p=>p['@_encoding']||!['native','LF','CRLF','CR'].includes(p['#text'])))throw new Error('Unsafe SVN caller EOL properties');const keywords=props.filter(p=>p['@_name']==='svn:keywords');if(keywords.length>1||keywords.some(p=>p['@_encoding']||typeof p['#text']!=='string'))throw new Error('Unsafe SVN keyword properties');return {...(keywords.length?{keywords:keywords[0]['#text']}:{}),...(eol.length?{eolStyle:eol[0]['#text']}:{}),revision:s['@_revision'],fingerprint:hash(JSON.stringify(canonical({status:entries[0],info,props,ancestorFingerprint:ancestors.fingerprint})))};}
10
10
  // Existing untracked/linked/directory paths compete too; absence grants no body read.
11
11
  export async function svnCallerProbes(root,names,options={}){if(!Array.isArray(names)||names.length>512||new Set(names).size!==names.length)throw new Error('Invalid SVN caller probes');const existing=[],identities=[];for(const name of [...names].sort()){relativePath(name);if(name.split('/').length>32)throw new Error('SVN caller probe depth limit exceeded');options.signal?.throwIfAborted();let cursor=root,identity=[];for(const part of name.split('/')){cursor=path.join(cursor,part);try{const s=await lstat(cursor);identity.push({part,kind:s.isSymbolicLink()?'link':s.isDirectory()?'dir':s.isFile()?'file':'other'});if(s.isSymbolicLink()){existing.push(name);break;}if(cursor===path.join(root,name))existing.push(name);if(!s.isDirectory())break;}catch(e){if(e.code!=='ENOENT'&&e.code!=='ENOTDIR')throw e;identity.push({part,kind:'missing'});break;}}identities.push({name,identity});if(Buffer.byteLength(JSON.stringify(identities))>1024*1024)throw new Error('SVN caller probe metadata limit exceeded');}return {paths:existing,fingerprint:hash(JSON.stringify(identities))};}
@@ -15,4 +15,4 @@ export function mergeinfoOnlyAncestorProperties(oldProps,newProps){
15
15
  if([...new Set([...Object.keys(old),...Object.keys(current)])].some(name=>name!=='svn:mergeinfo'&&old[name]!==current[name]))throw new Error('Unsafe SVN caller ancestor property change');
16
16
  }
17
17
  // Offline metadata on each explicit ancestor. Never follows external/switched roots.
18
- export async function svnCallerAncestors(root,scope,options={}){relativePath(scope);if(scope.split('/').length>32)throw new Error('SVN caller ancestor depth limit exceeded');const canonical=await realpath(root),names=['.',...scope.split('/').map((_,i)=>scope.split('/').slice(0,i+1).join('/'))],identities=[];let bytes=0,rootInfo;for(const name of names){options.signal?.throwIfAborted();const local=name==='.'?canonical:path.join(canonical,name);const stat=await lstat(local);if(stat.isSymbolicLink()||!stat.isDirectory())throw new Error('SVN caller scope requires real directories');const run=async args=>{const b=await checked('svn',['--non-interactive',...args,'--',name+'@'],{...options,cwd:canonical,maxBytes:64*1024});bytes+=b.length;if(bytes>256*1024)throw new Error('SVN caller ancestor metadata limit exceeded');return xml(b);};const status=await run(['status','--xml','--verbose','--ignore-externals','--depth','empty']);const entries=(status.status?.target??[]).flatMap(t=>t.entry??[]);if(entries.length!==1||entries[0]['@_path'].replaceAll('\\','/')!==name)throw new Error('SVN caller ancestor status missing');const s=entries[0]['wc-status'];if(s?.['@_item']!=='normal'||!['none','normal','modified'].includes(s['@_props'])||['@_switched','@_copied','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true'))throw new Error('Unsafe SVN caller ancestor state');const info=(await run(['info','--xml'])).info?.entry?.[0];if(!info||info['@_kind']!=='dir'||info['wc-info']?.['copy-from-url'])throw new Error('Unsafe SVN caller ancestor identity');rootInfo??=info;if(info.repository?.uuid!==rootInfo.repository?.uuid||info['wc-info']?.['wcroot-abspath']!==rootInfo['wc-info']?.['wcroot-abspath'])throw new Error('SVN caller ancestor external working root');if(name!=='.'){const expected=rootInfo.url.replace(/\/$/,'')+'/'+name.split('/').map(encodeURIComponent).join('/');if(info.url!==expected)throw new Error('Unsafe SVN caller switched ancestor URL');}const properties=await run(['proplist','--xml','--verbose']);const props=properties.properties?.target?.[0]?.property??[];if(props.some(p=>['svn:externals','svn:special'].includes(p['@_name'])))throw new Error('Unsafe SVN caller ancestor properties');let baselineProps;if(s['@_props']==='modified'){baselineProps=(await run(['proplist','--xml','--verbose','-r','BASE'])).properties?.target?.[0]?.property??[];mergeinfoOnlyAncestorProperties(baselineProps,props);}identities.push({path:name,status:entries[0],info,properties:props,...(baselineProps?{baselineProperties:baselineProps}: {})});}return {bytes,fingerprint:hash(JSON.stringify(canonicalValue(identities)))};}
18
+ export async function svnCallerAncestors(root,scope,options={}){if(scope!=='.')relativePath(scope);if(scope.split('/').length>32)throw new Error('SVN caller ancestor depth limit exceeded');const canonical=await realpath(root),names=scope==='.'?['.']:['.',...scope.split('/').map((_,i)=>scope.split('/').slice(0,i+1).join('/'))],identities=[];let bytes=0,rootInfo;for(const name of names){options.signal?.throwIfAborted();const local=name==='.'?canonical:path.join(canonical,name);const stat=await lstat(local);if(stat.isSymbolicLink()||!stat.isDirectory())throw new Error('SVN caller scope requires real directories');const run=async args=>{const b=await checked('svn',['--non-interactive',...args,'--',name+'@'],{...options,cwd:canonical,maxBytes:64*1024});bytes+=b.length;if(bytes>256*1024)throw new Error('SVN caller ancestor metadata limit exceeded');return xml(b);};const status=await run(['status','--xml','--verbose','--ignore-externals','--depth','empty']);const entries=(status.status?.target??[]).flatMap(t=>t.entry??[]);if(entries.length!==1||entries[0]['@_path'].replaceAll('\\','/')!==name)throw new Error('SVN caller ancestor status missing');const s=entries[0]['wc-status'];if(s?.['@_item']!=='normal'||!['none','normal','modified'].includes(s['@_props'])||['@_switched','@_copied','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true'))throw new Error('Unsafe SVN caller ancestor state');const info=(await run(['info','--xml'])).info?.entry?.[0];if(!info||info['@_kind']!=='dir'||info['wc-info']?.['copy-from-url'])throw new Error('Unsafe SVN caller ancestor identity');rootInfo??=info;if(info.repository?.uuid!==rootInfo.repository?.uuid||info['wc-info']?.['wcroot-abspath']!==rootInfo['wc-info']?.['wcroot-abspath'])throw new Error('SVN caller ancestor external working root');if(name!=='.'){const expected=rootInfo.url.replace(/\/$/,'')+'/'+name.split('/').map(encodeURIComponent).join('/');if(info.url!==expected)throw new Error('Unsafe SVN caller switched ancestor URL');}const properties=await run(['proplist','--xml','--verbose']);const props=properties.properties?.target?.[0]?.property??[];if(props.some(p=>['svn:externals','svn:special'].includes(p['@_name'])))throw new Error('Unsafe SVN caller ancestor properties');let baselineProps;if(s['@_props']==='modified'){baselineProps=(await run(['proplist','--xml','--verbose','-r','BASE'])).properties?.target?.[0]?.property??[];mergeinfoOnlyAncestorProperties(baselineProps,props);}identities.push({path:name,status:entries[0],info,properties:props,...(baselineProps?{baselineProperties:baselineProps}: {})});}return {bytes,fingerprint:hash(JSON.stringify(canonicalValue(identities)))};}
@@ -1,13 +1,23 @@
1
1
  import {svnCallerNode} from './svn-caller-node.mjs';
2
- import {readLocal} from './content.mjs';
2
+ import {readLocal,decode} from './content.mjs';
3
+ import {checked} from './process.mjs';
4
+ import {svnKeywordMapping} from './svn-keyword-mapping.mjs';
3
5
  import {svnEolMapping} from './svn-eol-mapping.mjs';
4
6
  export async function captureSvnExplicitContext(root,names,options={}){
5
- const signal=AbortSignal.any([AbortSignal.timeout(30000),...(options.signal?[options.signal]:[])]),bounded={...options,signal,maxFileBytes:Math.min(options.maxFileBytes??256*1024,256*1024)};
7
+ const signal=AbortSignal.any([AbortSignal.timeout(30000),...(options.signal?[options.signal]:[])]),bounded={...options,allowExplicitKeywords:true,signal,maxFileBytes:Math.min(options.maxFileBytes??256*1024,256*1024)};
6
8
  if(!Array.isArray(names)||names.length>20||new Set(names).size!==names.length)throw new Error('Invalid SVN explicit context selection');
7
9
  const context=[],identities=[];let bytes=0;
8
10
  for(const name of names){
9
11
  const node=await svnCallerNode(root,name,bounded),content=await readLocal(root,name,bounded);bytes+=content.bytes;if(bytes>1024*1024)throw new Error('SVN explicit context source byte limit exceeded');
10
- context.push({path:name,...content,...(node.eolStyle?{eolMapping:svnEolMapping(content.text,node.eolStyle)}:{})});identities.push({path:name,fingerprint:node.fingerprint});
12
+ let keywordMapping;
13
+ if(node.keywords){
14
+ const current=svnKeywordMapping(content.text,node.keywords);
15
+ const readBase=async(ignore)=>{const data=await checked('svn',['--non-interactive','cat',...(ignore?['--ignore-keywords']:[]),'-r','BASE','--',name+'@'],{...bounded,cwd:root,maxBytes:bounded.maxFileBytes});bytes+=data.length;if(bytes>1024*1024)throw new Error('SVN explicit context source byte limit exceeded');return decode(data);};
16
+ const base=svnKeywordMapping((await readBase(false)).text,node.keywords),repository=await readBase(true);
17
+ if(base.text!==repository.text)throw new Error('SVN explicit BASE keyword mapping differs from repository text');
18
+ const {text,...metadata}=current;keywordMapping={...metadata,status:'verified-line-view',notice:'Raw working text remains evidence; BASE contraction independently verified. Keyword view is not behavioral equivalence.'};
19
+ }
20
+ context.push({path:name,...content,...(keywordMapping?{keywordMapping}:{}),...(node.eolStyle?{eolMapping:svnEolMapping(content.text,node.eolStyle)}:{})});identities.push({path:name,fingerprint:node.fingerprint});
11
21
  }
12
22
  const verify=async()=>{for(const node of identities)if((await svnCallerNode(root,node.path,bounded)).fingerprint!==node.fingerprint)throw new Error('SVN explicit context identity changed during capture');signal.throwIfAborted();};
13
23
  return {context,verify,options:bounded,sourceBytes:bytes};