@feng0531/dsh-code-review 0.80.1 → 0.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -2
- package/CORE-ALIGNMENT-TASKS.md +19 -82
- package/CORE-SOURCE-INVENTORY.md +1 -1
- package/DEVELOPMENT.md +8 -1
- package/README.md +34 -9
- package/RELEASE-NOTES.md +11 -14
- package/SVN-RELEASE-EVIDENCE.md +114 -0
- package/SVN-SUPPORT-MATRIX.md +35 -135
- package/evaluation/README.md +1 -1
- package/index.mjs +2 -2
- package/package.json +2 -2
- package/src/primary-input.mjs +1 -1
- package/src/project-review-service.mjs +35 -0
- package/src/project-snapshot.mjs +45 -0
- package/src/review.mjs +12 -8
- package/src/service.mjs +8 -1
- package/src/svn-caller-node.mjs +1 -1
- package/src/svn-keyword-config.mjs +6 -15
- package/src/svn-replacement-tree.mjs +20 -3
- package/src/svn.mjs +1 -1
- package/src/verification-loop.mjs +1 -1
- package/src/window-findings.mjs +3 -0
package/src/svn-caller-node.mjs
CHANGED
|
@@ -6,6 +6,6 @@ import path from 'node:path';
|
|
|
6
6
|
import {svnCallerAncestors} from './svn-caller-safety.mjs';
|
|
7
7
|
const canonical=v=>Array.isArray(v)?v.map(canonical):v&&typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,canonical(v[k])])):v;
|
|
8
8
|
// Body authorization requires regular local node and offline SVN identity/properties.
|
|
9
|
-
export async function svnCallerNode(root,name,options={}){relativePath(name);const parent=path.posix.dirname(name);const ancestors=await svnCallerAncestors(root,parent,options);const stat=await lstat(path.join(root,name));if(stat.isSymbolicLink()||!stat.isFile())throw new Error('SVN caller source is not a regular file');const run=async args=>xml(await checked('svn',['--non-interactive',...args,'--',name+'@'],{...options,cwd:root,maxBytes:64*1024}));const entries=(await run(['status','--xml','--verbose','--ignore-externals','--depth','empty'])).status?.target?.flatMap(t=>t.entry??[])??[];if(entries.length!==1||entries[0]['@_path'].replaceAll('\\','/')!==name)throw new Error('SVN caller source status missing');const s=entries[0]['wc-status'];if(s?.['@_item']
|
|
9
|
+
export async function svnCallerNode(root,name,options={}){relativePath(name);const parent=path.posix.dirname(name);const ancestors=await svnCallerAncestors(root,parent,options);const stat=await lstat(path.join(root,name));if(stat.isSymbolicLink()||!stat.isFile())throw new Error('SVN caller source is not a regular file');const run=async args=>xml(await checked('svn',['--non-interactive',...args,'--',name+'@'],{...options,cwd:root,maxBytes:64*1024}));const entries=(await run(['status','--xml','--verbose','--ignore-externals','--depth','empty'])).status?.target?.flatMap(t=>t.entry??[])??[];if(entries.length!==1||entries[0]['@_path'].replaceAll('\\','/')!==name)throw new Error('SVN caller source status missing');const s=entries[0]['wc-status'];if(!(options.allowProjectCurrent===true?['normal','modified','added']:['normal']).includes(s?.['@_item'])||!(options.allowProjectCurrent===true?['none','normal','modified']:['none','normal']).includes(s['@_props'])||['@_switched','@_copied','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true'))throw new Error('Unsafe SVN caller source state');const info=(await run(['info','--xml'])).info?.entry?.[0];const rootInfo=xml(await checked('svn',['--non-interactive','info','--xml','--','.@'],{...options,cwd:root,maxBytes:64*1024})).info?.entry?.[0];if(info?.['@_kind']!=='file'||info['wc-info']?.['copy-from-url']||info.repository?.uuid!==rootInfo.repository?.uuid||info['wc-info']?.['wcroot-abspath']!==rootInfo['wc-info']?.['wcroot-abspath'])throw new Error('Unsafe SVN caller source identity');const expected=rootInfo.url.replace(/\/$/,'')+'/'+name.split('/').map(encodeURIComponent).join('/');if(info.url!==expected)throw new Error('Unsafe SVN caller switched source URL');const props=(await run(['proplist','--xml','--verbose'])).properties?.target?.[0]?.property??[];if(props.some(p=>['svn:special','svn:externals',...(options.allowExplicitKeywords===true?[]:['svn:keywords'])].includes(p['@_name'])))throw new Error('Unsafe SVN caller source properties');const eol=props.filter(p=>p['@_name']==='svn:eol-style');if(eol.length>1||eol.some(p=>p['@_encoding']||!['native','LF','CRLF','CR'].includes(p['#text'])))throw new Error('Unsafe SVN caller EOL properties');const keywords=props.filter(p=>p['@_name']==='svn:keywords');if(keywords.length>1||keywords.some(p=>p['@_encoding']||typeof p['#text']!=='string'))throw new Error('Unsafe SVN keyword properties');return {...(keywords.length?{keywords:keywords[0]['#text']}:{}),...(eol.length?{eolStyle:eol[0]['#text']}:{}),revision:s['@_revision'],fingerprint:hash(JSON.stringify(canonical({status:entries[0],info,props,ancestorFingerprint:ancestors.fingerprint})))};}
|
|
10
10
|
// Existing untracked/linked/directory paths compete too; absence grants no body read.
|
|
11
11
|
export async function svnCallerProbes(root,names,options={}){if(!Array.isArray(names)||names.length>512||new Set(names).size!==names.length)throw new Error('Invalid SVN caller probes');const existing=[],identities=[];for(const name of [...names].sort()){relativePath(name);if(name.split('/').length>32)throw new Error('SVN caller probe depth limit exceeded');options.signal?.throwIfAborted();let cursor=root,identity=[];for(const part of name.split('/')){cursor=path.join(cursor,part);try{const s=await lstat(cursor);identity.push({part,kind:s.isSymbolicLink()?'link':s.isDirectory()?'dir':s.isFile()?'file':'other'});if(s.isSymbolicLink()){existing.push(name);break;}if(cursor===path.join(root,name))existing.push(name);if(!s.isDirectory())break;}catch(e){if(e.code!=='ENOENT'&&e.code!=='ENOTDIR')throw e;identity.push({part,kind:'missing'});break;}}identities.push({name,identity});if(Buffer.byteLength(JSON.stringify(identities))>1024*1024)throw new Error('SVN caller probe metadata limit exceeded');}return {paths:existing,fingerprint:hash(JSON.stringify(identities))};}
|
|
@@ -1,19 +1,10 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Native SVN separates keyword definitions using ASCII whitespace, not shell quotes.
|
|
2
2
|
export function keywordConfigurationTokens(configuration){
|
|
3
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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');
|
|
4
|
+
if(configuration.includes(String.fromCharCode(34))||configuration.includes(String.fromCharCode(39)))throw new Error('Quoted SVN keyword configurations are unsupported; use %_ for a space');
|
|
5
|
+
if(/[\x00-\x08\x0e-\x1f\x7f]/.test(configuration))throw new Error('Control character in SVN keyword configuration');
|
|
6
|
+
if(configuration.includes(String.fromCharCode(92)))throw new Error('Unquoted escape in SVN keyword configuration');
|
|
7
|
+
const tokens=configuration.split(/[ \t\r\n\v\f]+/).filter(Boolean);
|
|
8
|
+
if(tokens.length>100)throw new Error('SVN keyword configuration token limit exceeded');
|
|
18
9
|
return tokens;
|
|
19
10
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {lstat} from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import {checked} from './process.mjs';
|
|
2
4
|
import {xml} from './svn.mjs';
|
|
3
5
|
import {readReplacementSource} from './svn-replacement-source.mjs';
|
|
@@ -20,19 +22,34 @@ export async function captureReplacementTree(root,rootInfo,name,options={}){
|
|
|
20
22
|
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
23
|
const files=[],receipts=[];
|
|
22
24
|
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;};
|
|
25
|
+
const ancestorReceipts=new Map();let verifying=false;
|
|
26
|
+
const ancestors=async p=>{
|
|
27
|
+
const parts=p.split('/').slice(0,-1),names=['.',...parts.map((_,i)=>parts.slice(0,i+1).join('/'))],records=[];
|
|
28
|
+
for(const name of names){
|
|
29
|
+
if(!verifying&&ancestorReceipts.has(name)){records.push(ancestorReceipts.get(name));continue;}
|
|
30
|
+
const stat=await lstat(name==='.'?root:path.join(root,name));if(stat.isSymbolicLink()||!stat.isDirectory())throw new Error('Replacement ancestor is not a real directory');
|
|
31
|
+
const node=xml(await run(['info','--xml','--',name+'@'])).info?.entry?.[0],identity=svnWorkingIdentity(node,rootInfo,name);
|
|
32
|
+
const entries=xml(await run(['status','--xml','--verbose','--ignore-externals','--depth','empty','--',name+'@'])).status?.target?.flatMap(t=>t.entry??[])??[],s=entries[0]?.['wc-status'];
|
|
33
|
+
if(entries.length!==1||!s||!['normal','modified','added','replaced'].includes(s['@_item'])||s['@_props']==='conflicted'||['@_switched','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true'))throw new Error('Unsafe replacement ancestor state');
|
|
34
|
+
const properties=await props(name);if(Object.hasOwn(properties,'svn:externals')||Object.hasOwn(properties,'svn:special'))throw new Error('Unsafe replacement ancestor properties');
|
|
35
|
+
const record={path:name,fingerprint:hash(JSON.stringify([identity.fingerprint,properties]))};
|
|
36
|
+
if(verifying){if(ancestorReceipts.get(name)?.fingerprint!==record.fingerprint)throw new Error('Replacement ancestor changed during capture');}else ancestorReceipts.set(name,record);records.push(record);
|
|
37
|
+
}return records;
|
|
38
|
+
};
|
|
23
39
|
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
40
|
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');
|
|
41
|
+
const ancestorEvidence=await ancestors(p);
|
|
25
42
|
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
43
|
if(node['@_kind']==='dir'&&identity.source.depth!=='infinity')throw new Error('Sparse replacement target cannot prove descendant inventory');
|
|
27
44
|
const properties=await props(p);if(Object.hasOwn(properties,'svn:externals'))throw new Error('Replacement externals require independent scope');
|
|
28
45
|
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'])};
|
|
46
|
+
return {kind:node['@_kind'],content,props:properties,source:identity.source,identity:hash(JSON.stringify([identity.fingerprint,ancestorEvidence])),ancestorEvidence,special:svnSpecialData(content,properties,node['@_kind'])};
|
|
30
47
|
};
|
|
31
48
|
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
49
|
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
50
|
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
51
|
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;
|
|
52
|
+
if(old)f.replacementSource=old.source;if(newer){f.workingSource=newer.source;f.ancestorEvidence=newer.ancestorEvidence;}
|
|
36
53
|
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
54
|
const oldSpecial=old?svnSpecialData(old.content,a,old.kind):null;if(oldSpecial||newer?.special)f.specialData={old:oldSpecial,new:newer?.special??null};
|
|
38
55
|
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};
|
|
@@ -41,7 +58,7 @@ export async function captureReplacementTree(root,rootInfo,name,options={}){
|
|
|
41
58
|
}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
59
|
}
|
|
43
60
|
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');
|
|
61
|
+
const verify=async()=>{verifying=true;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
62
|
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
63
|
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
64
|
};
|
package/src/svn.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { isDeepStrictEqual } from 'node:util';
|
|
|
16
16
|
import { XMLParser, XMLValidator } from 'fast-xml-parser';
|
|
17
17
|
import { checked } from './process.mjs';
|
|
18
18
|
import { hash, decode, readLocal, relativePath } from './content.mjs';
|
|
19
|
-
const parser = new XMLParser({ ignoreAttributes: false, parseTagValue: false, parseAttributeValue: false, trimValues: false, isArray: name => ['entry', 'target', 'property'].includes(name) });
|
|
19
|
+
const parser = new XMLParser({ htmlEntities: {}, ignoreAttributes: false, parseTagValue: false, parseAttributeValue: false, trimValues: false, isArray: name => ['entry', 'target', 'property'].includes(name) });
|
|
20
20
|
export function xml(bytes) {
|
|
21
21
|
const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
22
22
|
if (/<!DOCTYPE|<!ENTITY/i.test(source) || XMLValidator.validate(source) !== true) throw new Error('Unsafe or invalid SVN XML');
|
|
@@ -19,7 +19,7 @@ export async function verificationLoop(model, request, candidates, scope, option
|
|
|
19
19
|
const input = JSON.stringify({ contextSideNotice:CONTEXT_SIDE_NOTICE, ...(envelope.businessRequirement!==undefined?{businessRequirement:envelope.businessRequirement,requirementNotice:envelope.requirementNotice}:{}), defectGuidance: [...new Set(candidates.map(f => scope.catalog().find(s => s.fileId === f.fileId)?.path).filter(Boolean))].map(path => envelope.file?.path === path && envelope.defectGuidance ? envelope.defectGuidance : envelope.files?.find(f=>f.path===path)?.defectGuidance ?? defectGuidance(path)), rules, candidates: candidates.map((finding, i) => ({ candidateId: ids[i], finding })) });
|
|
20
20
|
const receipts = createEvidenceReceipts(scope.read);
|
|
21
21
|
const retrievalScope = { ...scope, read: receipts.read };
|
|
22
|
-
const response = await retrievalLoop(model, { ...request, input, instructions: VERIFICATION_INSTRUCTIONS }, retrievalScope, options);
|
|
22
|
+
const response = await retrievalLoop(model, { ...request, input, instructions:envelope.file?.reviewMode==='project'?VERIFICATION_INSTRUCTIONS+' 本次为当前项目源码审查,空old只是传输占位,不是历史版本。不要要求回归因果;regression.classification用uncertain并说明不适用,仅凭new侧原文核实当前缺陷和反证。':VERIFICATION_INSTRUCTIONS }, retrievalScope, options);
|
|
23
23
|
request.signal?.throwIfAborted();
|
|
24
24
|
return validateCandidateVerification(response, ids, scope.read, receipts.resolve, { catalog: scope.catalog(), fileIds: Object.fromEntries(ids.map((id, i) => [id, candidates[i].fileId])) });
|
|
25
25
|
}
|
package/src/window-findings.mjs
CHANGED
|
@@ -3,6 +3,9 @@ export function validateWindowFindings(findings, windows, synthesis = false) {
|
|
|
3
3
|
const edits = new Set(windows.flatMap(w => w.editIds));
|
|
4
4
|
for (const finding of findings) {
|
|
5
5
|
const attribution = finding.attribution;
|
|
6
|
+
if(attribution.status==='not-applicable'&&attribution.causality==='current-source-only'){
|
|
7
|
+
const anchor=finding.anchor;if(synthesis||anchor.kind!=='line'||anchor.side!=='new'||!windows.some(w=>anchor.start>=w.new.start&&anchor.end<w.new.start+w.new.count))throw new Error('Project finding outside approved current-source windows');continue;
|
|
8
|
+
}
|
|
6
9
|
if (attribution.status !== 'references-validated' || !attribution.editIds?.length || attribution.editIds.some(id => !edits.has(id))) throw new Error('Finding outside current window edits');
|
|
7
10
|
if (synthesis && windows.filter(w => w.editIds.some(id => attribution.editIds.includes(id))).length < 2) throw new Error('Synthesis finding must reference multiple windows');
|
|
8
11
|
const anchor = finding.anchor;
|