@feng0531/dsh-code-review 0.72.0 → 0.74.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.74.0 — SVN 本地依赖/调用方换行信息与正常属性修复
6
+
7
+ 工作副本自动依赖、显式导航入口与命中调用方保留EOL元数据,预览披露而不替换原文。修复调用方索引把已提交正常属性误当作不可选的遗漏;候选仍经节点/属性/URL/祖先核验,关键字、特殊和非正常状态不放开。不新增网络或读取命令,完整调用覆盖不作保证。
8
+
9
+ ## 0.73.0 — SVN 历史上下文换行来源信息
10
+
11
+ 固定历史显式、旧侧独有、自动依赖和命中调用方的来源保留各自EOL元数据,预览披露逐侧身份,映射与原文不符会被拒绝。不新增读取命令、不使用工作副本替代历史来源,不把元数据当作额外正文授权。其他SVN缺口仍继续。510项串行回归、66项最终上下文/检索兼容、实际宿主、隔离安装及四矩阵CI通过;原始Actions/npm/Release 143,452字节逐字节一致,双哈希/清单/冻结正文通过。
12
+
5
13
  ## 0.72.0 — 使用指南与 SVN 固定历史换行元数据
6
14
 
7
- README 改为可直接操作的五步流程,补齐 Git/SVN 场景、确认执行、报告阅读与排错示例。固定 SVN 历史变更文件新增逐侧 EOL 元数据,使用原版本正文和身份,不新增读取命令;缺失/目录侧明确 null。历史上下文映射及其他 SVN 缺口仍未闭环。
15
+ README 改为可直接操作的五步流程,补齐 Git/SVN 场景、确认执行、报告阅读与排错示例。固定 SVN 历史变更文件新增逐侧 EOL 元数据,使用原版本正文和身份,不新增读取命令;缺失/目录侧明确 null。历史上下文映射及其他 SVN 缺口仍未闭环。509项串行回归、12项历史组合、实际宿主与隔离安装、四矩阵CI通过;原始Actions/npm/Release包142,623字节逐字节一致,双哈希、清单及冻结发布正文核验通过。
8
16
 
9
17
  ## 0.71.0 — SVN 工作副本换行映射元数据
10
18
 
@@ -32,7 +32,9 @@
32
32
 
33
33
  ## 当前验证结论
34
34
 
35
- - 最近完整回归:507 项通过;冻结 v0.71.0 SVN 专项65项通过。
35
+ - 最近完整回归:510项通过;v0.73.0 最终上下文/检索兼容66项通过。
36
+ - v0.73.0 已发布固定历史上下文逐侧EOL来源元数据,显式/旧侧/依赖/调用方保持原身份,预览不含正文,篡改映射拒绝;宿主、隔离安装与四矩阵CI通过,原始Actions/npm/Release 143,452字节逐字节一致,双哈希/清单/冻结说明通过。工作副本关键字上下文及其余SVN缺口继续。
37
+ - v0.72.0 已发布可操作使用指南与固定历史变更文件逐侧EOL元数据,缺失/目录侧明确null,原来源身份/正文保持;实际宿主、隔离安装与四矩阵CI通过;原始Actions/npm/Release包142,623字节逐字节一致,双哈希/清单/冻结发布说明通过。历史上下文EOL、关键字上下文、未提交替换和跨URL等仍未闭环。
36
38
  - v0.71.0 已发布工作副本 EOL 纯元数据映射,原文/原字节证据保持,单CR明确不套用规范化行号。实际宿主、隔离安装和四矩阵CI通过;原始Actions/npm/Release 139,804字节逐字节一致,双哈希、清单和冻结正文核验通过。历史EOL元数据、关键字上下文、未提交替换和跨URL等继续待闭环。
37
39
  - v0.70.0 已发布标准 SVN 工作副本关键字原文审查,BASE收缩独立核验、原正文/hash/行号不替换,预览披露映射。504 项串行回归、宿主、隔离安装和四矩阵 CI 通过;原产物/npm/Release 137,678 字节逐字节一致,双哈希、清单与冻结发布说明通过。自定义/固定宽度关键字、换行规范化、未提交替换、跨URL分支等缺口仍未闭环。
