@feng0531/dsh-code-review 0.85.4 → 0.85.5

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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  本文件介绍对使用者有意义的变化。安装与参数见 [README](README.md),本次发布正文见 [发布说明](RELEASE-NOTES.md)。历史提交和安装包保留在 [GitHub Releases](https://github.com/f-e-n-g-0531/dsh-code-review/releases);本文不把开发日志当作功能介绍。
4
4
 
5
+ ## 0.85.5 — 凭据来源保护与审核边界修复
6
+
7
+ - SVN 独立替换树在读取正文前检查复制来源及祖先来源,阻断从凭据路径复制到普通目标的内容;调用方扫描不再把带源码扩展名的目录当作文件读取。
8
+ - Git 默认工作区范围合并暂存删除后重建的同路径记录,避免重复文件身份;自动上下文允许精确目录索引导入探测,但不扩大后代正文授权。
9
+ - 暂存区审核复用已选变更作为上下文时,按相同 index 版本验证,不再误与未暂存工作区内容比较。
10
+ - 综合证据接受同一快照、来源和版本侧连续或重叠读取的完整区间覆盖;仍拒绝有缺口、错来源或错版本的证据。
11
+ - 超过 20 个窗口批次时保留全部可执行主窗口及此前成果,单独披露综合受限并保持未完整审核状态,不再使整轮异常退出。
12
+
5
13
  ## 0.85.4 — 审查正确性、失败恢复与全项目提速
6
14
 
7
15
  `v0.85.2` 因发布说明缺少导航链接、`v0.85.3` 因 Windows 临时目录短路径别名未被新增测试 mock 规范化而未通过 CI,均未发布 npm 或 GitHub Release。本版本补齐链接、规范化测试匹配路径并重新执行完整门禁,不覆盖旧冻结标签、不削弱拒绝断言。
package/RELEASE-NOTES.md CHANGED
@@ -1,28 +1,21 @@
1
- # v0.85.4 发布说明
1
+ # v0.85.5 发布说明
2
2
 
3
3
  使用方式见 [README](README.md),详细变化见 [变更记录](CHANGELOG.md),发布门禁见 [开发说明](DEVELOPMENT.md)。
4
4
 
5
- `v0.85.2` 因发布说明缺少导航链接、`v0.85.3` 因 Windows runner 临时目录短路径别名未被两个新增测试 mock 规范化而未通过 CI,均未发布 npm 或 GitHub Release。本版本保留旧冻结标签,补齐文档链接并修正测试路径匹配后重新执行完整门禁,不削弱原有拒绝断言。
5
+ ## 安全修复
6
6
 
7
- ## 审查正确性与失败恢复
7
+ SVN 独立替换树现在在读取正文前检查复制来源及复制祖先来源,阻断把凭据文件复制到普通目标后绕过来源过滤的路径。限定调用方扫描也会区分目录与普通文件,带 `.js` 等源码扩展名的目录不再令预览整体失败。
8
8
 
9
- - 模型流现在支持合法的完整文本块;完整块替换对应增量而不是重复拼接,文本和推理块继续受输出预算限制。真正重复或非法的 JSON 仍会拒绝,不靠截断响应通过。
10
- - 后台保存队列在一次失败后仍可保存终态;成功落盘后释放已结束任务的内存,最终保存失败也会唤醒等待者并保留诊断。
11
- - 修复模型拒绝值为 null/undefined 时丢失报告、非法分段参数导致死循环、SVN 历史正文大小低报影响累计预算等问题。
12
- - 补全进程终止的 SIGKILL 回退及清理、注册表网络异常重试、Git 根路径空格保留、锚点越界重定位、具名导入尾随逗号,以及 JavaScript/C#/C++ 导航边界。
9
+ ## 审核正确性与可用性
13
10
 
14
- ## 全项目审核提速
11
+ - Git 默认工作区审核合并暂存删除后重建的同路径记录,保持文件身份唯一。
12
+ - 自动上下文的目录索引导入不再被误判为越界;仍只读取精确选中的候选正文,不授权目录下任意文件。
13
+ - 已选暂存变更同时作为显式上下文时,复用 index 内容并按同侧版本验证,不把尚未暂存的正文混入变更侧。
14
+ - 长编辑分次读取后,综合校验按同一快照、来源及版本侧计算连续覆盖;跨来源、有缺口或错误版本的读取仍不能作为有效证据。
15
+ - 文件分成超过 20 个窗口批次时,主窗口继续审核并保留已有成果;综合受限单独披露,整体仍为部分完成,不再以异常丢失整轮报告。
15
16
 
16
- 默认独立批次并发从 3 提高到 8,可用插件配置 projectConcurrency 在 1~8 内调整。保留主审、检索、证据复核及共享调用预算;遇到提供方并发限制或 429 时请调低。
17
+ ## 验证与边界
17
18
 
18
- 相同 12 个批次、每次模拟模型延迟 40ms 的离线对照中,旧默认 3 路约 182ms,新默认 8 路约 92ms,约快 1.98 倍;调用数、完成数和结果顺序一致。这不是实际模型吞吐承诺,不减少必需调用或承诺 token 成本下降。
19
+ 本版为六项已复现问题添加回归测试,并执行完整本地测试及隔离安装验证;正式发布须通过同一冻结标签提交的 Ubuntu/Windows × Node.js 22/24 四组 CI、安装包验证和 npm 实际工件一致性门禁。具体执行结果以该标签 CI 和发布工作流为准。
19
20
 
20
- 本次历史审核的 778 次模型调用中,263 次为额外 JSON 重生成。完整块兼容修复可避免对应场景的无谓纠正,但尚未证明它解释历史全部格式失败;真实提供方效果需要更新后复测。
21
-
22
- ## 验证与安全边界
23
-
24
- 本地完整测试 685 项:684 通过、1 项 Windows 平台跳过、0 失败、0 取消;另对新增流式、持久化及导航回归进行独立验证。正式发布必须经过冻结标签提交的 Linux/Windows × Node 22/24 四组 CI,以及隔离安装和注册表工件一致性门禁。
25
-
26
- 文件及祖先身份检查是路径竞态检测加固,不是原子目录相对打开;不能保证抵御恶意进程精确交换并恢复路径。严格隔离场景应在不可并发修改的独立快照中审核。
27
-
28
- 本版本不自动安装、升级或重启 DSH;需更新并重新加载插件后生效。
21
+ 本版本不保证模型始终输出合法 JSON,不扩大源码读取权限,不自动安装、升级或重启 DSH;更新并重新加载插件后生效。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-code-review",
3
- "version": "0.85.4",
3
+ "version": "0.85.5",
4
4
  "private": false,
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
@@ -5,7 +5,7 @@ export function coverageFollowup(report) {
5
5
  const items = report.files.map(file => {
6
6
  if (!file || typeof file.fileId !== 'string' || !file.fileId || ids.has(file.fileId) || typeof file.path !== 'string' || !file.path || paths.has(file.path) || !['completed', 'excluded', 'blocked', 'failed', 'cancelled', 'pending'].includes(file.status)) throw new Error('Invalid coverage file');
7
7
  ids.add(file.fileId); paths.add(file.path);
8
- const incompleteSynthesis=(file.stageFailures??[]).length>0||file.synthesisStatus==='failed';
8
+ const incompleteSynthesis=(file.stageFailures??[]).length>0||!!file.synthesisStatus&&file.synthesisStatus!=='completed';
9
9
  const incompleteVerification = report.findings.some(f => f.fileId === file.fileId && ['pending', 'incomplete'].includes(f.verification?.status));
10
10
  return { fileId: file.fileId, path: file.path, status: file.status, incompleteVerification,incompleteSynthesis,
11
11
  followup: file.status !== 'excluded' && (file.status !== 'completed' || incompleteVerification || incompleteSynthesis) };
@@ -21,7 +21,7 @@ export function buildCoveragePlan(input, options) {
21
21
  if (file.eligibility !== 'reviewable') return item;
22
22
  const prepared = preparePrimaryInput(input, file, options);
23
23
  const riskPlanning = options.enableRiskPlanning === true ? options.skipSmallRiskPlans === true ? riskPlanningDecision([file]) : {required:true,reason:'unconditional'} : {required:false,reason:'disabled'};
24
- return { ...item, ...prepared.metadata, riskPlanning, status: prepared.budget.fits ? 'ready' : 'input-blocked', minimumCalls: prepared.budget.fits ? ((prepared.batches?.length ?? 1) * (rounds + (riskPlanning.required ? 1 : 0)) + (prepared.synthesis?.budget.fits ? rounds : 0)) : 0 };
24
+ return { ...item, ...prepared.metadata, riskPlanning, status: prepared.budget.fits ? 'ready' : 'input-blocked', minimumCalls: prepared.budget.fits ? ((prepared.batches?.length ?? 1) * (rounds + (riskPlanning.required ? 1 : 0)) + (prepared.synthesis?.status === 'ready' ? rounds : 0)) : 0 };
25
25
  });
26
26
  const local = options.enableBusinessGrouping === true ? localBusinessGroups(input.files) : null;
27
27
  const groups = [...(options.grouping?.groups ?? [])];
package/src/git.mjs CHANGED
@@ -41,7 +41,13 @@ function parseChanges(bytes) {
41
41
 
42
42
  /** Keep only the entries the requested comparison actually observes. */
43
43
  function changesInScope(changes, scope) {
44
- if (scope === 'worktree') return changes;
44
+ // Porcelain reports a staged deletion and its untracked recreation separately.
45
+ // Worktree compares HEAD to the current file once, retaining the tracked entry
46
+ // (and its baseline/selection authority), not a second untracked identity.
47
+ if (scope === 'worktree') {
48
+ const tracked = new Set(changes.filter(change => change.rawStatus !== '??').map(change => change.path));
49
+ return changes.filter(change => change.rawStatus !== '??' || !tracked.has(change.path));
50
+ }
45
51
  if (scope === 'staged') return changes.filter(change => change.rawStatus[0] !== ' ' && change.rawStatus[0] !== '?');
46
52
  return changes.filter(change => change.rawStatus[1] !== ' ' || change.rawStatus === '??');
47
53
  }
@@ -167,8 +167,9 @@ export function preparePrimaryInput(input, file, { instructions, scope, grouping
167
167
 
168
168
  if (batches?.length > 1) {
169
169
  synthesis = prepareWindowSynthesis(batches.map(batch => batch.payload), measure);
170
- metadata.synthesisStatus = synthesis.budget.fits ? 'ready' : 'input-blocked';
171
- metadata.synthesisInputBytes = synthesis.budget.bytes;
170
+ metadata.synthesisStatus = synthesis.status === 'ready' ? 'ready' : 'blocked';
171
+ if (synthesis.reason) metadata.synthesisBlockedReason = synthesis.reason;
172
+ if (synthesis.budget) metadata.synthesisInputBytes = synthesis.budget.bytes;
172
173
  }
173
174
 
174
175
  const budget = measure(payload);
package/src/review.mjs CHANGED
@@ -136,8 +136,8 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
136
136
  const { changes, payload } = prepared;
137
137
  Object.assign(state, prepared.metadata);
138
138
  if (state.contextMode === 'approved-retrieval') report.limitations.push({ fileId: file.id, text: '窗口初始输入仅带上下文身份目录,全文须按需读取;不声明所有上下文已核查。' });
139
- if (prepared.synthesis) state.synthesisStatus = prepared.synthesis.budget.fits ? 'pending' : 'blocked';
140
- if (prepared.synthesis && !prepared.synthesis.budget.fits) report.limitations.push({ fileId: file.id, text: '跨窗口综合输入超限,未发送;单窗口完成不表示交互影响已检查' });
139
+ if (prepared.synthesis) state.synthesisStatus = prepared.synthesis.status === 'ready' ? 'pending' : 'blocked';
140
+ if (prepared.synthesis?.status === 'blocked') report.limitations.push({ fileId: file.id, text: (prepared.synthesis.reason === 'batch-limit' ? '跨窗口综合批次数超过上限(20),未发送;全部主窗口仍独立审查' : '跨窗口综合输入超限,未发送') + ';单窗口完成不表示交互影响已检查' });
141
141
  if (state.sourceMode === 'change-windows') state.windowCoverage = { completed: [], pending: [...state.windowIds] };
142
142
  if(changes.precision==='envelope')report.limitations.push({fileId:file.id,text:'精确差异不可得,按保守包络分段检查;包络包含可能未变的行,不证明精确编辑或回归因果。'});
143
143
  if (changes.status === 'limited') report.limitations.push({ fileId: file.id, text: '精确变更分析受限:' + changes.reason + ';仍按完整两侧内容审查' });
@@ -192,7 +192,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
192
192
  }
193
193
  }
194
194
  const batches = [...(prepared.batches ?? [{ payload, windowIds: state.windowIds ?? [] }])];
195
- if (prepared.synthesis?.budget.fits) batches.push({ payload: prepared.synthesis.payload, windowIds: [], synthesis: true });
195
+ if (prepared.synthesis?.status === 'ready') batches.push({ payload: prepared.synthesis.payload, windowIds: [], synthesis: true });
196
196
  const matchingInteractionPlans = interactionPlans.filter(p => input.files.filter(f => p.group.fileIds.includes(f.id)).at(-1)?.id === file.id);
197
197
  for (const interactionPlan of matchingInteractionPlans.filter(p => p.prepared.status === 'ready')) batches.push({ payload: interactionPlan.prepared.payload, windowIds: [], interaction: interactionPlan.group });
198
198
  const scheduled = Array.from({length:rounds},(_,i)=>batches.map(b=>({...b,round:i+1}))).flat();
package/src/snapshot.mjs CHANGED
@@ -147,7 +147,13 @@ export async function captureSnapshot(cwd, options = {}) {
147
147
 
148
148
  // Re-capture verifies both selected sides while collecting explicit context.
149
149
  if ((await capture(cwd, indexOptions)).id !== snapshot.id) throw new Error('Snapshot changed while collecting context');
150
- for (const item of context) if ((await readLocal(snapshot.root, item.path, indexOptions)).hash !== item.hash) throw new Error('Context changed during capture');
150
+ for (const item of context) {
151
+ // Overlapping staged context reuses the approved index side, not local bytes.
152
+ // The same-side re-capture above verifies its content and index identity,
153
+ // including when the working file differs or no longer exists.
154
+ if (type === 'git' && snapshot.changeScope === 'staged' && snapshot.files.some(file => file.path === item.path)) continue;
155
+ if ((await readLocal(snapshot.root, item.path, indexOptions)).hash !== item.hash) throw new Error('Context changed during capture');
156
+ }
151
157
  if (index && (await (type === 'git' ? gitContextIndex : svnContextIndex)(snapshot.root, indexOptions)).fingerprint !== index.fingerprint) throw new Error('Tracked context index changed during capture');
152
158
  const verifiedRules = await captureProjectRules(snapshot.root, rulePaths, { signal: options.signal, files: snapshot.files });
153
159
  if (JSON.stringify(verifiedRules) !== JSON.stringify(rules)) throw new Error('Rules changed during capture');
@@ -7,4 +7,11 @@ import {lstat} from 'node:fs/promises';
7
7
  import path from 'node:path';
8
8
  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;
9
9
  // Offline scoped status only, never svn list or repository-wide enumeration.
10
- export async function svnCallerIndex(root,scopes,options={}){const approved=validateCallerScopes(scopes),entries=[],chunks=[];let bytes=0;const seen=new Set();for(const scope of approved){const ancestors=await svnCallerAncestors(root,scope,options);chunks.push(ancestors.fingerprint);let cursor=root;for(const part of scope.split('/')){cursor=path.join(cursor,part);const s=await lstat(cursor);if(s.isSymbolicLink()||!s.isDirectory())throw new Error('SVN caller scope requires real directories');}const raw=await checked('svn',['--non-interactive','status','--xml','--verbose','--ignore-externals','--',scope+'@'],{...options,cwd:root,maxBytes:64*1024});bytes+=raw.length;if(bytes>64*1024)throw new Error('SVN caller index byte limit exceeded');const found=(xml(raw).status?.target??[]).flatMap(t=>t.entry??[]);for(const e of found){const name=relativePath(e['@_path'].replaceAll('\\','/'));if(name!==scope&&!name.startsWith(scope+'/'))throw new Error('SVN caller index escaped scope');if(seen.has(name))throw new Error('Duplicate SVN caller index path');seen.add(name);if(seen.size>256)throw new Error('SVN caller index path limit exceeded');entries.push({...e,'@_path':name});}if(!found.some(e=>e['@_path'].replaceAll('\\','/')===scope))throw new Error('SVN caller scope missing from status');chunks.push(scope);}entries.sort((a,b)=>a['@_path']<b['@_path']?-1:1);const unsafe=s=>!s||s['@_item']!=='normal'||!['none','normal'].includes(s['@_props'])||['@_switched','@_copied','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true');for(const scope of approved){const e=entries.find(e=>e['@_path']===scope);if(unsafe(e?.['wc-status']))throw new Error('Unsafe SVN caller directory state');}return {scopes:approved,paths:entries.filter(e=>!approved.includes(e['@_path'])&&!unsafe(e['wc-status'])).map(e=>e['@_path']),entries,records:entries.length,bytes,fingerprint:hash(JSON.stringify(canonical({scopes:chunks,entries})))};}
10
+ // Status XML has no node kind: bind local kinds and omit directories from body candidates.
11
+ // Links and other unsafe non-directory nodes still reach the strict source validator.
12
+ export async function svnCallerIndex(root,scopes,options={}){const approved=validateCallerScopes(scopes),entries=[],chunks=[];let bytes=0;const seen=new Set();for(const scope of approved){const ancestors=await svnCallerAncestors(root,scope,options);chunks.push(ancestors.fingerprint);let cursor=root;for(const part of scope.split('/')){cursor=path.join(cursor,part);const s=await lstat(cursor);if(s.isSymbolicLink()||!s.isDirectory())throw new Error('SVN caller scope requires real directories');}
13
+ const raw=await checked('svn',['--non-interactive','status','--xml','--verbose','--ignore-externals','--',scope+'@'],{...options,cwd:root,maxBytes:64*1024});bytes+=raw.length;if(bytes>64*1024)throw new Error('SVN caller index byte limit exceeded');const found=(xml(raw).status?.target??[]).flatMap(t=>t.entry??[]);
14
+ for(const e of found){const name=relativePath(e['@_path'].replaceAll('\\','/'));if(name!==scope&&!name.startsWith(scope+'/'))throw new Error('SVN caller index escaped scope');if(seen.has(name))throw new Error('Duplicate SVN caller index path');seen.add(name);if(seen.size>256)throw new Error('SVN caller index path limit exceeded');entries.push({...e,'@_path':name});}if(!found.some(e=>e['@_path'].replaceAll('\\','/')===scope))throw new Error('SVN caller scope missing from status');chunks.push(scope);}
15
+ entries.sort((a,b)=>a['@_path']<b['@_path']?-1:1);const unsafe=s=>!s||s['@_item']!=='normal'||!['none','normal'].includes(s['@_props'])||['@_switched','@_copied','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true');for(const scope of approved){const e=entries.find(e=>e['@_path']===scope);if(unsafe(e?.['wc-status']))throw new Error('Unsafe SVN caller directory state');}
16
+ const nodes=[];for(const e of entries.filter(e=>!approved.includes(e['@_path'])&&!unsafe(e['wc-status']))){options.signal?.throwIfAborted();const stat=await lstat(path.join(root,e['@_path']));nodes.push({path:e['@_path'],kind:stat.isDirectory()?'dir':stat.isSymbolicLink()?'link':stat.isFile()?'file':'other'});}
17
+ return {scopes:approved,paths:nodes.filter(n=>n.kind!=='dir').map(n=>n.path),entries,records:entries.length,bytes,fingerprint:hash(JSON.stringify(canonical({scopes:chunks,entries,nodes})))};}
@@ -6,11 +6,19 @@ import {readReplacementSource} from './svn-replacement-source.mjs';
6
6
  import {svnWorkingIdentity} from './svn-working-identity.mjs';
7
7
  import {hash,decode,relativePath,readLocal} from './content.mjs';
8
8
  import {isSecretPath} from './secret-path.mjs';
9
+ import {canonicalSvnUrl} from './svn-url.mjs';
9
10
  import {svnKeywordMapping} from './svn-keyword-mapping.mjs';
10
11
  import {svnEolMapping} from './svn-eol-mapping.mjs';
11
12
  import {svnSpecialData} from './svn-special.mjs';
12
13
  import {readWorkingSvnSpecial} from './svn-working-special.mjs';
13
14
 
15
+ // Copy provenance is not permission to capture credential bytes at a safe target name.
16
+ function assertCopySource(source,suffix=''){
17
+ if(source===null||source===undefined)return;
18
+ const url=canonicalSvnUrl(source),name=decodeURIComponent(new URL(url).pathname);
19
+ if(isSecretPath(name)||isSecretPath(name+(suffix?'/'+suffix:'')))throw new Error('Credential replacement copy source excluded');
20
+ }
21
+
14
22
  // Fixed repository tree versus explicitly selected working replacement tree.
15
23
  // Every descendant is a separate source/coverage unit, never an implicit approval.
16
24
  export async function captureReplacementTree(root,rootInfo,name,options={}){
@@ -26,13 +34,14 @@ export async function captureReplacementTree(root,rootInfo,name,options={}){
26
34
  const ancestors=async p=>{
27
35
  const parts=p.split('/').slice(0,-1),names=['.',...parts.map((_,i)=>parts.slice(0,i+1).join('/'))],records=[];
28
36
  for(const name of names){
29
- if(!verifying&&ancestorReceipts.has(name)){records.push(ancestorReceipts.get(name));continue;}
37
+ if(!verifying&&ancestorReceipts.has(name)){const record=ancestorReceipts.get(name);assertCopySource(record.copyFromUrl,name==='.'?p:p.slice(name.length+1));records.push(record);continue;}
30
38
  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
39
  const node=xml(await run(['info','--xml','--',name+'@'])).info?.entry?.[0],identity=svnWorkingIdentity(node,rootInfo,name);
32
40
  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
41
  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');
42
+ assertCopySource(identity.source.copyFromUrl,name==='.'?p:p.slice(name.length+1));
34
43
  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]))};
44
+ const record={path:name,copyFromUrl:identity.source.copyFromUrl,fingerprint:hash(JSON.stringify([identity.fingerprint,properties]))};
36
45
  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
46
  }return records;
38
47
  };
@@ -40,6 +49,7 @@ export async function captureReplacementTree(root,rootInfo,name,options={}){
40
49
  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
50
  const ancestorEvidence=await ancestors(p);
42
51
  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');
52
+ assertCopySource(identity.source.copyFromUrl);
43
53
  if(node['@_kind']==='dir'&&identity.source.depth!=='infinity')throw new Error('Sparse replacement target cannot prove descendant inventory');
44
54
  const properties=await props(p);if(Object.hasOwn(properties,'svn:externals'))throw new Error('Replacement externals require independent scope');
45
55
  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);
@@ -1,15 +1,31 @@
1
1
  // Audit only successful reads from this synthesis generation, never prior batches/searches.
2
2
  export function validateSynthesisReads(findings, changes, reads, catalog, fileId, snapshotId) {
3
3
  const sources = new Map(catalog.map(s => [s.id, s]));
4
+ const intervals = new Map();
5
+ for (const read of reads) {
6
+ const source = sources.get(read.sourceId);
7
+ if (read.kind !== 'read' || read.snapshotId !== snapshotId || source?.fileId !== fileId || !['old', 'new'].includes(source.side)) continue;
8
+ if (!Number.isSafeInteger(read.start) || read.start < 1 || !Number.isSafeInteger(read.count) || read.count < 1 || !Number.isSafeInteger(read.start + read.count)) continue;
9
+ const ranges = intervals.get(read.sourceId) ?? [];
10
+ ranges.push({ start: read.start, end: read.start + read.count });
11
+ intervals.set(read.sourceId, ranges);
12
+ }
13
+ // Never join different source identities, even when their file and side match.
14
+ for (const [id, ranges] of intervals) {
15
+ const merged = [];
16
+ for (const range of ranges.sort((a, b) => a.start - b.start)) {
17
+ const last = merged.at(-1);
18
+ if (last && range.start <= last.end) last.end = Math.max(last.end, range.end);
19
+ else merged.push({ ...range });
20
+ }
21
+ intervals.set(id, merged);
22
+ }
4
23
  for (const finding of findings) {
5
24
  for (const id of finding.attribution.editIds) {
6
25
  const edit = changes.edits.find(e => e.id === id);
7
26
  const covered = ['old', 'new'].some(side => {
8
27
  const range = edit?.[side];
9
- return range?.count > 0 && reads.some(r => {
10
- const source = sources.get(r.sourceId);
11
- return r.kind === 'read' && r.snapshotId === snapshotId && source?.fileId === fileId && source.side === side && r.start <= range.start && r.start + r.count >= range.start + range.count;
12
- });
28
+ return range?.count > 0 && [...intervals].some(([sourceId, ranges]) => sources.get(sourceId).side === side && ranges.some(r => r.start <= range.start && r.end >= range.start + range.count));
13
29
  });
14
30
  if (!covered) throw new Error('Synthesis referenced edit was not read in this generation');
15
31
  }
@@ -16,7 +16,10 @@ export async function gitContextIndex(root, options = {}) {
16
16
  let bytes;
17
17
  if(options.probePaths !== undefined){
18
18
  if(!Array.isArray(options.probePaths)||options.probePaths.length>512||new Set(options.probePaths).size!==options.probePaths.length)throw new Error('Invalid Git context probes');
19
- const chunks=[];let totalBytes=0;for(const name of [...options.probePaths].sort()){relativePath(name);const chunk=await checked('git',['--no-optional-locks','--literal-pathspecs','-c','core.fsmonitor=false','-c','core.untrackedCache=false','ls-files','--stage','-z','--',name],{...options,cwd:root,maxBytes:64*1024});const records=new TextDecoder('utf-8',{fatal:true}).decode(chunk).split('\0').filter(Boolean);for(const record of records)if(record.slice(record.indexOf('\t')+1)!==name)throw new Error('Git context probe expanded beyond literal path');totalBytes+=chunk.length;if(totalBytes>1024*1024)throw new Error('Tracked context index limit exceeded');chunks.push(chunk);}
19
+ // A literal pathspec alone still recursively matches a directory. Exclude
20
+ // its literal trailing-slash subtree before output, so directory probes are
21
+ // exact misses without enumerating or authorizing unrequested descendants.
22
+ const chunks=[];let totalBytes=0;for(const name of [...options.probePaths].sort()){relativePath(name);const chunk=await checked('git',['--no-optional-locks','-c','core.fsmonitor=false','-c','core.untrackedCache=false','ls-files','--stage','-z','--',':(top,literal)'+name,':(top,literal,exclude)'+name+'/'],{...options,cwd:root,maxBytes:64*1024});const records=new TextDecoder('utf-8',{fatal:true}).decode(chunk).split('\0').filter(Boolean);for(const record of records)if(record.slice(record.indexOf('\t')+1)!==name)throw new Error('Git context probe expanded beyond literal path');totalBytes+=chunk.length;if(totalBytes>1024*1024)throw new Error('Tracked context index limit exceeded');chunks.push(chunk);}
20
23
  bytes=Buffer.concat(chunks);if(bytes.length>1024*1024)throw new Error('Tracked context index limit exceeded');
21
24
  }else bytes = await checked('git', ['--no-optional-locks','-c','core.fsmonitor=false','-c','core.untrackedCache=false','ls-files','--stage','-z'], {...options,cwd:root,maxBytes:1024*1024});
22
25
  const records = new TextDecoder('utf-8',{fatal:true}).decode(bytes).split('\0').filter(Boolean);
@@ -1,6 +1,8 @@
1
1
  // Pure approved-data preparation, no reads or sends. No source text is silently clipped.
2
2
  export function prepareWindowSynthesis(batchPayloads, measure) {
3
- if (!Array.isArray(batchPayloads) || batchPayloads.length < 2 || batchPayloads.length > 20) throw new Error('Invalid synthesis batches');
3
+ if (!Array.isArray(batchPayloads) || batchPayloads.length < 2) throw new Error('Invalid synthesis batches');
4
+ // A synthesis scheduling limit must not discard any independently reviewable batch.
5
+ if (batchPayloads.length > 20) return { status: 'blocked', reason: 'batch-limit', batchCount: batchPayloads.length };
4
6
  const batches = batchPayloads.map(payload => JSON.parse(payload));
5
7
  const base = batches[0];
6
8
  const windows = batches.flatMap(batch => batch.windows);
@@ -8,5 +10,5 @@ export function prepareWindowSynthesis(batchPayloads, measure) {
8
10
  const windowIndex = windows.map(w => ({ id: w.id, editIds: w.editIds, old: { start: w.old.start, count: w.old.count }, new: { start: w.new.start, count: w.new.count } }));
9
11
  const payload = JSON.stringify({ ...base, windows: undefined, windowIndex, sourceMode: 'window-synthesis', windowNotice: '本请求综合检查所有窗口的共同影响,不重报单窗口问题;windowIndex仅为原始定位目录,不含源码,必须在批准catalog中读取相关旧新片段,不能仅凭目录认定缺陷。输出仍仅主file的发现,归因至少关联两个不同窗口的编辑;证据不足明确限制。' });
10
12
  const budget = measure(payload);
11
- return { payload, windows, windowIds: windows.map(w => w.id), budget };
13
+ return { status: budget.fits ? 'ready' : 'blocked', ...(budget.fits ? {} : { reason: 'input-budget' }), payload, windows, windowIds: windows.map(w => w.id), budget };
12
14
  }