@feng0531/dsh-code-review 0.83.1 → 0.85.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/src/review.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { REVIEW_INSTRUCTIONS, PROJECT_REVIEW_INSTRUCTIONS } from './prompts.mjs';
2
2
  import { reviewRounds } from './review-rounds.mjs';
3
3
  import { correctAnchor } from './anchor-correction.mjs';
4
+ import { correctedResponse } from './response-correction.mjs';
4
5
  import { validateBusinessRequirement } from './business-requirement.mjs';
5
6
  import { riskReadCoverage } from './risk-read-coverage.mjs';
6
7
  import { BUSINESS_GROUP_INSTRUCTIONS, localBusinessGroups, prepareBusinessGroups, validateBusinessGroups } from './business-groups.mjs';
@@ -21,11 +22,13 @@ import { createRetrievalScope } from './retrieval-scope.mjs';
21
22
  import { retrievalLoop } from './retrieval-loop.mjs';
22
23
  import { preparePrimaryInput } from './primary-input.mjs';
23
24
  import { validateAttribution } from './attribution.mjs';
25
+ import { changeScopeLabel } from './git-change-scope.mjs';
24
26
 
25
27
  export { REVIEW_INSTRUCTIONS };
26
28
  export { PROJECT_REVIEW_INSTRUCTIONS };
27
29
  const nonempty = value => typeof value === 'string' && value.trim().length > 0 && value.length <= 8000;
28
30
  const lines = text => text.replaceAll('\r\n', '\n').split('\n');
31
+ const protocolError = (code, message) => Object.assign(new Error(message), { code });
29
32
 