38
40
  - v0.69.0 已发布固定 SVN 历史限定调用方与autoContext组合,扫描两侧候选、仅命中侧正文捕获,同侧声明导航,统一采集/来源/探测预算;字面发现仍明确不完整。502 项串行回归、宿主、隔离安装和四矩阵 CI 通过;原产物/npm/Release 136,030 字节逐字节一致,双哈希、清单与冻结发布说明通过。全面 SVN 目标仍未完成。
package/README.md CHANGED
@@ -222,7 +222,7 @@ Git / SVN 历史审查也能使用这些参数;依赖和调用方按对应版
222
222
  - 插件遵循 DSH 工具权限;确认预览不等于绕过宿主权限。不执行项目代码、构建脚本或模型生成的 shell 命令。
223
223
  - Git 支持工作区、单提交、两版本比较。SVN 支持工作副本和固定历史端点;**尚未完整支持所有 SVN 状态**。
224
224
  - SVN 根目录属性可预览;同仓库复制新增以空目标旧侧审查,复制来源只作信息;移动的新增/删除分别覆盖。目录后代须逐项纳入,选择父目录不自动批准所有后代。
225
- - SVN 标准关键字展开,以及工作副本/固定历史变更文件的换行元数据保留原文证据;不以规范化结果证明行为等价。自定义/固定宽度关键字、未提交替换、冲突、特殊节点、切换/外部工作副本及跨 URL 比较等仍有限制。已提交固定历史替换与未提交替换的支持不同,详见 [SVN 支持矩阵](SVN-SUPPORT-MATRIX.md)。
225
+ - SVN 标准关键字展开,以及工作副本变更/自动依赖/命中调用方、固定历史变更及上下文的换行元数据保留原文证据;不以规范化结果证明行为等价。自定义/固定宽度关键字、未提交替换、冲突、特殊节点、切换/外部工作副本及跨 URL 比较等仍有限制。已提交固定历史替换与未提交替换的支持不同,详见 [SVN 支持矩阵](SVN-SUPPORT-MATRIX.md)。
226
226
  - 二进制、不能解码的文件、链接和超限内容会明确阻断,不静默截断后声称完成。文本导航不证明语义绑定、执行可达或完整调用覆盖。
227
227
 
228
228
  ## 规模限制
package/RELEASE-NOTES.md CHANGED
@@ -1,18 +1,14 @@
1
- # v0.72.0 发布说明
1
+ # v0.74.0 发布说明
2
2
 
3
- ## 可以照着操作的使用指南
3
+ ## SVN 工作副本依赖和调用方的换行信息
4
4
 
5
- README 已按实际操作重写:安装启用、选择仓库与模型、发送预览请求、核对并确认、查看结果。补充可复制的 Git 工作区/单提交/区间、SVN 单 revision/区间、多仓库和辅助上下文示例,并区分对话请求、工具 JSON 与终端命令。增加预览过期、权限、未跟踪文件及审查未完成的处理方式。
5
+ 使用 `autoContext:true` 补充依赖,或用 `callerScopePaths` 寻找引用方时,安全正常节点的 `svn:eol-style` 现在会与原始上下文一起保留。预览披露已捕获上下文的换行样式、计数和视图哈希,不显示正文。
6
6
 
7
- ## SVN 固定历史版本的换行元数据
7
+ 同时修复调用方索引遗漏已有正常属性文件的问题:SVN 将已提交属性标为 normal,而不是无属性的 none。两种正常状态现在都进入候选,正文读取前仍须通过节点、属性、URL及祖先检查;修改或冲突属性仍拒绝。
8
8
 
9
- 审查固定 revision 的变更文件时,预览和模型输入现在披露各文件侧的换行样式、计数及 LF 视图哈希。
9
+ - 仅使用已有离线元数据和原文,不增加 SVN 命令或网络访问。
10
+ - 自动依赖入口文件也保留元数据;属性变化使快照过期,执行前必须重新预览。
11
+ - 原正文、字节哈希和行号不变;单CR不能套用规范化LF行号,换行视图不证明行为等价。
12
+ - 关键字、特殊节点、切换/外部根和非正常属性继续受原安全检查约束,不把本修复视为完整调用覆盖。
10
13
 
