@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
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
|
|
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
|
-
|
|
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
|
|
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
|
|
174
|
-
|
|
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
|
|
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
|
}
|