@feng0531/dsh-code-review 0.79.0 → 0.80.1

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.
@@ -1,10 +1,14 @@
1
+ import {sameSvnUrl,canonicalSvnUrl} from './svn-url.mjs';
2
+ import {svnHistoryEndpoints} from './svn-history-endpoints.mjs';
1
3
  import {realpath} from 'node:fs/promises';
2
4
  import {checked} from './process.mjs';
3
5
  import {xml} from './svn.mjs';
4
6
  import {hash,decode,relativePath} from './content.mjs';
5
7
  import {isSecretPath,assertNonSecretPath} from './secret-path.mjs';
6
8
  import {captureProjectRules} from './project-rules.mjs';
9
+ import {captureEffectiveMergeinfo} from './svn-effective-mergeinfo.mjs';
7
10
  import {snapshotMergeSources} from './svn-mergeinfo.mjs';
11
+ import {svnSpecialData} from './svn-special.mjs';
8
12
  import {svnEolMapping} from './svn-eol-mapping.mjs';
9
13
  import {fixedSvnLookup} from './svn-history-lookup.mjs';
10
14
  import {svnHistoryDependencies} from './svn-history-dependencies.mjs';
@@ -24,7 +28,7 @@ export function svnHistoryRange(options) {
24
28
  return {base:String(Number(target)-1),target,mode:'svn-revision'};
25
29
  }
26
30
  const base=revision(options.svnBaseRevision),target=revision(options.svnTargetRevision);
27
- if (Number(base)>=Number(target)) throw new Error('SVN range requires base before target');
31
+ if(Number(base)>Number(target)||base===target&&!(options.svnBasePath!==undefined&&options.svnTargetPath!==undefined&&options.svnBasePath!==options.svnTargetPath))throw new Error('SVN range requires base before target, or distinct branch paths at the same revision');
28
32
  return {base,target,mode:'svn-range'};
29
33
  }
30
34
 