11
- - 使用对应仓库版本正文,不读取当前工作副本正文。原始字节哈希、UUID、URL 和版本身份保持不变。
12
- - 新增/删除缺少的一侧及目录侧标记为 null,不冒充空文件的换行映射。
13
- - CRLF/LF 保持原 LF 证据行号;单 CR 明确不能套用规范化后的行号。不以视图哈希相同证明行为等价。
14
- - 不新增 SVN 命令或远端权限,原共享预算和预览确认流程不变。
15
-
16
- 本批未新增历史上下文换行映射,也未解决未提交替换、跨 URL 分支比较或所有特殊工作副本状态。剩余范围见 [SVN 支持矩阵](SVN-SUPPORT-MATRIX.md)。
17
-
18
- 使用方式见 [README](README.md)。
14
+ 本批不放开工作副本关键字上下文;未提交替换、跨URL分支及有效继承mergeinfo等仍待实现。日常操作见 [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
- ## 下一批:固定历史 EOL 与使用指南(v0.72.0 待发布)
34
+ ## 下一批:本地导航 EOL 与正常属性候选(v0.74.0 待发布)
35
+
36
+ - `svnCallerNode` 返回既有已验证属性中的EOL样式,依赖链/显式导航入口/命中调用方根据原文生成映射,预览只披露元数据;不新增命令或网络范围。
37
+ - 真实复现发现带已提交属性的调用方被索引 none-only 条件漏选;改为 none/normal,正文仍经既有关键字/special/externals/URL/祖先安全检查。modified/conflicted 等不放开。
38
+ - `test/svn-working-context-eol.test.mjs` 离线真实仓库组合通过:入口→依赖和限定调用方映射、非匹配正文不捕获、预览0发送、模型原文、status不变、快照稳定及属性变化拒绝。既有关键字拒绝7项依赖专项通过;511项串行全量回归与11项本地导航安全专项、实际宿主/隔离安装通过,尚未发布。
39
+ - 独立无autoContext的显式本地上下文未因本批新增属性映射;工作副本关键字上下文与其余矩阵缺口继续保留。
40
+
41
+ ## 历史上下文 EOL(v0.73.0 已发布)
42
+
43
+ 冻结提交 `b60bf8e743de844958781ea62203c6bf77ef6e79`;标签CI `38698580756`,唯一发布运行 `38699278843`,原工件 `11956340295`。原始Actions/npm/Release 143,452字节逐字节一致,SHA-256 `98a6f905ef5e48fc93ee124b8804d012dac2d3ee8119faf85f9ff275b0269afa`,SHA-512、清单及冻结发布正文核验通过。
44
+
45
+ - 固定历史 readSource 把EOL元数据绑定各侧source,显式/旧侧独有/自动依赖/命中调用方均保留;不新增读取命令或放大预算。
46
+ - 预览 contextSources 列出实际捕获来源,不显示正文;context-identity 校验映射与该侧原文一致,拒绝篡改。
47
+ - `test/svn-history-context-eol.test.mjs` 真实仓库复现通过:四类上下文组合、旧侧不回流、未匹配正文不捕获、脏工作树隔离、0预览发送、模型原文、status不变与快照稳定。510项串行全量回归、最终66项上下文/检索兼容与9项历史组合、实际宿主与隔离安装、四矩阵标签和发布CI通过,正式发布及工件核验完成。
48
+ - 工作副本关键字上下文、未提交替换、跨URL与有效继承mergeinfo等缺口仍未完成。
49
+
50
+ ## 固定历史 EOL 与使用指南(v0.72.0 已发布)
51
+
52
+ 冻结提交 `8dffa16889c43705467e9df9331cf1b221268ed8`;标签CI `38694984442`,唯一发布运行 `38695576665`,原工件 `11953659439`。原始Actions/npm/Release包142,623字节逐字节一致;SHA-256 `79ff57378a74ffc5e2799a70cc3e9e8b27490ea664607a6c52dc00e5f577769d`,SHA-512、校验清单及冻结发布说明核验通过。
35
53
 
36
54
  - `src/svn-history.mjs` 对已有固定逐侧正文和属性计算 EOL 元数据,不新增 SVN 命令,不改变 UUID/URL/peg/operative revision 与原始字节哈希。
37
55
  - 缺失侧与目录侧明确 null;真实 SVN 测试覆盖 CRLF→LF、CR、新增/删除、文件→目录替换、单revision/区间一致、脏工作树隔离、预览0发送、模型原文及status不变。
38
- - `test/svn-history-eol.test.mjs` 与 EOL/历史替换组合7项通过;完整串行回归509项通过,历史导航/EOL/替换组合12项通过,实际宿主及隔离安装通过,尚未发布。历史上下文 EOL 元数据仍未新增。
56
+ - `test/svn-history-eol.test.mjs` 与 EOL/历史替换组合7项通过;完整串行回归509项通过,历史导航/EOL/替换组合12项通过,实际宿主及隔离安装、四矩阵标签CI与发布CI通过,正式发布及工件核验完成。历史上下文 EOL 元数据仍未新增。
39
57
  - README 重写为实际使用指南;`test/readme-usage.test.mjs` 验证 JSON 示例参数与真实工具定义一致,文档链接测试通过。
40
58
 
41
59
  ## 实现与复现入口索引
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-code-review",
3
- "version": "0.72.0",
3
+ "version": "0.74.0",
4
4
  "private": false,
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
@@ -1,11 +1,14 @@
1
1
  import { hash, relativePath } from './content.mjs';
2
2
  import { assertNonSecretPath } from './secret-path.mjs';
3
+ import {svnEolMapping} from './svn-eol-mapping.mjs';
4
+ import {isDeepStrictEqual} from 'node:util';
3
5
 
4
6
  export const CONTEXT_SIDE_NOTICE = 'context.text属于批准当前或target侧;oldText/oldRevision与context-old属于baseline旧侧。oldOnly:true表示只捕获旧侧,没有target正文,不证明目标路径不存在。不得互换版本或用旧侧上下文证明新侧行为;没有证据须保留uncertain。';
5
7
 
6
8
  function verifySvnSource(source, history, name, revision, text) {
7
9
  const expected=history.url?.replace(/\/$/,'')+'/'+name.split('/').map(encodeURIComponent).join('/');
8
10
  if(!source||source.repositoryUuid!==history.repositoryUuid||source.url!==expected||source.pegRevision!==revision||source.operativeRevision!==revision||!/^[0-9a-f]{64}$/.test(source.hash??'')||source.textHash!==hash(text))throw new Error('Invalid SVN historical context provenance');
11
+ if(source.eolMapping!==undefined&&!isDeepStrictEqual(source.eolMapping,svnEolMapping(text,source.eolMapping?.style)))throw new Error('Invalid SVN historical context EOL metadata');
9
12
  }
10
13
 
11
14
  // Validate approved snapshot data only. This does not authorize any capture.
@@ -32,10 +35,10 @@ export function contextSources(item, history) {
32
35
  }
33
36
  }
