@feng0531/dsh-code-review 0.60.1 → 0.62.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.62.0 — SVN 原生固定 revision 历史审查初始模式
6
+
7
+ 新增 svnRevision 与 svnBaseRevision/svnTargetRevision,读取工作副本根仓库 URL 的固定版本对象与双侧属性,不读脏工作树、不重建临时业务工作副本。来源绑定 UUID/URL/peg/operative revision/hash;远端含 UNC 文件共享必须显式 svnAllowNetwork。目录后代逐项覆盖、关键词用仓库未展开正文,保持原数量/源码/调用预算与预览确认链。初始模式不支持历史上下文、规则、调用方与跨 URL 分支比较。替换 diff 重建仅候选组件,未开放工作副本替换。
8
+
9
+ ## 0.61.0 — SVN 目录复制后代覆盖
10
+
11
+ 显式展开默认 status 隐藏的复制目录后代,冻结继承 copyfrom 来源;copied 的 normal/modified 后代按空目标旧侧新增审查,不把来源 BASE 误作目标旧侧。目录及后代分别覆盖,未全选、凭据或阻断子项使父目录覆盖不足。离线且共享原数量/字节预算,不新增来源正文读取或联网;复制替换仍阻断。465 项串行回归、宿主、隔离安装和四矩阵 CI 通过;原产物/npm/Release 120,159 字节逐字节一致,双哈希、清单与冻结发布说明通过。
12
+
5
13
  ## 0.60.1 — SVN 阻断状态的准确诊断
6
14
 
7
- 对替换/复制替换、缺失、阻塞、不完整、文本/属性/树冲突、switched 与 external 分别给出稳定 blockCode 和解释,预览及结构化报告保留原因码。普通替换实测 local BASE 无 pristine,复制替换可能返回来源 BASE,因此继续明确阻断,不合成旧侧、不冒充新增。诊断改进不表示真实双侧替换审查已支持。
15
+ 对替换/复制替换、缺失、阻塞、不完整、文本/属性/树冲突、switched 与 external 分别给出稳定 blockCode 和解释,预览及结构化报告保留原因码。普通替换实测 local BASE 无 pristine,复制替换可能返回来源 BASE,因此继续明确阻断,不合成旧侧、不冒充新增。诊断改进不表示真实双侧替换审查已支持。463 项串行回归、宿主、隔离安装和四矩阵 CI 通过;原产物/npm/Release 119,462 字节逐字节一致,双哈希、清单与冻结发布说明通过。
8
16
 
9
17
  ## 0.60.0 — SVN 普通目录新增与删除后代覆盖
10
18
 
@@ -32,7 +32,9 @@
32
32
 
33
33
  ## 当前验证结论
34
34
 
35
- - 最近完整回归:461 项通过。
35
+ - 最近完整回归:465 项通过。
36
+ - v0.61.0 已交付同仓库目录复制后代显式覆盖与继承来源身份;目标新增空旧侧、预览零发送、离线及凭据后代保护通过。465 项串行回归、宿主、隔离安装和四矩阵 CI 通过;原产物/npm/Release 120,159 字节逐字节一致,双哈希、清单与冻结发布说明通过。替换、关键字与历史等继续待实现。
37
+ - v0.60.1 已交付 SVN 阻断状态稳定诊断及预览/报告原因码;普通与复制替换仍明确拒绝,不冒充真实双侧支持。463 项串行回归、宿主、隔离安装和四矩阵 CI 通过;原产物/npm/Release 119,462 字节逐字节一致,双哈希、清单与冻结发布说明通过。全面 SVN 目标继续。
36
38
  - v0.60.0 已交付 SVN 普通目录新增/删除的后代逐项覆盖,离线补齐默认 status 隐藏的删除后代;显式选择、凭据排除与原预算仍生效。461 项串行回归、宿主、隔离安装和四矩阵 CI 通过;原产物/npm/Release 118,284 字节逐字节一致,双哈希、清单与冻结发布说明通过。目录复制、替换及历史等仍在后续矩阵中。
37
39
  - v0.59.0 已交付 SVN 同仓库复制新增文件和文件移动的新增/删除独立覆盖。459 项串行回归、实际宿主、隔离安装和四组 CI 通过;原产物/npm/Release 117,098 字节逐字节一致,SHA-256、SHA-512、清单与冻结发布说明通过。复制替换、目录后代和历史审查等仍未闭环。
38
40
  - v0.58.1 已交付 SVN 根目录 mergeinfo 属性预览修复及全面支持矩阵。456 项串行回归、实际宿主、隔离安装与四组 CI 通过;npm、Release 与原包 116,254 字节逐字节一致,SHA-256、SHA-512、清单与冻结发布说明核验通过。SVN 全面扩展目标仍在进行,历史与特殊状态尚未全部支持。
package/README.md CHANGED
@@ -44,7 +44,8 @@ DSH 的只读代码审查插件。它比较 Git 或 SVN 中的代码变更,使
44
44
  | Git 工作区 | HEAD 与工作区的净变化,包含暂存和未暂存改动;没有单独的 staged/unstaged 模式 |
45
45
  | Git 单个提交 | 使用 commit 参数;与唯一父提交比较,根提交与空旧版本比较。合并提交请使用区间模式 |
46
46
  | Git 两个版本 | 使用 baseRevision 和 targetRevision;比较两个版本的文件树,不自动计算合并基点 |
47
- | SVN 工作副本 | 本地 BASE 与工作副本比较,不连接远端;不支持 SVN 历史区间审查 |
47
+ | SVN 工作副本 | 本地 BASE 与工作副本比较,默认不连接远端 |
48
+ | SVN 固定历史版本(初始模式) | svnRevision 对比 r-1/r,或 svnBaseRevision/svnTargetRevision 固定树端点;从工作副本根的仓库 URL 读取,不使用脏工作树。远端必须显式 svnAllowNetwork:true |
48
49
  | 多个子仓库 | 用 repositoryPath 指定会话工作区中的一个仓库根目录;每个仓库分别预览和执行 |
49
50
 
50
51
  未跟踪文件必须通过 selectedPaths 显式选入。省略 selectedPaths 时,审查范围是仓库根目录的变更,不只是当前子目录。
@@ -67,7 +68,9 @@ DSH 的只读代码审查插件。它比较 Git 或 SVN 中的代码变更,使
67
68
 
68
69
  commit 与 baseRevision/targetRevision 不能同时使用。oldContextPaths 只能用于 Git 历史审查。
69
70
 
