@feng0531/dsh-code-review 0.73.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 +5 -1
- package/CORE-ALIGNMENT-TASKS.md +2 -1
- package/README.md +1 -1
- package/RELEASE-NOTES.md +9 -9
- package/SVN-SUPPORT-MATRIX.md +11 -2
- package/package.json +1 -1
- package/src/service.mjs +1 -1
- package/src/snapshot.mjs +1 -0
- package/src/svn-caller-capture.mjs +2 -1
- package/src/svn-caller-index.mjs +1 -1
- package/src/svn-caller-node.mjs +1 -1
- package/src/svn-dependency-chain.mjs +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
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
|
+
|
|
5
9
|
## 0.73.0 — SVN 历史上下文换行来源信息
|
|
6
10
|
|
|
7
|
-
固定历史显式、旧侧独有、自动依赖和命中调用方的来源保留各自EOL元数据,预览披露逐侧身份,映射与原文不符会被拒绝。不新增读取命令、不使用工作副本替代历史来源,不把元数据当作额外正文授权。其他SVN缺口仍继续。
|
|
11
|
+
固定历史显式、旧侧独有、自动依赖和命中调用方的来源保留各自EOL元数据,预览披露逐侧身份,映射与原文不符会被拒绝。不新增读取命令、不使用工作副本替代历史来源,不把元数据当作额外正文授权。其他SVN缺口仍继续。510项串行回归、66项最终上下文/检索兼容、实际宿主、隔离安装及四矩阵CI通过;原始Actions/npm/Release 143,452字节逐字节一致,双哈希/清单/冻结正文通过。
|
|
8
12
|
|
|
9
13
|
## 0.72.0 — 使用指南与 SVN 固定历史换行元数据
|
|
10
14
|
|
package/CORE-ALIGNMENT-TASKS.md
CHANGED
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
|
|
33
33
|
## 当前验证结论
|
|
34
34
|
|
|
35
|
-
- 最近完整回归:
|
|
35
|
+
- 最近完整回归:510项通过;v0.73.0 最终上下文/检索兼容66项通过。
|
|
36
|
+
- v0.73.0 已发布固定历史上下文逐侧EOL来源元数据,显式/旧侧/依赖/调用方保持原身份,预览不含正文,篡改映射拒绝;宿主、隔离安装与四矩阵CI通过,原始Actions/npm/Release 143,452字节逐字节一致,双哈希/清单/冻结说明通过。工作副本关键字上下文及其余SVN缺口继续。
|
|
36
37
|
- v0.72.0 已发布可操作使用指南与固定历史变更文件逐侧EOL元数据,缺失/目录侧明确null,原来源身份/正文保持;实际宿主、隔离安装与四矩阵CI通过;原始Actions/npm/Release包142,623字节逐字节一致,双哈希/清单/冻结发布说明通过。历史上下文EOL、关键字上下文、未提交替换和跨URL等仍未闭环。
|
|
37
38
|
- v0.71.0 已发布工作副本 EOL 纯元数据映射,原文/原字节证据保持,单CR明确不套用规范化行号。实际宿主、隔离安装和四矩阵CI通过;原始Actions/npm/Release 139,804字节逐字节一致,双哈希、清单和冻结正文核验通过。历史EOL元数据、关键字上下文、未提交替换和跨URL等继续待闭环。
|
|
38
39
|
- v0.70.0 已发布标准 SVN 工作副本关键字原文审查,BASE收缩独立核验、原正文/hash/行号不替换,预览披露映射。504 项串行回归、宿主、隔离安装和四矩阵 CI 通过;原产物/npm/Release 137,678 字节逐字节一致,双哈希、清单与冻结发布说明通过。自定义/固定宽度关键字、换行规范化、未提交替换、跨URL分支等缺口仍未闭环。
|
package/README.md
CHANGED
|
@@ -222,7 +222,7 @@ Git / SVN 历史审查也能使用这些参数;依赖和调用方按对应版
|
|
|
222
222
|
- 插件遵循 DSH 工具权限;确认预览不等于绕过宿主权限。不执行项目代码、构建脚本或模型生成的 shell 命令。
|
|
223
223
|
- Git 支持工作区、单提交、两版本比较。SVN 支持工作副本和固定历史端点;**尚未完整支持所有 SVN 状态**。
|
|
224
224
|
- SVN 根目录属性可预览;同仓库复制新增以空目标旧侧审查,复制来源只作信息;移动的新增/删除分别覆盖。目录后代须逐项纳入,选择父目录不自动批准所有后代。
|
|
225
|
-
- SVN
|
|
225
|
+
- SVN 标准关键字展开,以及工作副本变更/自动依赖/命中调用方、固定历史变更及上下文的换行元数据保留原文证据;不以规范化结果证明行为等价。自定义/固定宽度关键字、未提交替换、冲突、特殊节点、切换/外部工作副本及跨 URL 比较等仍有限制。已提交固定历史替换与未提交替换的支持不同,详见 [SVN 支持矩阵](SVN-SUPPORT-MATRIX.md)。
|
|
226
226
|
- 二进制、不能解码的文件、链接和超限内容会明确阻断,不静默截断后声称完成。文本导航不证明语义绑定、执行可达或完整调用覆盖。
|
|
227
227
|
|
|
228
228
|
## 规模限制
|
package/RELEASE-NOTES.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
# v0.
|
|
1
|
+
# v0.74.0 发布说明
|
|
2
2
|
|
|
3
|
-
## SVN
|
|
3
|
+
## SVN 工作副本依赖和调用方的换行信息
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
使用 `autoContext:true` 补充依赖,或用 `callerScopePaths` 寻找引用方时,安全正常节点的 `svn:eol-style` 现在会与原始上下文一起保留。预览披露已捕获上下文的换行样式、计数和视图哈希,不显示正文。
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- 只绑定实际捕获的版本侧。仅旧侧上下文不会因此读取新侧,未命中调用方不会因此成为批准正文。
|
|
9
|
-
- 元数据从原版本正文计算,检索来源校验会拒绝与该正文不符的映射。原始字节哈希、UUID、URL、peg/operative revision 和行号保持不变。
|
|
10
|
-
- 不新增 SVN 命令,不扩大仓库或网络范围,继续使用原共享采集、来源与模型输入预算。
|
|
7
|
+
同时修复调用方索引遗漏已有正常属性文件的问题:SVN 将已提交属性标为 normal,而不是无属性的 none。两种正常状态现在都进入候选,正文读取前仍须通过节点、属性、URL及祖先检查;修改或冲突属性仍拒绝。
|
|
11
8
|
|
|
12
|
-
|
|
9
|
+
- 仅使用已有离线元数据和原文,不增加 SVN 命令或网络访问。
|
|
10
|
+
- 自动依赖入口文件也保留元数据;属性变化使快照过期,执行前必须重新预览。
|
|
11
|
+
- 原正文、字节哈希和行号不变;单CR不能套用规范化LF行号,换行视图不证明行为等价。
|
|
12
|
+
- 关键字、特殊节点、切换/外部根和非正常属性继续受原安全检查约束,不把本修复视为完整调用覆盖。
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
本批不放开工作副本关键字上下文;未提交替换、跨URL分支及有效继承mergeinfo等仍待实现。日常操作见 [README](README.md),具体限制与证据见 [SVN支持矩阵](SVN-SUPPORT-MATRIX.md)。
|
package/SVN-SUPPORT-MATRIX.md
CHANGED
|
@@ -31,11 +31,20 @@
|
|
|
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
|
-
##
|
|
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、清单及冻结发布正文核验通过。
|
|
35
44
|
|
|
36
45
|
- 固定历史 readSource 把EOL元数据绑定各侧source,显式/旧侧独有/自动依赖/命中调用方均保留;不新增读取命令或放大预算。
|
|
37
46
|
- 预览 contextSources 列出实际捕获来源,不显示正文;context-identity 校验映射与该侧原文一致,拒绝篡改。
|
|
38
|
-
- `test/svn-history-context-eol.test.mjs` 真实仓库复现通过:四类上下文组合、旧侧不回流、未匹配正文不捕获、脏工作树隔离、0预览发送、模型原文、status不变与快照稳定。510项串行全量回归、最终66项上下文/检索兼容与9
|
|
47
|
+
- `test/svn-history-context-eol.test.mjs` 真实仓库复现通过:四类上下文组合、旧侧不回流、未匹配正文不捕获、脏工作树隔离、0预览发送、模型原文、status不变与快照稳定。510项串行全量回归、最终66项上下文/检索兼容与9项历史组合、实际宿主与隔离安装、四矩阵标签和发布CI通过,正式发布及工件核验完成。
|
|
39
48
|
- 工作副本关键字上下文、未提交替换、跨URL与有效继承mergeinfo等缺口仍未完成。
|
|
40
49
|
|
|
41
50
|
## 固定历史 EOL 与使用指南(v0.72.0 已发布)
|
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 } : {}), ...(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.history?.vcs==='svn'?{contextSources:snapshot.context.map(c=>({path:c.path,...(c.svnSource?{new:c.svnSource}:{}),...(c.oldSvnSource?{old:c.oldSvnSource}:{})}))}:{}), ...(options.oldContextPaths !== undefined ? {oldContextPaths:options.oldContextPaths,oldContextSources:snapshot.context.filter(c=>c.oldOnly).map(c=>({path:c.path,side:'context-old',revision:c.oldRevision,...(c.oldSvnSource?{svnSource:c.oldSvnSource}:{blobOid:c.oldBlobOid}),hash:c.oldHash}))} : {}), ...(snapshot.callerDiscovery ? {callerDiscovery:snapshot.callerDiscovery} : {}), ...(snapshot.autoContext ? { autoContext: snapshot.autoContext } : {}), rules: (snapshot.rules ?? []).map(({ path, hash }) => ({ path, hash })), notice: (snapshot.history?.vcs==='svn'&&options.autoContext===true?'SVN历史autoContext为精确依赖定位读取固定版本父目录有界清单,包含同目录非匹配名字元数据;仅匹配捕获正文发送模型,不扫描全仓,不用工作树替代历史版本。':'') + (options.oldContextPaths !== undefined ? 'oldContextPaths仅为baseline旧侧正文,无目标侧替代;选中变更复用原old身份,旧侧不能证明新侧行为。' : '') + (snapshot.callerDiscovery ? 'callerScopePaths已授权预览读取限定目录内tracked候选正文,包括不匹配文件;非匹配正文不发送模型,匹配捕获上下文会发送。仅字面引用导航,不是函数调用或完整调用覆盖。' : '') + (enableRiskPlanning ? '执行按plan阈值决定风险假设规划,小变更可跳过独立规划但仍主审并复核;计划不是证据,所有阶段共享原预算。' : '') + '执行会向上述模型提供方发送可审查文件两侧内容、预览列出的显式/自动上下文、所选项目规则全文及预览业务需求文本。模型可在该快照范围内多轮只读检索,不读取范围外文件;候选生成后会进行证据与反证复核,两阶段各最多3轮检索,共享每文件120秒和总模型调用100次预算;复核不能证明缺陷成立。plan.reviewRounds为独立审查工作量,非模型reasoning effort;后续轮不带首轮风险计划,所有轮共享原超时与预算。plan仅预检初始输入,ready不代表已审查;minimumCalls不含后续检索/复核/动态业务综合;多文件可先进行业务分组,分组失败保留原组并记录限制,动态综合数执行前未知,实际预算可能不足。请先向用户展示范围,获得确认后执行。' };
|
|
52
|
+
return { ...(options.businessRequirement!==undefined?{businessRequirement:{text:options.businessRequirement,hash:hash(options.businessRequirement),bytes:Buffer.byteLength(options.businessRequirement)}}:{}), previewId, plan, snapshotId: snapshot.id, repositoryRoot: snapshot.root, vcs: snapshot.vcs, ...(snapshot.history ? { history: snapshot.history } : {}), ...(snapshot.mergeSources ? {mergeSources:snapshot.mergeSources} : {}), model: current.route, modelSendingEnabled: allowModelSending, files: snapshot.files.map(f => ({ path: f.path, eligibility: f.eligibility, reason: f.reason, ...(f.eolMapping?{eolMapping:f.eolMapping,textNotice:f.textNotice}:{}), ...(f.keywordMapping?{keywordMapping:f.keywordMapping,textNotice:f.textNotice}:{}), ...(f.rawStatus==='replaced'&&snapshot.history?.vcs==='svn'?{oldKind:f.oldKind,newKind:f.newKind,nodeNotice:f.nodeNotice}:{}), ...(f.blockCode ? {blockCode:f.blockCode} : {}), ...(f.copySource ? {copySource:f.copySource,copyNotice:f.copyNotice} : {}) })), contextPaths: snapshot.context.map(c => c.path), ...(snapshot.context.some(c=>c.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);
|
package/src/svn-caller-index.mjs
CHANGED
|
@@ -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'
|
|
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})))};}
|
package/src/svn-caller-node.mjs
CHANGED
|
@@ -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;
|