34
37
  if (typeof item.text !== 'string') throw new Error('Missing current context text');
35
- const sources=[{side:'context',text:item.text,...(item.revision ? {revision:item.revision} : {})}];
38
+ const sources=[{side:'context',text:item.text,...(item.revision ? {revision:item.revision} : {}),...(history?.vcs==='svn'?{svnSource:structuredClone(item.svnSource)}:{})}];
36
39
  if (item.oldText !== undefined) {
37
40
  if (!history || !history.base || item.oldRevision !== history.base || item.revision !== history.target || typeof item.oldText !== 'string') throw new Error('Invalid historical context provenance');
38
- sources.push({side:'context-old',text:item.oldText,revision:item.oldRevision});
41
+ sources.push({side:'context-old',text:item.oldText,revision:item.oldRevision,...(history?.vcs==='svn'?{svnSource:structuredClone(item.oldSvnSource)}:{})});
39
42
  }
40
43
  return sources;
41
44
  }
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), ...(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.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) {
package/src/snapshot.mjs CHANGED
@@ -72,6 +72,7 @@ export async function captureSnapshot(cwd, options = {}) {
72
72
  const item={path:name,...await readLocal(snapshot.root,name,indexOptions)};
73
73
  const seed=explicitSeeds.find(c=>c.path===name);
74
74
  if(seed&&seed.hash!==item.hash)throw new Error('Explicit dependency seed changed during capture');
75
+ if(seed?.eolMapping)item.eolMapping=structuredClone(seed.eolMapping);
75
76
  context.push(item);
76
77
  }
77
78
  }
