@feng0531/dsh-code-review 0.66.0 → 0.67.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.67.0 — SVN 工作副本显式 mergeinfo 导航
6
+
7
+ 将有界 mergeinfo 来源路径/区间导航用于本地 BASE/工作侧属性变化,预览与结构化报告披露两侧元数据;复用历史解析,不扩大读取或网络范围。排除/阻断节点不回流,无效或超限保持明确状态、不输出部分导航。不是有效继承、归属或合并提交完整审查证明;全面 SVN 缺口继续保留。
8
+
9
+ ## 0.66.1 — SVN 合并记录变化下的工作副本导航修复
10
+
11
+ 修复根目录 mergeinfo 条目在自动依赖和反向调用方候选中被当作非法源码路径的失败;仅目录身份根节点可跳过源码候选。祖先属性 modified 仅在双侧属性比较确认只改变 svn:mergeinfo 时允许导航,旧/新属性仍入身份指纹;其他属性变化、externals/special、switched 或冲突继续拒绝。离线依赖和限定调用方集成验证通过,不扩展历史自动导航。488 项串行回归、宿主、隔离安装和四矩阵 CI 通过;原产物/npm/Release 130,642 字节逐字节一致,双哈希、清单与冻结发布说明通过。
12
+
5
13
  ## 0.66.0 — SVN 历史显式 mergeinfo 来源导航
6
14
 
7
- 对已批准历史变更的 svn:mergeinfo 属性增加有界来源路径/区间导航,保留非继承标记,不展开 revision、不读取分支源码。预览和报告 history 保留旧/新导航;无效或超限元数据明确标记且不输出部分导航,不阻断原属性审查。不是有效继承 mergeinfo、源码归属或已审查提交证明;跨 URL 比较仍待实现。
15
+ 对已批准历史变更的 svn:mergeinfo 属性增加有界来源路径/区间导航,保留非继承标记,不展开 revision、不读取分支源码。预览和报告 history 保留旧/新导航;无效或超限元数据明确标记且不输出部分导航,不阻断原属性审查。不是有效继承 mergeinfo、源码归属或已审查提交证明;跨 URL 比较仍待实现。485 项串行回归、宿主、隔离安装及四矩阵 CI 通过;原产物/npm/Release 129,866 字节逐字节一致,双哈希、清单与冻结发布说明通过。
8
16
 
9
17
  ## 0.65.0 — SVN 历史文件/目录类型替换
10
18
 
@@ -32,7 +32,9 @@
32
32
 
33
33
  ## 当前验证结论
34
34
 
35
- - 最近完整回归:482 项通过。
35
+ - 最近完整回归:488 项通过。
36
+ - v0.66.1 已修复根目录 mergeinfo 条目在工作副本依赖/调用方候选的非法路径失败,祖先仅合并记录属性变化经双侧比较后允许且仍入指纹。488 项串行回归、宿主、隔离安装和四矩阵 CI 通过;原产物/npm/Release 130,642 字节逐字节一致,双哈希、清单与冻结发布说明通过。全面 SVN 目标仍未完成。
37
+ - v0.66.0 已交付批准历史 mergeinfo 属性的有界来源/区间导航,不展开 revision、不读取来源,也不证明有效继承/归属/审查覆盖。485 项串行回归、宿主、隔离安装及四矩阵 CI 通过;原产物/npm/Release 129,866 字节逐字节一致,双哈希、清单与冻结发布说明通过。跨 URL 分支比较、自动历史链/调用方等仍未完成。
36
38
  - v0.65.0 已交付固定 SVN 历史文件/目录类型替换,各侧节点类型/正文/属性与来源独立绑定,预览披露且后代覆盖保护保留。482 项串行回归、宿主、隔离安装和四矩阵 CI 通过;原产物/npm/Release 128,792 字节逐字节一致,双哈希、清单与冻结发布说明通过。未提交替换、自动历史链/调用方和其他全面 SVN 缺口仍待实现。
37
39
  - v0.64.1 已修复已提交 SVN 历史替换的同路径增删记录,真实目标旧/新正文和属性逐侧读取。481 项串行回归、宿主、隔离安装和四矩阵 CI 通过;原产物/npm/Release 128,279 字节逐字节一致,双哈希、清单与冻结发布说明通过。未提交工作副本替换和类型变化等仍未支持,全面目标继续。
