@feng0531/dsh-code-review 0.64.1 → 0.66.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 +9 -1
- package/CORE-ALIGNMENT-TASKS.md +3 -1
- package/RELEASE-NOTES.md +8 -8
- package/SVN-SUPPORT-MATRIX.md +2 -2
- package/package.json +1 -1
- package/src/service.mjs +1 -1
- package/src/svn-history.mjs +15 -11
- package/src/svn-mergeinfo.mjs +28 -0
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.66.0 — SVN 历史显式 mergeinfo 来源导航
|
|
6
|
+
|
|
7
|
+
对已批准历史变更的 svn:mergeinfo 属性增加有界来源路径/区间导航,保留非继承标记,不展开 revision、不读取分支源码。预览和报告 history 保留旧/新导航;无效或超限元数据明确标记且不输出部分导航,不阻断原属性审查。不是有效继承 mergeinfo、源码归属或已审查提交证明;跨 URL 比较仍待实现。
|
|
8
|
+
|
|
9
|
+
## 0.65.0 — SVN 历史文件/目录类型替换
|
|
10
|
+
|
|
11
|
+
固定历史增删对分别记录 oldKind/newKind,读取各自节点正文和属性,支持已提交文件→目录和目录→文件。预览披露类型与说明,目录一侧后代独立覆盖且由深至浅传播未完成;目录空正文不作为空文件或旧上下文。未提交工作副本替换仍阻断,多记录与无效状态仍拒绝。482 项串行回归、宿主、隔离安装和四矩阵 CI 通过;原产物/npm/Release 128,792 字节逐字节一致,双哈希、清单与冻结发布说明通过。
|
|
12
|
+
|
|
5
13
|
## 0.64.1 — SVN 已提交历史替换双侧采集修复
|
|
6
14
|
|
|
7
|
-
固定 revision summary 中同一路径的同类型 deleted/added 两记录合并为 replaced,分别从目标旧/新 revision 读取正文及属性,避免此前将合法历史替换当作重复路径拒绝。只接受一对同类型增删,重复新增/删除、多记录或类型变化继续拒绝;目录替换仍检查子项覆盖。未提交工作副本替换不因此开放。
|
|
15
|
+
固定 revision summary 中同一路径的同类型 deleted/added 两记录合并为 replaced,分别从目标旧/新 revision 读取正文及属性,避免此前将合法历史替换当作重复路径拒绝。只接受一对同类型增删,重复新增/删除、多记录或类型变化继续拒绝;目录替换仍检查子项覆盖。未提交工作副本替换不因此开放。481 项串行回归、宿主、隔离安装及四矩阵 CI 通过;原产物/npm/Release 128,279 字节逐字节一致,双哈希、清单与冻结发布说明通过。
|
|
8
16
|
|
|
9
17
|
## 0.64.0 — SVN 历史旧侧独有上下文
|
|
10
18
|
|
package/CORE-ALIGNMENT-TASKS.md
CHANGED
|
@@ -32,7 +32,9 @@
|
|
|
32
32
|
|
|
33
33
|
## 当前验证结论
|
|
34
34
|
|
|
35
|
-
- 最近完整回归:
|
|
35
|
+
- 最近完整回归:482 项通过。
|
|
36
|
+
- v0.65.0 已交付固定 SVN 历史文件/目录类型替换,各侧节点类型/正文/属性与来源独立绑定,预览披露且后代覆盖保护保留。482 项串行回归、宿主、隔离安装和四矩阵 CI 通过;原产物/npm/Release 128,792 字节逐字节一致,双哈希、清单与冻结发布说明通过。未提交替换、自动历史链/调用方和其他全面 SVN 缺口仍待实现。
|
|
37
|
+
- v0.64.1 已修复已提交 SVN 历史替换的同路径增删记录,真实目标旧/新正文和属性逐侧读取。481 项串行回归、宿主、隔离安装和四矩阵 CI 通过;原产物/npm/Release 128,279 字节逐字节一致,双哈希、清单与冻结发布说明通过。未提交工作副本替换和类型变化等仍未支持,全面目标继续。
|
|
36
38
|
- v0.64.0 已交付 SVN 历史 oldContextPaths,仅旧侧正文及来源身份,不合成目标,选中旧变更复用原身份,排除项拒绝回流。479 项串行回归、宿主、隔离安装和四矩阵 CI 通过;原产物/npm/Release 127,879 字节逐字节一致,双哈希、清单与冻结发布说明通过。自动历史链、调用方及全面 SVN 矩阵仍未闭环。
|
|
37
39
|
- v0.63.0 已交付 SVN 历史显式双侧上下文与目标规则,以及模型 JSON/read 请求的安全诊断。477 项串行回归、宿主、隔离安装和四矩阵 CI 通过;原产物/npm/Release 127,355 字节逐字节一致,双哈希、清单与冻结发布说明通过。用户 loader 的实际协议失败尚未核验,不宣称已修复该样本;历史自动链/调用方、旧侧独有上下文及其他 SVN 缺口继续。
|
|
38
40
|
- v0.62.0 已交付 SVN 原生固定 revision/区间初始审查:显式远端门禁、固定双侧正文/属性/来源身份、目录后代覆盖和确认执行链。472 项串行回归、宿主、隔离安装与四矩阵 CI 通过;原产物/npm/Release 125,486 字节逐字节一致,双哈希、清单与冻结发布说明通过。历史上下文/规则/调用方、跨 URL 分支和工作副本替换等仍待实现。
|
package/RELEASE-NOTES.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
# v0.
|
|
1
|
+
# v0.66.0 发布说明
|
|
2
2
|
|
|
3
|
-
## SVN
|
|
3
|
+
## SVN 历史显式 mergeinfo 来源导航
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
从已批准固定历史变更中的 svn:mergeinfo 属性生成来源路径和 revision 区间导航,预览及报告 history 保存两侧结果。
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
7
|
+
- 保留非继承 `*` 标记,不枚举区间内每个 revision,不增加来源正文读取或网络请求。
|
|
8
|
+
- 导航限 16KiB、100 来源路径、500 区间;无效/超限时明确标记,不静默裁剪或展示部分结果。
|
|
9
|
+
- 已排除/阻断变更不进入导航,原属性正文仍按既有证据流程审查。
|
|
10
|
+
- 来源路径不代表有效继承 mergeinfo、提交归属、审查覆盖,也不授权读取分支源码。
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
跨 URL 分支比較、自动历史链、调用方、工作副本替换等仍未完成,详见 [SVN 支持矩阵](SVN-SUPPORT-MATRIX.md)。本版本不把导航作为缺陷证据或完整合并审计。
|
|
13
13
|
|
|
14
14
|
用法见 [README](README.md)。
|
package/SVN-SUPPORT-MATRIX.md
CHANGED
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
| 根目录属性/mergeinfo | v0.58.1 已发布;独立复现后修复,指定三文件和默认预览均通过、0 模型发送、状态不变;456 项串行回归、宿主、隔离安装和四矩阵 CI 通过,原产物/npm/Release 116,254 字节逐字节一致及双哈希/清单/发布说明通过 | 第一批先发布此修复 |
|
|
20
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,后者可能返回复制来源,禁止伪造旧侧。已新增未接入采集的 candidate-only 完整 diff 重建组件及真实离线测试:匹配完整工作侧,拒绝片段、错侧、截断与超限。diff
|
|
22
|
+
| 替换、删除再新增、文件/目录类型变化 | 普通与复制替换分别用 SVN_REPLACEMENT/SVN_COPIED_REPLACEMENT 诊断;离线探测确认前者 BASE 无 pristine,后者可能返回复制来源,禁止伪造旧侧。已新增未接入采集的 candidate-only 完整 diff 重建组件及真实离线测试:匹配完整工作侧,拒绝片段、错侧、截断与超限。diff 仍不能独立证明旧节点/编码/旧属性,因此未提交工作副本真实双侧替换仍未支持,不能把辅助组件算作支持完成。v0.64.1 已发布已提交固定历史版本的同路径 deleted/added 合并,真实目标旧/新正文与属性通过测试;481 项串行回归、宿主、隔离安装、四矩阵 CI 与原包/npm/Release 128,279 字节、双哈希/清单/发布说明通过。v0.65.0 已发布已提交固定历史文件/目录类型替换:分侧 kind/正文/属性、预览披露和目录后代覆盖;482 项串行回归、宿主、隔离安装、四矩阵 CI 与原包/npm/Release 128,792 字节、双哈希/清单/发布说明通过。未提交替换仍拒绝 | 第二批:区分 BASE 旧节点与目标节点 |
|
|
23
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
27
|
| 固定 revision 单提交 | v0.62.0 已发布 svnRevision:r-1/r 固定对象、UUID/URL/peg/operative revision 逐侧绑定,正文不读工作树;远端显式 svnAllowNetwork。472 项串行回归、宿主、隔离安装、四矩阵 CI 及原包/npm/Release 125,486 字节、双哈希/清单/发布说明通过。历史上下文/规则/调用方尚未支持 | 第五批:新增明确 SVN 参数与冻结身份 |
|
|
28
28
|
| 固定 revision 区间 | v0.62.0 已发布 svnBaseRevision/svnTargetRevision,固定树端点 summary+逐侧 cat/proplist,目录后代与属性纳入预算,不建临时工作树;同上述完整发布验证。分支间不同 URL 和历史上下文尚未支持 | 第五批:固定树端点与逐路径差异 |
|
|
29
|
-
| 分支复制、分支间比较、合并来源 | mergeinfo
|
|
29
|
+
| 分支复制、分支间比较、合并来源 | 本地新增批准历史 mergeinfo 属性的来源路径/区间导航,保留非继承标记、有界不展开 revision、不读取分支源码;invalid/limited 明确且不裁剪。待 v0.66.0 发布;有效继承 mergeinfo、跨 URL 比较、源码归属/合并提交覆盖仍未支持 | 第六批:URL/peg/operative revision 与 mergeinfo 导航披露 |
|
|
30
30
|
| 当前三层依赖与限定目录调用方 | 已有安全 normal 节点的离线有界测试;根属性修改可能使祖先检查拒绝,需单独验证 | 第四批补祖先/混合状态组合 |
|
|
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 字节、双哈希/清单/发布说明通过;自动三层历史链与历史调用方仍未实现 | 第六批:逐侧固定版本,不能回退至工作副本 |
|
|
32
32
|
| 真正 r7314→r7315 样本 | 用户未提供隔离副本路径;本地类似复现不是该样本验收 | 获得路径后只读生成预览,不自动发送模型 |
|
package/package.json
CHANGED
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 } : {}), model: current.route, modelSendingEnabled: allowModelSending, files: snapshot.files.map(f => ({ path: f.path, eligibility: f.eligibility, reason: f.reason, ...(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: (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 } : {}), model: current.route, modelSendingEnabled: allowModelSending, files: snapshot.files.map(f => ({ path: f.path, eligibility: f.eligibility, reason: f.reason, ...(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: (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/svn-history.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import {xml} from './svn.mjs';
|
|
|
4
4
|
import {hash,decode,relativePath} from './content.mjs';
|
|
5
5
|
import {isSecretPath,assertNonSecretPath} from './secret-path.mjs';
|
|
6
6
|
import {captureProjectRules} from './project-rules.mjs';
|
|
7
|
+
import {mergeinfoNavigation} from './svn-mergeinfo.mjs';
|
|
7
8
|
|
|
8
9
|
const revision = value => {
|
|
9
10
|
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');
|
|
@@ -33,8 +34,9 @@ export function svnHistoryDestination(value, allowNetwork) {
|
|
|
33
34
|
export function mergeSvnHistoryRecord(previous, item) {
|
|
34
35
|
if(!['added','deleted','modified','none'].includes(item?.['@_item'])||!['file','dir'].includes(item?.['@_kind']))throw new Error('Ambiguous or unsupported SVN history summary');
|
|
35
36
|
if(!previous)return item;
|
|
36
|
-
if(
|
|
37
|
-
|
|
37
|
+
if(!['added','deleted'].includes(previous['@_item'])||!['added','deleted'].includes(item['@_item'])||previous['@_item']===item['@_item'])throw new Error('Ambiguous or unsupported SVN history summary');
|
|
38
|
+
const old=previous['@_item']==='deleted'?previous:item,target=previous['@_item']==='added'?previous:item;
|
|
39
|
+
return {...target,'@_item':'replaced',oldKind:old['@_kind'],newKind:target['@_kind']};
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
// Fixed URL objects only. No checkout, update, merge, working body or lazy HEAD read.
|
|
@@ -101,23 +103,24 @@ export async function captureSvnHistory(cwd,options={}) {
|
|
|
101
103
|
const node=await identity(name,rev),props=await properties(name,rev);
|
|
102
104
|
if(Object.hasOwn(props,'svn:special'))throw new Error('Historical SVN special node excluded');
|
|
103
105
|
const content=node['@_kind']==='dir'?decode(Buffer.alloc(0)):decode(await run(['cat','--ignore-keywords','-r',rev,'--',at(name,rev)],true));
|
|
104
|
-
return {content,props,kind:node['@_kind'],source:{repositoryUuid:local.repository.uuid,url:at(name,rev).slice(0,-rev.length-1),pegRevision:rev,operativeRevision:rev,hash:content.hash,textHash:hash(content.text)}};
|
|
106
|
+
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)}};
|
|
105
107
|
};
|
|
106
108
|
const files=[];
|
|
107
109
|
for(const [name,record]of [...records].sort(([a],[b])=>a<b?-1:a>b?1:0)){
|
|
108
110
|
signal.throwIfAborted();
|
|
109
|
-
const status=record['@_item'],item={id:hash(name),path:name,kind:record['@_kind'],rawStatus:status,eligibility:'reviewable',properties:[],rightExists:status!=='deleted'};
|
|
111
|
+
const status=record['@_item'],item={id:hash(name),path:name,kind:record['@_kind'],oldKind:status==='added'?null:record.oldKind??record['@_kind'],newKind:status==='deleted'?null:record.newKind??record['@_kind'],rawStatus:status,eligibility:'reviewable',properties:[],rightExists:status!=='deleted'};
|
|
110
112
|
files.push(item);
|
|
111
113
|
if(isSecretPath(name)){item.eligibility='excluded';item.reason='Credential path excluded';continue;}
|
|
112
114
|
if(selectedPaths&&!selectedPaths.includes(name)){item.eligibility='excluded';item.reason='Not explicitly selected';continue;}
|
|
113
115
|
try{
|
|
114
|
-
const readSide=async(rev,exists)=>{
|
|
116
|
+
const readSide=async(rev,exists,kind)=>{
|
|
115
117
|
if(!exists)return {content:decode(Buffer.alloc(0)),props:{}};
|
|
116
118
|
const result=await readSource(name,rev);
|
|
117
|
-
if(result.kind!==
|
|
119
|
+
if(result.kind!==kind)throw new Error('Historical node kind differs');
|
|
118
120
|
return result;
|
|
119
121
|
};
|
|
120
|
-
const left=await readSide(range.base,status!=='added'),right=await readSide(range.target,status!=='deleted');
|
|
122
|
+
const left=await readSide(range.base,status!=='added',item.oldKind),right=await readSide(range.target,status!=='deleted',item.newKind);
|
|
123
|
+
if(status==='replaced')item.nodeNotice='Fixed historical replacement; each side has its own node kind. Directory content is not file text; descendant changes require separate coverage.';
|
|
121
124
|
item.left=left.content;item.right=right.content;
|
|
122
125
|
item.svnSources={...(left.source?{old:left.source}:{}),...(right.source?{new:right.source}:{})};
|
|
123
126
|
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});
|
|
@@ -126,9 +129,10 @@ export async function captureSvnHistory(cwd,options={}) {
|
|
|
126
129
|
}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;}
|
|
127
130
|
if(Buffer.byteLength(JSON.stringify(files))>maxSnapshotBytes)throw new Error('SVN historical snapshot limit exceeded');
|
|
128
131
|
}
|
|
129
|
-
for(const item of files.filter(f=>f.
|
|
132
|
+
for(const item of files.filter(f=>(f.oldKind==='dir'||f.newKind==='dir')&&['added','deleted','replaced'].includes(f.rawStatus)&&f.eligibility==='reviewable').sort((a,b)=>b.path.length-a.path.length)){
|
|
130
133
|
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;}
|
|
131
134
|
}
|
|
135
|
+
const mergeSources=files.filter(file=>file.eligibility==='reviewable').flatMap(file=>file.properties.filter(prop=>prop.name==='svn:mergeinfo').map(prop=>({path:file.path,old:mergeinfoNavigation(prop.old),new:mergeinfoNavigation(prop.new)})));
|
|
132
136
|
const context=[];
|
|
133
137
|
const targetContent=async(name)=>{
|
|
134
138
|
const changed=files.find(file=>file.path===name);
|
|
@@ -141,7 +145,7 @@ export async function captureSvnHistory(cwd,options={}) {
|
|
|
141
145
|
for(const name of [...contextPaths].sort()){
|
|
142
146
|
const target=await targetContent(name),record=records.get(name),changed=files.find(file=>file.path===name);
|
|
143
147
|
const item={path:name,...target.content,revision:range.target,svnSource:target.source};
|
|
144
|
-
if(changed){if(
|
|
148
|
+
if(changed){if(changed.oldKind==='file'){item.oldText=changed.left.text;item.oldRevision=range.base;item.oldSvnSource=changed.svnSources.old;}}
|
|
145
149
|
else{
|
|
146
150
|
// No change record means both fixed sides are present and equal. Read and verify
|
|
147
151
|
// rather than inferring baseline content from target or from the working tree.
|
|
@@ -153,7 +157,7 @@ export async function captureSvnHistory(cwd,options={}) {
|
|
|
153
157
|
for(const name of [...oldContextPaths].sort()){
|
|
154
158
|
const changed=files.find(file=>file.path===name);
|
|
155
159
|
if(changed){
|
|
156
|
-
if(changed.eligibility!=='reviewable'||changed.
|
|
160
|
+
if(changed.eligibility!=='reviewable'||changed.oldKind!=='file'||!changed.svnSources?.old)throw new Error('Old SVN context conflicts with excluded blocked or absent baseline change');
|
|
157
161
|
// The original changed-file old identity already authorizes this side.
|
|
158
162
|
continue;
|
|
159
163
|
}
|
|
@@ -167,7 +171,7 @@ export async function captureSvnHistory(cwd,options={}) {
|
|
|
167
171
|
const after=await run(['diff','--summarize','--xml','--notice-ancestry','-r',range.base+':'+range.target,'--',at('.',range.target)]);
|
|
168
172
|
if(!isDeepSummary(summary,after))throw new Error('SVN historical summary changed during capture');
|
|
169
173
|
await identity('.',range.base);await identity('.',range.target);
|
|
170
|
-
const snapshot={schemaVersion:1,vcs:'svn',root,repositoryUuid:local.repository.uuid,context,...(rules.length?{rules}:{}),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};
|
|
174
|
+
const snapshot={schemaVersion:1,vcs:'svn',root,repositoryUuid:local.repository.uuid,context,...(rules.length?{rules}:{}),history:{...range,vcs:'svn',url,repositoryUuid:local.repository.uuid,pegRevision:range.target,networkAccess,...(mergeSources.length?{mergeSources}:{}),semantics:'fixed revision tree endpoints; no merge attribution or working text'},files};
|
|
171
175
|
const serialized=JSON.stringify(snapshot);if(Buffer.byteLength(serialized)>maxSnapshotBytes)throw new Error('SVN historical snapshot limit exceeded');
|
|
172
176
|
return {...snapshot,id:hash(serialized)};
|
|
173
177
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {hash} from './content.mjs';
|
|
2
|
+
|
|
3
|
+
// Navigation over an already approved property. Never expands revisions or reads URLs.
|
|
4
|
+
export function mergeinfoNavigation(text) {
|
|
5
|
+
if(text===null || text==='')return {status:'parsed',sources:[],hash:hash(text??'')};
|
|
6
|
+
if(typeof text!=='string')return {status:'invalid',reason:'Mergeinfo property is not text',sources:[]};
|
|
7
|
+
if(Buffer.byteLength(text)>16*1024)return {status:'limited',reason:'Mergeinfo navigation byte limit exceeded',sources:[],hash:hash(text)};
|
|
8
|
+
const lines=text.split(/\r?\n/);if(lines.at(-1)==='')lines.pop();
|
|
9
|
+
if(lines.length>100)return {status:'limited',reason:'Mergeinfo navigation source limit exceeded',sources:[],hash:hash(text)};
|
|
10
|
+
const sources=[],seen=new Set();let rangeCount=0;
|
|
11
|
+
for(const line of lines){
|
|
12
|
+
const delimiter=line.lastIndexOf(':');
|
|
13
|
+
const sourcePath=line.slice(0,delimiter),list=line.slice(delimiter+1);
|
|
14
|
+
if(delimiter<1||!sourcePath.startsWith('/')||/[\x00-\x1f\\]/.test(sourcePath)||sourcePath.length>1024||(sourcePath!=='/'&&sourcePath.slice(1).split('/').some(part=>!part||part==='.'||part==='..'))||seen.has(sourcePath))return {status:'invalid',reason:'Mergeinfo navigation path or duplicate source invalid',sources:[],hash:hash(text)};
|
|
15
|
+
seen.add(sourcePath);
|
|
16
|
+
const ranges=[];
|
|
17
|
+
for(const token of list.split(',')){
|
|
18
|
+
if(++rangeCount>500)return {status:'limited',reason:'Mergeinfo navigation range limit exceeded',sources:[],hash:hash(text)};
|
|
19
|
+
const match=/^([1-9][0-9]*)(?:-([1-9][0-9]*))?(\*)?$/.exec(token);
|
|
20
|
+
if(!match)return {status:'invalid',reason:'Mergeinfo navigation revision range invalid',sources:[],hash:hash(text)};
|
|
21
|
+
const start=Number(match[1]),end=Number(match[2]??match[1]);
|
|
22
|
+
if(!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start>end)return {status:'invalid',reason:'Mergeinfo navigation revision range invalid',sources:[],hash:hash(text)};
|
|
23
|
+
ranges.push({start:String(start),end:String(end),inheritable:!match[3]});
|
|
24
|
+
}
|
|
25
|
+
sources.push({sourcePath,ranges});
|
|
26
|
+
}
|
|
27
|
+
return {status:'parsed',sources,hash:hash(text),notice:'Explicit mergeinfo navigation only; not effective inherited mergeinfo, change attribution, permission to read a source or evidence that listed revisions were reviewed.'};
|
|
28
|
+
}
|