@@ -4,6 +4,7 @@ import {svnCallerNode,svnCallerProbes} from './svn-caller-node.mjs';
4
4
  import {callerCandidates} from './caller-candidates.mjs';
5
5
  import {readLocal} from './content.mjs';
6
6
  import {isSecretPath} from './secret-path.mjs';
7
+ import {svnEolMapping} from './svn-eol-mapping.mjs';
7
8
 
8
9
  // Called only by preview capture, never by a model retrieval request.
9
10
  export async function captureSvnCallers(root, files, scopes, options={}) {
@@ -24,7 +25,7 @@ export async function captureSvnCallers(root, files, scopes, options={}) {
24
25
  const content=await readLocal(root,name,bounded);
25
26
  scannedBytes+=content.bytes;
26
27
  if(scannedBytes>1024*1024)throw new Error('Caller scan byte limit exceeded');
27
- scanned.push({path:name,...content,nodeFingerprint:identity.fingerprint,revision:identity.revision});
28
+ scanned.push({path:name,...content,...(identity.eolStyle?{eolMapping:svnEolMapping(content.text,identity.eolStyle)}:{}),nodeFingerprint:identity.fingerprint,revision:identity.revision});
28
29
  }
29
30
  const requested=callerCandidates(files,scanned,[]).probePaths;
30
31
  const probes=await svnCallerProbes(root,requested,bounded);
@@ -7,4 +7,4 @@ import {lstat} from 'node:fs/promises';
7
7
  import path from 'node:path';
8
8
  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;
9
9
  // Offline scoped status only, never svn list or repository-wide enumeration.
10
- export async function svnCallerIndex(root,scopes,options={}){const approved=validateCallerScopes(scopes),entries=[],chunks=[];let bytes=0;const seen=new Set();for(const scope of approved){const ancestors=await svnCallerAncestors(root,scope,options);chunks.push(ancestors.fingerprint);let cursor=root;for(const part of scope.split('/')){cursor=path.join(cursor,part);const s=await lstat(cursor);if(s.isSymbolicLink()||!s.isDirectory())throw new Error('SVN caller scope requires real directories');}const raw=await checked('svn',['--non-interactive','status','--xml','--verbose','--ignore-externals','--',scope+'@'],{...options,cwd:root,maxBytes:64*1024});bytes+=raw.length;if(bytes>64*1024)throw new Error('SVN caller index byte limit exceeded');const found=(xml(raw).status?.target??[]).flatMap(t=>t.entry??[]);for(const e of found){const name=relativePath(e['@_path'].replaceAll('\\','/'));if(name!==scope&&!name.startsWith(scope+'/'))throw new Error('SVN caller index escaped scope');if(seen.has(name))throw new Error('Duplicate SVN caller index path');seen.add(name);if(seen.size>256)throw new Error('SVN caller index path limit exceeded');entries.push({...e,'@_path':name});}if(!found.some(e=>e['@_path'].replaceAll('\\','/')===scope))throw new Error('SVN caller scope missing from status');chunks.push(scope);}entries.sort((a,b)=>a['@_path']<b['@_path']?-1:1);const unsafe=s=>!s||s['@_item']!=='normal'||s['@_props']!=='none'||['@_switched','@_copied','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true');for(const scope of approved){const e=entries.find(e=>e['@_path']===scope);if(unsafe(e?.['wc-status']))throw new Error('Unsafe SVN caller directory state');}return {scopes:approved,paths:entries.filter(e=>!approved.includes(e['@_path'])&&!unsafe(e['wc-status'])).map(e=>e['@_path']),entries,records:entries.length,bytes,fingerprint:hash(JSON.stringify(canonical({scopes:chunks,entries})))};}
10
+ export async function svnCallerIndex(root,scopes,options={}){const approved=validateCallerScopes(scopes),entries=[],chunks=[];let bytes=0;const seen=new Set();for(const scope of approved){const ancestors=await svnCallerAncestors(root,scope,options);chunks.push(ancestors.fingerprint);let cursor=root;for(const part of scope.split('/')){cursor=path.join(cursor,part);const s=await lstat(cursor);if(s.isSymbolicLink()||!s.isDirectory())throw new Error('SVN caller scope requires real directories');}const raw=await checked('svn',['--non-interactive','status','--xml','--verbose','--ignore-externals','--',scope+'@'],{...options,cwd:root,maxBytes:64*1024});bytes+=raw.length;if(bytes>64*1024)throw new Error('SVN caller index byte limit exceeded');const found=(xml(raw).status?.target??[]).flatMap(t=>t.entry??[]);for(const e of found){const name=relativePath(e['@_path'].replaceAll('\\','/'));if(name!==scope&&!name.startsWith(scope+'/'))throw new Error('SVN caller index escaped scope');if(seen.has(name))throw new Error('Duplicate SVN caller index path');seen.add(name);if(seen.size>256)throw new Error('SVN caller index path limit exceeded');entries.push({...e,'@_path':name});}if(!found.some(e=>e['@_path'].replaceAll('\\','/')===scope))throw new Error('SVN caller scope missing from status');chunks.push(scope);}entries.sort((a,b)=>a['@_path']<b['@_path']?-1:1);const unsafe=s=>!s||s['@_item']!=='normal'||!['none','normal'].includes(s['@_props'])||['@_switched','@_copied','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true');for(const scope of approved){const e=entries.find(e=>e['@_path']===scope);if(unsafe(e?.['wc-status']))throw new Error('Unsafe SVN caller directory state');}return {scopes:approved,paths:entries.filter(e=>!approved.includes(e['@_path'])&&!unsafe(e['wc-status'])).map(e=>e['@_path']),entries,records:entries.length,bytes,fingerprint:hash(JSON.stringify(canonical({scopes:chunks,entries})))};}
@@ -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');return {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);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})))};}
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))};}
@@ -1,6 +1,7 @@
1
1
  import {contextCandidates} from './context-candidates.mjs';
