@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/src/svn.mjs CHANGED
@@ -1,3 +1,9 @@
1
+ import {captureReplacementTree} from './svn-replacement-tree.mjs';
2
+ import {readReplacementSource} from './svn-replacement-source.mjs';
3
+ import {workingScopeEvidence} from './svn-working-scope.mjs';
4
+ import {workingEffectiveMergeinfo} from './svn-working-mergeinfo.mjs';
5
+ import {svnSpecialData} from './svn-special.mjs';
6
+ import {readWorkingSvnSpecial} from './svn-working-special.mjs';
1
7
  import {svnWorkingIdentity} from './svn-working-identity.mjs';
2
8
  import { isSecretPath } from './secret-path.mjs';
3
9
  import { svnStateIssue } from './svn-state.mjs';
@@ -67,14 +73,21 @@ export async function captureSvn(cwd, options = {}) {
67
73
  if (originalEntries.length > maxFiles) throw new Error('Change count limit exceeded');
68
74
  const entries = await structuralEntries(root, originalEntries, options, maxFiles);
69
75
  const known = new Set(entries.map(e => e['@_path'].replaceAll('\\', '/')));
70
- if (selectedPaths?.some(p => !known.has(p))) throw new Error('Selected path is not a current change');
71
- const files = [];
76
+ if (selectedPaths?.some(p => !known.has(p)&&!(options.svnReplacementBaseRevision!==undefined&&entries.some(e=>e['wc-status']?.['@_item']==='replaced'&&p.startsWith(e['@_path'].replaceAll('\\','/')+'/'))))) throw new Error('Selected path is not a current change');
77
+ const files = [],replacementTrees=[];
78
+ const replacementRoots=options.svnReplacementBaseRevision===undefined?[]:entries.filter(e=>e['wc-status']?.['@_item']==='replaced').map(e=>e['@_path'].replaceAll('\\','/')).sort().filter((p,i,all)=>!all.slice(0,i).some(parent=>p.startsWith(parent+'/')));
79
+ for(const name of replacementRoots){
80
+ if(isSecretPath(name))continue;
81
+ if(options.selectedPaths&&!options.selectedPaths.some(p=>p===name||p.startsWith(name+'/')))continue;
82
+ const tree=await captureReplacementTree(root,rootInfo,name,options);replacementTrees.push(tree);files.push(...tree.files);if(files.length>maxFiles)throw new Error('Combined replacement change count limit exceeded');
83
+ }
72
84
  for (const entry of entries) {
73
85
  signal?.throwIfAborted();
74
86
  const name = entry['@_path'].replaceAll('\\', '/');
87
+ if(replacementTrees.some(tree=>tree.files.some(f=>name===f.replacementTreeRoot||name.startsWith(f.replacementTreeRoot+'/'))))continue;
75
88
  const state = entry['wc-status'], status = state['@_item'];
76
89
  const item = { id: hash(name), path: name, ...(name === '.' ? {kind: 'dir'} : {}), rawStatus: status, baseRevision: state['@_revision'], eligibility: 'reviewable', properties: [] };
77
- files.push(item);
90
+ files.push(item);if(files.length>maxFiles)throw new Error('Combined change count limit exceeded');
78
91
  if (isSecretPath(name)) { item.eligibility='excluded'; item.reason='Credential path excluded'; continue; }
79
92
  if ((selectedPaths && !selectedPaths.includes(name)) || (status === 'unversioned' && !selectedPaths?.includes(name))) {
80
93
  item.eligibility = 'excluded'; item.reason = 'Not explicitly selected'; continue;
@@ -82,7 +95,8 @@ export async function captureSvn(cwd, options = {}) {
82
95
  try {
83
96
  if (name !== '.') relativePath(name);
84
97
  const copiedDescendant = !!entry.copyRoot && ['normal', 'modified', 'added'].includes(status) && state['@_copied'] === 'true';
85
- const issue = svnStateIssue(copiedDescendant ? {...state, '@_item':'added'} : state);
98
+ const independentReplacement=status==='replaced'&&options.svnReplacementBaseRevision!==undefined;
99
+ const issue = svnStateIssue(independentReplacement?{...state,'@_item':'modified','@_copied':'false'}:copiedDescendant ? {...state, '@_item':'added'} : state);
86
100
  if (issue) { item.blockCode = issue.code; throw new Error(issue.reason); }
87
101
  const hasBase = !copiedDescendant && !['added', 'unversioned'].includes(status);
88
102
  let kind;
@@ -92,7 +106,7 @@ export async function captureSvn(cwd, options = {}) {
92
106
  const identity=svnWorkingIdentity(node,rootInfo,name);
93
107
  item.workingSource=identity.source;item.workingIdentity=identity.fingerprint;
94
108
  kind = node['@_kind'];
95
- if (state['@_copied'] === 'true' || node['wc-info']?.['copy-from-url']) {
109
+ if (!independentReplacement&&(state['@_copied'] === 'true' || node['wc-info']?.['copy-from-url'])) {
96
110
  let source = node['wc-info']?.['copy-from-url'], revision = node['wc-info']?.['copy-from-rev'];
97
111
  if (!source && copiedDescendant) {
98
112
  const parent = files.find(file => file.path === entry.copyRoot);
@@ -111,35 +125,48 @@ export async function captureSvn(cwd, options = {}) {
111
125
  item.kind = kind;
112
126
  if (copiedDescendant) item.targetAddition = true;
113
127
  item.rightExists = status !== 'deleted';
114
- const leftProps = hasBase ? await properties(root, name, true, options) : {};
128
+ if(independentReplacement&&!['file','dir'].includes(kind))throw new Error('Unsupported replacement target kind');
129
+ const replacement=independentReplacement?await readReplacementSource(root,rootInfo,name,options.svnReplacementBaseRevision,options):null;
130
+ if(replacement?.descendants.length){item.blockCode='SVN_REPLACEMENT_OLD_DESCENDANTS';throw new Error('Replacement old directory descendants require separate dual-side tree capture; no implicit old child approval');}
131
+ if(replacement&&kind==='dir'){
132
+ const inventory=xml(await svn(root,['status','--xml','--verbose','--ignore-externals','--depth','infinity','--',target(name)],options)).status?.target?.flatMap(t=>t.entry??[])??[];
133
+ const descendants=inventory.filter(entry=>entry['@_path'].replaceAll('\\','/')!==name);
134
+ if(descendants.length||item.workingSource.depth!=='infinity'){item.blockCode='SVN_REPLACEMENT_NEW_DESCENDANTS';throw new Error('Replacement target directory is nonempty or sparse; descendants require separate node approval');}
135
+ item.replacementEmptyDirectory=true;
136
+ }
137
+ if(replacement){item.oldKind=replacement.kind;item.newKind=kind;item.nodeNotice='Independent fixed repository baseline and current target have separate node kinds; directories are not empty source files.';item.baseRevision=replacement.source.operativeRevision;item.replacementSource=replacement.source;item.contentNotice='Explicit repository revision baseline versus replacement working target; not local BASE, copy-source content, or proof that selected revision equals the removed node.';}
138
+ const leftProps = replacement?replacement.props:hasBase ? await properties(root, name, true, options) : {};
115
139
  const rightProps = status === 'deleted' || status === 'unversioned' ? {} : await properties(root, name, false, options);
116
- if ('svn:special' in leftProps || 'svn:special' in rightProps) throw new Error('SVN special nodes are excluded');
140
+ if(kind!=='file'&&('svn:special' in leftProps||'svn:special' in rightProps))throw new Error('SVN special property requires file-node link data');
117
141
  const keywordText=!!(leftProps['svn:keywords']||rightProps['svn:keywords']);
118
- if(keywordText&&kind!=='file')throw new Error('SVN keyword mapping requires regular file');
142
+ if(keywordText&&(kind!=='file'||replacement&&replacement.kind!=='file'))throw new Error('SVN keyword mapping requires regular file');
119
143
  item.textNotice = 'Text preserves SVN cat output and working bytes. CRLF/LF differences alone are not code defects.';
120
144
  for (const key of [...new Set([...Object.keys(leftProps), ...Object.keys(rightProps)])].sort()) {
121
145
  if (leftProps[key] !== rightProps[key]) item.properties.push({ name: key, old: leftProps[key] ?? null, new: rightProps[key] ?? null });
122
146
  }
123
147
  item.propertyIdentity = hash(JSON.stringify([leftProps, rightProps]));
124
- if (kind === 'dir') {
148
+ if (kind === 'dir'&&(!replacement||replacement.kind==='dir')) {
125
149
  if (status === 'unversioned') throw new Error('Unversioned directory requires explicit descendant tracking');
126
150
  if (status === 'deleted' || status === 'added' || copiedDescendant) {
127
151
  const descendants = entries.filter(entry => entry['@_path'].replaceAll('\\', '/').startsWith(name + '/')).map(entry => entry['@_path'].replaceAll('\\', '/'));
128
152
  if (selectedPaths && descendants.some(child => !selectedPaths.includes(child))) throw new Error('Directory descendants are not all explicitly selected');
153
+ if(item.workingSource?.depth!=='infinity'){item.blockCode='SVN_SPARSE_STRUCTURE';throw new Error('Sparse or unknown directory depth cannot prove complete structural descendant coverage');}
129
154
  item.structuralDescendants = descendants;
130
155
  item.structuralNotice = 'Directory node coverage is separate from each descendant; no implicit descendant approval.';
131
156
  }
132
- item.left = decode(Buffer.alloc(0)); item.right = decode(Buffer.alloc(0));
157
+ item.left = replacement?replacement.content:decode(Buffer.alloc(0)); item.right = decode(Buffer.alloc(0));
133
158
  } else {
134
- item.left = decode(hasBase ? await svn(root, ['cat', '-r', 'BASE', '--', target(name)], { ...options, maxBytes: maxFileBytes }) : Buffer.alloc(0));
135
- item.right = status === 'deleted' ? decode(Buffer.alloc(0)) : await readLocal(root, name, options);
159
+ item.left = replacement?replacement.content:decode(hasBase ? await svn(root, ['cat', '-r', 'BASE', '--', target(name)], { ...options, maxBytes: maxFileBytes }) : Buffer.alloc(0));
160
+ item.right = status === 'deleted'||kind==='dir' ? decode(Buffer.alloc(0)) : Object.hasOwn(rightProps,'svn:special')?(await readWorkingSvnSpecial(root,name,rightProps,options)).content:await readLocal(root, name, options);
161
+ const oldSpecial=svnSpecialData(item.left,leftProps,replacement?.kind??kind),newSpecial=svnSpecialData(item.right,rightProps,kind);
162
+ if(oldSpecial||newSpecial){item.specialData={old:oldSpecial,new:newSpecial};item.contentNotice='SVN link record data only on special sides; target strings authorize no target reads or source navigation.';}
136
163
  if(Object.hasOwn(leftProps,'svn:eol-style')||Object.hasOwn(rightProps,'svn:eol-style')){
137
164
  item.eolMapping={old:svnEolMapping(item.left.text,leftProps['svn:eol-style']??null),new:svnEolMapping(item.right.text,rightProps['svn:eol-style']??null)};
138
165
  item.textNotice+=' EOL mapping is hash-only metadata; raw text and LF evidence lines are authoritative. Lone CR has no identity mapping to normalized LF lines.';
139
166
  }
140
167
  if(keywordText){
141
168
  const old=svnKeywordMapping(item.left.text,leftProps['svn:keywords']??''),current=svnKeywordMapping(item.right.text,rightProps['svn:keywords']??'');
142
- if(hasBase){
169
+ if(hasBase&&!replacement){
143
170
  const repository=decode(await svn(root,['cat','--ignore-keywords','-r','BASE','--',target(name)],{...options,maxBytes:maxFileBytes}));
144
171
  if(old.text!==repository.text)throw new Error('SVN BASE keyword mapping differs from repository text');
145
172
  }
@@ -149,7 +176,7 @@ export async function captureSvn(cwd, options = {}) {
149
176
  }
150
177
  }
151
178
  } catch (error) {
152
- signal?.throwIfAborted(); item.eligibility = 'blocked'; item.reason = error.message;if(error.code)item.blockCode=error.code;
179
+ signal?.throwIfAborted();if(error.code==='SVN_CAPTURE_BUDGET')throw error; item.eligibility = 'blocked'; item.reason = error.message;if(error.code)item.blockCode=error.code;
153
180
  delete item.left; delete item.right;
154
181
  }
155
182
  }
@@ -163,21 +190,31 @@ export async function captureSvn(cwd, options = {}) {
163
190
  delete item.left; delete item.right;
164
191
  }
165
192
  }
166
- for (const item of files.filter(f => f.eligibility === 'reviewable')) {
193
+ for (const item of files.filter(f => f.eligibility === 'reviewable'&&!f.replacementTreeRoot)) {
167
194
  if(item.workingIdentity){const node=xml(await svn(root,['info','--xml','--',target(item.path)],options)).info.entry[0];if(svnWorkingIdentity(node,rootInfo,item.path).fingerprint!==item.workingIdentity)throw new Error('SVN node identity changed during capture');}
195
+ if(item.replacementEmptyDirectory){const inventory=xml(await svn(root,['status','--xml','--verbose','--ignore-externals','--depth','infinity','--',target(item.path)],options)).status?.target?.flatMap(t=>t.entry??[])??[];if(inventory.some(e=>e['@_path'].replaceAll('\\','/')!==item.path))throw new Error('Replacement target directory inventory changed during capture');}
168
196
  if (item.copySource) {
169
197
  const node = xml(await svn(root, ['info', '--xml', '--', target(item.path)], options)).info.entry[0];
170
198
  const identity = hash(JSON.stringify([node.url, node['@_kind'], node['@_revision'], node['wc-info']?.schedule, node['wc-info']?.['wcroot-abspath'], node['wc-info']?.['copy-from-url'] ?? null, node['wc-info']?.['copy-from-rev'] ?? null, node.repository]));
171
199
  if (identity !== item.copyNodeIdentity) throw new Error('Copy source identity changed during capture');
172
200
  }
173
- if (item.kind === 'file' && item.rawStatus !== 'deleted' && (await readLocal(root, item.path, options)).hash !== item.right.hash) throw new Error('Content changed during capture');
174
- const left = !item.targetAddition && !['added', 'unversioned'].includes(item.rawStatus) ? await properties(root, item.path, true, options) : {};
201
+ if(item.kind==='file'&&item.rawStatus!=='deleted'){
202
+ const current=item.specialData?.new?(await readWorkingSvnSpecial(root,item.path,await properties(root,item.path,false,options),options)).content:await readLocal(root,item.path,options);
203
+ if(current.hash!==item.right.hash)throw new Error('Content changed during capture');
204
+ }
205
+ const replacement=item.replacementSource?await readReplacementSource(root,rootInfo,item.path,options.svnReplacementBaseRevision,options):null;
206
+ if(replacement&&JSON.stringify(replacement.source)!==JSON.stringify(item.replacementSource))throw new Error('Replacement repository baseline changed during capture');
207
+ const left = replacement?replacement.props:!item.targetAddition && !['added', 'unversioned'].includes(item.rawStatus) ? await properties(root, item.path, true, options) : {};
175
208
  const right = ['deleted', 'unversioned'].includes(item.rawStatus) ? {} : await properties(root, item.path, false, options);
176
209
  if (hash(JSON.stringify([left, right])) !== item.propertyIdentity) throw new Error('Properties changed during capture');
177
210
  }
211
+ for(const tree of replacementTrees)await tree.verify();
178
212
  const finalRootInfo=xml(await svn(root,['info','--xml','--','.@'],options)).info.entry[0];
179
213
  if(svnWorkingIdentity(finalRootInfo,rootInfo,'.').fingerprint!==svnWorkingIdentity(rootInfo,rootInfo,'.').fingerprint)throw new Error('SVN root identity changed during capture');
214
+ if(options.svnEffectiveMergeinfo!==undefined&&typeof options.svnEffectiveMergeinfo!=='boolean')throw new Error('Invalid SVN effective mergeinfo opt-in');
215
+ const effectiveMergeSources=options.svnEffectiveMergeinfo===true?await workingEffectiveMergeinfo(root,rootInfo,files,options):undefined;
180
216
  const mergeSources = snapshotMergeSources(files);
181
- const snapshot = { schemaVersion: 1, vcs: 'svn', root, repositoryUuid: rootInfo.repository.uuid, repositoryUrl:rootInfo.url, ...(mergeSources.length ? {mergeSources} : {}), files };
217
+ const workingScope=workingScopeEvidence(svnWorkingIdentity(rootInfo,rootInfo,'.').source,files);
218
+ const snapshot = { schemaVersion: 1, vcs: 'svn', root, workingScope, repositoryUuid: rootInfo.repository.uuid, repositoryUrl:rootInfo.url,...(effectiveMergeSources?{effectiveMergeSources}:{}), ...(mergeSources.length ? {mergeSources} : {}), files };
182
219
  return { ...snapshot, id: hash(JSON.stringify(snapshot)) };
183
220
  }