30
33
  export function validateFindings(response, file) {
31
34
  return validateResponse(response, file);
@@ -34,20 +37,21 @@ export function validateFindings(response, file) {
34
37
  function validateResponse(response, file, knownChanges) {
35
38
  if (Buffer.byteLength(typeof response === 'string' ? response : JSON.stringify(response) ?? '') > 128 * 1024) throw new Error('Review response too large');
36
39
  if (typeof response === 'string') response = JSON.parse(response);
37
- if (!response || !Array.isArray(response.findings) || response.findings.length > 50 || !Array.isArray(response.limitations) || response.limitations.length > 50 || response.limitations.some(s => !nonempty(s))) throw new Error('Invalid review response');
40
+ if (!response || !Array.isArray(response.findings) || response.findings.length > 50 || !Array.isArray(response.limitations) || response.limitations.length > 50 || response.limitations.some(s => !nonempty(s))) throw protocolError('MODEL_INVALID_REVIEW_RESPONSE', 'Invalid review response');
38
41
  const changes = knownChanges ?? changeMap(file.left.text, file.right.text);
39
42
  const findings = response.findings.map(raw => {
40
- if (!raw || raw.fileId !== file.id || !['critical', 'high', 'medium', 'low'].includes(raw.severity)) throw new Error('Invalid finding identity or severity');
41
- for (const key of ['title', 'evidence', 'trigger', 'impact', 'suggestion']) if (!nonempty(raw[key])) throw new Error('Missing finding field: ' + key);
43
+ if (!raw || raw.fileId !== file.id) throw protocolError('MODEL_INVALID_FINDING_IDENTITY', 'Invalid finding identity or severity');
44
+ if (!['critical', 'high', 'medium', 'low'].includes(raw.severity)) throw protocolError('MODEL_INVALID_FINDING_SEVERITY', 'Invalid finding identity or severity');
45
+ for (const key of ['title', 'evidence', 'trigger', 'impact', 'suggestion']) if (!nonempty(raw[key])) throw protocolError('MODEL_MISSING_FINDING_FIELD', 'Missing finding field: ' + key);
42
46
  const anchor = raw.anchor;
43
47
  if(file.reviewMode==='project'&&(anchor?.side==='old'||anchor?.kind==='property'))throw new Error('Project findings require current source evidence');
44
- if (!anchor || !['line', 'property', 'file'].includes(anchor.kind)) throw new Error('Invalid anchor');
48
+ if (!anchor || !['line', 'property', 'file'].includes(anchor.kind)) throw protocolError('MODEL_INVALID_ANCHOR', 'Invalid anchor');
45
49
  let validated;
46
50
  if (anchor.kind === 'line') {
47
51
  const side = anchor.side === 'old' ? file.left : anchor.side === 'new' ? file.right : null;
48
- if (!side || !Number.isSafeInteger(anchor.start) || !Number.isSafeInteger(anchor.end) || anchor.start < 1 || anchor.end < anchor.start || !nonempty(anchor.snippet)) throw new Error('Invalid line anchor');
52
+ if (!side || !Number.isSafeInteger(anchor.start) || !Number.isSafeInteger(anchor.end) || anchor.start < 1 || anchor.end < anchor.start || !nonempty(anchor.snippet)) throw protocolError('MODEL_INVALID_ANCHOR', 'Invalid line anchor');
49
53
  const source = lines(side.text);
50
- if (anchor.end > source.length || source.slice(anchor.start - 1, anchor.end).join('\n') !== anchor.snippet.replaceAll('\r\n', '\n')) throw new Error('Finding snippet does not match snapshot');
54
+ if (anchor.end > source.length || source.slice(anchor.start - 1, anchor.end).join('\n') !== anchor.snippet.replaceAll('\r\n', '\n')) throw protocolError('MODEL_ANCHOR_MISMATCH', 'Finding snippet does not match snapshot');
51
55
  validated = { kind: 'line', side: anchor.side, start: anchor.start, end: anchor.end, snippet: anchor.snippet };
52
56
  } else if (anchor.kind === 'property') {
53
57
  if (!file.properties.some(p => p.name === anchor.name)) throw new Error('Unknown changed property');
@@ -111,6 +115,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
111
115
  }
112
116
  if(input.projectScope)report.projectScope=structuredClone(input.projectScope);
113
117
  if (input.history) report.history = structuredClone(input.history);
118
+ if (input.changeScope) report.changeScope = input.changeScope;
114
119
  if (input.mergeSources) report.mergeSources = structuredClone(input.mergeSources);
115
120
  if(input.workingScope)report.workingScope=structuredClone(input.workingScope);
116
121
  if(input.effectiveMergeSources)report.effectiveMergeSources=structuredClone(input.effectiveMergeSources);
@@ -126,7 +131,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
126
131
  await options.onProgress?.({stage:'file-started',snapshotId:input.id,fileId:file.id,path:file.path,status:state.status});
127
132
  if (state.status !== 'pending') continue;
128
133
  if (signal?.aborted) { state.status = 'cancelled'; state.reason = '用户取消'; continue; }
129
- if (report.modelCalls >= maxCalls) { state.reason = '模型调用预算耗尽'; continue; }
134
+ if (report.modelCalls >= maxCalls || options.remainingCalls?.() <= 0) { state.reason = '模型调用预算耗尽'; continue; }
130
135
  const prepared = preparePrimaryInput(input, file, { instructions, scope, grouping: report.grouping, maxInputBytes });
131
136
  const { changes, payload } = prepared;
132
137
  Object.assign(state, prepared.metadata);
@@ -139,13 +144,20 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
139
144
  if (state.groupFallback) report.limitations.push({ fileId: file.id, text: '关联组上下文超过输入预算,回退单文件审查' });
140
145
  if (state.initialInputBytes > maxInputBytes) { state.status = 'blocked'; state.reason = '输入超过预算,未截断或提交模型'; continue; }
141
146
  try {
142
- const beforeCall = () => {
147
+ const beforeCall = ({ reserved = false } = {}) => {
148
+ if (!reserved && options.remainingCalls?.() <= 0) throw Object.assign(new Error('Shared project model call limit exceeded'), { code: 'PROJECT_MODEL_CALL_LIMIT' });
143
149
  if (report.modelCalls >= maxCalls) throw new Error('Model call budget exceeded');
144
150
  report.modelCalls++;
145
151
  };
146
- const trackedModel=request=>model({...request,onProtocolEvent:async info=>{await options.onProgress?.({stage:'protocol-correction',fileId:file.id,path:file.path,...info});},onProtocolCorrection:async info=>{beforeCall();await request.onProtocolCorrection?.(info);await options.onProgress?.({stage:'protocol-correction',fileId:file.id,path:file.path,...info});}});
152
+ const onBudgetLimited = info => {
153
+ state.retrievalLimited = true;
154
+ (state.retrievalLimits ??= []).push(info);
155
+ report.limitations.push({ fileId: file.id, text: '检索预算受限,已保留现有结果但不能视为完整审查:' + info.reason });
156
+ };
157
+ const onProtocolEvent = async info => { await options.onProgress?.({ stage: 'protocol-correction', fileId: file.id, path: file.path, ...info }); };
158
+ const trackedModel=request=>model({...request,onProtocolEvent,onProtocolCorrection:async info=>{beforeCall({ reserved: true });await request.onProtocolCorrection?.(info);await options.onProgress?.({stage:'protocol-correction',fileId:file.id,path:file.path,...info});}});
147
159
  const executor = scope
148
- ? request => retrievalLoop(trackedModel, request, scope, { correctProtocol:options.correctProtocol===true,maxInputBytes, beforeCall, onRetrieved: records => {
160
+ ? (request, onLimited = () => {}) => retrievalLoop(trackedModel, { ...request, onProtocolEvent }, scope, { correctProtocol:options.correctProtocol===true,maxInputBytes, beforeCall, onBudgetLimited: info => { onBudgetLimited(info); onLimited(info); }, onRetrieved: records => {
149
161
  (state.retrievalAudit ??= []).push(...records);
150
162
  }, onTruncated: () => {
151
163
  if (!state.retrievalTruncated) report.limitations.push({ fileId: file.id, text: '检索结果达到数量上限,部分匹配未提供给模型' });
@@ -162,7 +174,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
162
174
  const groupInput = prepareBusinessGroups(input.files);
163
175
  if (Buffer.byteLength(groupInput) + Buffer.byteLength(BUSINESS_GROUP_INSTRUCTIONS) > maxInputBytes) throw new Error('Business grouping input budget exceeded');
164
176
  beforeCall();
165
- const response = await model({ ...enclosingRequest, instructions: BUSINESS_GROUP_INSTRUCTIONS, input: groupInput });
177
+ const response = await trackedModel({ ...enclosingRequest, instructions: BUSINESS_GROUP_INSTRUCTIONS, input: groupInput });
166
178
  enclosingRequest.signal.throwIfAborted();
167
179
  grouped = validateBusinessGroups(response, input.files);
168
180
  }
@@ -216,30 +228,48 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
216
228
  if(report.modelCalls>=maxCalls)throw new Error('Model call budget exceeded before next review pass');
217
229
  if(rounds>1) state.reviewPasses[batchIndex].status='running';
218
230
  const request = { ...enclosingRequest, input: batchPayload };
219
- const auditStart = state.retrievalAudit?.length ?? 0;
220
- let response = await executor(request);
221
- if (options.enableAnchorCorrection === true) {
222
- if (Buffer.byteLength(typeof response === 'string' ? response : JSON.stringify(response)) > 128 * 1024) throw new Error('Review response too large');
223
- const decoded = typeof response === 'string' ? JSON.parse(response) : structuredClone(response);
224
- if (Array.isArray(decoded?.findings) && decoded.findings.length <= 50) {
225
- decoded.findings = decoded.findings.map(raw => {
226
- const target = batch.interaction ? input.files.find(f => f.id === raw?.fileId && batch.interaction.fileIds.includes(f.id) && f.eligibility === 'reviewable') : file;
227
- if (!target) return raw;
228
- const correction = correctAnchor(raw, target, target === file ? changes : changeMap(target.left.text,target.right.text), { signal: request.signal });
229
- if (!['unchanged','not-applicable'].includes(correction.status)) (state.anchorCorrections ??= []).push(correction);
230
- return correction.status === 'corrected' ? correction.candidate : raw;
231
- });
232
- response = decoded;
231
+ let auditStart = state.retrievalAudit?.length ?? 0;
232
+ let finalResponseOnly = false;
233
+ const generate = async attempt => {
234
+ if (!initialInputBudget(attempt, scope, maxInputBytes).fits) throw new Error('Review correction input budget exceeded');
235
+ return executor(attempt, () => { finalResponseOnly = true; });
236
+ };
237
+ const validate = response => {
238
+ if (options.enableAnchorCorrection === true) {
239
+ if (Buffer.byteLength(typeof response === 'string' ? response : JSON.stringify(response) ?? '') > 128 * 1024) throw new Error('Review response too large');
240
+ const decoded = typeof response === 'string' ? JSON.parse(response) : structuredClone(response);
241
+ if (Array.isArray(decoded?.findings) && decoded.findings.length <= 50) {
242
+ decoded.findings = decoded.findings.map(raw => {
243
+ const target = batch.interaction ? input.files.find(f => f.id === raw?.fileId && batch.interaction.fileIds.includes(f.id) && f.eligibility === 'reviewable') : file;
244
+ if (!target) return raw;
245
+ const correction = correctAnchor(raw, target, target === file ? changes : changeMap(target.left.text,target.right.text), { signal: request.signal });
246
+ if (!['unchanged','not-applicable'].includes(correction.status)) (state.anchorCorrections ??= []).push(correction);
247
+ return correction.status === 'corrected' ? correction.candidate : raw;
248
+ });
249
+ response = decoded;
250
+ }
233
251
  }
234
- }
252
+ const generated = batch.interaction ? validateInteractionFindings(response, input, batch.interaction, (state.retrievalAudit ?? []).slice(auditStart), scope.catalog()) : validateResponse(response, file, changes);
253
+ if (batch.synthesis) validateSynthesisReads(generated.findings, changes, (state.retrievalAudit ?? []).slice(auditStart), scope.catalog(), file.id, input.id);
254
+ if (!batch.interaction && state.sourceMode === 'change-windows') validateWindowFindings(generated.findings, batch.synthesis ? prepared.synthesis.windows : JSON.parse(batch.payload).windows, batch.synthesis === true);
255
+ return generated;
256
+ };
257
+ const generated = await correctedResponse(generate, validate, request, {
258
+ enabled: options.correctProtocol === true,
259
+ canCorrect: () => !finalResponseOnly,
260
+ onCorrection: async info => {
261
+ // A fresh generation must establish its own synthesis/interaction read evidence.
262
+ auditStart = state.retrievalAudit?.length ?? 0;
263
+ (state.responseCorrections ??= []).push({ code: info.code, round: batch.round, phase });
264
+ report.limitations.push({ fileId: file.id, text: '主审响应校验失败,已请求一次显式纠正;原响应未接受,纠正不证明缺陷成立。' });
265
+ await options.onProgress?.({ stage: 'protocol-correction', fileId: file.id, path: file.path, ...info });
266
+ },
267
+ });
235
268
  if (options.enableRiskPlanning === true && !batch.synthesis && batch.round === 1) {
236
269
  const entry=state.riskPlans.at(-1);
237
270
  entry.sourceReadCoverage=riskReadCoverage(entry.risks,(state.retrievalAudit??[]).slice(auditStart),scope?.catalog()??[],input.id);
238
271
  if(entry.sourceReadCoverage.some(c=>c.unreadSourceIds.length)) report.limitations.push({fileId:file.id,text:'风险计划请求的部分来源未在本次生成阶段读取;不能视为已核查风险条件或排除问题'});
239
272
  }
240
- const generated = batch.interaction ? validateInteractionFindings(response, input, batch.interaction, (state.retrievalAudit ?? []).slice(auditStart), scope.catalog()) : validateResponse(response, file, changes);
241
- if (batch.synthesis) validateSynthesisReads(generated.findings, changes, (state.retrievalAudit ?? []).slice(auditStart), scope.catalog(), file.id, input.id);
242
- if (!batch.interaction && state.sourceMode === 'change-windows') validateWindowFindings(generated.findings, batch.synthesis ? prepared.synthesis.windows : JSON.parse(batch.payload).windows, batch.synthesis === true);
243
273
  request.signal.throwIfAborted();
244
274
  if(rounds>1)generated.findings.forEach(f=>{f.reviewRound=batch.round;});
245
275
  report.findings.push(...generated.findings);
@@ -248,8 +278,8 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
248
278
  if (options.enableVerification === true && generated.findings.length) {
249
279
  for (const finding of generated.findings) finding.verification = { status: 'pending', causality: 'unverified' };
250
280
  try {
251
- const verdicts = await verificationLoop(trackedModel, request, generated.findings, scope, {
252
- correctProtocol:options.correctProtocol===true,maxInputBytes, beforeCall,
281
+ const verdicts = await verificationLoop(trackedModel, { ...request, onProtocolEvent }, generated.findings, scope, {
282
+ correctProtocol:options.correctProtocol===true,maxInputBytes, beforeCall, onBudgetLimited,
253
283
  onRetrieved: records => { (state.retrievalAudit ??= []).push(...records); },
254
284
  onTruncated: () => { report.limitations.push({ fileId: file.id, text: '复核检索结果达到数量上限' }); },
255
285
  });
@@ -290,15 +320,17 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
290
320
  }, { instructions, input: payload }, timeoutMs, signal);
291
321
  if (signal?.aborted) throw signal.reason;
292
322
 
293
- state.status = 'completed';
323
+ state.status = state.retrievalLimited ? 'pending' : 'completed';
324
+ if (state.retrievalLimited) state.reason = '检索预算受限,未完成完整审查;已有发现保留,不表示通过';
294
325
 
295
326
  } catch (error) {
296
- state.status = signal?.aborted ? 'cancelled' : 'failed';
327
+ state.status = signal?.aborted ? 'cancelled' : error?.code === 'PROJECT_MODEL_CALL_LIMIT' ? 'pending' : 'failed';
297
328
  state.reason = error?.message ?? String(error);
298
329
  if(error.attempts)state.protocolAttempts=error.attempts;
330
+ if(error.responseAttempts)state.responseAttempts=error.responseAttempts;
299
331
  if(error.diagnostic)state.protocolDiagnostic=error.diagnostic;
300
332
  for(const pass of state.reviewPasses??[])if(pass.status==='running')pass.status=signal?.aborted?'cancelled':'failed';
301
- state.failureCode = signal?.aborted ? 'CANCELLED' : ['MODEL_INVALID_JSON','MODEL_INCOMPLETE_OUTPUT','MODEL_TIMEOUT','MODEL_INVALID_READ_REQUEST'].includes(error?.code) ? error.code : 'REVIEW_EXECUTION_FAILED';
333
+ state.failureCode = signal?.aborted ? 'CANCELLED' : ['MODEL_INVALID_JSON','MODEL_INCOMPLETE_OUTPUT','MODEL_TIMEOUT','MODEL_INVALID_READ_REQUEST','MODEL_OUTPUT_LIMIT','MODEL_PROTOCOL_ERROR','MODEL_INVALID_REVIEW_RESPONSE','MODEL_INVALID_FINDING_IDENTITY','MODEL_INVALID_FINDING_SEVERITY','MODEL_MISSING_FINDING_FIELD','MODEL_INVALID_ANCHOR','MODEL_ANCHOR_MISMATCH','READER_CALL_BUDGET_EXCEEDED','READER_OUTPUT_BUDGET_EXCEEDED','RETRIEVAL_ROUND_BUDGET_EXCEEDED','RETRIEVAL_FINAL_RESPONSE_REQUIRED','PROJECT_MODEL_CALL_LIMIT'].includes(error?.code) ? error.code : 'REVIEW_EXECUTION_FAILED';
302
334
  }
303
335
  }
304
336
  await options.onProgress?.({stage:'batch-complete',snapshotId:input.id,files:report.files,findings:report.findings,limitations:report.limitations});
@@ -367,6 +399,7 @@ export function markdownReport(report) {
367
399
  if(report.businessRequirement)output.push('- 业务需求:SHA256 '+safe(report.businessRequirement.hash)+' · '+report.businessRequirement.bytes+' bytes;不可信约束,不是证据或权限,全文不在报告重复。');
368
400
  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
401
  if (report.history) output.push('- Git历史审查:' + safe(report.history.mode) + ' · ' + safe(report.history.base ?? 'empty') + ' → ' + safe(report.history.target) + ';精确端点树差异,重命名按增删,不含工作树');
402
+ if (report.changeScope) output.push('- Git审查范围:' + safe(changeScopeLabel(report.changeScope)) + ';其他范围未审查,未跟踪文件仍默认排除。');
370
403
  if (report.businessGrouping) output.push('- 业务分组:' + safe(report.businessGrouping.status) + (report.businessGrouping.reason ? ' — ' + safe(report.businessGrouping.reason) : '') + ';仅调度假设,不证明依赖或覆盖');
371
404
  for (const file of report.files) for (const plan of file.riskPlans ?? []) output.push('- 风险计划:' + safe(file.path) + ' · ' + safe(plan.status) + ' · ' + plan.risks.length + '项待证假设;不表示缺陷成立或覆盖完成');
372
405
  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+'阶段完成;同一生命周期预算,后续轮无首轮计划;不证明代码正确。');
package/src/service.mjs CHANGED
@@ -5,6 +5,7 @@ import { reviewRounds as validateReviewRounds } from './review-rounds.mjs';
5
5
  import { validateCallerScopes } from './caller-index.mjs';
6
6
  import { validateBusinessRequirement } from './business-requirement.mjs';
7
7
  import { hash } from './content.mjs';
8
+ import { changeScopePreviewNotice } from './git-change-scope.mjs';
8
9
  import { resolveWorkspaceRepository } from './workspace-repository.mjs';
9
10
  import { randomUUID } from 'node:crypto';
10
11
  import { buildCoveragePlan } from './coverage-plan.mjs';
@@ -44,7 +45,8 @@ function describeFile(file, snapshot) {
44
45
 
45
46
  /** Compose the user-facing preview notice; text is verbatim from the original rules. */
46
47
  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
+ const scopeNotice = options.changeScope !== undefined && options.changeScope !== 'worktree' ? changeScopePreviewNotice(options.changeScope) : '';
49
+ return scopeNotice + (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
50
  }
49
51
 
50
52
  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 } = {}) {
@@ -59,7 +61,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
59
61
  exec = bind(exec);
60
62
  exec.signal.throwIfAborted();
61
63
  if (busy) throw new Error('Review service busy');
62
- if (!args || Object.keys(args).some(k => !['repositoryPath', 'selectedPaths', 'contextPaths', 'rulePaths', 'autoContext', 'commit', 'baseRevision', 'targetRevision','businessRequirement','callerScopePaths','oldContextPaths','reviewRounds','svnRevision','svnBaseRevision','svnTargetRevision','svnAllowNetwork','svnEffectiveMergeinfo','svnBasePath','svnTargetPath','svnReplacementBaseRevision','svnReplacementAllowNetwork'].includes(k))) throw new Error('Unknown preview argument');
64
+ if (!args || Object.keys(args).some(k => !['repositoryPath', 'selectedPaths', 'contextPaths', 'rulePaths', 'autoContext', 'commit', 'baseRevision', 'targetRevision','businessRequirement','callerScopePaths','oldContextPaths','reviewRounds','svnRevision','svnBaseRevision','svnTargetRevision','svnAllowNetwork','svnEffectiveMergeinfo','svnBasePath','svnTargetPath','svnReplacementBaseRevision','svnReplacementAllowNetwork','changeScope'].includes(k))) throw new Error('Unknown preview argument');
63
65
  if (args.autoContext !== undefined && typeof args.autoContext !== 'boolean') throw new Error('Invalid autoContext option');
64
66
  for (const key of ['selectedPaths', 'contextPaths', 'rulePaths', 'oldContextPaths']) if (args[key] !== undefined && (!Array.isArray(args[key]) || args[key].length > (key === 'rulePaths' ? 4 : key === 'oldContextPaths' ? 20 : 200) || args[key].some(p => typeof p !== 'string'))) throw new Error('Invalid path list');
65
67
  const rounds=validateReviewRounds(args.reviewRounds === undefined ? reviewRounds : args.reviewRounds);
@@ -87,6 +89,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
87
89
  snapshotId: snapshot.id,
88
90
  repositoryRoot: snapshot.root,
89
91
  vcs: snapshot.vcs,
92
+ ...(snapshot.changeScope ? { changeScope: snapshot.changeScope } : options.changeScope !== undefined ? { changeScope: options.changeScope } : {}),
90
93
  ...(snapshot.workingScope ? { workingScope: snapshot.workingScope } : {}),
91
94
  ...(snapshot.history ? { history: snapshot.history } : {}),
92
95
  ...(snapshot.mergeSources ? { mergeSources: snapshot.mergeSources } : {}),
@@ -14,6 +14,12 @@ function validQuery(query, limit) {
14
14
  return typeof query === 'string' && query.length > 0 && query.length <= 256 && !/[\r\n]/.test(query) && Number.isSafeInteger(limit) && limit >= 1 && limit <= 100;
15
15
  }
16
16
 
17
+ function budgetError(kind) {
18
+ const error = new Error('Reader ' + kind + ' budget exceeded');
19
+ error.code = kind === 'call' ? 'READER_CALL_BUDGET_EXCEEDED' : 'READER_OUTPUT_BUDGET_EXCEEDED';
20
+ return error;
21
+ }
22
+
17
23
  // This capability reads only caller-approved text entries, never the filesystem.
18
24
  export function createSnapshotReader(snapshotId, entries, { maxBytes = 4 * 1024 * 1024, maxCalls = 50, maxOutputBytes = 256 * 1024, signal } = {}) {
19
25
  if (typeof snapshotId !== 'string' || !snapshotId || snapshotId.length > 200) throw new Error('Invalid snapshot identity');
@@ -24,16 +30,21 @@ export function createSnapshotReader(snapshotId, entries, { maxBytes = 4 * 1024
24
30
  }
25
31
  signal?.throwIfAborted();
26
32
 
27
- let calls = 0, outputBytes = 0;
33
+ let calls = 0, outputBytes = 0, inTransaction = false;
28
34
  const begin = () => {
29
35
  signal?.throwIfAborted();
30
- if (calls >= maxCalls) throw new Error('Reader call budget exceeded');
36
+ if (calls >= maxCalls) throw budgetError('call');
31
37
  calls++;
32
38
  };
39
+ const resultSize = result => {
40
+ const size = Buffer.byteLength(JSON.stringify(result));
41
+ if (size > 64 * 1024) throw budgetError('output');
42
+ return size;
43
+ };
33
44
  const finish = result => {
34
45
  signal?.throwIfAborted();
35
- const size = Buffer.byteLength(JSON.stringify(result));
36
- if (size > 64 * 1024 || outputBytes + size > maxOutputBytes) throw new Error('Reader output budget exceeded');
46
+ const size = resultSize(result);
47
+ if (outputBytes + size > maxOutputBytes) throw budgetError('output');
37
48
  outputBytes += size;
38
49
  return result;
39
50
  };
@@ -47,55 +58,91 @@ export function createSnapshotReader(snapshotId, entries, { maxBytes = 4 * 1024
47
58
  sources.set(entry.id, { path: entry.path, side: entry.side, text: entry.text, hash: createHash('sha256').update(entry.text).digest('hex') });
48
59
  }
49
60
 
61
+ const find = ({ query, limit = 20 }) => {
62
+ if (!validQuery(query, limit)) throw new Error('Invalid find request');
63
+ const matches = [];
64
+ for (const [id, source] of sources) {
65
+ signal?.throwIfAborted();
66
+ if (typeof source.path !== 'string' || !source.path.includes(query)) continue;
67
+ if (matches.length === limit) return { snapshotId, matches, truncated: true };
68
+ matches.push({ sourceId: id, path: source.path, side: source.side, hash: source.hash });
69
+ }
70
+ return { snapshotId, matches, truncated: false };
71
+ };
72
+ const search = ({ query, limit = 20, sourceIds }) => {
73
+ if (!validQuery(query, limit)) throw new Error('Invalid search request');
74
+ 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');
75
+ const selected = sourceIds === undefined ? null : new Set(sourceIds);
76
+ const matches = [];
77
+ for (const [id, source] of sources) {
78
+ if (selected && !selected.has(id)) continue;
79
+ signal?.throwIfAborted();
80
+ for (const range of lineRanges(source.text)) {
81
+ if (!source.text.slice(range.start, range.end).includes(query)) continue;
82
+ if (matches.length === limit) return { snapshotId, matches, truncated: true };
83
+ matches.push({ sourceId: id, hash: source.hash, line: range.line });
84
+ }
85
+ }
86
+ return { snapshotId, matches, truncated: false };
87
+ };
88
+ const read = ({ id, start, count }) => {
89
+ if (!sources.has(id)) throw new Error('Source not authorized');
90
+ if (!Number.isSafeInteger(start) || start < 1 || !Number.isSafeInteger(count) || count < 1 || count > 200) throw new Error('Invalid line range');
91
+ const source = sources.get(id);
92
+ let beginOffset = -1, endOffset = 0, selectedCount = 0;
93
+ for (const range of lineRanges(source.text)) {
94
+ if (range.line < start) continue;
95
+ if (beginOffset < 0) beginOffset = range.start;
96
+ endOffset = range.end;
97
+ selectedCount++;
98
+ if (selectedCount === count) break;
99
+ }
100
+ if (beginOffset < 0) throw new Error('Line range outside source');
101
+ const text = source.text.slice(beginOffset, endOffset);
102
+ if (Buffer.byteLength(text) > 64 * 1024) throw budgetError('output');
103
+ return { snapshotId, sourceId: id, hash: source.hash, start, count: selectedCount, text, endOfSource: endOffset === source.text.length };
104
+ };
105
+
50
106
  return Object.freeze({
51
107
  usage: () => ({ calls, outputBytes }),
52
- find({ query, limit = 20 }) {
53
- begin();
54
- if (!validQuery(query, limit)) throw new Error('Invalid find request');
55
- const matches = [];
56
- for (const [id, source] of sources) {
108
+ budget: () => ({ maxCalls, maxOutputBytes, remainingCalls: maxCalls - calls, remainingOutputBytes: maxOutputBytes - outputBytes, maxResultBytes: 64 * 1024 }),
109
+ // A synchronous host-only batch commits its ledger only as a whole. This
110
+ // also covers decorated response bytes that preflight cannot predict.
111
+ // Callers must not publish results/audit records before this returns.
112
+ transaction(execute) {
113
+ signal?.throwIfAborted();
114
+ if (inTransaction || typeof execute !== 'function') throw new Error('Invalid reader transaction');
115
+ const previousCalls = calls, previousBytes = outputBytes;
116
+ inTransaction = true;
117
+ try {
118
+ const result = execute();
119
+ if (result && typeof result.then === 'function') throw new Error('Invalid asynchronous reader transaction');
57
120
  signal?.throwIfAborted();
58
- if (typeof source.path !== 'string' || !source.path.includes(query)) continue;
59
- if (matches.length === limit) return finish({ snapshotId, matches, truncated: true });
60
- matches.push({ sourceId: id, path: source.path, side: source.side, hash: source.hash });
61
- }
62
- return finish({ snapshotId, matches, truncated: false });
121
+ return result;
122
+ } catch (error) {
123
+ calls = previousCalls; outputBytes = previousBytes;
124
+ throw error;
125
+ } finally { inTransaction = false; }
63
126
  },
64
- search({ query, limit = 20, sourceIds }) {
65
- begin();
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');
68
- const selected = sourceIds === undefined ? null : new Set(sourceIds);
69
- const matches = [];
70
- for (const [id, source] of sources) {
71
- if (selected && !selected.has(id)) continue;
72
- signal?.throwIfAborted();
73
- for (const range of lineRanges(source.text)) {
74
- if (!source.text.slice(range.start, range.end).includes(query)) continue;
75
- if (matches.length === limit) return finish({ snapshotId, matches, truncated: true });
76
- matches.push({ sourceId: id, hash: source.hash, line: range.line });
77
- }
78
- }
79
- return finish({ snapshotId, matches, truncated: false });
127
+ // Host-only dry run. Validate ALL ranges and sources before charging anything.
128
+ // No evidence or receipts escape this preflight. Decorated reads are still
129
+ // measured by finish; this never reserves or increases the shared budget.
130
+ preflight(requests) {
131
+ signal?.throwIfAborted();
132
+ if (!Array.isArray(requests) || !requests.length || requests.length > 8) throw new Error('Invalid retrieval batch');
133
+ const results = requests.map(request => {
134
+ if (request?.kind === 'read') return read(request);
135
+ if (request?.kind === 'find') return find(request);
136
+ if (request?.kind === 'search') return search(request);
137
+ throw new Error('Unsupported retrieval operation');
138
+ });
139
+ if (calls + requests.length > maxCalls) throw budgetError('call');
140
+ const size = results.reduce((sum, result) => sum + resultSize(result), 0);
141
+ if (outputBytes + size > maxOutputBytes) throw budgetError('output');
80
142
  },
143
+ find(request) { begin(); return finish(find(request)); },
144
+ search(request) { begin(); return finish(search(request)); },
81
145
  // decorate is a host-only callback; model requests never supply it.
82
- read({ id, start, count }, decorate = value => value) {
83
- begin();
84
- if (!sources.has(id)) throw new Error('Source not authorized');
85
- if (!Number.isSafeInteger(start) || start < 1 || !Number.isSafeInteger(count) || count < 1 || count > 200) throw new Error('Invalid line range');
86
- const source = sources.get(id);
87
- let beginOffset = -1, endOffset = 0, selectedCount = 0;
88
- for (const range of lineRanges(source.text)) {
89
- if (range.line < start) continue;
90
- if (beginOffset < 0) beginOffset = range.start;
91
- endOffset = range.end;
92
- selectedCount++;
93
- if (selectedCount === count) break;
94
- }
95
- if (beginOffset < 0) throw new Error('Line range outside source');
96
- const text = source.text.slice(beginOffset, endOffset);
97
- if (Buffer.byteLength(text) > 64 * 1024) throw new Error('Reader output budget exceeded');
98
- return finish(decorate({ snapshotId, sourceId: id, hash: source.hash, start, count: selectedCount, text, endOfSource: endOffset === source.text.length }));
99
- },
146
+ read(request, decorate = value => value) { begin(); return finish(decorate(read(request))); },
100
147
  });
101
148
  }
package/src/snapshot.mjs CHANGED
@@ -16,6 +16,7 @@ import { captureProjectRules } from './project-rules.mjs';
16
16
  import { captureSvn } from './svn.mjs';
17
17
  import { captureSvnHistory } from './svn-history.mjs';
18
18
  import { readLocal, relativePath, hash } from './content.mjs';
19
+ import { CHANGE_SCOPES } from './git-change-scope.mjs';
19
20
 
20
21
  export async function detectVcs(cwd) {
21
22
  let current = await realpath(cwd);
@@ -50,6 +51,11 @@ export async function captureSnapshot(cwd, options = {}) {
50
51
  const { rulePaths, contextPaths, maxContextFiles, maxSnapshotBytes } = validateSnapshotOptions(options);
51
52
 
52
53
  const type = await detectVcs(cwd);
54
+ if (options.changeScope !== undefined) {
55
+ if (typeof options.changeScope !== 'string' || !CHANGE_SCOPES.includes(options.changeScope)) throw new Error('Invalid changeScope; expected worktree, staged or unstaged');
56
+ if (['commit', 'baseRevision', 'targetRevision', 'svnRevision', 'svnBaseRevision', 'svnTargetRevision', 'svnBasePath', 'svnTargetPath'].some(key => options[key] !== undefined)) throw new Error('changeScope applies to Git working-copy comparison only');
57
+ if (type !== 'git') throw new Error('changeScope requires a Git working copy');
58
+ }
53
59
  const replacementOptions = options.svnReplacementBaseRevision !== undefined || options.svnReplacementAllowNetwork !== undefined;
54
60
  if (replacementOptions) {
55
61
  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');