2
2
  import {svnCallerNode,svnCallerProbes} from './svn-caller-node.mjs';
3
3
  import {readLocal} from './content.mjs';
4
+ import {svnEolMapping} from './svn-eol-mapping.mjs';
4
5
  // Exact local path probes only. Every body requires safe offline SVN node identity.
5
6
  export async function captureSvnDependencyChain(root,files,options={}){
6
7
  const signal=AbortSignal.any([AbortSignal.timeout(30000),...(options.signal?[options.signal]:[])]),bounded={...options,signal,maxFileBytes:Math.min(options.maxFileBytes??256*1024,256*1024)};
@@ -9,7 +10,7 @@ export async function captureSvnDependencyChain(root,files,options={}){
9
10
  // Explicit context remains separately selected; safe normal nodes can seed navigation.
10
11
  const explicitSeeds=[];for(const name of [...new Set(options.contextPaths??[])].sort()){
11
12
  const changed=files.find(f=>f.path===name);if(changed){if(changed.eligibility!=='reviewable'||changed.rightExists===false)throw new Error('Context path is an excluded blocked or deleted change');continue;}
12
- const node=await svnCallerNode(root,name,bounded),item={path:name,...await readLocal(root,name,bounded)};bytes+=item.bytes;if(bytes>1024*1024)throw new Error('SVN dependency source byte limit exceeded');nodes.push({path:name,fingerprint:node.fingerprint});explicitSeeds.push(item);visited.add(name);frontier.push({path:name,eligibility:'reviewable',right:item});
13
+ const node=await svnCallerNode(root,name,bounded),item={path:name,...await readLocal(root,name,bounded)};if(node.eolStyle)item.eolMapping=svnEolMapping(item.text,node.eolStyle);bytes+=item.bytes;if(bytes>1024*1024)throw new Error('SVN dependency source byte limit exceeded');nodes.push({path:name,fingerprint:node.fingerprint});explicitSeeds.push(item);visited.add(name);frontier.push({path:name,eligibility:'reviewable',right:item});
13
14
  }
14
15
  const onProbe=paths=>{for(const p of paths)probes.add(p);if(probes.size>512)throw new Error('SVN dependency lookup limit exceeded');};
15
16
  for(let depth=1;depth<=3&&frontier.length;depth++){
@@ -17,7 +18,7 @@ export async function captureSvnDependencyChain(root,files,options={}){
17
18
  const plans=[];for(let i=0;i<frontier.length;i+=200)plans.push(contextCandidates(frontier.slice(i,i+200),index.paths));const next=[];
18
19
  for(const plan of plans){truncated ||= plan.truncated;for(const entry of plan.candidates){if(visited.has(entry.path)||reserved.has(entry.path))continue;visited.add(entry.path);const c={...entry,depth};candidates.push(c);if(candidates.length>20)throw new Error('SVN dependency candidate limit exceeded');
19
20
  if(context.length+new Set(options.contextPaths??[]).size>=limit){c.status='blocked';c.reason='Context file limit exceeded';continue;}
20
- try{const node=await svnCallerNode(root,entry.path,bounded),item={path:entry.path,...await readLocal(root,entry.path,bounded)};bytes+=item.bytes;if(bytes>1024*1024)throw new Error('SVN dependency source byte limit exceeded');context.push(item);nodes.push({path:item.path,fingerprint:node.fingerprint});c.status='captured';next.push({path:item.path,eligibility:'reviewable',right:item});}
21
+ try{const node=await svnCallerNode(root,entry.path,bounded),item={path:entry.path,...await readLocal(root,entry.path,bounded)};if(node.eolStyle)item.eolMapping=svnEolMapping(item.text,node.eolStyle);bytes+=item.bytes;if(bytes>1024*1024)throw new Error('SVN dependency source byte limit exceeded');context.push(item);nodes.push({path:item.path,fingerprint:node.fingerprint});c.status='captured';next.push({path:item.path,eligibility:'reviewable',right:item});}
21
22
  catch(error){signal.throwIfAborted();if(error.message==='SVN dependency source byte limit exceeded')throw error;c.status='blocked';c.reason=error.message;}
22
23
  }}frontier=next;
23
24
  }if(frontier.length)truncated=true;
@@ -108,7 +108,7 @@ export async function captureSvnHistory(cwd,options={}) {
108
108
  const node=await identity(name,rev),props=await properties(name,rev);
109
109
  if(Object.hasOwn(props,'svn:special'))throw new Error('Historical SVN special node excluded');
110
110
  const content=node['@_kind']==='dir'?decode(Buffer.alloc(0)):decode(await run(['cat','--ignore-keywords','-r',rev,'--',at(name,rev)],true));
111
- return {content,props,kind:node['@_kind'],source:{repositoryUuid:local.repository.uuid,url:at(name,rev).slice(0,-rev.length-1),pegRevision:rev,operativeRevision:rev,kind:node['@_kind'],hash:content.hash,textHash:hash(content.text)}};
111
+ return {content,props,kind:node['@_kind'],source:{repositoryUuid:local.repository.uuid,url:at(name,rev).slice(0,-rev.length-1),pegRevision:rev,operativeRevision:rev,kind:node['@_kind'],hash:content.hash,textHash:hash(content.text),...(node['@_kind']==='file'&&Object.hasOwn(props,'svn:eol-style')?{eolMapping:svnEolMapping(content.text,props['svn:eol-style'])}:{})}};
112
112
  };
113
113
  const files=[];
114
114
  for(const [name,record]of [...records].sort(([a],[b])=>a<b?-1:a>b?1:0)){