38
40
  - v0.64.0 已交付 SVN 历史 oldContextPaths,仅旧侧正文及来源身份,不合成目标,选中旧变更复用原身份,排除项拒绝回流。479 项串行回归、宿主、隔离安装和四矩阵 CI 通过;原产物/npm/Release 127,879 字节逐字节一致,双哈希、清单与冻结发布说明通过。自动历史链、调用方及全面 SVN 矩阵仍未闭环。
package/RELEASE-NOTES.md CHANGED
@@ -1,14 +1,14 @@
1
- # v0.66.0 发布说明
1
+ # v0.67.0 发布说明
2
2
 
3
- ## SVN 历史显式 mergeinfo 来源导航
3
+ ## SVN 工作副本显式 mergeinfo 导航
4
4
 
5
- 从已批准固定历史变更中的 svn:mergeinfo 属性生成来源路径和 revision 区间导航,预览及报告 history 保存两侧结果。
5
+ 本地 BASE/工作侧属性变更中的 svn:mergeinfo 现在也生成有界来源路径与 revision 区间导航,并在预览及结构化报告中披露。
6
6
 
7
- - 保留非继承 `*` 标记,不枚举区间内每个 revision,不增加来源正文读取或网络请求。
8
- - 导航限 16KiB、100 来源路径、500 区间;无效/超限时明确标记,不静默裁剪或展示部分结果。
9
- - 已排除/阻断变更不进入导航,原属性正文仍按既有证据流程审查。
10
- - 来源路径不代表有效继承 mergeinfo、提交归属、审查覆盖,也不授权读取分支源码。
7
+ - 复用固定历史解析规则:16KiB、100 来源路径、500 区间;不展开 revision 或读取来源分支。
8
+ - 仅处理可审查、已批准节点的属性;排除/阻断条目不回流。
9
+ - 无效/超限导航状态明确,不静默裁剪;原属性正文审查不受导航解析结果替代。
10
+ - 独立 SVN 测试验证预览零发送、工作状态不变与报告披露。
11
11
 
12
- 跨 URL 分支比較、自动历史链、调用方、工作副本替换等仍未完成,详见 [SVN 支持矩阵](SVN-SUPPORT-MATRIX.md)。本版本不把导航作为缺陷证据或完整合并审计。
12
+ 导航不是有效继承 mergeinfo、源码归属或合并提交审查覆盖的证明。跨 URL 比较、自动历史链/调用方、未提交替换及其他全面 SVN 项仍见 [支持矩阵](SVN-SUPPORT-MATRIX.md),目标尚未完成。
13
13
 
14
14
  用法见 [README](README.md)。
