@feng0531/dsh-code-review 0.36.0 → 0.37.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
@@ -1,9 +1,14 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.37.0
4
+
5
+ - callerScopePaths接入SVN离线限定目录normal引用方扫描;正文及负匹配hash/node/index/probe复核,共享原30秒、128正文/1MiB、复核2MiB、20上下文/4MiB快照,字面导航不证明语义调用覆盖。
6
+ - 祖先与节点真实路径/URL链/wcroot/UUID及属性校验,拒绝switched/external/special/keywords;竞争含本地未跟踪路径但无正文权限。真实SVN预览零发送、过期拒绝、满槽、二进制及超限验收,375项离线通过;待固定四矩阵与原工件发布,不宣称正式交付。
7
+
3
8
  ## 0.36.0
4
9
 
5
10
  - callerScopePaths支持Git固定commit/range两侧对象内有界字面引用发现;读取匹配/非匹配blob并绑定OID/hash/version与索引/竞争探针,execute重捕获;不checkout/fetch/读取工作树,SVN仍不支持。
6
- - 两侧共用30秒、128正文/1MiB、256记录/64KiB及512探针/1MiB;共享20上下文/4MiB快照,oldOnly复用不自动批准new侧;根提交、删除、竞争歧义、共享扫描耗尽及预览执行已验收。导航不证明调用/语义绑定或完整覆盖,真实模型质量未验收。
11
+ - 两侧共用30秒、128正文/1MiB、256记录/64KiB及512探针/1MiB;共享20上下文/4MiB快照,oldOnly复用不自动批准new侧;根提交、删除、竞争歧义、共享扫描耗尽及预览执行已验收。导航不证明调用/语义绑定或完整覆盖,真实模型质量未验收。370冻结测试及四矩阵通过;唯一发布37929860863原artifact11615712667的95603字节与npm/Release逐字节、双hash及清单一致。
7
12
 
8
13
  ## 0.35.0
9
14
 
package/README.md CHANGED
@@ -4,11 +4,11 @@
4
4
 
5
5
  ## 当前状态
6
6
 
7
- 当前正式版本为 0.35.0(固定标签四矩阵及npm/Release原工件字节双hash门禁通过)。JSON围栏兼容与有界窗口上下文已交付;实际项目审查效果仍需原报告和新预览执行验证。
7
+ 当前正式版本为 0.36.0(固定标签四矩阵及npm/Release原工件字节双hash门禁通过)。JSON围栏兼容与有界窗口上下文已交付;实际项目审查效果仍需原报告和新预览执行验证。
8
8
 
9
9
  Git历史预览新增oldContextPaths:最多20个显式仓库相对文件,与contextPaths/自动上下文共享20槽、256KiB每blob及4MiB快照。仅从baseline普通Git blob捕获,不checkout/fetch/读取工作树;根提交无baseline拒绝,working/SVN明确拒绝。旧侧条目携带oldRevision/oldBlobOid/oldHash,仅进入context-old;oldOnly表示仅批准旧侧,不证明target路径不存在。所选变更复用原changed-old,不重复上下文;excluded/blocked变更、链接/submodule、凭据路径不能重新进入。预览显示身份元数据、不显示正文,确认执行才按宿主正常权限发送。主审/综合/复核按侧提示,计入原输入预算,旧证据不能冒充新侧回归证明。
10
10
 
11
- 预览可选callerScopePaths,最多4个非根、非重叠相对目录,支持Git工作区及固定commit/range两侧对象;SVN仍不支持。历史两侧共用30秒、128正文/1MiB、256记录/64KiB、512竞争探针/1MiB,blob版本/OID/hash及非匹配身份绑定;不读取工作树。预览读取目录内tracked JS/TS/C-family候选正文(含非匹配),仅匹配引用方作为上下文发送模型;128扫描文件/1MiB正文/256索引记录/64KiB索引/512竞争路径探针,发现与重核共享30秒,复核累计2MiB,仍共享20上下文及4MiB快照。绑定所有扫描hash、索引及竞争探针;execute重新捕获。SVN明确不支持,不静默忽略;历史root无旧侧,删除仅旧侧导航,oldOnly上下文复用不新增new授权。字面引用仅导航,不证明函数调用或完整覆盖,报告保留partial限制。
11
+ 预览可选callerScopePaths,最多4个非根、非重叠相对目录,支持Git/SVN工作区及Git固定commit/range两侧对象。SVN仅离线normal普通节点,校验祖先与文件URL链/wcroot/UUID及属性,拒绝switched/external/special/keywords;竞争路径含本地未跟踪项但不授其正文。历史两侧共用30秒、128正文/1MiB、256记录/64KiB、512竞争探针/1MiB,blob版本/OID/hash及非匹配身份绑定;不读取工作树。预览读取目录内tracked JS/TS/C-family候选正文(含非匹配),仅匹配引用方作为上下文发送模型;128扫描文件/1MiB正文/256索引记录/64KiB索引/512竞争路径探针,发现与重核共享30秒,复核累计2MiB,仍共享20上下文及4MiB快照。绑定所有扫描hash、索引及竞争探针;execute重新捕获。SVN目录深度<=32、索引256条/64KiB、探针512条/1MiB;与Git工作区同共享30秒/正文128条1MiB/复核2MiB,缺正文/超限fail-closed;历史root无旧侧,删除仅旧侧导航,oldOnly上下文复用不新增new授权。字面引用仅导航,不证明函数调用或完整覆盖,报告保留partial限制。
12
12
 
