@feng0531/dsh-code-review 0.78.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.
- package/CHANGELOG.md +13 -1
- package/CORE-ALIGNMENT-TASKS.md +3 -1
- package/DEVELOPMENT.md +2 -2
- package/README.md +27 -1
- package/RELEASE-NOTES.md +15 -9
- package/SVN-SUPPORT-MATRIX.md +33 -2
- package/index.mjs +1 -1
- package/package.json +1 -1
- package/src/caller-candidates.mjs +1 -1
- package/src/context-candidates.mjs +1 -1
- package/src/context-identity.mjs +8 -6
- package/src/file-relations.mjs +2 -1
- package/src/process.mjs +5 -1
- package/src/review.mjs +2 -0
- package/src/service.mjs +3 -2
- package/src/snapshot.mjs +10 -1
- package/src/svn-capture-budget.mjs +6 -0
- package/src/svn-dependency-chain.mjs +1 -1
- package/src/svn-diagnostics.mjs +16 -0
- package/src/svn-effective-mergeinfo.mjs +46 -0
- package/src/svn-history-callers.mjs +5 -5
- package/src/svn-history-dependencies.mjs +4 -3
- package/src/svn-history-endpoints.mjs +15 -0
- package/src/svn-history-lookup.mjs +4 -4
- package/src/svn-history.mjs +85 -46
- package/src/svn-keyword-config.mjs +19 -0
- package/src/svn-keyword-mapping.mjs +34 -12
- package/src/svn-replacement-source.mjs +25 -0
- package/src/svn-replacement-tree.mjs +49 -0
- package/src/svn-special.mjs +10 -0
- package/src/svn-url.mjs +8 -0
- package/src/svn-working-identity.mjs +4 -3
- package/src/svn-working-mergeinfo.mjs +31 -0
- package/src/svn-working-scope.mjs +7 -0
- package/src/svn-working-special.mjs +24 -0
- package/src/svn.mjs +55 -18
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {relativePath} from './content.mjs';
|
|
2
|
+
import {assertNonSecretPath} from './secret-path.mjs';
|
|
3
|
+
|
|
4
|
+
// Cross-branch endpoints are repository-relative paths, never arbitrary URLs.
|
|
5
|
+
export function svnHistoryEndpoints(local,options,destination){
|
|
6
|
+
const hasBase=options.svnBasePath!==undefined,hasTarget=options.svnTargetPath!==undefined;
|
|
7
|
+
if(hasBase!==hasTarget)throw new Error('SVN branch comparison requires both svnBasePath and svnTargetPath');
|
|
8
|
+
if(!hasBase)return {baseUrl:destination(local.url,options.svnAllowNetwork).url,targetUrl:destination(local.url,options.svnAllowNetwork).url,crossUrl:false};
|
|
9
|
+
if(options.svnRevision!==undefined)throw new Error('SVN branch paths require explicit revision range');
|
|
10
|
+
if(!local.repository?.root||!local.repository?.uuid)throw new Error('SVN repository root identity required');
|
|
11
|
+
const endpoint=value=>{if(value!=='.'){relativePath(value);assertNonSecretPath(value);}const root=destination(local.repository.root,options.svnAllowNetwork).url;return value==='.'?root:root+'/'+value.split('/').map(encodeURIComponent).join('/');};
|
|
12
|
+
const baseUrl=endpoint(options.svnBasePath),targetUrl=endpoint(options.svnTargetPath);
|
|
13
|
+
if(baseUrl!==targetUrl&&(baseUrl.startsWith(targetUrl+'/')||targetUrl.startsWith(baseUrl+'/')))throw new Error('Overlapping SVN branch roots require unambiguous endpoint path mapping');
|
|
14
|
+
return {baseUrl,targetUrl,crossUrl:baseUrl!==targetUrl};
|
|
15
|
+
}
|
|
@@ -6,16 +6,16 @@ import {relativePath} from './content.mjs';
|
|
|
6
6
|
export function fixedSvnLookup({list,signal,maxEntries=512}={}) {
|
|
7
7
|
if(typeof list!=='function'||!Number.isSafeInteger(maxEntries)||maxEntries<1||maxEntries>512)throw new Error('Invalid SVN historical lookup capability');
|
|
8
8
|
const directories=new Map();let entries=0;
|
|
9
|
-
const lookup=async(name,revision)=>{
|
|
9
|
+
const lookup=async(name,revision,side)=>{
|
|
10
10
|
relativePath(name);signal?.throwIfAborted();
|
|
11
11
|
if(typeof revision!=='string'||!/^(0|[1-9][0-9]*)$/.test(revision)||!Number.isSafeInteger(Number(revision)))throw new Error('Invalid SVN lookup revision');
|
|
12
|
-
const parent=path.posix.dirname(name),key=JSON.stringify([revision,parent]);
|
|
12
|
+
const parent=path.posix.dirname(name),key=JSON.stringify([side??null,revision,parent]);
|
|
13
13
|
if(!directories.has(key)){
|
|
14
14
|
if(parent!=='.'){
|
|
15
|
-
const node=await lookup(parent,revision);
|
|
15
|
+
const node=await lookup(parent,revision,side);
|
|
16
16
|
if(!node||node.kind!=='dir')return null;
|
|
17
17
|
}
|
|
18
|
-
const records=await list(parent,revision);signal?.throwIfAborted();
|
|
18
|
+
const records=await list(parent,revision,side);signal?.throwIfAborted();
|
|
19
19
|
if(!Array.isArray(records))throw new Error('Invalid fixed SVN directory listing');
|
|
20
20
|
const children=new Map();
|
|
21
21
|
for(const record of records){
|
package/src/svn-history.mjs
CHANGED
|
@@ -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
|
|
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
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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(
|
|
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
|
|
90
|
-
|
|
91
|
-
const
|
|
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
|
|
110
|
-
const content=node['@_kind']==='dir'?decode(Buffer.alloc(0)):decode(await run(['cat','--ignore-keywords','-r',rev,'--',at(name,rev)],true));
|
|
111
|
-
|
|
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]
|
|
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]
|
|
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(
|
|
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
|
|
241
|
-
|
|
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,22 +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
|
-
//
|
|
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');
|
|
9
|
+
const enabled=new Set(),custom=[];
|
|
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);
|
|
13
|
+
if(!match||standard.has(match[1])||enabled.has(match[1]))throw new Error('Unsupported SVN custom keyword configuration');
|
|
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});
|
|
20
|
+
}
|
|
21
|
+
return {enabled,custom};
|
|
22
|
+
}
|
|
23
|
+
|
|
5
24
|
export function svnKeywordMapping(text,configuration){
|
|
6
|
-
if(typeof text!=='string'||
|
|
7
|
-
const enabled=
|
|
8
|
-
if([...enabled].some(name=>!standard.has(name)))throw new Error('Unsupported SVN custom keyword configuration');
|
|
9
|
-
let replacements=0;
|
|
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;
|
|
10
27
|
const normalized=text.replace(/\$([A-Za-z][A-Za-z0-9]*)(:{1,2})([^\r\n$]*)\$/g,(literal,name,separator,value)=>{
|
|
11
28
|
if(!enabled.has(name))return literal;
|
|
12
|
-
if(separator
|
|
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');
|
|
13
36
|
replacements++;return '$'+name+'$';
|
|
14
37
|
});
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
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');}
|
|
20
42
|
if(text.split('\n').length!==normalized.split('\n').length)throw new Error('SVN keyword mapping changed line identity');
|
|
21
|
-
return {text:normalized,rawTextHash:hash(text),textHash:hash(normalized),replacements,lineMapping:'identity',status:'candidate-only',notice:'Line-preserving
|
|
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.'};
|
|
22
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
|
+
}
|
package/src/svn-url.mjs
ADDED
|
@@ -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
|
-
|
|
5
|
-
if(!
|
|
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
|
+
}
|