@@ -26,8 +26,8 @@
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 属性的来源路径/区间导航,保留非继承标记、有界不展开 revision、不读取分支源码;invalid/limited 明确且不裁剪。待 v0.66.0 发布;有效继承 mergeinfo、跨 URL 比较、源码归属/合并提交覆盖仍未支持 | 第六批:URL/peg/operative revision 与 mergeinfo 导航披露 |
30
- | 当前三层依赖与限定目录调用方 | 已有安全 normal 节点的离线有界测试;根属性修改可能使祖先检查拒绝,需单独验证 | 第四批补祖先/混合状态组合 |
29
+ | 分支复制、分支间比较、合并来源 | v0.66.0 已发布批准历史 mergeinfo 属性的来源路径/区间导航,保留非继承标记、有界不展开 revision、不读取分支源码;invalid/limited 明确且不裁剪。485 项串行回归、宿主、隔离安装、四矩阵 CI 与原包/npm/Release 129,866 字节、双哈希/清单/发布说明通过;本地新增工作副本 BASE/工作侧属性的同一有界导航及预览/报告披露,待 v0.67.0 发布。有效继承 mergeinfo、跨 URL 比较、源码归属/合并提交覆盖仍未支持 | 第六批:URL/peg/operative revision 与 mergeinfo 导航披露 |
30
+ | 当前三层依赖与限定目录调用方 | v0.66.1 已发布根目录仅 mergeinfo 变化下的自动依赖/限定调用方修复:根目录不作源码候选,祖先双侧属性仅 mergeinfo 差异可通过且入指纹;其他属性/unsafe 状态仍拒绝。488 项串行回归、宿主、隔离安装、四矩阵 CI 与原包/npm/Release 130,642 字节、双哈希/清单/发布说明通过。其他祖先/混合状态继续待验证 | 第四批补祖先/混合状态组合 |
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 样本 | 用户未提供隔离副本路径;本地类似复现不是该样本验收 | 获得路径后只读生成预览,不自动发送模型 |
33
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-code-review",
3
- "version": "0.66.0",
3
+ "version": "0.67.0",
4
4
  "private": false,
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
@@ -31,6 +31,7 @@ export function callerCandidates(files, scanned, resolutionPaths, {includeBindin
31
31
  const resolved = new Set(resolutionPaths);
32
32
  const changed = new Set(), targets = new Set();
33
33
  for (const file of files) {
34
+ if(file.path==='.'&&file.kind==='dir'){changed.add(file.path);continue;}
34
35
  relativePath(file.path); changed.add(file.path);
35
36
  if (file.oldPath) { relativePath(file.oldPath); changed.add(file.oldPath); }
36
37
  if (file.eligibility === 'reviewable' && file.rightExists !== false && !isSecretPath(file.path)) targets.add(file.path);
@@ -7,6 +7,7 @@ export function contextCandidates(files, trackedPaths, {maxCandidates=20,onProbe
7
7
  const changed=new Set(files.flatMap(f=>[f.path,...(f.oldPath?[f.oldPath]:[])]));
8
8
  const found=new Map();const add=(p,from,reason)=>{if(!tracked.has(p)||(!includeChanged&&changed.has(p)))return;if(!found.has(p))found.set(p,{path:p,fromPaths:[],reasons:[],confidence:'navigation-only'});const v=found.get(p);if(!v.fromPaths.includes(from))v.fromPaths.push(from);if(!v.reasons.includes(reason))v.reasons.push(reason);};
9
9
  for(const f of files){
10
+ if(f.path==='.'&&f.kind==='dir')continue; // Root properties have no source dependency syntax.
10
11
  relativePath(f.path);if(f.eligibility!=='reviewable'||f.rightExists===false)continue;
11
12
  const text=f.right?.text;if(typeof text!=='string'||Buffer.byteLength(text)>256*1024)continue;
12
13
  if(text.includes('/*')||text.includes(String.fromCharCode(96))||text.includes('R"')||/\\\r?\n/.test(text))continue;
package/src/review.mjs CHANGED
@@ -105,6 +105,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
105
105
  if (input.autoContext.truncated || input.autoContext.candidates.some(c => c.status === 'blocked')) report.limitations.push({ text: '自动上下文未完全捕获:存在截断或阻塞,仅实际捕获路径可用于证据。' });
106
106
  }
107
107
  if (input.history) report.history = structuredClone(input.history);
108
+ if (input.mergeSources) report.mergeSources = structuredClone(input.mergeSources);
108
109
  report.rules = (input.rules ?? []).map(({ path, hash }) => ({ path, hash }));
109
110
  if (options.enableGrouping === true) report.grouping = buildReviewGroups(input.files, inferFileRelations(input.files));
110
111
  report.interactions = [];
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.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不含后续检索/复核/动态业务综合;多文件可先进行业务分组,分组失败保留原组并记录限制,动态综合数执行前未知,实际预算可能不足。请先向用户展示范围,获得确认后执行。' };
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.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) {
@@ -4,5 +4,15 @@ import {relativePath,hash} from './content.mjs';
4
4
  import {lstat,realpath} from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  const canonicalValue=v=>Array.isArray(v)?v.map(canonicalValue):v&&typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,canonicalValue(v[k])])):v;
7
+ function propertyMap(props){
8
+ const result=Object.create(null);
9
+ for(const p of props){if(typeof p['@_name']!=='string'||p['@_encoding']||Object.hasOwn(result,p['@_name']))throw new Error('Unsafe SVN caller ancestor property representation');result[p['@_name']]=p['#text']??'';}
10
+ return result;
11
+ }
12
+ export function mergeinfoOnlyAncestorProperties(oldProps,newProps){
13
+ const old=propertyMap(oldProps),current=propertyMap(newProps);
14
+ for(const name of ['svn:externals','svn:special'])if(Object.hasOwn(old,name)||Object.hasOwn(current,name))throw new Error('Unsafe SVN caller ancestor properties');
15
+ if([...new Set([...Object.keys(old),...Object.keys(current)])].some(name=>name!=='svn:mergeinfo'&&old[name]!==current[name]))throw new Error('Unsafe SVN caller ancestor property change');
16
+ }
7
17
  // Offline metadata on each explicit ancestor. Never follows external/switched roots.
8
- export async function svnCallerAncestors(root,scope,options={}){relativePath(scope);if(scope.split('/').length>32)throw new Error('SVN caller ancestor depth limit exceeded');const canonical=await realpath(root),names=['.',...scope.split('/').map((_,i)=>scope.split('/').slice(0,i+1).join('/'))],identities=[];let bytes=0,rootInfo;for(const name of names){options.signal?.throwIfAborted();const local=name==='.'?canonical:path.join(canonical,name);const stat=await lstat(local);if(stat.isSymbolicLink()||!stat.isDirectory())throw new Error('SVN caller scope requires real directories');const run=async args=>{const b=await checked('svn',['--non-interactive',...args,'--',name+'@'],{...options,cwd:canonical,maxBytes:64*1024});bytes+=b.length;if(bytes>256*1024)throw new Error('SVN caller ancestor metadata limit exceeded');return xml(b);};const status=await run(['status','--xml','--verbose','--ignore-externals','--depth','empty']);const entries=(status.status?.target??[]).flatMap(t=>t.entry??[]);if(entries.length!==1||entries[0]['@_path'].replaceAll('\\','/')!==name)throw new Error('SVN caller ancestor status missing');const s=entries[0]['wc-status'];if(s?.['@_item']!=='normal'||!['none','normal'].includes(s['@_props'])||['@_switched','@_copied','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true'))throw new Error('Unsafe SVN caller ancestor state');const info=(await run(['info','--xml'])).info?.entry?.[0];if(!info||info['@_kind']!=='dir'||info['wc-info']?.['copy-from-url'])throw new Error('Unsafe SVN caller ancestor identity');rootInfo??=info;if(info.repository?.uuid!==rootInfo.repository?.uuid||info['wc-info']?.['wcroot-abspath']!==rootInfo['wc-info']?.['wcroot-abspath'])throw new Error('SVN caller ancestor external working root');if(name!=='.'){const expected=rootInfo.url.replace(/\/$/,'')+'/'+name.split('/').map(encodeURIComponent).join('/');if(info.url!==expected)throw new Error('Unsafe SVN caller switched ancestor URL');}const properties=await run(['proplist','--xml','--verbose']);const props=properties.properties?.target?.[0]?.property??[];if(props.some(p=>['svn:externals','svn:special'].includes(p['@_name'])))throw new Error('Unsafe SVN caller ancestor properties');identities.push({path:name,status:entries[0],info,properties:props});}return {bytes,fingerprint:hash(JSON.stringify(canonicalValue(identities)))};}
18
+ export async function svnCallerAncestors(root,scope,options={}){relativePath(scope);if(scope.split('/').length>32)throw new Error('SVN caller ancestor depth limit exceeded');const canonical=await realpath(root),names=['.',...scope.split('/').map((_,i)=>scope.split('/').slice(0,i+1).join('/'))],identities=[];let bytes=0,rootInfo;for(const name of names){options.signal?.throwIfAborted();const local=name==='.'?canonical:path.join(canonical,name);const stat=await lstat(local);if(stat.isSymbolicLink()||!stat.isDirectory())throw new Error('SVN caller scope requires real directories');const run=async args=>{const b=await checked('svn',['--non-interactive',...args,'--',name+'@'],{...options,cwd:canonical,maxBytes:64*1024});bytes+=b.length;if(bytes>256*1024)throw new Error('SVN caller ancestor metadata limit exceeded');return xml(b);};const status=await run(['status','--xml','--verbose','--ignore-externals','--depth','empty']);const entries=(status.status?.target??[]).flatMap(t=>t.entry??[]);if(entries.length!==1||entries[0]['@_path'].replaceAll('\\','/')!==name)throw new Error('SVN caller ancestor status missing');const s=entries[0]['wc-status'];if(s?.['@_item']!=='normal'||!['none','normal','modified'].includes(s['@_props'])||['@_switched','@_copied','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true'))throw new Error('Unsafe SVN caller ancestor state');const info=(await run(['info','--xml'])).info?.entry?.[0];if(!info||info['@_kind']!=='dir'||info['wc-info']?.['copy-from-url'])throw new Error('Unsafe SVN caller ancestor identity');rootInfo??=info;if(info.repository?.uuid!==rootInfo.repository?.uuid||info['wc-info']?.['wcroot-abspath']!==rootInfo['wc-info']?.['wcroot-abspath'])throw new Error('SVN caller ancestor external working root');if(name!=='.'){const expected=rootInfo.url.replace(/\/$/,'')+'/'+name.split('/').map(encodeURIComponent).join('/');if(info.url!==expected)throw new Error('Unsafe SVN caller switched ancestor URL');}const properties=await run(['proplist','--xml','--verbose']);const props=properties.properties?.target?.[0]?.property??[];if(props.some(p=>['svn:externals','svn:special'].includes(p['@_name'])))throw new Error('Unsafe SVN caller ancestor properties');let baselineProps;if(s['@_props']==='modified'){baselineProps=(await run(['proplist','--xml','--verbose','-r','BASE'])).properties?.target?.[0]?.property??[];mergeinfoOnlyAncestorProperties(baselineProps,props);}identities.push({path:name,status:entries[0],info,properties:props,...(baselineProps?{baselineProperties:baselineProps}: {})});}return {bytes,fingerprint:hash(JSON.stringify(canonicalValue(identities)))};}
@@ -4,7 +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
+ import {snapshotMergeSources} from './svn-mergeinfo.mjs';
8
8
 
9
9
  const revision = value => {
10
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');
@@ -132,7 +132,7 @@ export async function captureSvnHistory(cwd,options={}) {
132
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)){
133
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;}
134
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)})));
135
+ const mergeSources=snapshotMergeSources(files);
136
136
  const context=[];