70
- 自动上下文和引用方发现是保守的文本导航,不是编译器分析。动态导入、宏、重载、继承和实例方法可能无法解析;找到引用不等于证明函数实际被调用。历史调用方的命名导入线索逐侧对应相同版本声明,标明 side/revision;缺少该版本的批准正文不回退另一侧。Git 历史可沿修改前后两侧分别捕获最多三层依赖;SVN 可沿安全的当前工作副本节点取得最多三层依赖,不读取 SVN 历史。已批准正文中的唯一命名声明和唯一 default 导出会标出位置;同名、重导出或跨侧内容不猜测。命名空间导入后的独立成员调用可导航至唯一命名声明;写入、逃逸或间接调用不推定绑定,不是实例类型分析。
71
+ SVN 历史模式使用独立参数,不可与 Git 参数混用。示例:`{"svnRevision":"7315","svnAllowNetwork":true}`;或 `{"svnBaseRevision":"7314","svnTargetRevision":"7315","svnAllowNetwork":true}`。只接受固定十进制 revision,不接受 HEAD、日期或相对别名。仅选择工作副本根当前仓库 URL,不支持任意远端 URL;预览会读取明确端点的仓库元数据与源码(远端需显式许可),但不发送模型。初始历史模式不支持 autoContext、contextPaths、oldContextPaths、rulePaths、callerScopePaths,不能回退到当前工作副本。目录新增/删除按后代逐项覆盖,凭据、链接/特殊节点与超限继续拒绝。
72
+
73
+ 自动上下文和引用方发现是保守的文本导航,不是编译器分析。动态导入、宏、重载、继承和实例方法可能无法解析;找到引用不等于证明函数实际被调用。历史调用方的命名导入线索逐侧对应相同版本声明,标明 side/revision;缺少该版本的批准正文不回退另一侧。Git 历史可沿修改前后两侧分别捕获最多三层依赖;SVN 工作副本模式可沿安全的当前节点取得最多三层依赖;初始固定 revision 历史模式尚不支持历史依赖导航。已批准正文中的唯一命名声明和唯一 default 导出会标出位置;同名、重导出或跨侧内容不猜测。命名空间导入后的独立成员调用可导航至唯一命名声明;写入、逃逸或间接调用不推定绑定,不是实例类型分析。
71
74
 
72
75
  ## 如何读报告
73
76
 
@@ -89,7 +92,7 @@ commit 与 baseRevision/targetRevision 不能同时使用。oldContextPaths 只
89
92
  - 不执行项目代码、构建脚本或模型生成的 shell 命令。模型只能在批准的快照中查找、搜索和读取。
90
93
  - 常见凭据路径会被排除,但普通源码也可能含秘密。路径过滤不能代替人工检查。
91
94
  - 二进制、不能解码的文件、链接和超限内容会被阻断,不会自动截断后假装完成。
92
- - SVN 同仓库复制新增文件以空目标旧侧审查,copyfrom URL/revision/UUID 在预览中披露,仅作来源信息,不自动读取来源正文,也不认定为已验证重命名。文件移动的新增/删除两侧分别覆盖。普通目录新增/删除会逐项列出后代;选择目录不会自动批准后代,未全选或子项阻断会标明覆盖不足。复制目录、替换、切换目录、外部工作副本、冲突及部分属性状态仍未支持;含 svn:keywords 的文件仍阻断。全面扩展进度见 [SVN 支持矩阵](SVN-SUPPORT-MATRIX.md)。
95
+ - SVN 同仓库复制新增文件以空目标旧侧审查,copyfrom URL/revision/UUID 在预览中披露,仅作来源信息,不自动读取来源正文,也不认定为已验证重命名。文件移动的新增/删除两侧分别覆盖。普通目录新增/删除会逐项列出后代;选择目录不会自动批准后代,未全选或子项阻断会标明覆盖不足。同仓库目录复制会显式展开后代,将继承 copied 的正常/修改节点作为空目标旧侧的新增审查,来源身份逐项披露。替换、切换目录、外部工作副本、冲突及部分属性状态仍未支持;含 svn:keywords 的文件仍阻断。全面扩展进度见 [SVN 支持矩阵](SVN-SUPPORT-MATRIX.md)。
93
96
 
94
97
  ## 默认规模限制
95
98
 
package/RELEASE-NOTES.md CHANGED
@@ -1,13 +1,18 @@
1
- # v0.60.1 发布说明
1
+ # v0.62.0 发布说明
2
2
 
3
- ## SVN 阻断状态的准确诊断
3
+ ## SVN 原生固定 revision 历史审查初始模式
4
4
 
5
- 区分普通替换、复制替换、缺失、阻塞、不完整、文本冲突、属性冲突、树冲突、switched 与 external,提供稳定 blockCode 与解释;预览和结构化审查报告保留原因码。
5
+ 新增 `svnRevision`(r-1 与 r)和 `svnBaseRevision` / `svnTargetRevision`(明确旧/新树端点),可直接审查工作副本根对应仓库 URL 的固定历史版本,无须在业务工作副本回退或合并。
6
6
 
