@feng0531/dsh-code-review 0.82.3 → 0.83.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 +8 -0
- package/README.md +1 -1
- package/RELEASE-NOTES.md +8 -7
- package/index.mjs +11 -12
- package/package.json +1 -1
- package/src/background-review.mjs +84 -11
- package/src/business-groups.mjs +1 -1
- package/src/class-bindings.mjs +45 -12
- package/src/defect-guidance.mjs +158 -16
- package/src/dsh-model.mjs +107 -15
- package/src/git-history.mjs +202 -56
- package/src/git.mjs +64 -34
- package/src/import-bindings.mjs +74 -65
- package/src/job-progress.mjs +21 -0
- package/src/model-contract.mjs +17 -1
- package/src/primary-input.mjs +85 -26
- package/src/project-report.mjs +22 -5
- package/src/project-review-service.mjs +177 -37
- package/src/project-snapshot.mjs +101 -40
- package/src/prompts.mjs +9 -0
- package/src/protocol-guidance.mjs +13 -1
- package/src/recovery-coverage.mjs +25 -1
- package/src/retrieval-loop.mjs +66 -28
- package/src/review-jobs.mjs +6 -4
- package/src/review.mjs +36 -33
- package/src/risk-plan.mjs +1 -1
- package/src/service.mjs +136 -5
- package/src/snapshot-reader.mjs +18 -5
- package/src/snapshot.mjs +69 -42
- package/src/svn-capture-budget.mjs +22 -5
- package/src/svn-effective-mergeinfo.mjs +59 -37
- package/src/svn-eol-mapping.mjs +22 -10
- package/src/svn-history-endpoints.mjs +18 -12
- package/src/svn-history-lookup.mjs +34 -26
- package/src/svn-keyword-config.mjs +11 -8
- package/src/svn-keyword-mapping.mjs +67 -36
- package/src/svn-mergeinfo.mjs +34 -22
- package/src/svn-navigation-budget.mjs +15 -4
- package/src/svn-special.mjs +13 -8
- package/src/svn-url.mjs +18 -6
- package/src/svn-working-identity.mjs +22 -8
- package/src/svn-working-scope.mjs +16 -5
- package/src/tool-view.mjs +5 -0
- package/src/verification-loop.mjs +2 -1
package/src/review-jobs.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {jobProgress} from './job-progress.mjs';
|
|
1
2
|
import {recoveryCoverage} from './recovery-coverage.mjs';
|
|
2
3
|
import {randomUUID} from 'node:crypto';
|
|
3
4
|
import {mkdir,writeFile,rename,readFile,lstat,realpath} from 'node:fs/promises';
|
|
@@ -6,20 +7,21 @@ import path from 'node:path';
|
|
|
6
7
|
// Report storage is host-owned, never inside a reviewed working copy.
|
|
7
8
|
export function createReviewJobs({directory}={}){
|
|
8
9
|
if(typeof directory!=='string'||!path.isAbsolute(directory))throw new Error('Absolute host report directory required');
|
|
9
|
-
const jobs=new Map();let disposed=false,canonicalDirectory;
|
|
10
|
+
const jobs=new Map(),listeners=new Map();const changed=id=>{for(const fn of listeners.get(id)??[])fn();listeners.delete(id);};let disposed=false,canonicalDirectory;
|
|
10
11
|
const identity=exec=>{const a=exec?.agent,s=a?.session;if(!s||!a.options?.provider||!a.options?.model||!s.header?.cwd)throw new Error('Review job requires session identity');const id=s.header.id??s.id;if(typeof id!=='string'||!id)throw new Error('Durable session id required');return {sessionId:id,cwd:s.header.cwd,provider:a.options.provider,model:a.options.model,reasoningEffort:a.options.reasoningEffort??null};};
|
|
11
12
|
const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
|
|
12
13
|
const pageCheckpoint=(c,offset,limit)=>({...c,...(c.files?{files:c.files.slice(offset,offset+limit),totalFiles:c.files.length}:{}),...(c.findings?{findings:c.findings.slice(offset,offset+limit),totalFindings:c.findings.length}: {})});
|
|
13
14
|
const filename=id=>{if(typeof id!=='string'||!/^[a-f0-9-]{36}$/.test(id))throw new Error('Invalid review job id');return path.join(directory,id+'.json');};
|
|
14
15
|
async function safeDirectory(){await mkdir(directory,{recursive:true,mode:0o700});const stat=await lstat(directory),resolved=await realpath(directory);if(stat.isSymbolicLink()||!stat.isDirectory()||(canonicalDirectory!==undefined&&resolved!==canonicalDirectory))throw new Error('Unsafe report directory');canonicalDirectory??=resolved;}
|
|
15
|
-
async function save(job){await safeDirectory();const tmp=path.join(directory,job.id+'.'+randomUUID()+'.tmp');await writeFile(tmp,JSON.stringify(job.record),{mode:0o600,flag:'wx'});await rename(tmp,filename(job.id));}
|
|
16
|
+
async function save(job){await safeDirectory();const tmp=path.join(directory,job.id+'.'+randomUUID()+'.tmp');await writeFile(tmp,JSON.stringify(job.record),{mode:0o600,flag:'wx'});await rename(tmp,filename(job.id));changed(job.id);}
|
|
16
17
|
async function owned(id,exec){const who=identity(exec),job=jobs.get(id);if(job){if(!same(who,job.record.owner))throw new Error('Foreign review job');return job.record;}await safeDirectory();const name=filename(id),stat=await lstat(name);if(stat.isSymbolicLink()||!stat.isFile())throw new Error('Unsafe report file');const r=JSON.parse(await readFile(name,'utf8'));if(!same(who,r.owner))throw new Error('Foreign review job');if(['queued','running'].includes(r.status)){r.status='interrupted';r.notice='Host restarted or plugin disposed. Saved checkpoints are available; resending requires a fresh preview and approval.';}return r;}
|
|
17
18
|
return {
|
|
18
19
|
async start(exec,work){if(disposed)throw new Error('Review job service disposed');exec.signal?.throwIfAborted();const who=identity(exec);const relative=path.relative(path.resolve(who.cwd),directory);if(relative===''||(!relative.startsWith('..'+path.sep)&&relative!=='..'&&!path.isAbsolute(relative)))throw new Error('Report store must be outside reviewed workspace');if([...jobs.values()].some(j=>j.record.owner.sessionId===who.sessionId&&['queued','running'].includes(j.record.status)))throw new Error('Session review job already active');const id=randomUUID(),controller=new AbortController(),job={id,controller,record:{jobId:id,owner:who,status:'queued',stage:'queued',checkpoints:[],createdAt:new Date().toISOString()},pending:Promise.resolve()};jobs.set(id,job);try{await save(job);}catch(error){jobs.delete(id);throw error;}const enqueue=fn=>{job.pending=job.pending.then(fn);return job.pending;};
|
|
19
20
|
// Own cancellation explicitly. The short start RPC ending is NOT task cancellation.
|
|
20
|
-
job.done=(async()=>{await Promise.resolve();try{job.record.status='running';await enqueue(()=>save(job));const result=await work({signal:controller.signal,checkpoint:async value=>enqueue(async()=>{job.record.stage=value.stage??'reviewing';job.record.checkpoints.push(structuredClone(value));await save(job);})});job.record.result=result;job.record.status=controller.signal.aborted?'cancelled':'completed';}catch(error){job.record.status=controller.signal.aborted?'cancelled':'failed';job.record.error=error?.message??String(error);}finally{job.record.finishedAt=new Date().toISOString();try{await enqueue(()=>save(job));}catch(error){job.record.status='failed';job.record.persistenceError=error.message;}}})();return {jobId:id,status:'queued',notice:'Background job accepted. Poll status; start is not a completed review. Explicit cancel or plugin disposal stops it.'};
|
|
21
|
+
job.done=(async()=>{await Promise.resolve();try{job.record.status='running';await enqueue(()=>save(job));const result=await work({signal:controller.signal,checkpoint:async value=>enqueue(async()=>{job.record.stage=value.stage??'reviewing';job.record.checkpoints.push(structuredClone(value));await save(job);})});job.record.result=result;job.record.status=controller.signal.aborted?'cancelled':'completed';}catch(error){job.record.status=controller.signal.aborted?'cancelled':'failed';job.record.error=error?.message??String(error);}finally{job.record.finishedAt=new Date().toISOString();try{await enqueue(()=>save(job));}catch(error){job.record.status='failed';job.record.persistenceError=error.message;}}})();return {jobId:id,status:'queued',nextAction:'Call code_review_wait until terminal status; do not finish the user request at start.',notice:'Background job accepted. Poll status; start is not a completed review. Explicit cancel or plugin disposal stops it.'};
|
|
21
22
|
},
|
|
22
|
-
async status(id,exec,page){const r=structuredClone(await owned(id,exec));r.recovery=recoveryCoverage(r);if(!page)return r;const {offset=0,limit=20,checkpointIndex}=page;if(!Number.isSafeInteger(offset)||offset<0||!Number.isSafeInteger(limit)||limit<1||limit>100)throw new Error('Invalid result page');if(checkpointIndex!==undefined){if(!Number.isSafeInteger(checkpointIndex)||checkpointIndex<0||checkpointIndex>=r.checkpoints.length)throw new Error('Invalid checkpoint index');return {jobId:r.jobId,status:r.status,checkpointIndex,checkpoint:pageCheckpoint(r.checkpoints[checkpointIndex],offset,limit),page:{offset,limit}};}const result=r.result;const report=result?.report;return {...r,result:undefined,recovery:{...r.recovery,unfinishedStages:r.recovery.unfinishedStages.slice(offset,offset+limit),files:r.recovery.files.slice(offset,offset+limit),windows:r.recovery.windows.slice(offset,offset+limit),suggestedPaths:r.recovery.suggestedPaths.slice(offset,offset+limit),totalObservedFiles:r.recovery.files.length},checkpoints:r.checkpoints.slice(offset,offset+limit).map(c=>pageCheckpoint(c,0,limit)),checkpointPage:{offset,limit,total:r.checkpoints.length},...(result?{resultSummary:report?{status:report.status,coverage:report.coverage,model:report.model,snapshotId:report.snapshotId}:({...result,files:result.files?.slice(offset,offset+limit),filePage:result.files?{offset,limit,total:result.files.length}:undefined}),resultPage:report?{files:report.files?.slice(offset,offset+limit),findings:report.findings?.slice(offset,offset+limit),totalFiles:report.files?.length,totalFindings:report.findings?.length}:undefined}: {})};},
|
|
23
|
+
async status(id,exec,page){const r=structuredClone(await owned(id,exec));r.recovery=recoveryCoverage(r);r.progress=jobProgress(r);r.waitRevision=r.checkpoints.length;r.nextAction=['queued','running'].includes(r.status)?'Call code_review_wait; do not end the user review request while task runs.':r.result?.previewId?'Show preview and request user confirmation before execute.':'Collect final report and unfinished scope, then provide one final answer.';if(!page)return r;const {offset=0,limit=20,checkpointIndex}=page;if(!Number.isSafeInteger(offset)||offset<0||!Number.isSafeInteger(limit)||limit<1||limit>100)throw new Error('Invalid result page');if(checkpointIndex!==undefined){if(!Number.isSafeInteger(checkpointIndex)||checkpointIndex<0||checkpointIndex>=r.checkpoints.length)throw new Error('Invalid checkpoint index');return {jobId:r.jobId,status:r.status,checkpointIndex,checkpoint:pageCheckpoint(r.checkpoints[checkpointIndex],offset,limit),page:{offset,limit}};}const result=r.result;const report=result?.report;return {...r,result:undefined,recovery:{...r.recovery,unfinishedStages:r.recovery.unfinishedStages.slice(offset,offset+limit),files:r.recovery.files.slice(offset,offset+limit),windows:r.recovery.windows.slice(offset,offset+limit),suggestedPaths:r.recovery.suggestedPaths.slice(offset,offset+limit),totalObservedFiles:r.recovery.files.length},checkpoints:r.checkpoints.slice(offset,offset+limit).map(c=>pageCheckpoint(c,0,limit)),checkpointPage:{offset,limit,total:r.checkpoints.length},...(result?{resultSummary:report?{status:report.status,coverage:report.coverage,model:report.model,snapshotId:report.snapshotId}:({...result,files:result.files?.slice(offset,offset+limit),filePage:result.files?{offset,limit,total:result.files.length}:undefined}),resultPage:report?{files:report.files?.slice(offset,offset+limit),findings:report.findings?.slice(offset,offset+limit),totalFiles:report.files?.length,totalFindings:report.findings?.length}:undefined}: {})};},
|
|
24
|
+
async wait(id,exec,{after,timeoutMs=25000,signal}={}){const r=await owned(id,exec);if(!Number.isSafeInteger(timeoutMs)||timeoutMs<1||timeoutMs>60000)throw new Error('Invalid wait duration');if(after!==undefined&&(!Number.isSafeInteger(after)||after<0))throw new Error('Invalid wait revision');const revision=r.checkpoints.length;if(!['queued','running'].includes(r.status)||(after!==undefined&&after!==revision))return {changed:true};signal?.throwIfAborted();await new Promise((resolve,reject)=>{let timer;const cleanup=()=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);listeners.get(id)?.delete(done);};const done=()=>{cleanup();resolve();},abort=()=>{cleanup();reject(signal.reason);};if(!listeners.has(id))listeners.set(id,new Set());listeners.get(id).add(done);signal?.addEventListener('abort',abort,{once:true});timer=setTimeout(done,timeoutMs);const latest=jobs.get(id)?.record;if(latest&&(latest.checkpoints.length!==revision||!['queued','running'].includes(latest.status)))done();if(signal?.aborted)abort();});return {changed:true};},
|
|
23
25
|
async cancel(id,exec){await owned(id,exec);const j=jobs.get(id);if(j)j.controller.abort(new Error('User cancelled review job'));return {jobId:id,cancelRequested:!!j};},
|
|
24
26
|
async dispose(){disposed=true;for(const j of jobs.values())j.controller.abort(new Error('Review plugin disposed'));await Promise.all([...jobs.values()].map(j=>j.done));}
|
|
25
27
|
};
|
package/src/review.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { REVIEW_INSTRUCTIONS, PROJECT_REVIEW_INSTRUCTIONS } from './prompts.mjs';
|
|
1
2
|
import { reviewRounds } from './review-rounds.mjs';
|
|
2
3
|
import { correctAnchor } from './anchor-correction.mjs';
|
|
3
4
|
import { validateBusinessRequirement } from './business-requirement.mjs';
|
|
@@ -21,8 +22,8 @@ import { retrievalLoop } from './retrieval-loop.mjs';
|
|
|
21
22
|
import { preparePrimaryInput } from './primary-input.mjs';
|
|
22
23
|
import { validateAttribution } from './attribution.mjs';
|
|
23
24
|
|
|
24
|
-
export
|
|
25
|
-
export
|
|
25
|
+
export { REVIEW_INSTRUCTIONS };
|
|
26
|
+
export { PROJECT_REVIEW_INSTRUCTIONS };
|
|
26
27
|
const nonempty = value => typeof value === 'string' && value.trim().length > 0 && value.length <= 8000;
|
|
27
28
|
const lines = text => text.replaceAll('\r\n', '\n').split('\n');
|
|
28
29
|
|
|
@@ -327,36 +328,7 @@ const safe = text => Array.from(String(text ?? '')).map(c => {
|
|
|
327
328
|
if ('*_[]#|!()\\'.includes(c) || c.charCodeAt(0) === 96) return '\\' + c;
|
|
328
329
|
return c;
|
|
329
330
|
}).join('');
|
|
330
|
-
|
|
331
|
-
const output = ['# Code Review 报告', '', '状态:' + report.status, '快照:' + report.snapshotId, '', '## 覆盖情况'];
|
|
332
|
-
if (report.callerDiscovery) output.push('- 引用方发现:扫描 '+report.callerDiscovery.scanned.length+' 个文件,捕获 '+report.callerDiscovery.capturedPaths.length+' 个上下文;仅批准目录字面引用导航,不证明函数调用或完整覆盖。');
|
|
333
|
-
if(report.businessRequirement)output.push('- 业务需求:SHA256 '+safe(report.businessRequirement.hash)+' · '+report.businessRequirement.bytes+' bytes;不可信约束,不是证据或权限,全文不在报告重复。');
|
|
334
|
-
for (const file of report.files) for (const correction of file.anchorCorrections ?? []) output.push('- 定位纠正:' + safe(correction.originalCandidate.fileId) + ' · ' + safe(correction.status) + ' · ' + safe(correction.reason) + ';原完整候选保留JSON,仅定位不证明因果');
|
|
335
|
-
if (report.history) output.push('- Git历史审查:' + safe(report.history.mode) + ' · ' + safe(report.history.base ?? 'empty') + ' → ' + safe(report.history.target) + ';精确端点树差异,重命名按增删,不含工作树');
|
|
336
|
-
if (report.businessGrouping) output.push('- 业务分组:' + safe(report.businessGrouping.status) + (report.businessGrouping.reason ? ' — ' + safe(report.businessGrouping.reason) : '') + ';仅调度假设,不证明依赖或覆盖');
|
|
337
|
-
for (const file of report.files) for (const plan of file.riskPlans ?? []) output.push('- 风险计划:' + safe(file.path) + ' · ' + safe(plan.status) + ' · ' + plan.risks.length + '项待证假设;不表示缺陷成立或覆盖完成');
|
|
338
|
-
for (const file of report.files) if(file.reviewPasses)output.push('- 独立审查轮次 '+safe(file.path)+':'+file.reviewPasses.filter(p=>p.status==='completed').length+'/'+file.reviewPasses.length+'阶段完成;同一生命周期预算,后续轮无首轮计划;不证明代码正确。');
|
|
339
|
-
for(const file of report.files)for(const failure of file.stageFailures??[])output.push('- 未完成阶段:'+safe(file.path)+' · '+safe(failure.phase)+' · '+safe(failure.code)+';主审成果保留。');
|
|
340
|
-
for (const file of report.files) output.push('- ' + safe(file.path) + ':' + file.status + (file.reason ? ' — ' + safe(file.reason) : ''));
|
|
341
|
-
if (report.grouping) {
|
|
342
|
-
const names = new Map(report.files.map(f => [f.fileId, f.path]));
|
|
343
|
-
output.push('', '## 关联上下文', '关联仅为命名提示,不证明依赖或缺陷;每个主文件独立审查。');
|
|
344
|
-
for (const link of report.grouping.links) output.push('- ' + safe(names.get(link.from)) + ' ↔ ' + safe(names.get(link.to)) + ':' + safe(link.reasons.join(', ')) + (link.split ? '(受组上限限制,已拆组)' : ''));
|
|
345
|
-
for (const file of report.files) if (file.relatedFileIds?.length) output.push('- ' + safe(file.path) + ':输入包含 ' + file.relatedFileIds.length + ' 个关联文件');
|
|
346
|
-
}
|
|
347
|
-
for (const file of report.files) if (file.windowCoverage) output.push('- ' + safe(file.path) + ':变更窗口已完成 ' + file.windowCoverage.completed.length + ',待审 ' + file.windowCoverage.pending.length + ';仅表示窗口分析完成,不证明全文正确');
|
|
348
|
-
for (const file of report.files) if (file.synthesisStatus) output.push('- ' + safe(file.path) + ':跨窗口综合 ' + safe(file.synthesisStatus) + ';不等于事实正确性证明');
|
|
349
|
-
for (const interaction of report.interactions ?? []) output.push('- 跨文件综合 ' + safe(interaction.groupId) + ':' + safe(interaction.status) + ';共享组末主文件超时,不证明完整交互覆盖');
|
|
350
|
-
if (report.rules?.length) output.push('', '## 项目规则', '规则仅为已批准约束数据,不扩大权限或证明缺陷。', ...report.rules.map(rule => '- ' + safe(rule.path) + ' · SHA256 ' + safe(rule.hash)));
|
|
351
|
-
if (report.followup?.suggestedPaths.length) output.push('', '## 后续选择建议', '仅为原快照未完成项;不会自动续审。再次执行需新预览、新审批,并显式选择规则和上下文。阻断项需先处理原因;关联上下文不等于主文件已审查。', ...report.followup.items.filter(item => item.followup).map(item => '- ' + safe(item.path) + ':' + item.status + (item.incompleteVerification ? '(候选复核未完成)' : '')));
|
|
352
|
-
output.push('', '## 审查发现');
|
|
353
|
-
if (!report.findings.length) output.push(report.status === 'completed' ? '在已审查范围内未发现具体问题;不代表代码已被证明正确。' : '当前没有有效问题记录,但审查存在未完成项或限制,不能视为通过。');
|
|
354
|
-
// Recompute from exact data rather than trusting potentially stale group metadata.
|
|
355
|
-
const duplicates = new Map();
|
|
356
|
-
for (const group of groupDuplicateFindings(report.findings)) {
|
|
357
|
-
for (const index of group.findingIndices.slice(1)) duplicates.set(index, group.findingIndices[0]);
|
|
358
|
-
}
|
|
359
|
-
for (const [index, finding] of report.findings.entries()) {
|
|
331
|
+
function renderFinding(output, finding, index, duplicates) {
|
|
360
332
|
const a = finding.anchor;
|
|
361
333
|
const location = a.kind === 'line' ? a.side + ':' + a.start + '-' + a.end : a.kind === 'property' ? '属性 ' + a.name : '文件级';
|
|
362
334
|
output.push('', '### [' + finding.severity + '] ' + safe(finding.title), safe(finding.path) + ' · ' + safe(location));
|
|
@@ -371,7 +343,7 @@ export function markdownReport(report) {
|
|
|
371
343
|
output.push('- 本处变更引用:' + safe([...finding.attribution.editIds, ...finding.attribution.properties].join(', ')));
|
|
372
344
|
output.push('- 本处定位原文:' + safe(a.snippet ?? '文件或属性定位'));
|
|
373
345
|
output.push('- 复核状态:模型复核支持;完整证据同前述发现,不是事实或因果证明');
|
|
374
|
-
|
|
346
|
+
return;
|
|
375
347
|
}
|
|
376
348
|
const verification = finding.verification;
|
|
377
349
|
if (verification) {
|
|
@@ -388,6 +360,37 @@ export function markdownReport(report) {
|
|
|
388
360
|
}
|
|
389
361
|
for (const [key, label] of [['trigger','触发条件'],['evidence','证据'],['impact','影响'],['suggestion','建议']]) output.push('- ' + label + ':' + safe(finding[key]));
|
|
390
362
|
}
|
|
363
|
+
|
|
364
|
+
export function markdownReport(report) {
|
|
365
|
+
const output = ['# Code Review 报告', '', '状态:' + report.status, '快照:' + report.snapshotId, '', '## 覆盖情况'];
|
|
366
|
+
if (report.callerDiscovery) output.push('- 引用方发现:扫描 '+report.callerDiscovery.scanned.length+' 个文件,捕获 '+report.callerDiscovery.capturedPaths.length+' 个上下文;仅批准目录字面引用导航,不证明函数调用或完整覆盖。');
|
|
367
|
+
if(report.businessRequirement)output.push('- 业务需求:SHA256 '+safe(report.businessRequirement.hash)+' · '+report.businessRequirement.bytes+' bytes;不可信约束,不是证据或权限,全文不在报告重复。');
|
|
368
|
+
for (const file of report.files) for (const correction of file.anchorCorrections ?? []) output.push('- 定位纠正:' + safe(correction.originalCandidate.fileId) + ' · ' + safe(correction.status) + ' · ' + safe(correction.reason) + ';原完整候选保留JSON,仅定位不证明因果');
|
|
369
|
+
if (report.history) output.push('- Git历史审查:' + safe(report.history.mode) + ' · ' + safe(report.history.base ?? 'empty') + ' → ' + safe(report.history.target) + ';精确端点树差异,重命名按增删,不含工作树');
|
|
370
|
+
if (report.businessGrouping) output.push('- 业务分组:' + safe(report.businessGrouping.status) + (report.businessGrouping.reason ? ' — ' + safe(report.businessGrouping.reason) : '') + ';仅调度假设,不证明依赖或覆盖');
|
|
371
|
+
for (const file of report.files) for (const plan of file.riskPlans ?? []) output.push('- 风险计划:' + safe(file.path) + ' · ' + safe(plan.status) + ' · ' + plan.risks.length + '项待证假设;不表示缺陷成立或覆盖完成');
|
|
372
|
+
for (const file of report.files) if(file.reviewPasses)output.push('- 独立审查轮次 '+safe(file.path)+':'+file.reviewPasses.filter(p=>p.status==='completed').length+'/'+file.reviewPasses.length+'阶段完成;同一生命周期预算,后续轮无首轮计划;不证明代码正确。');
|
|
373
|
+
for(const file of report.files)for(const failure of file.stageFailures??[])output.push('- 未完成阶段:'+safe(file.path)+' · '+safe(failure.phase)+' · '+safe(failure.code)+';主审成果保留。');
|
|
374
|
+
for (const file of report.files) output.push('- ' + safe(file.path) + ':' + file.status + (file.reason ? ' — ' + safe(file.reason) : ''));
|
|
375
|
+
if (report.grouping) {
|
|
376
|
+
const names = new Map(report.files.map(f => [f.fileId, f.path]));
|
|
377
|
+
output.push('', '## 关联上下文', '关联仅为命名提示,不证明依赖或缺陷;每个主文件独立审查。');
|
|
378
|
+
for (const link of report.grouping.links) output.push('- ' + safe(names.get(link.from)) + ' ↔ ' + safe(names.get(link.to)) + ':' + safe(link.reasons.join(', ')) + (link.split ? '(受组上限限制,已拆组)' : ''));
|
|
379
|
+
for (const file of report.files) if (file.relatedFileIds?.length) output.push('- ' + safe(file.path) + ':输入包含 ' + file.relatedFileIds.length + ' 个关联文件');
|
|
380
|
+
}
|
|
381
|
+
for (const file of report.files) if (file.windowCoverage) output.push('- ' + safe(file.path) + ':变更窗口已完成 ' + file.windowCoverage.completed.length + ',待审 ' + file.windowCoverage.pending.length + ';仅表示窗口分析完成,不证明全文正确');
|
|
382
|
+
for (const file of report.files) if (file.synthesisStatus) output.push('- ' + safe(file.path) + ':跨窗口综合 ' + safe(file.synthesisStatus) + ';不等于事实正确性证明');
|
|
383
|
+
for (const interaction of report.interactions ?? []) output.push('- 跨文件综合 ' + safe(interaction.groupId) + ':' + safe(interaction.status) + ';共享组末主文件超时,不证明完整交互覆盖');
|
|
384
|
+
if (report.rules?.length) output.push('', '## 项目规则', '规则仅为已批准约束数据,不扩大权限或证明缺陷。', ...report.rules.map(rule => '- ' + safe(rule.path) + ' · SHA256 ' + safe(rule.hash)));
|
|
385
|
+
if (report.followup?.suggestedPaths.length) output.push('', '## 后续选择建议', '仅为原快照未完成项;不会自动续审。再次执行需新预览、新审批,并显式选择规则和上下文。阻断项需先处理原因;关联上下文不等于主文件已审查。', ...report.followup.items.filter(item => item.followup).map(item => '- ' + safe(item.path) + ':' + item.status + (item.incompleteVerification ? '(候选复核未完成)' : '')));
|
|
386
|
+
output.push('', '## 审查发现');
|
|
387
|
+
if (!report.findings.length) output.push(report.status === 'completed' ? '在已审查范围内未发现具体问题;不代表代码已被证明正确。' : '当前没有有效问题记录,但审查存在未完成项或限制,不能视为通过。');
|
|
388
|
+
// Recompute from exact data rather than trusting potentially stale group metadata.
|
|
389
|
+
const duplicates = new Map();
|
|
390
|
+
for (const group of groupDuplicateFindings(report.findings)) {
|
|
391
|
+
for (const index of group.findingIndices.slice(1)) duplicates.set(index, group.findingIndices[0]);
|
|
392
|
+
}
|
|
393
|
+
for (const [index, finding] of report.findings.entries()) renderFinding(output, finding, index, duplicates);
|
|
391
394
|
if (report.limitations.length) output.push('', '## 限制', ...report.limitations.map(l => '- ' + safe(l.text)));
|
|
392
395
|
return output.join('\n') + '\n';
|
|
393
396
|
}
|
package/src/risk-plan.mjs
CHANGED
|
@@ -12,7 +12,7 @@ export function riskPlanningDecision(files) {
|
|
|
12
12
|
return {required:maxFileChanged>=50||(files.length>=2&&totalChanged>=100),reason:maxFileChanged>=50?'file-threshold':files.length>=2&&totalChanged>=100?'group-threshold':'below-threshold',totalChanged,maxFileChanged};
|
|
13
13
|
}
|
|
14
14
|
// Plans are untrusted hypotheses, never findings or authority to expand scope.
|
|
15
|
-
export
|
|
15
|
+
export { RISK_PLAN_INSTRUCTIONS } from './prompts.mjs';
|
|
16
16
|
export function validateRiskPlan(response, files, catalog) {
|
|
17
17
|
const value = typeof response === 'string' ? JSON.parse(response) : response;
|
|
18
18
|
if (!value || Object.keys(value).some(k => k !== 'risks') || !Array.isArray(value.risks) || value.risks.length > 20 || Buffer.byteLength(JSON.stringify(value)) > 64*1024) throw new Error('Invalid risk plan');
|
package/src/service.mjs
CHANGED
|
@@ -21,6 +21,32 @@ function owner(exec) {
|
|
|
21
21
|
if (!agent || typeof cwd !== 'string' || !cwd || !agent.options?.provider || !agent.options?.model) throw new Error('Active Agent with cwd and model is required');
|
|
22
22
|
return { agent, cwd, route: { provider: agent.options.provider, model: agent.options.model, ...(agent.options.reasoningEffort ? { reasoningEffort: agent.options.reasoningEffort } : {}) } };
|
|
23
23
|
}
|
|
24
|
+
/** Project one snapshot file into its preview shape; keeps the dense conditionals in one place. */
|
|
25
|
+
function describeFile(file, snapshot) {
|
|
26
|
+
return {
|
|
27
|
+
path: file.path,
|
|
28
|
+
...(file.workingSource ? { workingSource: file.workingSource } : {}),
|
|
29
|
+
...(file.replacementSource ? { replacementSource: file.replacementSource, contentNotice: file.contentNotice } : {}),
|
|
30
|
+
eligibility: file.eligibility,
|
|
31
|
+
reason: file.reason,
|
|
32
|
+
...(file.specialData ? { specialData: file.specialData, contentNotice: file.contentNotice } : {}),
|
|
33
|
+
...(file.eolMapping ? { eolMapping: file.eolMapping, textNotice: file.textNotice } : {}),
|
|
34
|
+
...(file.keywordMapping ? { keywordMapping: file.keywordMapping, textNotice: file.textNotice } : {}),
|
|
35
|
+
...(file.rawStatus === 'replaced' && (snapshot.history?.vcs === 'svn' || file.replacementSource)
|
|
36
|
+
? { oldKind: file.oldKind, newKind: file.newKind, nodeNotice: file.nodeNotice }
|
|
37
|
+
: {}),
|
|
38
|
+
...(file.blockCode
|
|
39
|
+
? { blockCode: file.blockCode, ...(svnDiagnostic(file.blockCode) ? { diagnostic: svnDiagnostic(file.blockCode) } : {}) }
|
|
40
|
+
: {}),
|
|
41
|
+
...(file.copySource ? { copySource: file.copySource, copyNotice: file.copyNotice } : {}),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Compose the user-facing preview notice; text is verbatim from the original rules. */
|
|
46
|
+
function buildPreviewNotice(snapshot, options, enableRiskPlanning) {
|
|
47
|
+
return (snapshot.history?.vcs==='svn'&&options.autoContext===true?'SVN历史autoContext为精确依赖定位读取固定版本父目录有界清单,包含同目录非匹配名字元数据;仅匹配捕获正文发送模型,不扫描全仓,不用工作树替代历史版本。':'') + (options.oldContextPaths !== undefined ? 'oldContextPaths仅为baseline旧侧正文,无目标侧替代;选中变更复用原old身份,旧侧不能证明新侧行为。' : '') + (snapshot.callerDiscovery ? 'callerScopePaths已授权预览读取限定目录内tracked候选正文,包括不匹配文件;非匹配正文不发送模型,匹配捕获上下文会发送。仅字面引用导航,不是函数调用或完整调用覆盖。' : '') + (enableRiskPlanning ? '执行按plan阈值决定风险假设规划,小变更可跳过独立规划但仍主审并复核;计划不是证据,所有阶段共享原预算。' : '') + '执行会向上述模型提供方发送可审查文件两侧内容、预览列出的显式/自动上下文、所选项目规则全文及预览业务需求文本。模型可在该快照范围内多轮只读检索,不读取范围外文件;候选生成后会进行证据与反证复核,两阶段各最多3轮检索,不设插件内部默认时间或总模型调用次数截止,遵循宿主/用户取消;输入字节和检索轮次保护仍保留;复核不能证明缺陷成立。plan.reviewRounds为独立审查工作量,非模型reasoning effort;后续轮不带首轮风险计划,所有轮共享原超时与预算。plan仅预检初始输入,ready不代表已审查;minimumCalls不含后续检索/复核/动态业务综合;多文件可先进行业务分组,分组失败保留原组并记录限制,动态综合数执行前未知,实际预算可能不足。请先向用户展示范围,获得确认后执行。';
|
|
48
|
+
}
|
|
49
|
+
|
|
24
50
|
function createSessionReviewService(llm, { capture = captureSnapshot, now = Date.now, ttlMs = 300000, maxPreviews = 8, allowModelSending = false, enableRiskPlanning = false, skipSmallRiskPlans = true, reviewRounds = 1, enableBusinessGrouping = false, enableAnchorCorrection = false, authorize = async () => false } = {}) {
|
|
25
51
|
for (const value of [ttlMs, maxPreviews]) if (!Number.isSafeInteger(value) || value < 1) throw new Error('Invalid preview limit');
|
|
26
52
|
const lifetime = new AbortController();
|
|
@@ -52,7 +78,43 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
52
78
|
while (previews.size >= maxPreviews) previews.delete(previews.keys().next().value);
|
|
53
79
|
const previewId = randomUUID();
|
|
54
80
|
previews.set(previewId, { ...current, target, options, rounds, snapshotId: snapshot.id, expires: now() + ttlMs });
|
|
55
|
-
return {
|
|
81
|
+
return {
|
|
82
|
+
...(options.businessRequirement !== undefined
|
|
83
|
+
? { businessRequirement: { text: options.businessRequirement, hash: hash(options.businessRequirement), bytes: Buffer.byteLength(options.businessRequirement) } }
|
|
84
|
+
: {}),
|
|
85
|
+
previewId,
|
|
86
|
+
plan,
|
|
87
|
+
snapshotId: snapshot.id,
|
|
88
|
+
repositoryRoot: snapshot.root,
|
|
89
|
+
vcs: snapshot.vcs,
|
|
90
|
+
...(snapshot.workingScope ? { workingScope: snapshot.workingScope } : {}),
|
|
91
|
+
...(snapshot.history ? { history: snapshot.history } : {}),
|
|
92
|
+
...(snapshot.mergeSources ? { mergeSources: snapshot.mergeSources } : {}),
|
|
93
|
+
...(snapshot.effectiveMergeSources ? { effectiveMergeSources: snapshot.effectiveMergeSources } : {}),
|
|
94
|
+
model: current.route,
|
|
95
|
+
modelSendingEnabled: allowModelSending,
|
|
96
|
+
files: snapshot.files.map(f => describeFile(f, snapshot)),
|
|
97
|
+
contextPaths: snapshot.context.map(c => c.path),
|
|
98
|
+
...(snapshot.context.some(c => c.keywordMapping)
|
|
99
|
+
? { contextKeywordMappings: snapshot.context.filter(c => c.keywordMapping).map(c => ({ path: c.path, keywordMapping: c.keywordMapping })) }
|
|
100
|
+
: {}),
|
|
101
|
+
...(snapshot.context.some(c => c.eolMapping)
|
|
102
|
+
? { contextEolMappings: snapshot.context.filter(c => c.eolMapping).map(c => ({ path: c.path, eolMapping: c.eolMapping })) }
|
|
103
|
+
: {}),
|
|
104
|
+
...(snapshot.history?.vcs === 'svn'
|
|
105
|
+
? { contextSources: snapshot.context.map(c => ({ path: c.path, ...(c.svnSource ? { new: c.svnSource } : {}), ...(c.oldSvnSource ? { old: c.oldSvnSource } : {}) })) }
|
|
106
|
+
: {}),
|
|
107
|
+
...(options.oldContextPaths !== undefined
|
|
108
|
+
? {
|
|
109
|
+
oldContextPaths: options.oldContextPaths,
|
|
110
|
+
oldContextSources: snapshot.context.filter(c => c.oldOnly).map(c => ({ path: c.path, side: 'context-old', revision: c.oldRevision, ...(c.oldSvnSource ? { svnSource: c.oldSvnSource } : { blobOid: c.oldBlobOid }), hash: c.oldHash })),
|
|
111
|
+
}
|
|
112
|
+
: {}),
|
|
113
|
+
...(snapshot.callerDiscovery ? { callerDiscovery: snapshot.callerDiscovery } : {}),
|
|
114
|
+
...(snapshot.autoContext ? { autoContext: snapshot.autoContext } : {}),
|
|
115
|
+
rules: (snapshot.rules ?? []).map(({ path, hash }) => ({ path, hash })),
|
|
116
|
+
notice: buildPreviewNotice(snapshot, options, enableRiskPlanning),
|
|
117
|
+
};
|
|
56
118
|
} finally { busy = false; }
|
|
57
119
|
},
|
|
58
120
|
async execute(args, exec) {
|
|
@@ -87,8 +149,77 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
87
149
|
};
|
|
88
150
|
}
|
|
89
151
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
152
|
+
/** Strip reviewMode from change-mode preview args before routing to the changes service. */
|
|
153
|
+
function normalizeChangesArgs(args) {
|
|
154
|
+
return args?.reviewMode === 'changes'
|
|
155
|
+
? Object.fromEntries(Object.entries(args).filter(([key]) => key !== 'reviewMode'))
|
|
156
|
+
: args;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Route one preview/execute call to the matching per-mode service. */
|
|
160
|
+
function routeCall(kind, args, exec, { changes, project }) {
|
|
161
|
+
if (kind === 'preview') {
|
|
162
|
+
return args?.reviewMode === 'project'
|
|
163
|
+
? project.preview(args, exec)
|
|
164
|
+
: changes.preview(normalizeChangesArgs(args), exec);
|
|
165
|
+
}
|
|
166
|
+
return project.owns(args?.previewId)
|
|
167
|
+
? project.execute(args, exec)
|
|
168
|
+
: changes.execute(args, exec);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function createReviewService(llm, options = {}) {
|
|
172
|
+
const sessions = new Map();
|
|
173
|
+
let disposed = false;
|
|
174
|
+
|
|
175
|
+
function createSessionEntry() {
|
|
176
|
+
const changes = createSessionReviewService(llm, options);
|
|
177
|
+
const project = createProjectReviewService(llm, options);
|
|
178
|
+
let locked = false;
|
|
179
|
+
|
|
180
|
+
const call = async (kind, args, exec) => {
|
|
181
|
+
if (locked) throw new Error('Review service busy');
|
|
182
|
+
locked = true;
|
|
183
|
+
try {
|
|
184
|
+
return await routeCall(kind, args, exec, { changes, project });
|
|
185
|
+
} finally {
|
|
186
|
+
locked = false;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
preview: (args, exec) => call('preview', args, exec),
|
|
192
|
+
execute: (args, exec) => call('execute', args, exec),
|
|
193
|
+
dispose() {
|
|
194
|
+
changes.dispose();
|
|
195
|
+
project.dispose();
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function get(exec) {
|
|
201
|
+
if (disposed) throw new Error('Review service disposed');
|
|
202
|
+
const agent = exec?.agent;
|
|
203
|
+
const key = agent?.session ?? agent;
|
|
204
|
+
if (!key || typeof key !== 'object') throw new Error('Active Agent required');
|
|
205
|
+
if (!sessions.has(key)) sessions.set(key, createSessionEntry());
|
|
206
|
+
return sessions.get(key);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
async preview(args, exec) {
|
|
211
|
+
const started = Date.now();
|
|
212
|
+
await exec?.reviewCheckpoint?.({ stage: 'preview-started', runtime: REVIEW_RUNTIME });
|
|
213
|
+
const result = await get(exec).preview(args, exec);
|
|
214
|
+
return { ...result, runtime: REVIEW_RUNTIME, elapsedMs: Date.now() - started };
|
|
215
|
+
},
|
|
216
|
+
async execute(args, exec) {
|
|
217
|
+
return get(exec).execute(args, exec);
|
|
218
|
+
},
|
|
219
|
+
dispose() {
|
|
220
|
+
disposed = true;
|
|
221
|
+
for (const service of sessions.values()) service.dispose();
|
|
222
|
+
sessions.clear();
|
|
223
|
+
},
|
|
224
|
+
};
|
|
94
225
|
}
|
package/src/snapshot-reader.mjs
CHANGED
|
@@ -10,15 +10,26 @@ function* lineRanges(text) {
|
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
function validQuery(query, limit) {
|
|
14
|
+
return typeof query === 'string' && query.length > 0 && query.length <= 256 && !/[\r\n]/.test(query) && Number.isSafeInteger(limit) && limit >= 1 && limit <= 100;
|
|
15
|
+
}
|
|
16
|
+
|
|
13
17
|
// This capability reads only caller-approved text entries, never the filesystem.
|
|
14
18
|
export function createSnapshotReader(snapshotId, entries, { maxBytes = 4 * 1024 * 1024, maxCalls = 50, maxOutputBytes = 256 * 1024, signal } = {}) {
|
|
15
19
|
if (typeof snapshotId !== 'string' || !snapshotId || snapshotId.length > 200) throw new Error('Invalid snapshot identity');
|
|
16
20
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 16 * 1024 * 1024) throw new Error('Invalid reader budget');
|
|
17
21
|
if (!Array.isArray(entries) || entries.length > 500) throw new Error('Invalid reader entries');
|
|
18
|
-
for (const [value, cap] of [[maxCalls, 1000], [maxOutputBytes, 4 * 1024 * 1024]])
|
|
22
|
+
for (const [value, cap] of [[maxCalls, 1000], [maxOutputBytes, 4 * 1024 * 1024]]) {
|
|
23
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > cap) throw new Error('Invalid reader budget');
|
|
24
|
+
}
|
|
19
25
|
signal?.throwIfAborted();
|
|
26
|
+
|
|
20
27
|
let calls = 0, outputBytes = 0;
|
|
21
|
-
const begin = () => {
|
|
28
|
+
const begin = () => {
|
|
29
|
+
signal?.throwIfAborted();
|
|
30
|
+
if (calls >= maxCalls) throw new Error('Reader call budget exceeded');
|
|
31
|
+
calls++;
|
|
32
|
+
};
|
|
22
33
|
const finish = result => {
|
|
23
34
|
signal?.throwIfAborted();
|
|
24
35
|
const size = Buffer.byteLength(JSON.stringify(result));
|
|
@@ -26,6 +37,7 @@ export function createSnapshotReader(snapshotId, entries, { maxBytes = 4 * 1024
|
|
|
26
37
|
outputBytes += size;
|
|
27
38
|
return result;
|
|
28
39
|
};
|
|
40
|
+
|
|
29
41
|
const sources = new Map();
|
|
30
42
|
let bytes = 0;
|
|
31
43
|
for (const entry of entries) {
|
|
@@ -34,11 +46,12 @@ export function createSnapshotReader(snapshotId, entries, { maxBytes = 4 * 1024
|
|
|
34
46
|
if (bytes > maxBytes) throw new Error('Reader content budget exceeded');
|
|
35
47
|
sources.set(entry.id, { path: entry.path, side: entry.side, text: entry.text, hash: createHash('sha256').update(entry.text).digest('hex') });
|
|
36
48
|
}
|
|
49
|
+
|
|
37
50
|
return Object.freeze({
|
|
38
51
|
usage: () => ({ calls, outputBytes }),
|
|
39
52
|
find({ query, limit = 20 }) {
|
|
40
53
|
begin();
|
|
41
|
-
if (
|
|
54
|
+
if (!validQuery(query, limit)) throw new Error('Invalid find request');
|
|
42
55
|
const matches = [];
|
|
43
56
|
for (const [id, source] of sources) {
|
|
44
57
|
signal?.throwIfAborted();
|
|
@@ -50,8 +63,8 @@ export function createSnapshotReader(snapshotId, entries, { maxBytes = 4 * 1024
|
|
|
50
63
|
},
|
|
51
64
|
search({ query, limit = 20, sourceIds }) {
|
|
52
65
|
begin();
|
|
53
|
-
if (
|
|
54
|
-
if (sourceIds !== undefined && (!Array.isArray(sourceIds) || !sourceIds.length || sourceIds.length > 20 || new Set(sourceIds).size !== sourceIds.length || sourceIds.some(id
|
|
66
|
+
if (!validQuery(query, limit)) throw new Error('Invalid search request');
|
|
67
|
+
if (sourceIds !== undefined && (!Array.isArray(sourceIds) || !sourceIds.length || sourceIds.length > 20 || new Set(sourceIds).size !== sourceIds.length || sourceIds.some(id => !sources.has(id)))) throw new Error('Invalid search source filter');
|
|
55
68
|
const selected = sourceIds === undefined ? null : new Set(sourceIds);
|
|
56
69
|
const matches = [];
|
|
57
70
|
for (const [id, source] of sources) {
|
package/src/snapshot.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {createSvnCaptureBudget} from './svn-capture-budget.mjs';
|
|
2
|
-
import {captureSvnDependencyChain} from './svn-dependency-chain.mjs';
|
|
3
|
-
import {createSvnNavigationBudget} from './svn-navigation-budget.mjs';
|
|
4
|
-
import {captureSvnExplicitContext} from './svn-explicit-context.mjs';
|
|
5
|
-
import {captureDependencyChain} from './dependency-chain.mjs';
|
|
6
|
-
import {captureSvnCallers} from './svn-caller-capture.mjs';
|
|
1
|
+
import { createSvnCaptureBudget } from './svn-capture-budget.mjs';
|
|
2
|
+
import { captureSvnDependencyChain } from './svn-dependency-chain.mjs';
|
|
3
|
+
import { createSvnNavigationBudget } from './svn-navigation-budget.mjs';
|
|
4
|
+
import { captureSvnExplicitContext } from './svn-explicit-context.mjs';
|
|
5
|
+
import { captureDependencyChain } from './dependency-chain.mjs';
|
|
6
|
+
import { captureSvnCallers } from './svn-caller-capture.mjs';
|
|
7
7
|
import { captureCallers } from './caller-capture.mjs';
|
|
8
8
|
import { validateCallerScopes } from './caller-index.mjs';
|
|
9
9
|
import { bindBusinessRequirement, validateBusinessRequirement } from './business-requirement.mjs';
|
|
@@ -32,7 +32,8 @@ export async function detectVcs(cwd) {
|
|
|
32
32
|
current = parent;
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
|
|
35
|
+
|
|
36
|
+
function validateSnapshotOptions(options) {
|
|
36
37
|
validateBusinessRequirement(options.businessRequirement);
|
|
37
38
|
const { rulePaths = [], contextPaths = [], maxContextFiles = 20, maxSnapshotBytes = 4 * 1024 * 1024 } = options;
|
|
38
39
|
for (const value of [maxContextFiles, maxSnapshotBytes]) if (!Number.isSafeInteger(value) || value < 1) throw new Error('Invalid snapshot limit');
|
|
@@ -42,77 +43,102 @@ export async function captureSnapshot(cwd, options = {}) {
|
|
|
42
43
|
if (options.callerScopePaths !== undefined) validateCallerScopes(options.callerScopePaths);
|
|
43
44
|
if (options.oldContextPaths !== undefined && (!Array.isArray(options.oldContextPaths) || options.oldContextPaths.length > 20 || new Set(options.oldContextPaths).size !== options.oldContextPaths.length)) throw new Error('Invalid old context paths');
|
|
44
45
|
for (const name of options.oldContextPaths ?? []) relativePath(name);
|
|
46
|
+
return { rulePaths, contextPaths, maxContextFiles, maxSnapshotBytes };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function captureSnapshot(cwd, options = {}) {
|
|
50
|
+
const { rulePaths, contextPaths, maxContextFiles, maxSnapshotBytes } = validateSnapshotOptions(options);
|
|
51
|
+
|
|
45
52
|
const type = await detectVcs(cwd);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if(
|
|
49
|
-
if(
|
|
53
|
+
const replacementOptions = options.svnReplacementBaseRevision !== undefined || options.svnReplacementAllowNetwork !== undefined;
|
|
54
|
+
if (replacementOptions) {
|
|
55
|
+
if (type !== 'svn' || ['svnRevision', 'svnBaseRevision', 'svnTargetRevision', 'svnBasePath', 'svnTargetPath', 'commit', 'baseRevision', 'targetRevision'].some(key => options[key] !== undefined)) throw new Error('Replacement repository baseline requires SVN working-copy mode');
|
|
56
|
+
if (typeof options.svnReplacementBaseRevision !== 'string' || !/^(0|[1-9][0-9]*)$/.test(options.svnReplacementBaseRevision) || !Number.isSafeInteger(Number(options.svnReplacementBaseRevision))) throw new Error('Replacement baseline requires fixed numeric revision');
|
|
57
|
+
if (options.svnReplacementAllowNetwork !== undefined && typeof options.svnReplacementAllowNetwork !== 'boolean') throw new Error('Invalid replacement network permission');
|
|
50
58
|
}
|
|
51
|
-
if(options.svnEffectiveMergeinfo!==undefined&&type!=='svn')throw new Error('Effective SVN mergeinfo requires SVN repository');
|
|
52
|
-
if (['svnRevision','svnBaseRevision','svnTargetRevision','svnAllowNetwork','svnBasePath','svnTargetPath'].some(key=>options[key]!==undefined)) {
|
|
53
|
-
if (type!=='svn') throw new Error('SVN revision parameters require SVN working copy');
|
|
54
|
-
return bindBusinessRequirement(await captureSvnHistory(cwd,options),options.businessRequirement);
|
|
59
|
+
if (options.svnEffectiveMergeinfo !== undefined && type !== 'svn') throw new Error('Effective SVN mergeinfo requires SVN repository');
|
|
60
|
+
if (['svnRevision', 'svnBaseRevision', 'svnTargetRevision', 'svnAllowNetwork', 'svnBasePath', 'svnTargetPath'].some(key => options[key] !== undefined)) {
|
|
61
|
+
if (type !== 'svn') throw new Error('SVN revision parameters require SVN working copy');
|
|
62
|
+
return bindBusinessRequirement(await captureSvnHistory(cwd, options), options.businessRequirement);
|
|
55
63
|
}
|
|
56
|
-
if (options.oldContextPaths !== undefined && (type !== 'git' || !['commit','baseRevision','targetRevision'].some(k => options[k] !== undefined))) throw new Error('Old context requires Git historical review');
|
|
57
|
-
if (['commit','baseRevision','targetRevision'].some(k => options[k] !== undefined)) {
|
|
64
|
+
if (options.oldContextPaths !== undefined && (type !== 'git' || !['commit', 'baseRevision', 'targetRevision'].some(k => options[k] !== undefined))) throw new Error('Old context requires Git historical review');
|
|
65
|
+
if (['commit', 'baseRevision', 'targetRevision'].some(k => options[k] !== undefined)) {
|
|
58
66
|
if (type !== 'git') throw new Error('Git revision review is unsupported on SVN');
|
|
59
|
-
return bindBusinessRequirement(await captureGitHistory(cwd, options),options.businessRequirement);
|
|
67
|
+
return bindBusinessRequirement(await captureGitHistory(cwd, options), options.businessRequirement);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (type === 'svn') {
|
|
71
|
+
const budget = createSvnCaptureBudget(options.signal);
|
|
72
|
+
options = { ...options, signal: budget.signal, svnCaptureBudget: budget };
|
|
60
73
|
}
|
|
61
|
-
if(type==='svn'){const budget=createSvnCaptureBudget(options.signal);options={...options,signal:budget.signal,svnCaptureBudget:budget};}
|
|
62
74
|
const capture = type === 'git' ? captureGit : captureSvn;
|
|
63
75
|
const snapshot = await capture(cwd, options);
|
|
64
76
|
if (!Array.isArray(rulePaths) || rulePaths.some(name => contextPaths.includes(name))) throw new Error('Rule paths must be separate from context paths');
|
|
65
77
|
const rules = await captureProjectRules(snapshot.root, rulePaths, { signal: options.signal, files: snapshot.files });
|
|
66
|
-
const navigationBudget=type==='svn'?createSvnNavigationBudget(options.signal):undefined;
|
|
67
|
-
const navigationOptions=navigationBudget?{...options,signal:navigationBudget.signal,svnNavigationBudget:navigationBudget}:options;
|
|
78
|
+
const navigationBudget = type === 'svn' ? createSvnNavigationBudget(options.signal) : undefined;
|
|
79
|
+
const navigationOptions = navigationBudget ? { ...options, signal: navigationBudget.signal, svnNavigationBudget: navigationBudget } : options;
|
|
80
|
+
|
|
68
81
|
const context = [];
|
|
69
82
|
let autoContext, index, verifyDependencies, explicitSeeds = [], indexOptions = options;
|
|
70
|
-
if (options.autoContext === true && type==='git') {
|
|
71
|
-
const chain=await captureDependencyChain(snapshot.root,snapshot.files,options);
|
|
72
|
-
explicitSeeds=chain.explicitSeeds;
|
|
83
|
+
if (options.autoContext === true && type === 'git') {
|
|
84
|
+
const chain = await captureDependencyChain(snapshot.root, snapshot.files, options);
|
|
85
|
+
explicitSeeds = chain.explicitSeeds;
|
|
86
|
+
context.push(...chain.context);
|
|
87
|
+
autoContext = chain.autoContext;
|
|
88
|
+
index = chain.index;
|
|
89
|
+
indexOptions = chain.indexOptions;
|
|
73
90
|
} else if (options.autoContext === true) {
|
|
74
|
-
const chain=await captureSvnDependencyChain(snapshot.root,snapshot.files,navigationOptions);
|
|
75
|
-
explicitSeeds=chain.explicitSeeds;
|
|
91
|
+
const chain = await captureSvnDependencyChain(snapshot.root, snapshot.files, navigationOptions);
|
|
92
|
+
explicitSeeds = chain.explicitSeeds;
|
|
93
|
+
context.push(...chain.context);
|
|
94
|
+
autoContext = chain.autoContext;
|
|
95
|
+
indexOptions = chain.indexOptions;
|
|
96
|
+
verifyDependencies = chain.verify;
|
|
76
97
|
}
|
|
77
98
|
let verifyExplicit;
|
|
78
|
-
if(type==='svn'&&options.autoContext!==true&&contextPaths.length){
|
|
79
|
-
const explicit=await captureSvnExplicitContext(snapshot.root,[...new Set(contextPaths)].filter(name
|
|
80
|
-
explicitSeeds=explicit.context;
|
|
99
|
+
if (type === 'svn' && options.autoContext !== true && contextPaths.length) {
|
|
100
|
+
const explicit = await captureSvnExplicitContext(snapshot.root, [...new Set(contextPaths)].filter(name => !snapshot.files.some(f => f.path === name)).sort(), navigationOptions);
|
|
101
|
+
explicitSeeds = explicit.context;
|
|
102
|
+
verifyExplicit = explicit.verify;
|
|
103
|
+
indexOptions = explicit.options;
|
|
81
104
|
}
|
|
105
|
+
|
|
82
106
|
for (const name of [...new Set(contextPaths)].sort()) {
|
|
83
107
|
const changed = snapshot.files.find(f => f.path === name);
|
|
84
108
|
if (changed) {
|
|
85
109
|
if (changed.eligibility !== 'reviewable') throw new Error('Context path is an excluded or blocked change');
|
|
86
110
|
if (changed.rightExists === false) throw new Error('Deleted path cannot be working context');
|
|
87
|
-
if(changed.specialData)throw new Error('SVN link records cannot be working source context');
|
|
88
|
-
context.push({ path: name, ...changed.right
|
|
111
|
+
if (changed.specialData) throw new Error('SVN link records cannot be working source context');
|
|
112
|
+
context.push({ path: name, ...changed.right, ...(changed.eolMapping?.new ? { eolMapping: structuredClone(changed.eolMapping.new) } : {}) });
|
|
89
113
|
} else {
|
|
90
|
-
const seed=explicitSeeds.find(c=>c.path===name);
|
|
91
|
-
const item=type==='svn'&&options.autoContext!==true&&seed?structuredClone(seed):{path:name
|
|
92
|
-
if(seed&&seed.hash!==item.hash)throw new Error('Explicit dependency seed changed during capture');
|
|
93
|
-
if(seed?.eolMapping)item.eolMapping=structuredClone(seed.eolMapping);
|
|
94
|
-
if(seed?.keywordMapping)item.keywordMapping=structuredClone(seed.keywordMapping);
|
|
114
|
+
const seed = explicitSeeds.find(c => c.path === name);
|
|
115
|
+
const item = type === 'svn' && options.autoContext !== true && seed ? structuredClone(seed) : { path: name, ...await readLocal(snapshot.root, name, indexOptions) };
|
|
116
|
+
if (seed && seed.hash !== item.hash) throw new Error('Explicit dependency seed changed during capture');
|
|
117
|
+
if (seed?.eolMapping) item.eolMapping = structuredClone(seed.eolMapping);
|
|
118
|
+
if (seed?.keywordMapping) item.keywordMapping = structuredClone(seed.keywordMapping);
|
|
95
119
|
context.push(item);
|
|
96
120
|
}
|
|
97
121
|
}
|
|
122
|
+
|
|
98
123
|
let callerDiscovery, verifyCallers;
|
|
99
124
|
if (options.callerScopePaths !== undefined) {
|
|
100
|
-
const callers = await (type==='git'?captureCallers:captureSvnCallers)(snapshot.root, snapshot.files, options.callerScopePaths, type==='svn'?navigationOptions:options);
|
|
125
|
+
const callers = await (type === 'git' ? captureCallers : captureSvnCallers)(snapshot.root, snapshot.files, options.callerScopePaths, type === 'svn' ? navigationOptions : options);
|
|
101
126
|
verifyCallers = callers.verify;
|
|
102
127
|
callerDiscovery = { ...callers.discovery, capturedPaths: [] };
|
|
103
128
|
for (const item of callers.context) {
|
|
104
129
|
const present = context.find(c => c.path === item.path);
|
|
105
130
|
const matches = callerDiscovery.candidates.filter(c => c.fromPath === item.path);
|
|
106
131
|
if (present && present.hash !== item.hash) throw new Error('Caller context changed during capture');
|
|
107
|
-
if (!present && context.length >= Math.min(maxContextFiles,20)) {
|
|
108
|
-
for (const candidate of matches) { candidate.status='blocked'; candidate.captureReason='Context file limit exceeded'; }
|
|
132
|
+
if (!present && context.length >= Math.min(maxContextFiles, 20)) {
|
|
133
|
+
for (const candidate of matches) { candidate.status = 'blocked'; candidate.captureReason = 'Context file limit exceeded'; }
|
|
109
134
|
continue;
|
|
110
135
|
}
|
|
111
136
|
if (!present) context.push(item);
|
|
112
137
|
callerDiscovery.capturedPaths.push(item.path);
|
|
113
|
-
for (const candidate of matches) candidate.status='captured';
|
|
138
|
+
for (const candidate of matches) candidate.status = 'captured';
|
|
114
139
|
}
|
|
115
140
|
}
|
|
141
|
+
|
|
116
142
|
// Re-capture verifies both selected sides while collecting explicit context.
|
|
117
143
|
if ((await capture(cwd, indexOptions)).id !== snapshot.id) throw new Error('Snapshot changed while collecting context');
|
|
118
144
|
for (const item of context) if ((await readLocal(snapshot.root, item.path, indexOptions)).hash !== item.hash) throw new Error('Context changed during capture');
|
|
@@ -122,9 +148,10 @@ export async function captureSnapshot(cwd, options = {}) {
|
|
|
122
148
|
if (verifyExplicit) await verifyExplicit();
|
|
123
149
|
if (verifyDependencies) await verifyDependencies();
|
|
124
150
|
if (verifyCallers) await verifyCallers();
|
|
125
|
-
|
|
151
|
+
|
|
152
|
+
const result = { ...snapshot, context, ...(callerDiscovery ? { callerDiscovery } : {}), ...(autoContext ? { autoContext } : {}), ...(rules.length ? { rules } : {}) };
|
|
126
153
|
delete result.id;
|
|
127
154
|
const serialized = JSON.stringify(result);
|
|
128
155
|
if (Buffer.byteLength(serialized) > maxSnapshotBytes) throw new Error('Snapshot size limit exceeded');
|
|
129
|
-
return bindBusinessRequirement({ ...result, id: hash(serialized) },options.businessRequirement);
|
|
156
|
+
return bindBusinessRequirement({ ...result, id: hash(serialized) }, options.businessRequirement);
|
|
130
157
|
}
|