13
13
  最终报告记录业务需求hash/bytes用于溯源,不重复需求正文;真实Git当前/历史捕获及服务预览执行预算集成已验收。
14
14
 
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 working or fixed commit/range review; SVN unsupported. 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 bounded direct tracked Git/SVN import/include context captured and shown in preview.' }, contextPaths: { type: 'array', items: { type: 'string' }, maxItems: 20 }, rulePaths: { type: 'array', items: { type: 'string' }, maxItems: 4 } } },
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 bounded direct tracked Git/SVN import/include context captured and shown in preview.' }, 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.36.0",
3
+ "version": "0.37.0",
4
4
  "private": false,
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
package/src/snapshot.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {captureSvnCallers} from './svn-caller-capture.mjs';
1
2
  import { captureCallers } from './caller-capture.mjs';
2
3
  import { validateCallerScopes } from './caller-index.mjs';
3
4
  import { bindBusinessRequirement, validateBusinessRequirement } from './business-requirement.mjs';
@@ -38,7 +39,6 @@ export async function captureSnapshot(cwd, options = {}) {
38
39
  for (const name of options.oldContextPaths ?? []) relativePath(name);
39
40
  const type = await detectVcs(cwd);
40
41
  if (options.oldContextPaths !== undefined && (type !== 'git' || !['commit','baseRevision','targetRevision'].some(k => options[k] !== undefined))) throw new Error('Old context requires Git historical review');
41
- if (options.callerScopePaths !== undefined && type !== 'git') throw new Error('Caller discovery currently requires Git review');
42
42
  if (['commit','baseRevision','targetRevision'].some(k => options[k] !== undefined)) {
43
43
  if (type !== 'git') throw new Error('Git revision review is unsupported on SVN');
44
44
  return bindBusinessRequirement(await captureGitHistory(cwd, options),options.businessRequirement);
@@ -72,7 +72,7 @@ export async function captureSnapshot(cwd, options = {}) {
72
72
  }
73
73
  let callerDiscovery, verifyCallers;
74
74
  if (options.callerScopePaths !== undefined) {
75
- const callers = await captureCallers(snapshot.root, snapshot.files, options.callerScopePaths, options);
75
+ const callers = await (type==='git'?captureCallers:captureSvnCallers)(snapshot.root, snapshot.files, options.callerScopePaths, options);
76
76
  verifyCallers = callers.verify;
77
77
  callerDiscovery = { ...callers.discovery, capturedPaths: [] };
78
78
  for (const item of callers.context) {
@@ -0,0 +1,48 @@
1
+ import {svnCallerIndex} from './svn-caller-index.mjs';
2
+ import {svnCallerNode,svnCallerProbes} from './svn-caller-node.mjs';
3
+
4
+ import {callerCandidates} from './caller-candidates.mjs';
5
+ import {readLocal} from './content.mjs';
6
+ import {isSecretPath} from './secret-path.mjs';
7
+
8
+ // Called only by preview capture, never by a model retrieval request.
9
+ export async function captureSvnCallers(root, files, scopes, options={}) {
10
+ const deadline=AbortSignal.timeout(30000);
11
+ const signal=options.signal?AbortSignal.any([options.signal,deadline]):deadline;
12
+ const bounded={...options,signal,maxFileBytes:Math.min(options.maxFileBytes??256*1024,256*1024)};
13
+ const index=await svnCallerIndex(root,scopes,bounded);
14
+ const changed=new Set(files.flatMap(f=>[f.path,...(f.oldPath?[f.oldPath]:[])]));
15
+ const rules=new Set(options.rulePaths??[]),scanned=[],skipped=[];
16
+ let scannedBytes=0;
17
+ for(const name of index.paths){
18
+ signal.throwIfAborted();
19
+ if(changed.has(name)||rules.has(name)||isSecretPath(name)) {skipped.push({path:name,reason:'Excluded from caller discovery'});continue;}
20
+ if(!/\.(mjs|cjs|js|jsx|ts|tsx|c|cc|cpp|cxx|h|hh|hpp|hxx|shader|hlsl|glsl|cginc|hlsli|compute)$/i.test(name)){skipped.push({path:name,reason:'Unsupported caller language'});continue;}
21
+ if(scanned.length>=128)throw new Error('Caller scan file limit exceeded');
22
+ // Read failures are fatal: unread bytes are not negative search evidence.
23
+ const identity=await svnCallerNode(root,name,bounded);
24
+ const content=await readLocal(root,name,bounded);
25
+ scannedBytes+=content.bytes;
26
+ 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
+ }
29
+ const requested=callerCandidates(files,scanned,[]).probePaths;
30
+ const probes=await svnCallerProbes(root,requested,bounded);
31
+ const plan=callerCandidates(files,scanned,probes.paths);
32
+ let verifiedBytes=0;
33
+ const verify=async()=>{
34
+ for(const source of scanned){
35
+ const identity=await svnCallerNode(root,source.path,bounded);
36
+ if(identity.fingerprint!==source.nodeFingerprint)throw new Error('SVN caller node changed during capture');
37
+ const again=await readLocal(root,source.path,bounded);verifiedBytes+=again.bytes;
38
+ if(verifiedBytes>2*1024*1024)throw new Error('Caller verification byte limit exceeded');
39
+ if(again.hash!==source.hash)throw new Error('Caller scan source changed during capture');
40
+ }
41
+ if((await svnCallerIndex(root,scopes,bounded)).fingerprint!==index.fingerprint)throw new Error('Caller index changed during capture');
42
+ if((await svnCallerProbes(root,requested,bounded)).fingerprint!==probes.fingerprint)throw new Error('Caller resolution changed during capture');
43
+ signal.throwIfAborted();
44
+ };
45
+ await verify();
46
+ const paths=new Set(plan.candidates.map(c=>c.fromPath));
47
+ return {verify,context:scanned.filter(s=>paths.has(s.path)),discovery:{scopes:index.scopes,indexFingerprint:index.fingerprint,probeFingerprint:probes.fingerprint,scanned:scanned.map(({path,hash,bytes,nodeFingerprint,revision})=>({path,hash,bytes,nodeFingerprint,revision})),scannedBytes,skipped,candidates:plan.candidates,notice:plan.notice,incomplete:true,limitations:['Only supported literal references inside approved scopes were searched; no complete caller or semantic coverage.']}};
48
+ }
@@ -0,0 +1,10 @@
1
+ import {svnCallerAncestors} from './svn-caller-safety.mjs';
2
+ import {checked} from './process.mjs';
3
+ import {xml} from './svn.mjs';
4
+ import {relativePath,hash} from './content.mjs';
5
+ import {validateCallerScopes} from './caller-index.mjs';
6
+ import {lstat} from 'node:fs/promises';
7
+ import path from 'node:path';
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
+ // Offline scoped status only, never svn list or repository-wide enumeration.
10
+ export async function svnCallerIndex(root,scopes,options={}){const approved=validateCallerScopes(scopes),entries=[],chunks=[];let bytes=0;const seen=new Set();for(const scope of approved){const ancestors=await svnCallerAncestors(root,scope,options);chunks.push(ancestors.fingerprint);let cursor=root;for(const part of scope.split('/')){cursor=path.join(cursor,part);const s=await lstat(cursor);if(s.isSymbolicLink()||!s.isDirectory())throw new Error('SVN caller scope requires real directories');}const raw=await checked('svn',['--non-interactive','status','--xml','--verbose','--ignore-externals','--',scope+'@'],{...options,cwd:root,maxBytes:64*1024});bytes+=raw.length;if(bytes>64*1024)throw new Error('SVN caller index byte limit exceeded');const found=(xml(raw).status?.target??[]).flatMap(t=>t.entry??[]);for(const e of found){const name=relativePath(e['@_path'].replaceAll('\\','/'));if(name!==scope&&!name.startsWith(scope+'/'))throw new Error('SVN caller index escaped scope');if(seen.has(name))throw new Error('Duplicate SVN caller index path');seen.add(name);if(seen.size>256)throw new Error('SVN caller index path limit exceeded');entries.push({...e,'@_path':name});}if(!found.some(e=>e['@_path'].replaceAll('\\','/')===scope))throw new Error('SVN caller scope missing from status');chunks.push(scope);}entries.sort((a,b)=>a['@_path']<b['@_path']?-1:1);const unsafe=s=>!s||s['@_item']!=='normal'||s['@_props']!=='none'||['@_switched','@_copied','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true');for(const scope of approved){const e=entries.find(e=>e['@_path']===scope);if(unsafe(e?.['wc-status']))throw new Error('Unsafe SVN caller directory state');}return {scopes:approved,paths:entries.filter(e=>!approved.includes(e['@_path'])&&!unsafe(e['wc-status'])).map(e=>e['@_path']),entries,records:entries.length,bytes,fingerprint:hash(JSON.stringify(canonical({scopes:chunks,entries})))};}
@@ -0,0 +1,11 @@
1
+ import {checked} from './process.mjs';
2
+ import {xml} from './svn.mjs';
3
+ import {relativePath,hash} from './content.mjs';
4
+ import {lstat} from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import {svnCallerAncestors} from './svn-caller-safety.mjs';
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
+ // 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})))};}
10
+ // Existing untracked/linked/directory paths compete too; absence grants no body read.
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))};}
@@ -0,0 +1,8 @@
1
+ import {checked} from './process.mjs';
2
+ import {xml} from './svn.mjs';
3
+ import {relativePath,hash} from './content.mjs';
4
+ import {lstat,realpath} from 'node:fs/promises';
5
+ import path from 'node:path';
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
+ // 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)))};}