7
- - 缺失不冒充计划删除;冲突标记不冒充已解决目标。
8
- - 普通替换的 local BASE 可能无 pristine,复制替换的 BASE 可能属于来源,均不能当作目标旧侧。
9
- - 独立 SVN 替换测试验证不读取伪造双侧、预览零发送、覆盖非完成及工作副本状态不变。
7
+ - 来源逐侧绑定仓库 UUID、URL、peg revision、operative revision 和正文哈希,不使用脏工作树。
8
+ - 非本地仓库访问必须显式 `svnAllowNetwork:true`(含 file://server 网络共享)。预览会读取指定历史元数据与源码,但零模型发送;确认执行仍重验快照。
9
+ - 捕获正文、属性及目录增删后代;历史关键词用 svn cat --ignore-keywords 的未展开仓库正文,不伪造工作侧行号。
10
+ - 共享原文件数、源码/快照上限及有界命令、元数据和总采集时间;超限不冒充成功。
10
11
 
11
- 本版本提升诊断,不宣称替换或冲突场景已经可进行完整代码审查;这些项目仍在 [SVN 支持矩阵](SVN-SUPPORT-MATRIX.md) 中保留待实现。正常节点的读取与预算不变。
12
+ ## 初始模式限制
12
13
 
13
- 日常使用见 [README](README.md)。
14
+ 只接受数字 revision,不接受 HEAD/日期别名,不支持任意 URL、跨 URL 分支比较和历史 contextPaths/oldContextPaths/rulePaths/autoContext/callerScopePaths。历史依赖及调用方仍待后续实现。
15
+
16
+ 离线替换 diff 重建组件仅返回 candidate-only,不能独立证明旧节点完整来源/编码/属性,未接入工作副本替换采集。全面 SVN 目标尚未完成,见 [SVN 支持矩阵](SVN-SUPPORT-MATRIX.md)。
17
+
18
+ 参数与用法见 [README](README.md)。
@@ -17,15 +17,15 @@
17
17
  | 常规文件修改、普通新增、删除 | 已有 BASE/工作字节与属性捕获测试 | 维持并扩充组合验收 |
18
18
  | 子目录属性变更 | 已有独立属性审查测试 | 第一批 |
19
19
  | 根目录属性/mergeinfo | v0.58.1 已发布;独立复现后修复,指定三文件和默认预览均通过、0 模型发送、状态不变;456 项串行回归、宿主、隔离安装和四矩阵 CI 通过,原产物/npm/Release 116,254 字节逐字节一致及双哈希/清单/发布说明通过 | 第一批先发布此修复 |
20
- | 复制/移动、copyfrom 及目录结构变更 | v0.59.0 已发布同仓库复制新增文件及文件移动的新增/删除独立覆盖:新增目标旧侧为空,copyfrom 只作来源信息,预览披露并冻结身份;离线与凭据来源拒绝测试通过。459 项串行回归、宿主、隔离安装、四矩阵 CI 通过;原产物/npm/Release 117,098 字节逐字节一致,双哈希/清单/冻结发布说明通过。复制目录及后代覆盖仍未实现 | 第二批:明确旧侧来源和后代覆盖 |
20
+ | 复制/移动、copyfrom 及目录结构变更 | v0.59.0 已发布同仓库复制新增文件及文件移动的新增/删除独立覆盖:新增目标旧侧为空,copyfrom 只作来源信息,预览披露并冻结身份;离线与凭据来源拒绝测试通过。459 项串行回归、宿主、隔离安装、四矩阵 CI 通过;原产物/npm/Release 117,098 字节逐字节一致,双哈希/清单/冻结发布说明通过。v0.61.0 已发布同仓库目录复制后代展开与继承来源身份,目标旧侧为空且预览披露。465 项串行回归、宿主、隔离安装、四矩阵 CI 与原包/npm/Release 120,159 字节、双哈希/清单/冻结发布说明通过。复制替换仍未支持 | 第二批:明确旧侧来源和后代覆盖 |
21
21
  | 普通新增/删除目录及后代 | v0.60.0 已发布:补齐默认 status 隐藏的删除后代,离线递归枚举与末尾清单复核;普通结构目录只在后代全选且可审查时开放,凭据/阻断子项导致目录覆盖不足。461 项串行回归、宿主、隔离安装与四矩阵 CI 通过;原产物/npm/Release 118,284 字节逐字节一致,双哈希/清单/冻结发布说明通过 | 第二批普通目录子批 |
22
- | 替换、删除再新增、文件/目录类型变化 | 普通与复制替换分别用 SVN_REPLACEMENT/SVN_COPIED_REPLACEMENT 诊断;离线探测确认前者 BASE 无 pristine,后者可能返回复制来源,禁止伪造旧侧。真实双侧替换仍未支持 | 第二批:区分 BASE 旧节点与目标节点 |
23
- | 缺失、文本/属性/树冲突、阻塞 | 本地新增稳定状态诊断:SVN_MISSING_NODE/OBSTRUCTED_NODE/INCOMPLETE_NODE/TEXT_CONFLICT/PROPERTY_CONFLICT/TREE_CONFLICT 等;预览与报告透传 blockCode。诊断不算真实双侧审查支持,仍需后续场景实现,待本批发布核验 | 第三批:明确状态诊断;不能合成不存在的工作侧或声称审核通过 |
22
+ | 替换、删除再新增、文件/目录类型变化 | 普通与复制替换分别用 SVN_REPLACEMENT/SVN_COPIED_REPLACEMENT 诊断;离线探测确认前者 BASE 无 pristine,后者可能返回复制来源,禁止伪造旧侧。已新增未接入采集的 candidate-only 完整 diff 重建组件及真实离线测试:匹配完整工作侧,拒绝片段、错侧、截断与超限。diff 仍不能独立证明旧节点/编码/旧属性,因此真实双侧替换仍未支持,不能把辅助组件算作支持完成 | 第二批:区分 BASE 旧节点与目标节点 |
23
+ | 缺失、文本/属性/树冲突、阻塞 | v0.60.1 已发布稳定状态诊断:SVN_MISSING_NODE/OBSTRUCTED_NODE/INCOMPLETE_NODE/TEXT_CONFLICT/PROPERTY_CONFLICT/TREE_CONFLICT 等,预览与报告透传 blockCode。463 项串行回归、宿主、隔离安装、四矩阵 CI 与原包/npm/Release 119,462 字节、双哈希/清单/发布说明通过。诊断不算真实双侧支持,仍需后续实现 | 第三批:明确状态诊断;不能合成不存在的工作侧或声称审核通过 |
24
24
  | svn:keywords、svn:eol-style | keywords 当前阻断;换行保留原文但无规范化映射 | 第三批:双侧可验证映射,保留原字节和定位 |
25
25
  | svn:special/链接 | 当前阻断,不跟随链接取正文 | 第三批:如支持只审链接目标数据;不沿链接读取文件 |
26
26
  | switched、externals、稀疏/混合 revision | 多数拒绝;调用方/依赖链要求祖先 normal | 第四批:明确仓库边界与独立来源,禁止隐式跨仓读取 |
27
- | 固定 revision 单提交 | 未实现;Git commit 参数不能冒充 SVN revision | 第五批:新增明确 SVN 参数与冻结身份 |
28
- | 固定 revision 区间 | 未实现;不能通过临时重建工作树冒充原生历史能力 | 第五批:固定树端点与逐路径差异 |
27
+ | 固定 revision 单提交 | 本地开发新增 svnRevision:r-1/r 固定对象、UUID/URL/peg/operative revision 逐侧绑定,正文不读工作树;远端读取显式 svnAllowNetwork,初始无历史上下文/规则/调用方,待最终回归与发布 | 第五批:新增明确 SVN 参数与冻结身份 |
28
+ | 固定 revision 区间 | 本地开发新增 svnBaseRevision/svnTargetRevision,固定树端点 summary+逐侧 cat/proplist,目录后代与属性纳入预算;不建临时工作树,待最终回归与发布。分支间不同 URL 和历史上下文尚未支持 | 第五批:固定树端点与逐路径差异 |
29
29
  | 分支复制、分支间比较、合并来源 | mergeinfo 当前仅作为属性文本;不证明源码归属或被合并提交均已审查 | 第六批:URL/peg/operative revision 与 mergeinfo 导航披露 |
30
30
  | 当前三层依赖与限定目录调用方 | 已有安全 normal 节点的离线有界测试;根属性修改可能使祖先检查拒绝,需单独验证 | 第四批补祖先/混合状态组合 |
31
31
  | 历史两侧上下文与调用方 | 未实现 | 第六批:逐侧固定版本,不能回退至工作副本 |
package/index.mjs CHANGED
@@ -12,7 +12,7 @@ export function apply(ctx, config = {}) {
12
12
  };
13
13
  ctx.tools.register({
14
14
  name: 'code_review_preview', description: 'Preview Git/SVN working-copy or Git commit/range review scope and model destination without sending source to a model. repositoryPath selects an explicit child repository relative to session workspace; file paths are repository-relative. Preview each repository separately. Show the preview before execution.',
15
- parameters: { type: 'object', additionalProperties: false, properties: { reviewRounds: { type: 'integer', minimum: 1, maximum: 3, description: 'Explicit independent review passes, default 1. Preview discloses extra sends; later passes omit the first risk plan. Shares existing timeout, call and retrieval budgets, not provider reasoning effort. Bound to this preview; execute cannot override.' }, oldContextPaths: { type: 'array', maxItems: 20, items: { type: 'string' }, description: 'Explicit Git historical baseline-only blob context paths. Shares 20 context slots. No target text is inferred; selected old changes reuse their original identity, excluded changes cannot reenter. Unsupported on working reviews and SVN.' }, callerScopePaths: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' }, description: 'Explicit non-root repository-relative directories for bounded tracked literal reverse import/include discovery. Git/SVN working review or Git fixed commit/range review. SVN uses offline normal nodes, rejects unsafe ancestor states and special/keyword properties. Historical sides share scan budgets and use object blobs only. Preview reads candidate bodies, including nonmatches; only captured matching context is sent. Navigation only, not complete caller coverage.' }, businessRequirement: { type: 'string', maxLength: 16384, description: 'Optional business constraint text up to 16KiB UTF8; untrusted data, not evidence or permission. Bound to preview and sent in review phases.' }, commit: { type: 'string', description: 'Git commit versus sole parent; root empty, merge requires range.' }, baseRevision: { type: 'string', description: 'Git old endpoint; requires targetRevision, mutually exclusive with commit.' }, targetRevision: { type: 'string', description: 'Git new endpoint, exact tree endpoints not merge-base. Explicit context uses target tree with available baseline provenance; oldContextPaths is baseline-only. Historical caller discovery scans fixed sides; explicit rules use target-tree blobs.' }, repositoryPath: { type: 'string', description: 'Workspace-relative explicit child Git/SVN repository root, forward slashes; omit for current repository.' }, selectedPaths: { type: 'array', items: { type: 'string' }, maxItems: 200 }, autoContext: { type: 'boolean', description: 'Opt in to tracked literal import/include context shown in preview. Working Git follows changed and explicitly selected context sources at most 3 dependency layers under shared 512 exact probes, 20 candidates/context slots, 1MiB source and 30s verification budget. Git history follows at most 3 baseline/target dependency layers with shared limits, object reads only; SVN follows at most 3 safe offline current-side layers with exact path probes and node/ancestor identity verification, sharing limits; no SVN historical review. Not semantic binding or complete definitions.' }, contextPaths: { type: 'array', items: { type: 'string' }, maxItems: 20 }, rulePaths: { type: 'array', items: { type: 'string' }, maxItems: 4 } } },
15
+ parameters: { type: 'object', additionalProperties: false, properties: { svnRevision: { type: 'string', description: 'SVN fixed numeric revision reviewed against revision minus one. Requires SVN working-copy root; no HEAD/date aliases.' }, svnBaseRevision: { type: 'string', description: 'SVN fixed numeric old endpoint; requires svnTargetRevision, mutually exclusive with svnRevision and Git revision parameters.' }, svnTargetRevision: { type: 'string', description: 'SVN fixed numeric new endpoint, greater than old endpoint. Fixed repository objects, not working-copy text. Initial mode rejects context/rules/callers.' }, svnAllowNetwork: { type: 'boolean', description: 'Explicit opt-in to read the working-copy repository URL at fixed SVN revisions during preview/execute; preview sends no source to a model. Required for non-file repository URLs; default denies remote access. Does not allow checkout/update/merge or arbitrary URLs.' }, reviewRounds: { type: 'integer', minimum: 1, maximum: 3, description: 'Explicit independent review passes, default 1. Preview discloses extra sends; later passes omit the first risk plan. Shares existing timeout, call and retrieval budgets, not provider reasoning effort. Bound to this preview; execute cannot override.' }, oldContextPaths: { type: 'array', maxItems: 20, items: { type: 'string' }, description: 'Explicit Git historical baseline-only blob context paths. Shares 20 context slots. No target text is inferred; selected old changes reuse their original identity, excluded changes cannot reenter. Unsupported on working reviews and SVN.' }, callerScopePaths: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' }, description: 'Explicit non-root repository-relative directories for bounded tracked literal reverse import/include discovery. Git/SVN working review or Git fixed commit/range review. SVN uses offline normal nodes, rejects unsafe ancestor states and special/keyword properties. Historical sides share scan budgets and use object blobs only. Preview reads candidate bodies, including nonmatches; only captured matching context is sent. Navigation only, not complete caller coverage.' }, businessRequirement: { type: 'string', maxLength: 16384, description: 'Optional business constraint text up to 16KiB UTF8; untrusted data, not evidence or permission. Bound to preview and sent in review phases.' }, commit: { type: 'string', description: 'Git commit versus sole parent; root empty, merge requires range.' }, baseRevision: { type: 'string', description: 'Git old endpoint; requires targetRevision, mutually exclusive with commit.' }, targetRevision: { type: 'string', description: 'Git new endpoint, exact tree endpoints not merge-base. Explicit context uses target tree with available baseline provenance; oldContextPaths is baseline-only. Historical caller discovery scans fixed sides; explicit rules use target-tree blobs.' }, repositoryPath: { type: 'string', description: 'Workspace-relative explicit child Git/SVN repository root, forward slashes; omit for current repository.' }, selectedPaths: { type: 'array', items: { type: 'string' }, maxItems: 200 }, autoContext: { type: 'boolean', description: 'Opt in to tracked literal import/include context shown in preview. Working Git follows changed and explicitly selected context sources at most 3 dependency layers under shared 512 exact probes, 20 candidates/context slots, 1MiB source and 30s verification budget. Git history follows at most 3 baseline/target dependency layers with shared limits, object reads only; SVN follows at most 3 safe offline current-side layers with exact path probes and node/ancestor identity verification, sharing limits; no SVN historical review. Not semantic binding or complete definitions.' }, contextPaths: { type: 'array', items: { type: 'string' }, maxItems: 20 }, rulePaths: { type: 'array', items: { type: 'string' }, maxItems: 4 } } },
16
16
  output,
17
17
  async execute(args, exec) { const preview = await service.preview(args, exec); return { json: JSON.stringify(preview), markdown: '' }; },
18
18
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-code-review",
3
- "version": "0.60.1",
3
+ "version": "0.62.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
@@ -30,7 +30,7 @@ export function createReviewService(llm, { capture = captureSnapshot, now = Date
30
30
  exec = bind(exec);
31
31
  exec.signal.throwIfAborted();
32
32
  if (busy) throw new Error('Review service busy');
33
- if (!args || Object.keys(args).some(k => !['repositoryPath', 'selectedPaths', 'contextPaths', 'rulePaths', 'autoContext', 'commit', 'baseRevision', 'targetRevision','businessRequirement','callerScopePaths','oldContextPaths','reviewRounds'].includes(k))) throw new Error('Unknown preview argument');
33
+ if (!args || Object.keys(args).some(k => !['repositoryPath', 'selectedPaths', 'contextPaths', 'rulePaths', 'autoContext', 'commit', 'baseRevision', 'targetRevision','businessRequirement','callerScopePaths','oldContextPaths','reviewRounds','svnRevision','svnBaseRevision','svnTargetRevision','svnAllowNetwork'].includes(k))) throw new Error('Unknown preview argument');
34
34
  if (args.autoContext !== undefined && typeof args.autoContext !== 'boolean') throw new Error('Invalid autoContext option');
35
35
  for (const key of ['selectedPaths', 'contextPaths', 'rulePaths', 'oldContextPaths']) if (args[key] !== undefined && (!Array.isArray(args[key]) || args[key].length > (key === 'rulePaths' ? 4 : key === 'oldContextPaths' ? 20 : 200) || args[key].some(p => typeof p !== 'string'))) throw new Error('Invalid path list');
36
36
  const rounds=validateReviewRounds(args.reviewRounds === undefined ? reviewRounds : args.reviewRounds);
package/src/snapshot.mjs CHANGED
@@ -11,6 +11,7 @@ import { captureGitHistory } from './git-history.mjs';
11
11
  import { captureGit } from './git.mjs';
12
12
  import { captureProjectRules } from './project-rules.mjs';
13
13
  import { captureSvn } from './svn.mjs';
14
+ import { captureSvnHistory } from './svn-history.mjs';
14
15
  import { readLocal, relativePath, hash } from './content.mjs';
15
16
 
16
17
  export async function detectVcs(cwd) {
@@ -39,6 +40,10 @@ export async function captureSnapshot(cwd, options = {}) {
39
40
  if (options.oldContextPaths !== undefined && (!Array.isArray(options.oldContextPaths) || options.oldContextPaths.length > 20 || new Set(options.oldContextPaths).size !== options.oldContextPaths.length)) throw new Error('Invalid old context paths');
40
41
  for (const name of options.oldContextPaths ?? []) relativePath(name);
41
42
  const type = await detectVcs(cwd);
43
+ if (['svnRevision','svnBaseRevision','svnTargetRevision','svnAllowNetwork'].some(key=>options[key]!==undefined)) {
44
+ if (type!=='svn') throw new Error('SVN revision parameters require SVN working copy');
45
+ return bindBusinessRequirement(await captureSvnHistory(cwd,options),options.businessRequirement);
46
+ }
42
47
  if (options.oldContextPaths !== undefined && (type !== 'git' || !['commit','baseRevision','targetRevision'].some(k => options[k] !== undefined))) throw new Error('Old context requires Git historical review');
43
48
  if (['commit','baseRevision','targetRevision'].some(k => options[k] !== undefined)) {
44
49
  if (type !== 'git') throw new Error('Git revision review is unsupported on SVN');
@@ -0,0 +1,128 @@
1
+ import {realpath} from 'node:fs/promises';
2
+ import {checked} from './process.mjs';
3
+ import {xml} from './svn.mjs';
4
+ import {hash,decode,relativePath} from './content.mjs';
5
+ import {isSecretPath} from './secret-path.mjs';
6
+
7
+ const revision = value => {
8
+ if (typeof value !== 'string' || !/^(0|[1-9][0-9]*)$/.test(value) || !Number.isSafeInteger(Number(value))) throw new Error('SVN revision must be a fixed nonnegative decimal string');
9
+ return value;
10
+ };
11
+ export function svnHistoryRange(options) {
12
+ if (['commit','baseRevision','targetRevision'].some(key=>options[key]!==undefined)) throw new Error('SVN and Git revision parameters cannot be mixed');
13
+ if (options.svnRevision!==undefined) {
14
+ if (options.svnBaseRevision!==undefined || options.svnTargetRevision!==undefined) throw new Error('SVN revision and range are mutually exclusive');
15
+ const target=revision(options.svnRevision);
16
+ if (target==='0') throw new Error('SVN revision zero has no previous revision');
17
+ return {base:String(Number(target)-1),target,mode:'svn-revision'};
18
+ }
19
+ const base=revision(options.svnBaseRevision),target=revision(options.svnTargetRevision);
20
+ if (Number(base)>=Number(target)) throw new Error('SVN range requires base before target');
21
+ return {base,target,mode:'svn-range'};
22
+ }
23
+
24
+ export function svnHistoryDestination(value, allowNetwork) {
25
+ const scope=new URL(value);
26
+ if(scope.username||scope.password||scope.search||scope.hash||!['file:','https:','http:','svn:','svn+ssh:'].includes(scope.protocol))throw new Error('Unsafe SVN repository URL');
27
+ const networkAccess=scope.protocol!=='file:' || !!scope.hostname;
28
+ if(networkAccess && allowNetwork!==true)throw new Error('SVN remote history requires explicit svnAllowNetwork: true before repository access');
29
+ return {url:scope.href.replace(/\/$/,''),networkAccess};
30
+ }
31
+
32
+ // Fixed URL objects only. No checkout, update, merge, working body or lazy HEAD read.
33
+ export async function captureSvnHistory(cwd,options={}) {
34
+ const range=svnHistoryRange(options);
35
+ if (options.svnAllowNetwork!==undefined && typeof options.svnAllowNetwork!=='boolean') throw new Error('Invalid SVN network opt-in');
36
+ if (options.autoContext===true || options.callerScopePaths!==undefined || ['contextPaths','oldContextPaths','rulePaths'].some(key=>options[key]!==undefined && (!Array.isArray(options[key]) || options[key].length))) throw new Error('SVN historical context, rules and callers are not supported in this initial mode');
37
+ const {maxFiles=200,maxFileBytes=256*1024,maxSnapshotBytes=4*1024*1024,selectedPaths}=options;
38
+ for(const [value,cap]of [[maxFiles,200],[maxFileBytes,256*1024],[maxSnapshotBytes,4*1024*1024]])if(!Number.isSafeInteger(value)||value<1||value>cap)throw new Error('Invalid SVN history limit');
39
+ if(selectedPaths!==undefined && (!Array.isArray(selectedPaths)||selectedPaths.length>200||new Set(selectedPaths).size!==selectedPaths.length))throw new Error('Invalid SVN history selection');
40
+ for(const name of selectedPaths??[])if(name!=='.')relativePath(name);
41
+ const signal=AbortSignal.any([AbortSignal.timeout(30000),...(options.signal?[options.signal]:[])]);
42
+ let commands=0,metadataBytes=0,sourceBytes=0;
43
+ const budgetError = message => Object.assign(new Error(message), {code:'SVN_HISTORY_BUDGET'});
44
+ const run=async(args,body=false)=>{
45
+ signal.throwIfAborted();if(++commands>512 || metadataBytes>1024*1024 || sourceBytes>maxSnapshotBytes)throw budgetError('SVN historical shared budget exceeded');
46
+ const bytes=await checked('svn',['--non-interactive',...args],{cwd,signal,maxBytes:body?maxFileBytes:1024*1024});
47
+ if(body){sourceBytes+=bytes.length;if(sourceBytes>maxSnapshotBytes)throw budgetError('SVN historical source budget exceeded');}
48
+ else{metadataBytes+=bytes.length;if(metadataBytes>1024*1024)throw budgetError('SVN historical metadata budget exceeded');}
49
+ return bytes;
50
+ };
51
+ const local=xml(await run(['info','--xml','--','.@'])).info?.entry?.[0];
52
+ if(!local?.['wc-info']?.['wcroot-abspath'] || !local.repository?.uuid || !local.url)throw new Error('Missing SVN working-copy identity');
53
+ const root=await realpath(local['wc-info']['wcroot-abspath']);
54
+ if(root!==await realpath(cwd))throw new Error('SVN historical scope must be working-copy root');
55
+ const {url,networkAccess}=svnHistoryDestination(local.url,options.svnAllowNetwork);
56
+ const at=(name,rev)=>(name==='.'?url:url+'/'+name.split('/').map(encodeURIComponent).join('/'))+'@'+rev;
57
+ const identity=async(name,rev)=>{
58
+ const node=xml(await run(['info','--xml','-r',rev,'--',at(name,rev)])).info?.entry?.[0];
59
+ const expected=at(name,rev).slice(0,-rev.length-1);
60
+ if(!node||node.url!==expected||String(node['@_revision'])!==rev||node.repository?.uuid!==local.repository.uuid||node.repository?.root!==local.repository.root||!['file','dir'].includes(node['@_kind']))throw new Error('SVN fixed historical identity mismatch');
61
+ return node;
62
+ };
63
+ await identity('.',range.base);await identity('.',range.target);
64
+ const summary=await run(['diff','--summarize','--xml','--notice-ancestry','-r',range.base+':'+range.target,'--',at('.',range.target)]);
65
+ const raw=xml(summary).diff?.paths?.path;
66
+ const paths=raw===undefined?[]:Array.isArray(raw)?raw:[raw];
67
+ if(paths.length>maxFiles)throw new Error('SVN historical change count limit exceeded');
68
+ const records=new Map();
69
+ for(const item of paths){
70
+ const value=item['#text'];
71
+ if(typeof value!=='string'||(value!==url&&!value.startsWith(url+'/')))throw new Error('SVN historical path escaped scope');
72
+ const name=value===url?'.':value.slice(url.length+1).split('/').map(part=>{
73
+ const decoded=decodeURIComponent(part);if(decoded.includes('/')||decoded.includes('\\'))throw new Error('Encoded SVN path separator');return decoded;
74
+ }).join('/');
75
+ if(name!=='.')relativePath(name);
76
+ if(records.has(name)||!['added','deleted','modified','none'].includes(item['@_item'])||!['file','dir'].includes(item['@_kind']))throw new Error('Ambiguous or unsupported SVN history summary');
77
+ records.set(name,item);
78
+ }
79
+ if(selectedPaths?.some(name=>!records.has(name)))throw new Error('Selected path is not an SVN historical change');
80
+ const properties=async(name,rev)=>{
81
+ const targets=xml(await run(['proplist','--xml','--verbose','-r',rev,'--',at(name,rev)])).properties?.target??[];
82
+ const result=Object.create(null);
83
+ for(const prop of targets.flatMap(t=>t.property??[])){
84
+ if(typeof prop['@_name']!=='string'||prop['@_encoding']||Object.hasOwn(result,prop['@_name']))throw new Error('Unsupported historical property representation');
85
+ result[prop['@_name']]=prop['#text']??'';
86
+ }
87
+ return Object.fromEntries(Object.entries(result).sort(([a],[b])=>a.localeCompare(b)));
88
+ };
89
+ const files=[];
90
+ for(const [name,record]of [...records].sort(([a],[b])=>a<b?-1:a>b?1:0)){
91
+ signal.throwIfAborted();
92
+ const status=record['@_item'],item={id:hash(name),path:name,kind:record['@_kind'],rawStatus:status,eligibility:'reviewable',properties:[],rightExists:status!=='deleted'};
93
+ files.push(item);
94
+ if(isSecretPath(name)){item.eligibility='excluded';item.reason='Credential path excluded';continue;}
95
+ if(selectedPaths&&!selectedPaths.includes(name)){item.eligibility='excluded';item.reason='Not explicitly selected';continue;}
96
+ try{
97
+ const readSide=async(rev,exists)=>{
98
+ if(!exists)return {content:decode(Buffer.alloc(0)),props:{}};
99
+ const node=await identity(name,rev),props=await properties(name,rev);
100
+ if(node['@_kind']!==item.kind)throw new Error('Historical node kind differs');
101
+ if(Object.hasOwn(props,'svn:special'))throw new Error('Historical SVN special node excluded');
102
+ const content=node['@_kind']==='dir'?decode(Buffer.alloc(0)):decode(await run(['cat','--ignore-keywords','-r',rev,'--',at(name,rev)],true));
103
+ return {content,props,source:{repositoryUuid:local.repository.uuid,url:at(name,rev).slice(0,-rev.length-1),pegRevision:rev,operativeRevision:rev,hash:content.hash}};
104
+ };
105
+ const left=await readSide(range.base,status!=='added'),right=await readSide(range.target,status!=='deleted');
106
+ item.left=left.content;item.right=right.content;
107
+ item.svnSources={...(left.source?{old:left.source}:{}),...(right.source?{new:right.source}:{})};
108
+ for(const key of [...new Set([...Object.keys(left.props),...Object.keys(right.props)])].sort())if(left.props[key]!==right.props[key])item.properties.push({name:key,old:left.props[key]??null,new:right.props[key]??null});
109
+ item.propertyIdentity=hash(JSON.stringify([left.props,right.props]));
110
+ item.textNotice='Fixed revision repository text; keywords unexpanded by svn cat --ignore-keywords. No working-copy text or keyword line remapping.';
111
+ }catch(error){signal.throwIfAborted();if(error.code==='SVN_HISTORY_BUDGET')throw error;item.eligibility='blocked';item.reason=error.message;delete item.left;delete item.right;}
112
+ if(Buffer.byteLength(JSON.stringify(files))>maxSnapshotBytes)throw new Error('SVN historical snapshot limit exceeded');
113
+ }
114
+ for(const item of files.filter(f=>f.kind==='dir'&&['added','deleted'].includes(f.rawStatus)&&f.eligibility==='reviewable')){
115
+ if(files.some(child=>child.path.startsWith(item.path==='.'?'':item.path+'/')&&child!==item&&child.eligibility!=='reviewable')){item.eligibility='blocked';item.reason='Historical directory descendant coverage incomplete';delete item.left;delete item.right;}
116
+ }
117
+ // Read the same fixed summary and repository identity again; never use HEAD.
118
+ const after=await run(['diff','--summarize','--xml','--notice-ancestry','-r',range.base+':'+range.target,'--',at('.',range.target)]);
119
+ if(!isDeepSummary(summary,after))throw new Error('SVN historical summary changed during capture');
120
+ await identity('.',range.base);await identity('.',range.target);
121
+ const snapshot={schemaVersion:1,vcs:'svn',root,repositoryUuid:local.repository.uuid,context:[],history:{...range,vcs:'svn',url,repositoryUuid:local.repository.uuid,pegRevision:range.target,networkAccess,semantics:'fixed revision tree endpoints; no merge attribution or working text'},files};
122
+ const serialized=JSON.stringify(snapshot);if(Buffer.byteLength(serialized)>maxSnapshotBytes)throw new Error('SVN historical snapshot limit exceeded');
123
+ return {...snapshot,id:hash(serialized)};
124
+ }
125
+ function isDeepSummary(a,b){
126
+ 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;
127
+ return JSON.stringify(canonical(xml(a)))===JSON.stringify(canonical(xml(b)));
128
+ }
@@ -0,0 +1,57 @@
1
+ import { relativePath } from './content.mjs';
2
+
3
+ // Candidate reconstruction only: a diff does not establish old-node identity or
4
+ // old properties. Capture must verify those separately before approving a source.
5
+ export function replacementTextCandidate(diff, name, workingText, { maxBytes = 256 * 1024 } = {}) {
6
+ relativePath(name);
7
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 16 * 1024 * 1024) throw new Error('Invalid replacement byte limit');
8
+ if (typeof diff !== 'string' || typeof workingText !== 'string' || Buffer.byteLength(diff) > 2 * maxBytes + 16 * 1024 || Buffer.byteLength(workingText) > maxBytes) throw new Error('Replacement diff input limit exceeded');
9
+ if (!diff.endsWith('\n')) throw new Error('Replacement diff record is truncated');
10
+ const lines = diff.split('\n');
11
+ const header = index => lines[index]?.replace(/\r$/, '');
12
+ if (header(0) !== 'Index: ' + name || !/^=+$/.test(header(1) ?? '')) throw new Error('Unexpected replacement diff scope');
13
+ const oldHeader = header(2), newHeader = header(3);
14
+ const prefix = '--- ' + name + '\t(revision ';
15
+ if (!oldHeader?.startsWith(prefix) || !/^[0-9]+\)$/.test(oldHeader.slice(prefix.length)) || newHeader !== '+++ ' + name + '\t(working copy)') throw new Error('Replacement diff requires target revision and working side');
16
+ const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@$/.exec(header(4) ?? '');
17
+ if (!hunk) throw new Error('Replacement diff requires one full hunk');
18
+ const oldStart = Number(hunk[1]), oldCount = Number(hunk[2] ?? 1), newStart = Number(hunk[3]), newCount = Number(hunk[4] ?? 1);
19
+ if (![oldStart,oldCount,newStart,newCount].every(Number.isSafeInteger) || oldStart !== (oldCount ? 1 : 0) || newStart !== (newCount ? 1 : 0) || oldCount > maxBytes || newCount > maxBytes) throw new Error('Replacement hunk does not start at both file boundaries');
20
+ const oldLines = [], newLines = [];
21
+ let previous, oldNoEol = false, newNoEol = false;
22
+ for (let i = 5; i < lines.length; i++) {
23
+ const line = lines[i];
24
+ if (i === lines.length - 1 && line === '') break;
25
+ if (header(i) === '\') {
26
+ if (!previous) throw new Error('Detached replacement EOF marker');
27
+ if (previous !== '+') {
28
+ if (oldNoEol) throw new Error('Duplicate old EOF marker');
29
+ oldNoEol = true;
30
+ }
31
+ if (previous !== '-') {
32
+ if (newNoEol) throw new Error('Duplicate new EOF marker');
33
+ newNoEol = true;
34
+ }
35
+ previous = undefined;
36
+ continue;
37
+ }
38
+ const marker = line[0];
39
+ if (![' ', '-', '+'].includes(marker)) throw new Error('Unexpected replacement diff record');
40
+ if (marker !== '+') {
41
+ if (oldNoEol) throw new Error('Old text follows EOF marker');
42
+ oldLines.push(line.slice(1));
43
+ }
44
+ if (marker !== '-') {
45
+ if (newNoEol) throw new Error('New text follows EOF marker');
46
+ newLines.push(line.slice(1));
47
+ }
48
+ previous = marker;
49
+ if (oldLines.length > oldCount || newLines.length > newCount) throw new Error('Replacement hunk count exceeded');
50
+ }
51
+ if (oldLines.length !== oldCount || newLines.length !== newCount) throw new Error('Replacement hunk truncated');
52
+ const text = (items, noEol) => items.join('\n') + (items.length && !noEol ? '\n' : '');
53
+ const oldText = text(oldLines, oldNoEol), newText = text(newLines, newNoEol);
54
+ if (Buffer.byteLength(oldText) > maxBytes || Buffer.byteLength(newText) > maxBytes) throw new Error('Replacement text limit exceeded');
55
+ if (newText !== workingText) throw new Error('Replacement diff does not match complete working text');
56
+ return { oldText, newText, oldRevision: oldHeader.slice(prefix.length, -1), status: 'candidate-only', notice: 'Not approved old-node identity, bytes, encoding or properties; capture still requires independent provenance verification.' };
57
+ }
package/src/svn.mjs CHANGED
@@ -23,11 +23,11 @@ async function properties(root, name, baseline, options) {
23
23
  }
24
24
  return Object.fromEntries(Object.entries(result).sort(([a], [b]) => a.localeCompare(b)));
25
25
  }
26
- // Default status hides deleted directory descendants. Expand only explicit deleted
27
- // versioned roots, sharing one metadata cap and the original change-count limit.
28
- async function deletedEntries(root, entries, options, limit) {
26
+ // Default status hides deleted and copied directory descendants. Expand only
27
+ // explicit structural roots under the shared metadata and change-count caps.
28
+ async function structuralEntries(root, entries, options, limit) {
29
29
  const byPath = new Map(entries.map(entry => [entry['@_path'].replaceAll('\\', '/'), entry]));
30
- const roots = entries.filter(entry => entry['wc-status']?.['@_item'] === 'deleted').map(entry => entry['@_path'].replaceAll('\\', '/')).sort();
30
+ const roots = entries.filter(entry => entry['wc-status']?.['@_item'] === 'deleted' || (entry['wc-status']?.['@_item'] === 'added' && entry['wc-status']?.['@_copied'] === 'true')).map(entry => entry['@_path'].replaceAll('\\', '/')).sort();
31
31
  const visited = [];
32
32
  let bytes = 0;
33
33
  for (const name of roots) {
@@ -41,8 +41,11 @@ async function deletedEntries(root, entries, options, limit) {
41
41
  const child = entry['@_path'].replaceAll('\\', '/');
42
42
  relativePath(child);
43
43
  if (child !== name && !child.startsWith(name + '/')) throw new Error('SVN deleted descendant escaped scope');
44
- if (entry['wc-status']?.['@_item'] !== 'deleted') throw new Error('Unexpected SVN deleted descendant state');
45
- if (!byPath.has(child)) byPath.set(child, entry);
44
+ const parentState = byPath.get(name)['wc-status'];
45
+ if (parentState['@_item'] === 'deleted' && entry['wc-status']?.['@_item'] !== 'deleted') throw new Error('Unexpected SVN deleted descendant state');
46
+ const item = structuredClone(entry);
47
+ if (parentState['@_item'] === 'added' && child !== name && item['wc-status']?.['@_copied'] === 'true') item.copyRoot = name;
48
+ if (!byPath.has(child) || item.copyRoot) byPath.set(child, item);
46
49
  if (byPath.size > limit) throw new Error('Change count limit exceeded');
47
50
  }
48
51
  }
@@ -57,7 +60,7 @@ export async function captureSvn(cwd, options = {}) {
57
60
  const before = await svn(root, statusArgs, options);
58
61
  const originalEntries = xml(before).status.target.flatMap(t => t.entry ?? []);
59
62
  if (originalEntries.length > maxFiles) throw new Error('Change count limit exceeded');
60
- const entries = await deletedEntries(root, originalEntries, options, maxFiles);
63
+ const entries = await structuralEntries(root, originalEntries, options, maxFiles);
61
64
  const known = new Set(entries.map(e => e['@_path'].replaceAll('\\', '/')));
62
65
  if (selectedPaths?.some(p => !known.has(p))) throw new Error('Selected path is not a current change');
63
66
  const files = [];
@@ -73,24 +76,33 @@ export async function captureSvn(cwd, options = {}) {
73
76
  }
74
77
  try {
75
78
  if (name !== '.') relativePath(name);
76
- const issue = svnStateIssue(state);
79
+ const copiedDescendant = !!entry.copyRoot && ['normal', 'modified', 'added'].includes(status) && state['@_copied'] === 'true';
80
+ const issue = svnStateIssue(copiedDescendant ? {...state, '@_item':'added'} : state);
77
81
  if (issue) { item.blockCode = issue.code; throw new Error(issue.reason); }
78
- const hasBase = !['added', 'unversioned'].includes(status);
82
+ const hasBase = !copiedDescendant && !['added', 'unversioned'].includes(status);
79
83
  let kind;
80
84
  if (status === 'unversioned') kind = (await lstat(path.join(root, name))).isDirectory() ? 'dir' : 'file';
81
85
  else {
82
86
  const node = xml(await svn(root, ['info', '--xml', '--', target(name)], options)).info.entry[0];
83
87
  kind = node['@_kind'];
84
88
  if (state['@_copied'] === 'true' || node['wc-info']?.['copy-from-url']) {
85
- const source = node['wc-info']?.['copy-from-url'], revision = node['wc-info']?.['copy-from-rev'];
86
- if (status !== 'added' || kind !== 'file' || typeof source !== 'string' || !source || !/^[0-9]+$/.test(String(revision)) || node.repository?.uuid !== info.repository.uuid || node.repository?.root !== info.repository.root) throw new Error('Copied node requires a same-repository added file with fixed copy source');
89
+ let source = node['wc-info']?.['copy-from-url'], revision = node['wc-info']?.['copy-from-rev'];
90
+ if (!source && copiedDescendant) {
91
+ const parent = files.find(file => file.path === entry.copyRoot);
92
+ if (!parent?.copySource || parent.eligibility !== 'reviewable') throw new Error('Copied ancestor identity unavailable');
93
+ source = parent.copySource.url.replace(/\/$/,'') + '/' + name.slice(entry.copyRoot.length+1).split('/').map(encodeURIComponent).join('/');
94
+ revision = parent.copySource.revision;
95
+ item.copyAncestor = entry.copyRoot;
96
+ }
97
+ if ((!copiedDescendant && status !== 'added') || !['file','dir'].includes(kind) || typeof source !== 'string' || !source || !/^[0-9]+$/.test(String(revision)) || node.repository?.uuid !== info.repository.uuid || node.repository?.root !== info.repository.root) throw new Error('Copied node requires a same-repository added target with fixed copy source');
87
98
  if (isSecretPath(decodeURIComponent(new URL(source).pathname))) throw new Error('Credential copy source excluded');
88
99
  item.copySource = { url: source, revision: String(revision), repositoryUuid: node.repository.uuid };
89
100
  item.copyNotice = 'Target addition is reviewed against an empty target baseline; copy source is provenance only, not an approved source body or verified rename.';
90
- item.copyNodeIdentity = hash(JSON.stringify([node.url, node['@_kind'], node['@_revision'], node['wc-info']?.schedule, node['wc-info']?.['wcroot-abspath'], source, String(revision), node.repository]));
101
+ item.copyNodeIdentity = hash(JSON.stringify([node.url, node['@_kind'], node['@_revision'], node['wc-info']?.schedule, node['wc-info']?.['wcroot-abspath'], node['wc-info']?.['copy-from-url'] ?? null, node['wc-info']?.['copy-from-rev'] ?? null, node.repository]));
91
102
  }
92
103
  }
93
104
  item.kind = kind;
105
+ if (copiedDescendant) item.targetAddition = true;
94
106
  item.rightExists = status !== 'deleted';
95
107
  const leftProps = hasBase ? await properties(root, name, true, options) : {};
96
108
  const rightProps = status === 'deleted' || status === 'unversioned' ? {} : await properties(root, name, false, options);
@@ -103,7 +115,7 @@ export async function captureSvn(cwd, options = {}) {
103
115
  item.propertyIdentity = hash(JSON.stringify([leftProps, rightProps]));
104
116
  if (kind === 'dir') {
105
117
  if (status === 'unversioned') throw new Error('Unversioned directory requires explicit descendant tracking');
106
- if (status === 'deleted' || status === 'added') {
118
+ if (status === 'deleted' || status === 'added' || copiedDescendant) {
107
119
  const descendants = entries.filter(entry => entry['@_path'].replaceAll('\\', '/').startsWith(name + '/')).map(entry => entry['@_path'].replaceAll('\\', '/'));
108
120
  if (selectedPaths && descendants.some(child => !selectedPaths.includes(child))) throw new Error('Directory descendants are not all explicitly selected');
109
121
  item.structuralDescendants = descendants;
@@ -121,7 +133,7 @@ export async function captureSvn(cwd, options = {}) {
121
133
  }
122
134
  const after = await svn(root, statusArgs, options);
123
135
  if (!isDeepStrictEqual(xml(after), xml(before))) throw new Error('Working copy changed during capture');
124
- if (!isDeepStrictEqual(await deletedEntries(root, xml(after).status.target.flatMap(t => t.entry ?? []), options, maxFiles), entries)) throw new Error('Deleted descendants changed during capture');
136
+ if (!isDeepStrictEqual(await structuralEntries(root, xml(after).status.target.flatMap(t => t.entry ?? []), options, maxFiles), entries)) throw new Error('Deleted descendants changed during capture');
125
137
  // A directory must not look complete when any descendant was blocked/excluded.
126
138
  for (const item of files.filter(f => f.structuralDescendants).sort((a,b) => b.path.length - a.path.length)) {
127
139
  if (item.structuralDescendants.some(name => files.find(f => f.path === name)?.eligibility !== 'reviewable')) {
@@ -132,11 +144,11 @@ export async function captureSvn(cwd, options = {}) {
132
144
  for (const item of files.filter(f => f.eligibility === 'reviewable')) {
133
145
  if (item.copySource) {
134
146
  const node = xml(await svn(root, ['info', '--xml', '--', target(item.path)], options)).info.entry[0];
135
- const identity = hash(JSON.stringify([node.url, node['@_kind'], node['@_revision'], node['wc-info']?.schedule, node['wc-info']?.['wcroot-abspath'], node['wc-info']?.['copy-from-url'], String(node['wc-info']?.['copy-from-rev']), node.repository]));
147
+ const identity = hash(JSON.stringify([node.url, node['@_kind'], node['@_revision'], node['wc-info']?.schedule, node['wc-info']?.['wcroot-abspath'], node['wc-info']?.['copy-from-url'] ?? null, node['wc-info']?.['copy-from-rev'] ?? null, node.repository]));
136
148
  if (identity !== item.copyNodeIdentity) throw new Error('Copy source identity changed during capture');
137
149
  }
138
150
  if (item.kind === 'file' && item.rawStatus !== 'deleted' && (await readLocal(root, item.path, options)).hash !== item.right.hash) throw new Error('Content changed during capture');
139
- const left = !['added', 'unversioned'].includes(item.rawStatus) ? await properties(root, item.path, true, options) : {};
151
+ const left = !item.targetAddition && !['added', 'unversioned'].includes(item.rawStatus) ? await properties(root, item.path, true, options) : {};
140
152
  const right = ['deleted', 'unversioned'].includes(item.rawStatus) ? {} : await properties(root, item.path, false, options);
141
153
  if (hash(JSON.stringify([left, right])) !== item.propertyIdentity) throw new Error('Properties changed during capture');
142
154
  }