@@ -47,6 +51,7 @@ export function mergeSvnHistoryRecord(previous, item) {
47
51
  // Fixed URL objects only. No checkout, update, merge, working body or lazy HEAD read.
48
52
  export async function captureSvnHistory(cwd,options={}) {
49
53
  const range=svnHistoryRange(options);
54
+ if(options.svnEffectiveMergeinfo!==undefined&&typeof options.svnEffectiveMergeinfo!=='boolean')throw new Error('Invalid SVN effective mergeinfo opt-in');
50
55
  if (options.svnAllowNetwork!==undefined && typeof options.svnAllowNetwork!=='boolean') throw new Error('Invalid SVN network opt-in');
51
56
  if (options.callerScopePaths!==undefined) validateCallerScopes(options.callerScopePaths);
52
57
  const contextPaths=options.contextPaths??[],oldContextPaths=options.oldContextPaths??[],rulePaths=options.rulePaths??[],maxContextFiles=options.maxContextFiles??20;
@@ -71,32 +76,41 @@ export async function captureSvnHistory(cwd,options={}) {
71
76
  if(!local?.['wc-info']?.['wcroot-abspath'] || !local.repository?.uuid || !local.url)throw new Error('Missing SVN working-copy identity');
72
77
  const root=await realpath(local['wc-info']['wcroot-abspath']);
73
78
  if(root!==await realpath(cwd))throw new Error('SVN historical scope must be working-copy root');
74
- const {url,networkAccess}=svnHistoryDestination(local.url,options.svnAllowNetwork);
75
- const at=(name,rev)=>(name==='.'?url:url+'/'+name.split('/').map(encodeURIComponent).join('/'))+'@'+rev;
76
- const identity=async(name,rev)=>{
77
- const node=xml(await run(['info','--xml','-r',rev,'--',at(name,rev)])).info?.entry?.[0];
78
- const expected=at(name,rev).slice(0,-rev.length-1);
79
- if(!node||node.url!==expected||String(node['@_revision'])!==rev||node.repository?.uuid!==local.repository.uuid||node.repository?.root!==local.repository.root||!['file','dir'].includes(node['@_kind']))throw new Error('SVN fixed historical identity mismatch');
79
+ const endpoints=svnHistoryEndpoints(local,options,svnHistoryDestination);
80
+ const {url,networkAccess}=svnHistoryDestination(endpoints.targetUrl,options.svnAllowNetwork);
81
+ const sideFor=(rev,side)=>side??(rev===range.base?'old':'new');
82
+ const rootFor=(rev,side)=>sideFor(rev,side)==='old'?endpoints.baseUrl:endpoints.targetUrl;
83
+ const at=(name,rev,side)=>(name==='.'?rootFor(rev,side):rootFor(rev,side)+'/'+name.split('/').map(encodeURIComponent).join('/'))+'@'+rev;
84
+ const summaryArgs=endpoints.crossUrl?['diff','--summarize','--xml','--notice-ancestry','--',at('.',range.base,'old'),at('.',range.target,'new')]:['diff','--summarize','--xml','--notice-ancestry','-r',range.base+':'+range.target,'--',at('.',range.target)];
85
+ const identity=async(name,rev,side)=>{
86
+ const node=xml(await run(['info','--xml','-r',rev,'--',at(name,rev,side)])).info?.entry?.[0];
87
+ const expected=at(name,rev,side).slice(0,-rev.length-1);
88
+ if(!node||!sameSvnUrl(node.url,expected)||String(node['@_revision'])!==rev||node.repository?.uuid!==local.repository.uuid||!sameSvnUrl(node.repository?.root,local.repository.root)||!['file','dir'].includes(node['@_kind']))throw new Error('SVN fixed historical identity mismatch');
80
89
  return node;
81
90
  };
82
- await identity('.',range.base);await identity('.',range.target);
83
- const summary=await run(['diff','--summarize','--xml','--notice-ancestry','-r',range.base+':'+range.target,'--',at('.',range.target)]);
91
+ await identity('.',range.base,'old');await identity('.',range.target,'new');
92
+ const summary=await run(summaryArgs);
84
93
  const raw=xml(summary).diff?.paths?.path;
85
94
  const paths=raw===undefined?[]:Array.isArray(raw)?raw:[raw];
86
95
  if(paths.length>maxFiles)throw new Error('SVN historical change count limit exceeded');
87
96
  const records=new Map();
88
97
  for(const item of paths){
89
- const value=item['#text'];
90
- if(typeof value!=='string'||(value!==url&&!value.startsWith(url+'/')))throw new Error('SVN historical path escaped scope');
91
- const name=value===url?'.':value.slice(url.length+1).split('/').map(part=>{
98
+ const rawPath=item['#text'];
99
+ const value=typeof rawPath==='string'?canonicalSvnUrl(rawPath):rawPath;
100
+ const scope=typeof value==='string'?[canonicalSvnUrl(endpoints.baseUrl),canonicalSvnUrl(endpoints.targetUrl)].sort((a,b)=>b.length-a.length).find(base=>value===base||value.startsWith(base+'/')):undefined;
101
+ if(!scope)throw new Error('SVN historical path escaped fixed endpoint scopes');
102
+ const name=value===scope?'.':value.slice(scope.length+1).split('/').map(part=>{
92
103
  const decoded=decodeURIComponent(part);if(decoded.includes('/')||decoded.includes('\\'))throw new Error('Encoded SVN path separator');return decoded;
93
104
  }).join('/');
94
105
  if(name!=='.')relativePath(name);
106
+ // SVN may render every summary path relative to one comparison root.
107
+ // Actions choose existence sides; each actual read separately verifies its URL.
108
+ // Scope matching here bounds paths only, and is not read authority.
95
109
  records.set(name,mergeSvnHistoryRecord(records.get(name),item));
96
110
  }
97
111
  if(selectedPaths?.some(name=>!records.has(name)))throw new Error('Selected path is not an SVN historical change');
98
- const properties=async(name,rev)=>{
99
- const targets=xml(await run(['proplist','--xml','--verbose','-r',rev,'--',at(name,rev)])).properties?.target??[];
112
+ const properties=async(name,rev,side)=>{
113
+ const targets=xml(await run(['proplist','--xml','--verbose','-r',rev,'--',at(name,rev,side)])).properties?.target??[];
100
114
  const result=Object.create(null);
101
115
  for(const prop of targets.flatMap(t=>t.property??[])){
102
116
  if(typeof prop['@_name']!=='string'||prop['@_encoding']||Object.hasOwn(result,prop['@_name']))throw new Error('Unsupported historical property representation');
@@ -104,11 +118,12 @@ export async function captureSvnHistory(cwd,options={}) {
104
118
  }
105
119
  return Object.fromEntries(Object.entries(result).sort(([a],[b])=>a.localeCompare(b)));
106
120
  };
107
- const readSource=async(name,rev)=>{
108
- const node=await identity(name,rev),props=await properties(name,rev);
109
- if(Object.hasOwn(props,'svn:special'))throw new Error('Historical SVN special node excluded');
110
- const content=node['@_kind']==='dir'?decode(Buffer.alloc(0)):decode(await run(['cat','--ignore-keywords','-r',rev,'--',at(name,rev)],true));
111
- 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),...(node['@_kind']==='file'&&Object.hasOwn(props,'svn:eol-style')?{eolMapping:svnEolMapping(content.text,props['svn:eol-style'])}:{})}};
121
+ const readSource=async(name,rev,allowSpecial=false,side)=>{
122
+ const node=await identity(name,rev,side),props=await properties(name,rev,side);
123
+ if(Object.hasOwn(props,'svn:special')&&!allowSpecial)throw new Error('Historical SVN special node is link data, not navigation context');
124
+ const content=node['@_kind']==='dir'?decode(Buffer.alloc(0)):decode(await run(['cat','--ignore-keywords','-r',rev,'--',at(name,rev,side)],true));
125
+ const special=svnSpecialData(content,props,node['@_kind']);
126
+ return {content,props,...(special?{special}:{}),kind:node['@_kind'],source:{repositoryUuid:local.repository.uuid,url:at(name,rev,side).slice(0,-rev.length-1),pegRevision:rev,operativeRevision:rev,kind:node['@_kind'],hash:content.hash,textHash:hash(content.text),...(node['@_kind']==='file'&&Object.hasOwn(props,'svn:eol-style')?{eolMapping:svnEolMapping(content.text,props['svn:eol-style'])}:{})}};
112
127
  };
113
128
  const files=[];
114
129
  for(const [name,record]of [...records].sort(([a],[b])=>a<b?-1:a>b?1:0)){
@@ -118,15 +133,16 @@ export async function captureSvnHistory(cwd,options={}) {
118
133
  if(isSecretPath(name)){item.eligibility='excluded';item.reason='Credential path excluded';continue;}
119
134
  if(selectedPaths&&!selectedPaths.includes(name)){item.eligibility='excluded';item.reason='Not explicitly selected';continue;}
120
135
  try{
121
- const readSide=async(rev,exists,kind)=>{
136
+ const readSide=async(rev,exists,kind,side)=>{
122
137
  if(!exists)return {content:decode(Buffer.alloc(0)),props:{}};
123
- const result=await readSource(name,rev);
138
+ const result=await readSource(name,rev,true,side);
124
139
  if(result.kind!==kind)throw new Error('Historical node kind differs');
125
140
  return result;
126
141
  };
127
- const left=await readSide(range.base,status!=='added',item.oldKind),right=await readSide(range.target,status!=='deleted',item.newKind);
142
+ const left=await readSide(range.base,status!=='added',item.oldKind,'old'),right=await readSide(range.target,status!=='deleted',item.newKind,'new');
128
143
  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.';
129
144
  item.left=left.content;item.right=right.content;
145
+ if(left.special||right.special){item.specialData={old:left.special??null,new:right.special??null};item.contentNotice='Each special side is repository link-record data only, not source code or followed target content. Non-special side remains ordinary source. Link targets do not authorize reads.';}
130
146
  item.svnSources={...(left.source?{old:left.source}:{}),...(right.source?{new:right.source}:{})};
131
147
  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});
132
148
  item.propertyIdentity=hash(JSON.stringify([left.props,right.props]));
@@ -147,10 +163,10 @@ export async function captureSvnHistory(cwd,options={}) {
147
163
  const targetContent=async(name)=>{
148
164
  const changed=files.find(file=>file.path===name);
149
165
  if(changed){
150
- if(changed.eligibility!=='reviewable'||changed.rightExists===false||changed.kind!=='file')throw new Error('Historical context or rule is excluded blocked deleted or not a file');
166
+ if(changed.eligibility!=='reviewable'||changed.rightExists===false||changed.kind!=='file'||changed.specialData)throw new Error('Historical context or rule is excluded blocked deleted or not a file');
151
167
  return {content:changed.right,source:changed.svnSources.new};
152
168
  }
153
- const result=await readSource(name,range.target);if(result.kind!=='file')throw new Error('Historical context or rule requires a file');return result;
169
+ const result=await readSource(name,range.target,false,'new');if(result.kind!=='file')throw new Error('Historical context or rule requires a file');return result;
154
170
  };
155
171
  for(const name of [...contextPaths].sort()){
156
172
  const target=await targetContent(name),record=records.get(name),changed=files.find(file=>file.path===name);
@@ -159,7 +175,7 @@ export async function captureSvnHistory(cwd,options={}) {
159
175
  else{
160
176
  // No change record means both fixed sides are present and equal. Read and verify
161
177
  // rather than inferring baseline content from target or from the working tree.
162
- const old=await readSource(name,range.base);if(old.kind!=='file'||old.content.hash!==target.content.hash||JSON.stringify(old.props)!==JSON.stringify(target.props))throw new Error('Unchanged historical context identity mismatch');
178
+ const old=await readSource(name,range.base,false,'old');if(old.kind!=='file'||(!endpoints.crossUrl&&(old.content.hash!==target.content.hash||JSON.stringify(old.props)!==JSON.stringify(target.props))))throw new Error('Unchanged historical context identity mismatch');
163
179
  item.oldText=old.content.text;item.oldRevision=range.base;item.oldSvnSource=old.source;
164
180
  }
165
181
  context.push(item);
@@ -167,11 +183,11 @@ export async function captureSvnHistory(cwd,options={}) {
167
183
  for(const name of [...oldContextPaths].sort()){
168
184
  const changed=files.find(file=>file.path===name);
169
185
  if(changed){
170
- if(changed.eligibility!=='reviewable'||changed.oldKind!=='file'||!changed.svnSources?.old)throw new Error('Old SVN context conflicts with excluded blocked or absent baseline change');
186
+ if(changed.eligibility!=='reviewable'||changed.oldKind!=='file'||changed.specialData?.old||!changed.svnSources?.old)throw new Error('Old SVN context conflicts with excluded blocked or absent baseline change');
171
187
  // The original changed-file old identity already authorizes this side.
172
188
  continue;
173
189
  }
174
- const old=await readSource(name,range.base);if(old.kind!=='file')throw new Error('Old SVN context requires baseline file');
190
+ const old=await readSource(name,range.base,false,'old');if(old.kind!=='file')throw new Error('Old SVN context requires baseline file');
175
191
  context.push({path:name,oldOnly:true,oldText:old.content.text,oldHash:hash(old.content.text),oldRevision:range.base,oldSvnSource:old.source});
176
192
  }
177
193
  const rules=await captureProjectRules(root,rulePaths,{signal,files,read:async(_root,name)=>{
@@ -179,40 +195,40 @@ export async function captureSvnHistory(cwd,options={}) {
179
195
  }});
180
196
  let autoContext,callerDiscovery;
181
197
  if(options.autoContext===true||options.callerScopePaths!==undefined){
182
- const probes=fixedSvnLookup({signal,list:async(name,rev)=>{
183
- const directory=await identity(name,rev);if(directory['@_kind']!=='dir')throw new Error('Historical lookup parent is not directory');
184
- const data=xml(await run(['list','--xml','--depth','immediates','-r',rev,'--',at(name,rev)]));
198
+ const probes=fixedSvnLookup({signal,list:async(name,rev,side)=>{
199
+ const directory=await identity(name,rev,side);if(directory['@_kind']!=='dir')throw new Error('Historical lookup parent is not directory');
200
+ const data=xml(await run(['list','--xml','--depth','immediates','-r',rev,'--',at(name,rev,side)]));
185
201
  const lists=data.lists?.list;
186
202
  const one=Array.isArray(lists)?lists:[lists];
187
- if(one.length!==1||!one[0]||one[0]['@_path']!==at(name,rev).slice(0,-rev.length-1))throw new Error('Historical directory listing identity mismatch');
203
+ if(one.length!==1||!one[0]||!sameSvnUrl(one[0]['@_path'],at(name,rev,side).slice(0,-rev.length-1)))throw new Error('Historical directory listing identity mismatch');
188
204
  return (one[0].entry??[]).map(entry=>({name:entry.name,kind:entry['@_kind']}));
189
205
  }});
190
206
  const exactProbes=new Map();
191
- const lookup=async(name,rev)=>{
192
- const key=JSON.stringify([rev,name]);
207
+ const lookup=async(name,rev,side)=>{
208
+ const key=JSON.stringify([sideFor(rev,side),rev,name]);
193
209
  if(!exactProbes.has(key)){
194
210
  if(exactProbes.size>=512)throw new Error('SVN historical shared navigation probe limit exceeded');
195
- exactProbes.set(key,await probes.lookup(name,rev));
211
+ exactProbes.set(key,await probes.lookup(name,rev,side));
196
212
  }
197
213
  return exactProbes.get(key);
198
214
  };
199
215
  let navigationBytes=context.reduce((sum,item)=>sum+Buffer.byteLength(item.text??'')+Buffer.byteLength(item.oldText??''),0);
200
- const navigationRead=async(name,rev)=>{
201
- const value=await readSource(name,rev);navigationBytes+=value.content.bytes;
216
+ const navigationRead=async(name,rev,side)=>{
217
+ const value=await readSource(name,rev,false,side);navigationBytes+=value.content.bytes;
202
218
  if(navigationBytes>1024*1024)throw new Error('SVN historical shared navigation source limit exceeded');
203
219
  return value;
204
220
  };
205
221
  if(navigationBytes>1024*1024)throw new Error('SVN historical shared navigation source limit exceeded');
206
222
  if(options.callerScopePaths!==undefined){
207
223
  let indexRecords=0;
208
- const index=async(scopes,rev)=>{
224
+ const index=async(scopes,rev,side)=>{
209
225
  const entries=[];
210
226
  for(const scope of scopes){
211
- const node=await lookup(scope,rev);if(!node)continue;if(node.kind!=='dir')throw new Error('Historical caller scope is not directory');
212
- await identity(scope,rev);
213
- const data=xml(await run(['list','--xml','--depth','infinity','-r',rev,'--',at(scope,rev)]));
227
+ const node=await lookup(scope,rev,side);if(!node)continue;if(node.kind!=='dir')throw new Error('Historical caller scope is not directory');
228
+ await identity(scope,rev,side);
229
+ const data=xml(await run(['list','--xml','--depth','infinity','-r',rev,'--',at(scope,rev,side)]));
214
230
  const lists=data.lists?.list,one=Array.isArray(lists)?lists:[lists];
215
- if(one.length!==1||!one[0]||one[0]['@_path']!==at(scope,rev).slice(0,-rev.length-1))throw new Error('Historical caller listing identity mismatch');
231
+ if(one.length!==1||!one[0]||!sameSvnUrl(one[0]['@_path'],at(scope,rev,side).slice(0,-rev.length-1)))throw new Error('Historical caller listing identity mismatch');
216
232
  for(const entry of one[0].entry??[]){
217
233
  if(++indexRecords>256)throw new Error('Historical caller shared index limit exceeded');
218
234
  const name=relativePath(entry.name);entries.push({path:scope+'/'+name,kind:entry['@_kind']});
@@ -228,15 +244,38 @@ export async function captureSvnHistory(cwd,options={}) {
228
244
  }
229
245
  if(callerDiscovery)callerDiscovery.navigationSourceBytes=navigationBytes;
230
246
  }
247
+ const effectiveMergeSources=[];
248
+ if(options.svnEffectiveMergeinfo===true){
249
+ const cache=new Map();
250
+ const readAncestor=async(name,rev,side)=>{
251
+ const key=JSON.stringify([side, name,rev]);if(cache.has(key))return cache.get(key);
252
+ const node=await identity(name,rev,side),props=await properties(name,rev,side);
253
+ const source={repositoryUuid:local.repository.uuid,url:at(name,rev,side).slice(0,-rev.length-1),pegRevision:rev,operativeRevision:rev,kind:node['@_kind'],propertyHash:hash(JSON.stringify(props))};
254
+ const record={path:name,present:Object.hasOwn(props,'svn:mergeinfo'),...(Object.hasOwn(props,'svn:mergeinfo')?{value:props['svn:mergeinfo']}:{}),identity:hash(JSON.stringify(source)),source};
255
+ cache.set(key,record);return record;
256
+ };
257
+ for(const file of files.filter(f=>f.eligibility==='reviewable')){
258
+ const result={path:file.path};
259
+ for(const [side,rev,kind]of [['old',range.base,file.oldKind],['new',range.target,file.newKind]]){
260
+ if(kind===null){result[side]={status:'absent',sources:[]};continue;}
261
+ result[side]=await captureEffectiveMergeinfo(file.path,{signal,boundary:'.',complete:sameSvnUrl(rootFor(rev,side),local.repository.root),read:name=>readAncestor(name,rev,side)});
262
+ }
263
+ effectiveMergeSources.push(result);
264
+ }
265
+ // Evidence contains complete source identities, not just opaque hashes.
266
+ for(const result of effectiveMergeSources)for(const side of ['old','new'])for(const evidence of result[side].evidence??[]){const rev=side==='old'?range.base:range.target;evidence.source=cache.get(JSON.stringify([side,evidence.path,rev])).source;}
267
+ }
231
268
  // Read the same fixed summary and repository identity again; never use HEAD.
232
- const after=await run(['diff','--summarize','--xml','--notice-ancestry','-r',range.base+':'+range.target,'--',at('.',range.target)]);
269
+ const after=await run(summaryArgs);
233
270
  if(!isDeepSummary(summary,after))throw new Error('SVN historical summary changed during capture');
234
- await identity('.',range.base);await identity('.',range.target);
235
- const snapshot={schemaVersion:1,vcs:'svn',root,repositoryUuid:local.repository.uuid,context,...(rules.length?{rules}:{}),...(autoContext?{autoContext}:{}),...(callerDiscovery?{callerDiscovery}:{}),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};
271
+ await identity('.',range.base,'old');await identity('.',range.target,'new');
272
+ const snapshot={schemaVersion:1,vcs:'svn',root,repositoryUuid:local.repository.uuid,context,...(rules.length?{rules}:{}),...(autoContext?{autoContext}:{}),...(callerDiscovery?{callerDiscovery}:{}),history:{...range,vcs:'svn',url,baseUrl:endpoints.baseUrl,targetUrl:endpoints.targetUrl,crossUrl:endpoints.crossUrl,repositoryUuid:local.repository.uuid,pegRevision:range.target,networkAccess,...(options.svnEffectiveMergeinfo===true?{effectiveMergeSources,effectiveMergeinfoNotice:'Explicit opt-in ancestor properties within selected URL boundary; unknown at incomplete boundaries. No branch source reads or merge attribution.'}:{}),...(mergeSources.length?{mergeSources}:{}),semantics:'fixed revision tree endpoints; no merge attribution or working text'},files};
236
273
  const serialized=JSON.stringify(snapshot);if(Buffer.byteLength(serialized)>maxSnapshotBytes)throw new Error('SVN historical snapshot limit exceeded');
237
274
  return {...snapshot,id:hash(serialized)};
238
275
  }
239
276
  function isDeepSummary(a,b){
240
- 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;
241
- return JSON.stringify(canonical(xml(a)))===JSON.stringify(canonical(xml(b)));
277
+ const records=bytes=>{const data=xml(bytes),raw=data.diff?.paths?.path;const paths=raw===undefined?[]:Array.isArray(raw)?raw:[raw];return paths.map(record=>{if(typeof record?.['#text']!=='string')throw new Error('Invalid summary path identity');return JSON.stringify(Object.fromEntries(Object.entries({...record,'#text':canonicalSvnUrl(record['#text'])}).sort(([a],[b])=>a.localeCompare(b))));}).sort();};
278
+ // SVN does not promise ordering among diff-summary sibling records. Sorting
279
+ // preserves multiplicity and all record attributes; no source changes hidden.
280
+ return JSON.stringify(records(a))===JSON.stringify(records(b));
242
281
  }
@@ -0,0 +1,19 @@
1
+ // Bounded quoting tokenizer, not a shell parser. Never executes configuration.
2
+ export function keywordConfigurationTokens(configuration){
3
+ if(typeof configuration!=='string'||Buffer.byteLength(configuration)>4096)throw new Error('Invalid SVN keyword configuration');
4
+ if(configuration.includes(String.fromCharCode(34))||configuration.includes(String.fromCharCode(39)))throw new Error('Quoted SVN keyword configurations are not supported by verified native token semantics; use %_ for a space');
5
+ const tokens=[];let token='',quote=null,started=false;
6
+ for(let i=0;i<configuration.length;i++){
7
+ const c=configuration[i];
8
+ if(c==='\0'||c==='\r'||c==='\n'||c.charCodeAt(0)<32&&c!=='\t')throw new Error('Control character in SVN keyword configuration');
9
+ if(quote){if(c===quote){quote=null;}else if(c==='\\'){const next=configuration[++i];if(next!==quote&&next!=='\\')throw new Error('Unsupported escape in SVN keyword configuration');token+=next;}else token+=c;started=true;continue;}
10
+ if(c==='\"'||c==="'"){quote=c;started=true;continue;}
11
+ if(/\s/.test(c)){if(started){tokens.push(token);token='';started=false;}continue;}
12
+ if(c==='\\')throw new Error('Unquoted escape in SVN keyword configuration');
13
+ token+=c;started=true;
14
+ }
15
+ if(quote)throw new Error('Unterminated SVN keyword configuration quote');
16
+ if(started)tokens.push(token);
17
+ if(tokens.length>100||tokens.some(t=>!t))throw new Error('Invalid SVN keyword configuration token count or empty token');
18
+ return tokens;
19
+ }
@@ -1,27 +1,44 @@
1
1
  import {hash} from './content.mjs';
2
+ import {keywordConfigurationTokens} from './svn-keyword-config.mjs';
2
3
  const standard=new Set(['Date','LastChangedDate','Revision','Rev','LastChangedRevision','Author','LastChangedBy','HeadURL','URL','Id','Header']);
3
4
 
4
- // A line-preserving view only. Raw source remains authoritative for evidence.
5
- export function svnKeywordMapping(text,configuration){
6
- if(typeof text!=='string'||typeof configuration!=='string'||Buffer.byteLength(text)>256*1024||Buffer.byteLength(configuration)>4096)throw new Error('Invalid SVN keyword mapping input');
5
+ // Parse bounded quoted and unquoted SVN formats. Expansion is verified by SVN BASE,
6
+ // never reimplemented here; raw source remains the only evidence.
7
+ export function svnKeywordConfiguration(configuration){
8
+ if(typeof configuration!=='string'||Buffer.byteLength(configuration)>4096)throw new Error('Invalid SVN keyword configuration');
7
9
  const enabled=new Set(),custom=[];
8
- for(const token of configuration.trim().split(/\s+/).filter(Boolean)){
9
- if(standard.has(token)){enabled.add(token);continue;}
10
- const match=/^([A-Za-z][A-Za-z0-9]*)=(%r)$/.exec(token);
10
+ for(const token of keywordConfigurationTokens(configuration)){
11
+ if(standard.has(token)){if(enabled.has(token))throw new Error('Duplicate SVN keyword configuration');enabled.add(token);continue;}
12
+ const match=/^([A-Za-z][A-Za-z0-9]*)=(.+)$/.exec(token);
11
13
  if(!match||standard.has(match[1])||enabled.has(match[1]))throw new Error('Unsupported SVN custom keyword configuration');
12
- enabled.add(match[1]);custom.push({name:match[1],format:match[2]});
14
+ const format=match[2];
15
+ for(let i=0;i<format.length;i++){
16
+ const c=format[i];if(c==='%'){if(!'radDuRPb_HI%'.includes(format[++i]??'')||i>=format.length)throw new Error('Unsupported SVN custom keyword format');}
17
+ else if(!/[A-Za-z0-9_.:/ ()-]/.test(c))throw new Error('Unsupported SVN custom keyword literal');
18
+ }
19
+ enabled.add(match[1]);custom.push({name:match[1],format});
13
20
  }
14
- let replacements=0;
21
+ return {enabled,custom};
22
+ }
23
+
24
+ export function svnKeywordMapping(text,configuration){
25
+ if(typeof text!=='string'||Buffer.byteLength(text)>256*1024)throw new Error('Invalid SVN keyword mapping input');
26
+ const {enabled,custom}=svnKeywordConfiguration(configuration);let replacements=0,fixedWidth=0;
15
27
  const normalized=text.replace(/\$([A-Za-z][A-Za-z0-9]*)(:{1,2})([^\r\n$]*)\$/g,(literal,name,separator,value)=>{
16
28
  if(!enabled.has(name))return literal;
17
- if(separator!==':'||!/^ .+ $/.test(value))throw new Error('Unsupported SVN fixed-width or ambiguous keyword expansion');
29
+ if(separator==='::'){
30
+ // SVN reserves the complete field width, including the initial space and
31
+ // final space (or # truncation marker); shrink values without changing width.
32
+ if(value.length<2||value[0]!==' '||![' ','#'].includes(value.at(-1)))throw new Error('Ambiguous SVN fixed-width keyword expansion');
33
+ fixedWidth++;if(/[^ ]/.test(value))replacements++;return '$'+name+'::'+' '.repeat(value.length)+'$';
34
+ }
35
+ if(!/^ .+ $/.test(value))throw new Error('Ambiguous SVN keyword expansion');
18
36
  replacements++;return '$'+name+'$';
19
37
  });
20
- // Reject enabled malformed expansions, rather than silently offering a partial mapping.
21
- for(const name of enabled){
22
- const escaped=name.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
23
- if(new RegExp('\\$'+escaped+':').test(normalized))throw new Error('Incomplete SVN keyword expansion mapping');
24
- }
38
+ // Fixed fields intentionally retain :: after contraction. Any other colon
39
+ // occurrence must be a complete recognized field, not silently partial data.
40
+ const residual=normalized.replace(/\$([A-Za-z][A-Za-z0-9]*)::( +)\$/g,(literal,name)=>enabled.has(name)?'':literal);
41
+ for(const name of enabled){const escaped=name.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');if(new RegExp('\\$'+escaped+':').test(residual))throw new Error('Incomplete SVN keyword expansion mapping');}
25
42
  if(text.split('\n').length!==normalized.split('\n').length)throw new Error('SVN keyword mapping changed line identity');
26
- return {text:normalized,rawTextHash:hash(text),textHash:hash(normalized),replacements,...(custom.length?{customKeywords:custom}:{}),lineMapping:'identity',status:'candidate-only',notice:'Line-preserving standard keyword view, not evidence of equivalent program behavior; raw source remains required. Capture must independently verify BASE contraction and keep raw evidence.'};
43
+ return {text:normalized,rawTextHash:hash(text),textHash:hash(normalized),replacements,...(fixedWidth?{fixedWidthFields:fixedWidth}:{}),...(custom.length?{customKeywords:custom}:{}),lineMapping:'identity',status:'candidate-only',notice:'Line-preserving keyword view only; fixed-field width retained. BASE contraction must be independently verified against svn cat --ignore-keywords. Raw source and original line numbers remain evidence, not behavioral equivalence.'};
27
44
  }
@@ -0,0 +1,25 @@
1
+ import {sameSvnUrl} from './svn-url.mjs';
2
+ import {checked} from './process.mjs';
3
+ import {xml} from './svn.mjs';
4
+ import {decode,hash,relativePath} from './content.mjs';
5
+
6
+ // Explicit independent repository baseline; never local BASE/copy-source pristine.
7
+ export async function readReplacementSource(root,rootInfo,name,revision,options={}){
8
+ relativePath(name);if(typeof revision!=='string'||!/^(0|[1-9][0-9]*)$/.test(revision)||!Number.isSafeInteger(Number(revision)))throw new Error('Replacement baseline requires fixed numeric revision');
9
+ const url=rootInfo.url.replace(/\/$/,'')+'/'+name.split('/').map(encodeURIComponent).join('/'),destination=new URL(url);
10
+ if(destination.username||destination.password||destination.search||destination.hash||!['file:','http:','https:','svn:','svn+ssh:'].includes(destination.protocol))throw new Error('Unsafe replacement baseline URL');
11
+ if((destination.protocol!=='file:'||destination.hostname)&&options.svnReplacementAllowNetwork!==true)throw new Error('Replacement repository baseline requires explicit svnReplacementAllowNetwork');
12
+ const at=url+'@'+revision;const run=async(args,body=false)=>checked('svn',['--non-interactive',...args,'--',at],{...options,cwd:root,maxBytes:body?options.maxFileBytes??256*1024:256*1024});
13
+ const node=xml(await run(['info','--xml','-r',revision])).info?.entry?.[0];
14
+ if(!sameSvnUrl(node?.url,url)||String(node['@_revision'])!==revision||node.repository?.uuid!==rootInfo.repository.uuid||!sameSvnUrl(node.repository?.root,rootInfo.repository.root)||!['file','dir'].includes(node['@_kind']))throw new Error('Replacement baseline identity or kind mismatch');
15
+ const props=Object.create(null);for(const p of xml(await run(['proplist','--xml','--verbose','-r',revision])).properties?.target?.[0]?.property??[]){if(typeof p['@_name']!=='string'||p['@_encoding']||Object.hasOwn(props,p['@_name']))throw new Error('Unsafe replacement baseline property representation');props[p['@_name']]=p['#text']??'';}
16
+ let descendants=[];
17
+ if(node['@_kind']==='dir'){
18
+ const listing=xml(await run(['list','--xml','--depth','infinity','-r',revision]));
19
+ const lists=listing.lists?.list,one=Array.isArray(lists)?lists:[lists];
20
+ if(one.length!==1||!one[0]||!sameSvnUrl(one[0]['@_path'],url))throw new Error('Replacement old directory listing identity mismatch');
21
+ const seen=new Set();for(const entry of one[0].entry??[]){const child=relativePath(entry.name);if(seen.has(child)||!['file','dir'].includes(entry['@_kind']))throw new Error('Invalid replacement old descendant');seen.add(child);if(seen.size>200)throw new Error('Replacement old descendant count limit exceeded');descendants.push({path:name+'/'+child,kind:entry['@_kind']});}
22
+ }
23
+ const content=node['@_kind']==='dir'?decode(Buffer.alloc(0)):decode(await run(['cat','--ignore-keywords','-r',revision],true));
24
+ return {content,props,kind:node['@_kind'],descendants,source:{repositoryUuid:rootInfo.repository.uuid,url,pegRevision:revision,operativeRevision:revision,kind:node['@_kind'],descendantHash:hash(JSON.stringify(descendants)),hash:content.hash,textHash:hash(content.text),propertyHash:hash(JSON.stringify(Object.entries(props).sort()))}};
25
+ }
@@ -0,0 +1,49 @@
1
+ import {checked} from './process.mjs';
2
+ import {xml} from './svn.mjs';
3
+ import {readReplacementSource} from './svn-replacement-source.mjs';
4
+ import {svnWorkingIdentity} from './svn-working-identity.mjs';
5
+ import {hash,decode,relativePath,readLocal} from './content.mjs';
6
+ import {isSecretPath} from './secret-path.mjs';
7
+ import {svnKeywordMapping} from './svn-keyword-mapping.mjs';
8
+ import {svnEolMapping} from './svn-eol-mapping.mjs';
9
+ import {svnSpecialData} from './svn-special.mjs';
10
+ import {readWorkingSvnSpecial} from './svn-working-special.mjs';
11
+
12
+ // Fixed repository tree versus explicitly selected working replacement tree.
13
+ // Every descendant is a separate source/coverage unit, never an implicit approval.
14
+ export async function captureReplacementTree(root,rootInfo,name,options={}){
15
+ relativePath(name);if(isSecretPath(name))throw new Error('Credential replacement root excluded');const revision=options.svnReplacementBaseRevision;
16
+ const run=async args=>checked('svn',['--non-interactive',...args],{...options,cwd:root,maxBytes:1024*1024});
17
+ const inventory=async()=>{const data=xml(await run(['status','--xml','--verbose','--ignore-externals','--depth','infinity','--',name+'@']));const map=new Map();for(const entry of data.status?.target?.flatMap(t=>t.entry??[])??[]){const p=entry['@_path'].replaceAll('\\','/');relativePath(p);if(p!==name&&!p.startsWith(name+'/')||map.has(p))throw new Error('Replacement working inventory escaped scope or duplicated');map.set(p,entry);if(map.size>200)throw new Error('Replacement working descendant count limit exceeded');}if(!map.has(name))throw new Error('Replacement root missing from working inventory');return map;};
18
+ const current=await inventory(),oldRoot=await readReplacementSource(root,rootInfo,name,revision,options);
19
+ const oldNodes=new Map([[name,oldRoot.kind],...oldRoot.descendants.map(x=>[x.path,x.kind])]);const paths=[...new Set([...oldNodes.keys(),...current.keys()])].sort();if(paths.length>200)throw new Error('Replacement combined descendant count limit exceeded');
20
+ if(options.selectedPaths?.some(p=>(p===name||p.startsWith(name+'/'))&&!paths.includes(p)))throw new Error('Selected replacement descendant absent from both approved inventories');
21
+ const files=[],receipts=[];
22
+ const props=async p=>{const data=xml(await run(['proplist','--xml','--verbose','--',p+'@']));const out=Object.create(null);for(const x of data.properties?.target?.[0]?.property??[]){if(typeof x['@_name']!=='string'||x['@_encoding']||Object.hasOwn(out,x['@_name']))throw new Error('Unsafe replacement working properties');out[x['@_name']]=x['#text']??'';}return out;};
23
+ const readCurrent=async p=>{const entry=current.get(p),s=entry?.['wc-status'];if(!entry)return null;if(!s)throw new Error('Replacement status missing node state');if(s['@_item']==='deleted')return null;
24
+ if(['@_switched','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true')||!['normal','modified','added','replaced'].includes(s['@_item'])||s['@_props']==='conflicted')throw new Error('Unsafe replacement descendant state');
25
+ const node=xml(await run(['info','--xml','--',p+'@'])).info?.entry?.[0],identity=svnWorkingIdentity(node,rootInfo,p);if(!['file','dir'].includes(node['@_kind']))throw new Error('Unsupported replacement descendant kind');
26
+ if(node['@_kind']==='dir'&&identity.source.depth!=='infinity')throw new Error('Sparse replacement target cannot prove descendant inventory');
27
+ const properties=await props(p);if(Object.hasOwn(properties,'svn:externals'))throw new Error('Replacement externals require independent scope');
28
+ const content=node['@_kind']==='dir'?decode(Buffer.alloc(0)):Object.hasOwn(properties,'svn:special')?(await readWorkingSvnSpecial(root,p,properties,options)).content:await readLocal(root,p,options);
29
+ return {kind:node['@_kind'],content,props:properties,source:identity.source,identity:identity.fingerprint,special:svnSpecialData(content,properties,node['@_kind'])};
30
+ };
31
+ for(const p of paths){const f={id:hash(p),path:p,rawStatus:p===name?'replaced':'replacement-descendant',eligibility:'reviewable',properties:[],replacementTreeRoot:name,contentNotice:'Explicit fixed repository baseline versus current replacement tree. Not recovered local BASE or copy-source history. Every descendant requires independent approval; link records are data only.'};files.push(f);
32
+ if(isSecretPath(p)){f.eligibility='excluded';f.reason='Credential replacement path excluded';continue;}if(options.selectedPaths&&!options.selectedPaths.includes(p)){f.eligibility='excluded';f.reason='Replacement descendant not explicitly selected';continue;}
33
+ try{const old=oldNodes.has(p)?await readReplacementSource(root,rootInfo,p,revision,options):null,newer=await readCurrent(p);if(!old&&!newer)throw new Error('Replacement inventory node has no captured side');
34
+ f.oldKind=old?.kind??null;f.newKind=newer?.kind??null;f.kind=newer?.kind??old.kind;f.rightExists=!!newer;f.left=old?.content??decode(Buffer.alloc(0));f.right=newer?.content??decode(Buffer.alloc(0));f.baseRevision=old?revision:undefined;
35
+ if(old)f.replacementSource=old.source;if(newer)f.workingSource=newer.source;
36
+ const a=old?.props??{},b=newer?.props??{};for(const key of [...new Set([...Object.keys(a),...Object.keys(b)])].sort())if(a[key]!==b[key])f.properties.push({name:key,old:a[key]??null,new:b[key]??null});
37
+ const oldSpecial=old?svnSpecialData(old.content,a,old.kind):null;if(oldSpecial||newer?.special)f.specialData={old:oldSpecial,new:newer?.special??null};
38
+ if(Object.hasOwn(a,'svn:eol-style')||Object.hasOwn(b,'svn:eol-style'))f.eolMapping={old:old?.kind==='file'?svnEolMapping(f.left.text,a['svn:eol-style']??null):null,new:newer?.kind==='file'?svnEolMapping(f.right.text,b['svn:eol-style']??null):null};
39
+ if(b['svn:keywords']&&newer?.kind==='file'){const {text,...mapping}=svnKeywordMapping(f.right.text,b['svn:keywords']);f.keywordMapping={new:{...mapping,status:'working-line-view'},notice:'Old side is fixed repository text read with --ignore-keywords, not local BASE. Current contraction preserves lines only, not behavioral equivalence.'};}
40
+ receipts.push({path:p,old:old?.source??null,current:newer?hash(JSON.stringify([newer.identity,newer.props,newer.content.hash])):null});
41
+ }catch(error){options.signal?.throwIfAborted();if(error.code==='SVN_CAPTURE_BUDGET')throw error;f.eligibility='blocked';f.reason=error.message;delete f.left;delete f.right;}
42
+ }
43
+ for(const f of files.filter(f=>f.oldKind==='dir'||f.newKind==='dir').sort((a,b)=>b.path.length-a.path.length)){const children=files.filter(x=>x.path.startsWith(f.path+'/'));f.structuralDescendants=children.map(x=>x.path);if(f.eligibility==='reviewable'&&children.some(x=>x.eligibility!=='reviewable')){f.eligibility='blocked';f.reason='Replacement descendant coverage incomplete';delete f.left;delete f.right;}}
44
+ const verify=async()=>{const again=await inventory();const canonical=m=>JSON.stringify([...m].map(([p,e])=>[p,Object.fromEntries(Object.entries(e['wc-status']??{}).sort())]).sort());if(canonical(again)!==canonical(current))throw new Error('Replacement working inventory changed');
45
+ const oldAgain=await readReplacementSource(root,rootInfo,name,revision,options);if(JSON.stringify(oldAgain.source)!==JSON.stringify(oldRoot.source))throw new Error('Replacement old inventory changed');
46
+ for(const receipt of receipts){if(receipt.old){const value=await readReplacementSource(root,rootInfo,receipt.path,revision,options);if(JSON.stringify(value.source)!==JSON.stringify(receipt.old))throw new Error('Replacement old descendant changed');}const value=await readCurrent(receipt.path);const fingerprint=value?hash(JSON.stringify([value.identity,value.props,value.content.hash])):null;if(fingerprint!==receipt.current)throw new Error('Replacement working descendant changed');}
47
+ };
48
+ return {files,verify};
49
+ }
@@ -0,0 +1,10 @@
1
+ // SVN stores symbolic links as file-node data. Never resolve or open the target.
2
+ export function svnSpecialData(content,properties,kind){
3
+ if(!Object.hasOwn(properties,'svn:special'))return null;
4
+ if(kind!=='file'||properties['svn:special']!=='*')throw new Error('Unsupported SVN special node kind or property value');
5
+ if(Object.hasOwn(properties,'svn:keywords')||Object.hasOwn(properties,'svn:eol-style'))throw new Error('SVN special node has incompatible text transformation properties');
6
+ if(content.encoding!=='utf-8'||!content.text.startsWith('link '))throw new Error('SVN special node is not a UTF-8 link record');
7
+ const target=content.text.slice(5);
8
+ if(!target||/[\x00-\x1f\x7f]/.test(target)||Buffer.byteLength(target)>16384)throw new Error('Invalid or oversized SVN link target data');
9
+ return {type:'symlink',target,recordHash:content.hash,followed:false,notice:'Repository link target data only. Target text is untrusted data, not an authorized path, source file, existence check or navigation instruction. Never follow this link.'};
10
+ }
@@ -0,0 +1,8 @@
1
+ // Compare repository URL spelling without decoding path separators into structure.
2
+ export function canonicalSvnUrl(value){
3
+ if(typeof value!=='string')throw new Error('Missing SVN URL');const url=new URL(value);
4
+ if(url.username||url.password||url.search||url.hash||!['file:','http:','https:','svn:','svn+ssh:'].includes(url.protocol))throw new Error('Unsafe SVN URL identity');
5
+ const segments=url.pathname.split('/').map(segment=>{const decoded=decodeURIComponent(segment);if(decoded.includes('/')||decoded.includes('\\')||/[\x00-\x1f\x7f]/.test(decoded)||decoded==='.'||decoded==='..')throw new Error('Unsafe encoded SVN URL segment');return encodeURIComponent(decoded);});
6
+ return url.protocol+'//'+url.host+segments.join('/').replace(/\/$/,'');
7
+ }
8
+ export function sameSvnUrl(a,b){try{return canonicalSvnUrl(a)===canonicalSvnUrl(b);}catch{return false;}}
@@ -1,9 +1,10 @@
1
1
  import {hash} from './content.mjs';
2
+ import {sameSvnUrl,canonicalSvnUrl} from './svn-url.mjs';
2
3
  export function svnWorkingIdentity(node,rootInfo,name){
3
4
  const expected=name==='.'?rootInfo.url:rootInfo.url.replace(/\/$/,'')+'/'+name.split('/').map(encodeURIComponent).join('/');
4
- const sameUrl=(a,b)=>{try{const x=new URL(a),y=new URL(b);return x.protocol===y.protocol&&x.host===y.host&&x.username===y.username&&x.password===y.password&&decodeURIComponent(x.pathname)===decodeURIComponent(y.pathname)&&x.search===y.search&&x.hash===y.hash;}catch{return false;}};
5
- if(!sameUrl(node?.url,expected))throw Object.assign(new Error('SVN node URL differs from selected working-copy path; switched ancestors are unsupported'),{code:'SVN_SWITCHED_ANCESTOR'});
5
+
6
+ if(!sameSvnUrl(node?.url,expected))throw Object.assign(new Error('SVN node URL differs from selected working-copy path; switched ancestors are unsupported'),{code:'SVN_SWITCHED_ANCESTOR'});
6
7
  if(!rootInfo.repository?.uuid||node.repository?.uuid!==rootInfo.repository.uuid||node.repository?.root!==rootInfo.repository.root||node['wc-info']?.['wcroot-abspath']!==rootInfo['wc-info']?.['wcroot-abspath'])throw Object.assign(new Error('SVN node repository or working-copy identity mismatch'),{code:'SVN_NODE_IDENTITY'});
7
- const source={url:node.url,repositoryUuid:node.repository.uuid,repositoryRoot:node.repository.root,revision:String(node['@_revision']),kind:node['@_kind'],schedule:node['wc-info']?.schedule,copyFromUrl:node['wc-info']?.['copy-from-url']??null,copyFromRevision:node['wc-info']?.['copy-from-rev']??null};
8
+ const source={url:canonicalSvnUrl(node.url),repositoryUuid:node.repository.uuid,repositoryRoot:node.repository.root,revision:String(node['@_revision']),kind:node['@_kind'],...(node['@_kind']==='dir'?{depth:node['wc-info']?.depth??null}:{}),schedule:node['wc-info']?.schedule,copyFromUrl:node['wc-info']?.['copy-from-url']??null,copyFromRevision:node['wc-info']?.['copy-from-rev']??null};
8
9
  return {source,fingerprint:hash(JSON.stringify(source))};
9
10
  }
@@ -0,0 +1,31 @@
1
+ import {checked} from './process.mjs';
2
+ import {xml} from './svn.mjs';
3
+ import {hash} from './content.mjs';
4
+ import {svnWorkingIdentity} from './svn-working-identity.mjs';
5
+ import {captureEffectiveMergeinfo} from './svn-effective-mergeinfo.mjs';
6
+
7
+ export async function workingEffectiveMergeinfo(root,rootInfo,files,options={}){
8
+ const signal=AbortSignal.any([AbortSignal.timeout(30000),...(options.signal?[options.signal]:[])]);let commands=0,bytes=0;const cache=new Map();
9
+ const run=async args=>{signal.throwIfAborted();if(++commands>512)throw new Error('SVN working mergeinfo command budget exceeded');const data=await checked('svn',['--non-interactive',...args],{...options,cwd:root,signal,maxBytes:256*1024});bytes+=data.length;if(bytes>1024*1024)throw new Error('SVN working mergeinfo metadata budget exceeded');return xml(data);};
10
+ const read=async(name,side,revision)=>{
11
+ const key=JSON.stringify([name,side]);if(cache.has(key)){const value=cache.get(key);if(side==='old'&&value.source.revision!==revision)throw new Error('Mixed BASE revisions cannot prove effective mergeinfo');return value;}
12
+ const node=(await run(['info','--xml','--',name+'@'])).info?.entry?.[0];const identity=svnWorkingIdentity(node,rootInfo,name);
13
+ const entries=(await run(['status','--xml','--verbose','--ignore-externals','--depth','empty','--',name+'@'])).status?.target?.flatMap(t=>t.entry??[])??[];const state=entries[0]?.['wc-status'];
14
+ if(entries.length!==1||!state||['@_switched','@_file-external','@_tree-conflicted','@_copied'].some(k=>state[k]==='true')||!['normal','modified','deleted'].includes(state['@_item'])||state['@_props']==='conflicted')throw new Error('Unsafe ancestor state for effective mergeinfo');
15
+ if(side==='old'&&String(node['@_revision'])!==revision)throw new Error('Mixed BASE revisions cannot prove effective mergeinfo');
16
+ const props=(await run(['proplist','--xml','--verbose',...(side==='old'?['-r','BASE']:[]),'--',name+'@'])).properties?.target?.[0]?.property??[];const values=Object.create(null);
17
+ for(const p of props){if(typeof p['@_name']!=='string'||p['@_encoding']||Object.hasOwn(values,p['@_name']))throw new Error('Unsafe effective mergeinfo property representation');values[p['@_name']]=p['#text']??'';}
18
+ if(Object.hasOwn(values,'svn:externals')||Object.hasOwn(values,'svn:special'))throw new Error('Unsafe ancestor properties for effective mergeinfo');
19
+ const source={...identity.source,side,revision:String(node['@_revision']),propertyHash:hash(JSON.stringify(Object.entries(values).sort()))};
20
+ const record={path:name,present:Object.hasOwn(values,'svn:mergeinfo'),...(Object.hasOwn(values,'svn:mergeinfo')?{value:values['svn:mergeinfo']}:{}),identity:hash(JSON.stringify(source)),source};cache.set(key,record);return record;
21
+ };
22
+ const results=[];
23
+ for(const file of files.filter(f=>f.eligibility==='reviewable')){const value={path:file.path};for(const side of ['old','new']){
24
+ if(side==='old'&&['added','unversioned'].includes(file.rawStatus)||side==='new'&&file.rawStatus==='deleted'){value[side]={status:'absent',sources:[]};continue;}
25
+ if(file.replacementTreeRoot||file.replacementSource||file.copySource||file.targetAddition||file.rawStatus==='unversioned'){value[side]={status:'unknown',reason:'Added/copied/untracked ancestor semantics require separate provenance',sources:[]};continue;}
26
+ try{value[side]=await captureEffectiveMergeinfo(file.path,{signal,boundary:'.',complete:rootInfo.url===rootInfo.repository.root,read:name=>read(name,side,String(file.baseRevision))});for(const evidence of value[side].evidence??[])evidence.source=cache.get(JSON.stringify([evidence.path,side])).source;}catch(error){signal.throwIfAborted();if(error.code==='SVN_CAPTURE_BUDGET'||/budget exceeded/.test(error.message))throw error;value[side]={status:'unknown',reason:error.message,sources:[]};}
27
+ }results.push(value);}
28
+ // Re-read every captured property/identity after collection, charging same budget.
29
+ const original=[...cache.entries()];cache.clear();for(const [key,record]of original){const [name,side]=JSON.parse(key);if((await read(name,side,record.source.revision)).identity!==record.identity)throw new Error('Effective working mergeinfo changed during capture');}
30
+ return results;
31
+ }
@@ -0,0 +1,7 @@
1
+ // Describes observed evidence only; never infers a complete working tree from status.
2
+ export function workingScopeEvidence(rootSource,files){
3
+ const revisions=[...new Set(files.filter(f=>f.eligibility==='reviewable'&&f.workingSource&&/^[0-9]+$/.test(f.workingSource.revision)).map(f=>f.workingSource.revision))].sort((a,b)=>Number(a)-Number(b));
4
+ const sparse=files.filter(f=>f.workingSource?.kind==='dir'&&f.workingSource.depth!=='infinity').map(f=>({path:f.path,depth:f.workingSource.depth??'unknown'}));
5
+ if(rootSource.depth!=='infinity'&&!sparse.some(s=>s.path==='.'))sparse.unshift({path:'.',depth:rootSource.depth??'unknown'});
6
+ return {root:rootSource,observedBaseRevisions:revisions,mixedObservedRevisions:revisions.length>1,sparseDirectories:sparse,inventoryComplete:false,notice:'Only selected/observed change metadata is described. Mixed revisions bind each node BASE independently, not a common historical tree. Sparse or unenumerated paths are not deletion evidence or reviewed coverage; no update/deepen/external traversal was performed.'};
7
+ }
@@ -0,0 +1,24 @@
1
+ import {lstat,readlink,realpath} from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {relativePath,readLocal,decode} from './content.mjs';
4
+ import {assertNonSecretPath} from './secret-path.mjs';
5
+ import {svnSpecialData} from './svn-special.mjs';
6
+
7
+ // Validate ancestors but never realpath/open a symbolic-link leaf or its target.
8
+ export async function readWorkingSvnSpecial(root,name,properties,options={}){
9
+ relativePath(name);assertNonSecretPath(name);options.signal?.throwIfAborted();
10
+ const canonical=await realpath(root);let cursor=canonical;const parts=name.split('/');
11
+ for(const part of parts.slice(0,-1)){cursor=path.join(cursor,part);const stat=await lstat(cursor);if(stat.isSymbolicLink()||!stat.isDirectory())throw new Error('SVN special ancestor must be a real directory');}
12
+ const filename=path.join(cursor,parts.at(-1)),before=await lstat(filename);let content;
13
+ if(before.isSymbolicLink()){
14
+ const target=await readlink(filename,{encoding:'buffer'});if(target.length>16384)throw new Error('SVN link target byte limit exceeded');
15
+ const bytes=Buffer.concat([Buffer.from('link '),target]);if(bytes.length>(options.maxFileBytes??256*1024))throw new Error('SVN special file size limit exceeded');
16
+ content=decode(bytes);const after=await lstat(filename);const again=await readlink(filename,{encoding:'buffer'});
17
+ if(!after.isSymbolicLink()||before.ino!==after.ino||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs||!again.equals(target))throw new Error('SVN link changed during capture');
18
+ }else{
19
+ if(!before.isFile())throw new Error('SVN special working node is neither link nor regular link record');
20
+ content=await readLocal(canonical,name,options);
21
+ }
22
+ options.signal?.throwIfAborted();
23
+ return {content,special:svnSpecialData(content,properties,'file')};
24
+ }