137
137
  const targetContent=async(name)=>{
138
138
  const changed=files.find(file=>file.path===name);
@@ -1,5 +1,9 @@
1
1
  import {hash} from './content.mjs';
2
2
 
3
+ export function snapshotMergeSources(files) {
4
+ return 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)})));
5
+ }
6
+
3
7
  // Navigation over an already approved property. Never expands revisions or reads URLs.
4
8
  export function mergeinfoNavigation(text) {
5
9
  if(text===null || text==='')return {status:'parsed',sources:[],hash:hash(text??'')};
package/src/svn.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { isSecretPath } from './secret-path.mjs';
2
2
  import { svnStateIssue } from './svn-state.mjs';
3
+ import { snapshotMergeSources } from './svn-mergeinfo.mjs';
3
4
  import { realpath, lstat } from 'node:fs/promises';
4
5
  import path from 'node:path';
5
6
  import { isDeepStrictEqual } from 'node:util';
@@ -152,6 +153,7 @@ export async function captureSvn(cwd, options = {}) {
152
153
  const right = ['deleted', 'unversioned'].includes(item.rawStatus) ? {} : await properties(root, item.path, false, options);
153
154
  if (hash(JSON.stringify([left, right])) !== item.propertyIdentity) throw new Error('Properties changed during capture');
154
155
  }
155
- const snapshot = { schemaVersion: 1, vcs: 'svn', root, repositoryUuid: info.repository.uuid, files };
156
+ const mergeSources = snapshotMergeSources(files);
157
+ const snapshot = { schemaVersion: 1, vcs: 'svn', root, repositoryUuid: info.repository.uuid, ...(mergeSources.length ? {mergeSources} : {}), files };
156
158
  return { ...snapshot, id: hash(JSON.stringify(snapshot)) };
157
159
  }