@feng0531/dsh-code-review 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,9 +1,19 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.37.0
4
+
5
+ - callerScopePaths接入SVN离线限定目录normal引用方扫描;正文及负匹配hash/node/index/probe复核,共享原30秒、128正文/1MiB、复核2MiB、20上下文/4MiB快照,字面导航不证明语义调用覆盖。
6
+ - 祖先与节点真实路径/URL链/wcroot/UUID及属性校验,拒绝switched/external/special/keywords;竞争含本地未跟踪路径但无正文权限。真实SVN预览零发送、过期拒绝、满槽、二进制及超限验收,375项离线通过;待固定四矩阵与原工件发布,不宣称正式交付。
7
+
8
+ ## 0.36.0
9
+
10
+ - callerScopePaths支持Git固定commit/range两侧对象内有界字面引用发现;读取匹配/非匹配blob并绑定OID/hash/version与索引/竞争探针,execute重捕获;不checkout/fetch/读取工作树,SVN仍不支持。
11
+ - 两侧共用30秒、128正文/1MiB、256记录/64KiB及512探针/1MiB;共享20上下文/4MiB快照,oldOnly复用不自动批准new侧;根提交、删除、竞争歧义、共享扫描耗尽及预览执行已验收。导航不证明调用/语义绑定或完整覆盖,真实模型质量未验收。370冻结测试及四矩阵通过;唯一发布37929860863原artifact11615712667的95603字节与npm/Release逐字节、双hash及清单一致。
12
+
3
13
  ## 0.35.0
4
14
 
5
15
  - 自动历史旧侧直接依赖导航命中已批准删除时复用原changed-old身份,excluded/blocked不能重新作为context进入,不多占上下文槽;共享原512探针、20候选/上下文、blob/快照字节限额,无checkout/fetch/工作树读取。
6
- - 普通变更继续走原变化身份,不新增重复正文;显式oldContext去重,满上下文槽不阻塞已批准旧身份复用。导航不证明语义调用/绑定,B2完整能力仍需后续来源场景。363离线验收通过,待冻结远端门禁。
16
+ - 普通变更继续走原变化身份,不新增重复正文;显式oldContext去重,满上下文槽不阻塞已批准旧身份复用。导航不证明语义调用/绑定,B2完整能力仍需后续来源场景。363离线验收、固定标签四矩阵及唯一发布37918056608通过;原artifact11610243325的92968字节与npm/Release逐字节、双hash及校验清单一致。
7
17
 
8
18
  ## 0.34.1
9
19
 
package/README.md CHANGED
@@ -4,11 +4,11 @@
4
4
 
5
5
  ## 当前状态
6
6
 
7
- 当前正式版本为 0.34.1(固定标签四矩阵及npm/Release原工件字节双hash门禁通过)。JSON围栏兼容与有界窗口上下文已交付;实际项目审查效果仍需原报告和新预览执行验证。
7
+ 当前正式版本为 0.36.0(固定标签四矩阵及npm/Release原工件字节双hash门禁通过)。JSON围栏兼容与有界窗口上下文已交付;实际项目审查效果仍需原报告和新预览执行验证。
8
8
 
9
9
  Git历史预览新增oldContextPaths:最多20个显式仓库相对文件,与contextPaths/自动上下文共享20槽、256KiB每blob及4MiB快照。仅从baseline普通Git blob捕获,不checkout/fetch/读取工作树;根提交无baseline拒绝,working/SVN明确拒绝。旧侧条目携带oldRevision/oldBlobOid/oldHash,仅进入context-old;oldOnly表示仅批准旧侧,不证明target路径不存在。所选变更复用原changed-old,不重复上下文;excluded/blocked变更、链接/submodule、凭据路径不能重新进入。预览显示身份元数据、不显示正文,确认执行才按宿主正常权限发送。主审/综合/复核按侧提示,计入原输入预算,旧证据不能冒充新侧回归证明。
10
10
 
11
- 预览可选callerScopePaths,最多4个非根、非重叠相对目录,仅Git工作区。预览读取目录内tracked JS/TS/C-family候选正文(含非匹配),仅匹配引用方作为上下文发送模型;128扫描文件/1MiB正文/256索引记录/64KiB索引/512竞争路径探针,发现与重核共享30秒,复核累计2MiB,仍共享20上下文及4MiB快照。绑定所有扫描hash、索引及竞争探针;execute重新捕获。历史/SVN明确不支持,不静默忽略。字面引用仅导航,不证明函数调用或完整覆盖,报告保留partial限制。
11
+ 预览可选callerScopePaths,最多4个非根、非重叠相对目录,支持Git/SVN工作区及Git固定commit/range两侧对象。SVN仅离线normal普通节点,校验祖先与文件URL链/wcroot/UUID及属性,拒绝switched/external/special/keywords;竞争路径含本地未跟踪项但不授其正文。历史两侧共用30秒、128正文/1MiB、256记录/64KiB、512竞争探针/1MiB,blob版本/OID/hash及非匹配身份绑定;不读取工作树。预览读取目录内tracked JS/TS/C-family候选正文(含非匹配),仅匹配引用方作为上下文发送模型;128扫描文件/1MiB正文/256索引记录/64KiB索引/512竞争路径探针,发现与重核共享30秒,复核累计2MiB,仍共享20上下文及4MiB快照。绑定所有扫描hash、索引及竞争探针;execute重新捕获。SVN目录深度<=32、索引256条/64KiB、探针512条/1MiB;与Git工作区同共享30秒/正文128条1MiB/复核2MiB,缺正文/超限fail-closed;历史root无旧侧,删除仅旧侧导航,oldOnly上下文复用不新增new授权。字面引用仅导航,不证明函数调用或完整覆盖,报告保留partial限制。
12
12
 
13
13
  最终报告记录业务需求hash/bytes用于溯源,不重复需求正文;真实Git当前/历史捕获及服务预览执行预算集成已验收。
14
14
 
@@ -30,7 +30,7 @@ C#自动上下文支持窄语法同目录Type.cs静态零参调用探针,需fi
30
30
 
31
31
  历史捕获用diff-tree精确端点变更元数据,不前置完整枚举文件树;显式上下文/规则和直接导入依赖按字面非递归路径查询。自动依赖最多512个待查路径,仍保留200变更/20上下文/4MiB快照限制,大树小提交不因无关文件元数据超限。
32
32
 
33
- 历史自动上下文也导航旧侧import/include,仅未修改且两侧普通blob;上下文oldText/oldRevision和目标revision明确,检索context-old单独身份,仍共用20文件总量及4MiB快照。旧侧独有/删除上下文暂不捕获,调用者覆盖仍不保证。
33
+ 历史自动上下文导航旧侧import/include:未修改依赖保留两侧普通blob及版本;已批准删除依赖复用原changed-old,不重复捕获或占用上下文槽。excluded/blocked删除不恢复,目标链接不赋予新侧权限。新增上下文仍共享20槽、blob字节及4MiB快照限额,自动来源边界其余场景待验收;导航不证明调用者覆盖或符号绑定。
34
34
 
35
35
  单提交审查直接读取原始commit头的父节点,不受浅克隆遍历边界影响;缺失父树明确失败且不自动fetch,不能把非根提交当空旧侧。
36
36
 
package/index.mjs CHANGED
@@ -12,7 +12,7 @@ export function apply(ctx, config = {}) {
12
12
  };
13
13
  ctx.tools.register({
14
14
  name: 'code_review_preview', description: 'Preview Git/SVN working-copy or Git commit/range review scope and model destination without sending source to a model. repositoryPath selects an explicit child repository relative to session workspace; file paths are repository-relative. Preview each repository separately. Show the preview before execution.',
15
- parameters: { type: 'object', additionalProperties: false, properties: { reviewRounds: { type: 'integer', minimum: 1, maximum: 3, description: 'Explicit independent review passes, default 1. Preview discloses extra sends; later passes omit the first risk plan. Shares existing timeout, call and retrieval budgets, not provider reasoning effort. Bound to this preview; execute cannot override.' }, oldContextPaths: { type: 'array', maxItems: 20, items: { type: 'string' }, description: 'Explicit Git historical baseline-only blob context paths. Shares 20 context slots. No target text is inferred; selected old changes reuse their original identity, excluded changes cannot reenter. Unsupported on working reviews and SVN.' }, callerScopePaths: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' }, description: 'Explicit non-root repository-relative directories for bounded tracked literal reverse import/include discovery. Git working review only. Preview reads candidate bodies, including nonmatches; only captured matching context is sent. Navigation only, not complete caller coverage.' }, businessRequirement: { type: 'string', maxLength: 16384, description: 'Optional business constraint text up to 16KiB UTF8; untrusted data, not evidence or permission. Bound to preview and sent in review phases.' }, commit: { type: 'string', description: 'Git commit versus sole parent; root empty, merge requires range.' }, baseRevision: { type: 'string', description: 'Git old endpoint; requires targetRevision, mutually exclusive with commit.' }, targetRevision: { type: 'string', description: 'Git new endpoint, exact tree endpoints not merge-base. Context is captured from target tree only; explicit rules are target-tree blobs too.' }, repositoryPath: { type: 'string', description: 'Workspace-relative explicit child Git/SVN repository root, forward slashes; omit for current repository.' }, selectedPaths: { type: 'array', items: { type: 'string' }, maxItems: 200 }, autoContext: { type: 'boolean', description: 'Opt in to bounded direct tracked Git/SVN import/include context captured and shown in preview.' }, contextPaths: { type: 'array', items: { type: 'string' }, maxItems: 20 }, rulePaths: { type: 'array', items: { type: 'string' }, maxItems: 4 } } },
15
+ parameters: { type: 'object', additionalProperties: false, properties: { reviewRounds: { type: 'integer', minimum: 1, maximum: 3, description: 'Explicit independent review passes, default 1. Preview discloses extra sends; later passes omit the first risk plan. Shares existing timeout, call and retrieval budgets, not provider reasoning effort. Bound to this preview; execute cannot override.' }, oldContextPaths: { type: 'array', maxItems: 20, items: { type: 'string' }, description: 'Explicit Git historical baseline-only blob context paths. Shares 20 context slots. No target text is inferred; selected old changes reuse their original identity, excluded changes cannot reenter. Unsupported on working reviews and SVN.' }, callerScopePaths: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' }, description: 'Explicit non-root repository-relative directories for bounded tracked literal reverse import/include discovery. Git/SVN working review or Git fixed commit/range review. SVN uses offline normal nodes, rejects unsafe ancestor states and special/keyword properties. Historical sides share scan budgets and use object blobs only. Preview reads candidate bodies, including nonmatches; only captured matching context is sent. Navigation only, not complete caller coverage.' }, businessRequirement: { type: 'string', maxLength: 16384, description: 'Optional business constraint text up to 16KiB UTF8; untrusted data, not evidence or permission. Bound to preview and sent in review phases.' }, commit: { type: 'string', description: 'Git commit versus sole parent; root empty, merge requires range.' }, baseRevision: { type: 'string', description: 'Git old endpoint; requires targetRevision, mutually exclusive with commit.' }, targetRevision: { type: 'string', description: 'Git new endpoint, exact tree endpoints not merge-base. Explicit context uses target tree with available baseline provenance; oldContextPaths is baseline-only. Historical caller discovery scans fixed sides; explicit rules use target-tree blobs.' }, repositoryPath: { type: 'string', description: 'Workspace-relative explicit child Git/SVN repository root, forward slashes; omit for current repository.' }, selectedPaths: { type: 'array', items: { type: 'string' }, maxItems: 200 }, autoContext: { type: 'boolean', description: 'Opt in to bounded direct tracked Git/SVN import/include context captured and shown in preview.' }, contextPaths: { type: 'array', items: { type: 'string' }, maxItems: 20 }, rulePaths: { type: 'array', items: { type: 'string' }, maxItems: 4 } } },
16
16
  output,
17
17
  async execute(args, exec) { const preview = await service.preview(args, exec); return { json: JSON.stringify(preview), markdown: '' }; },
18
18
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-code-review",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
4
4
  "private": false,
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
@@ -1,3 +1,4 @@
1
+ import {captureHistoryCallers} from './history-caller-capture.mjs';
1
2
  import { isSecretPath, assertNonSecretPath } from './secret-path.mjs';
2
3
  import {captureProjectRules} from './project-rules.mjs';
3
4
  import {contextCandidates} from './context-candidates.mjs';
@@ -52,5 +53,7 @@ export async function captureGitHistory(cwd, options={}) {
52
53
  const rules=await captureProjectRules(root,rulePaths,{signal,files,read:async (_root,name,{maxFileBytes:limit})=>{const entry=right.get(name);if(!regular(entry))throw new Error('Historical rule not a target regular blob');return decode(await git(root,['cat-file','blob',entry.oid],limit));}});
53
54
  let autoContext;
54
55
  if(options.autoContext===true){const probes=new Set();const oldFiles=files.map(f=>f.eligibility==='reviewable'?{...f,right:f.left,rightExists:true}:f);const onProbe=paths=>{for(const p of paths)probes.add(p);if(probes.size>512)throw new Error('Historical context lookup limit exceeded');};contextCandidates(files,[],{onProbe});contextCandidates(oldFiles,[],{onProbe});await lookup(target,right,[...probes]);await lookup(base,left,[...probes]);const plan=contextCandidates(files,[...right.keys()]);const oldPlan=contextCandidates(files.map(f=>f.eligibility==='reviewable'?{...f,right:f.left,rightExists:true}:f),[...left.keys()],{includeChanged:true});const combined=new Map(plan.candidates.map(c=>[c.path,{...c,sourceSides:['new']}]));for(const c of oldPlan.candidates){if(files.some(f=>f.path===c.path)&&['100644','100755'].includes(right.get(c.path)?.mode))continue;if(combined.has(c.path))combined.get(c.path).sourceSides.push('old');else combined.set(c.path,{...c,sourceSides:['old']});}plan.candidates=[...combined.values()].sort((a,b)=>a.path<b.path?-1:a.path>b.path?1:0);plan.truncated ||= oldPlan.truncated;autoContext={...plan,notice:'Direct baseline/target-tree import/include navigation only; no caller coverage, binding or working-tree reads.',target,base,candidates:plan.candidates.filter(c=>!contextPaths.includes(c.path)&&!oldContextPaths.includes(c.path)&&!rulePaths.includes(c.path)),capturedPaths:[]};for(const c of autoContext.candidates){signal?.throwIfAborted();try{const changed=files.find(f=>f.path===c.path);if(c.sourceSides.length===1&&c.sourceSides[0]==='old'&&!regular(right.get(c.path))){if(changed){if(changed.eligibility!=='reviewable'||!changed.left||!regular(left.get(c.path)))throw new Error('Old dependency conflicts with excluded blocked or absent old change');c.status='reused-changed-old';continue;}if(context.length>=maxContextFiles)throw new Error('Context file limit exceeded');const entry=left.get(c.path);if(!regular(entry))throw new Error('Old dependency not a baseline regular blob');const content=decode(await git(root,['cat-file','blob',entry.oid],maxFileBytes));context.push({path:c.path,oldOnly:true,oldText:content.text,oldHash:hash(content.text),oldBlobOid:entry.oid,oldRevision:base});c.status='captured';autoContext.capturedPaths.push(c.path);continue;}if(context.length>=maxContextFiles)throw new Error('Context file limit exceeded');context.push(await captureContext(c.path));c.status='captured';autoContext.capturedPaths.push(c.path);}catch(error){signal?.throwIfAborted();c.status='blocked';c.reason=error.message;}}}
55
- const snapshot={context,...(rules.length?{rules}:{}),...(autoContext?{autoContext}:{}),schemaVersion:1,vcs:'git',root,baseline:base,history:{mode:commit!==undefined?'commit':'range',base,target,semantics:'exact-tree-endpoints; renames represented as add/delete'},files};const serialized=JSON.stringify(snapshot);if(Buffer.byteLength(serialized)>maxSnapshotBytes)throw new Error('Historical snapshot limit exceeded');return {...snapshot,id:hash(serialized)};
56
+ let callerDiscovery;
57
+ if(options.callerScopePaths!==undefined){const callers=await captureHistoryCallers(root,files,{base,target},options.callerScopePaths,options);callerDiscovery=callers.discovery;for(const item of callers.context){const matches=callerDiscovery.candidates.filter(c=>c.fromPath===item.path),present=context.find(c=>c.path===item.path);if(present){if((present.text!==undefined&&present.text!==item.text)||(present.oldText!==undefined&&present.oldText!==item.oldText))throw new Error('Historical caller context identity conflict');for(const c of matches){const available=c.side==='old'?present.oldText!==undefined:present.text!==undefined;c.status=available?'reused-context':'blocked';if(!available)c.captureReason='Existing context did not approve this version side';}continue;}if(context.length>=maxContextFiles){for(const c of matches){c.status='blocked';c.captureReason='Context file limit exceeded';}continue;}context.push(item);callerDiscovery.capturedPaths.push(item.path);for(const c of matches)c.status='captured';}}
58
+ const snapshot={context,...(callerDiscovery?{callerDiscovery}:{}),...(rules.length?{rules}:{}),...(autoContext?{autoContext}:{}),schemaVersion:1,vcs:'git',root,baseline:base,history:{mode:commit!==undefined?'commit':'range',base,target,semantics:'exact-tree-endpoints; renames represented as add/delete'},files};const serialized=JSON.stringify(snapshot);if(Buffer.byteLength(serialized)>maxSnapshotBytes)throw new Error('Historical snapshot limit exceeded');return {...snapshot,id:hash(serialized)};
56
59
  }
@@ -0,0 +1,15 @@
1
+ import {historyCallerIndex,historyCallerProbes} from './history-caller-index.mjs';
2
+ import {callerCandidates} from './caller-candidates.mjs';
3
+ import {checked} from './process.mjs';
4
+ import {decode,hash} from './content.mjs';
5
+ import {isSecretPath} from './secret-path.mjs';
6
+ // Both sides share one capture lifecycle and all discovery limits. Never called by model readers.
7
+ export async function captureHistoryCallers(root,files,history,scopes,options={}){
8
+ const deadline=AbortSignal.timeout(30000),signal=options.signal?AbortSignal.any([options.signal,deadline]):deadline,bounded={...options,signal};
9
+ const changed=new Set(files.map(f=>f.path)),rules=new Set(options.rulePaths??[]),scanned=[],candidates=[],indexes=[],contexts=new Map();let scanBytes=0,indexBytes=0,indexRecords=0,probeBytes=0;const allProbes=new Set();
10
+ for(const [side,commit] of [['old',history.base],['new',history.target]]){if(!commit)continue;const index=await historyCallerIndex(root,commit,scopes,bounded);indexBytes+=index.bytes;indexRecords+=index.records;if(indexBytes>64*1024||indexRecords>256)throw new Error('Historical caller shared index limit exceeded');const sources=[];
11
+ for(const entry of index.entries){signal.throwIfAborted();if(changed.has(entry.path)||rules.has(entry.path)||isSecretPath(entry.path)||!/\.(mjs|cjs|js|jsx|ts|tsx|c|cc|cpp|cxx|h|hh|hpp|hxx|shader|hlsl|glsl|cginc|hlsli|compute)$/i.test(entry.path))continue;if(scanned.length>=128)throw new Error('Historical caller shared scan file limit exceeded');const content=decode(await checked('git',['--no-optional-locks','cat-file','blob',entry.oid],{...bounded,cwd:root,maxBytes:Math.min(options.maxFileBytes??256*1024,256*1024)}));scanBytes+=content.bytes;if(scanBytes>1024*1024)throw new Error('Historical caller shared scan byte limit exceeded');const source={path:entry.path,...content,blobOid:entry.oid,revision:commit,side};scanned.push(source);sources.push(source);}
12
+ const targets=side==='old'?files.map(f=>({...f,rightExists:!!f.left})):files;const requested=callerCandidates(targets,sources,[]).probePaths;for(const p of requested)allProbes.add(p);if(allProbes.size>512)throw new Error('Historical caller shared probe limit exceeded');const probes=await historyCallerProbes(root,commit,requested,bounded);probeBytes+=probes.bytes;if(probeBytes>1024*1024)throw new Error('Historical caller shared probe byte limit exceeded');const plan=callerCandidates(targets,sources,probes.paths);indexes.push({side,commit,indexFingerprint:index.fingerprint,probeFingerprint:probes.fingerprint});for(const c of plan.candidates)candidates.push({...c,side,revision:commit});}
13
+ const wanted=new Set(candidates.map(c=>c.fromPath));for(const s of scanned){if(!wanted.has(s.path))continue;const item=contexts.get(s.path)??{path:s.path};if(s.side==='new')Object.assign(item,{text:s.text,hash:s.hash,bytes:s.bytes,encoding:s.encoding,revision:s.revision});else Object.assign(item,{oldText:s.text,oldHash:hash(s.text),oldBlobOid:s.blobOid,oldRevision:s.revision});contexts.set(s.path,item);}for(const item of contexts.values())if(item.text===undefined)item.oldOnly=true;signal.throwIfAborted();
14
+ return {context:[...contexts.values()].sort((a,b)=>a.path<b.path?-1:1),discovery:{scopes,history,indexes,scanned:scanned.map(({path,hash,bytes,blobOid,revision,side})=>({path,hash,bytes,blobOid,revision,side})),scannedBytes:scanBytes,candidates,capturedPaths:[],incomplete:true,notice:'Fixed baseline/target literal references in explicit scopes only; no worktree reads, semantic binding or complete caller coverage.',limitations:['Only supported literal references within approved scopes were searched.']}};
15
+ }
@@ -0,0 +1,11 @@
1
+ import {checked} from './process.mjs';
2
+ import {relativePath,hash} from './content.mjs';
3
+ import {validateCallerScopes} from './caller-index.mjs';
4
+ const oidPattern=/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
5
+ function revision(value){if(typeof value!=='string'||!oidPattern.test(value))throw new Error('Historical caller index requires fixed commit OID');}
6
+ const git=(root,args,options)=>checked('git',['--no-optional-locks','--literal-pathspecs',...args],{...options,cwd:root,maxBytes:64*1024});
7
+ function records(chunk){const text=new TextDecoder('utf-8',{fatal:true}).decode(chunk);if(text&&!text.endsWith('\0'))throw new Error('Incomplete historical caller index');return text.split('\0').filter(Boolean).map(record=>{const m=/^(\d{6}) (blob|tree|commit) ([a-f0-9]{40}|[a-f0-9]{64})\t([\s\S]+)$/.exec(record);if(!m)throw new Error('Invalid historical caller record');return {mode:m[1],type:m[2],oid:m[3],path:relativePath(m[4])};});}
8
+ // Directory scopes only. No complete tree enumeration and no worktree/index reads.
9
+ export async function historyCallerIndex(root,commit,scopes,options={}){revision(commit);const approved=validateCallerScopes(scopes),chunks=[],entries=[];let bytes=0,count=0;for(const scope of approved){const chunk=await git(root,['ls-tree','-r','-z',commit,'--',scope],options);bytes+=chunk.length;if(bytes>64*1024)throw new Error('Historical caller index byte limit exceeded');for(const entry of records(chunk)){if(++count>256)throw new Error('Historical caller index path limit exceeded');if(!entry.path.startsWith(scope+'/'))throw new Error('Historical caller scope is not a directory or escaped scope');entries.push(entry);}chunks.push(Buffer.from(scope+'\0'),chunk);}return {commit,scopes:approved,entries:entries.filter(e=>e.type==='blob'&&['100644','100755'].includes(e.mode)).sort((a,b)=>a.path<b.path?-1:1),fingerprint:hash(Buffer.concat([Buffer.from(commit+'\0'),...chunks])),records:count,bytes};}
10
+ // A tree, link or submodule remains a resolution competitor, never a body grant.
11
+ export async function historyCallerProbes(root,commit,paths,options={}){revision(commit);if(!Array.isArray(paths)||paths.length>512||new Set(paths).size!==paths.length)throw new Error('Invalid historical caller probes');const chunks=[Buffer.from(commit+'\0')],existing=[];let bytes=0;for(const name of [...paths].sort()){relativePath(name);const chunk=await git(root,['ls-tree','-z',commit,'--',name],options);bytes+=chunk.length;if(bytes>1024*1024)throw new Error('Historical caller probe metadata limit exceeded');const found=records(chunk);if(found.some(e=>e.path!==name))throw new Error('Historical caller probe escaped exact path');if(found.length)existing.push(name);chunks.push(Buffer.from(name+'\0'),chunk);}return {commit,paths:existing,fingerprint:hash(Buffer.concat(chunks)),bytes};}
package/src/snapshot.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {captureSvnCallers} from './svn-caller-capture.mjs';
1
2
  import { captureCallers } from './caller-capture.mjs';
2
3
  import { validateCallerScopes } from './caller-index.mjs';
3
4
  import { bindBusinessRequirement, validateBusinessRequirement } from './business-requirement.mjs';
@@ -38,7 +39,6 @@ export async function captureSnapshot(cwd, options = {}) {
38
39
  for (const name of options.oldContextPaths ?? []) relativePath(name);
39
40
  const type = await detectVcs(cwd);
40
41
  if (options.oldContextPaths !== undefined && (type !== 'git' || !['commit','baseRevision','targetRevision'].some(k => options[k] !== undefined))) throw new Error('Old context requires Git historical review');
41
- if (options.callerScopePaths !== undefined && (type !== 'git' || ['commit','baseRevision','targetRevision'].some(k => options[k] !== undefined))) throw new Error('Caller discovery currently requires Git working review');
42
42
  if (['commit','baseRevision','targetRevision'].some(k => options[k] !== undefined)) {
43
43
  if (type !== 'git') throw new Error('Git revision review is unsupported on SVN');
44
44
  return bindBusinessRequirement(await captureGitHistory(cwd, options),options.businessRequirement);
@@ -72,7 +72,7 @@ export async function captureSnapshot(cwd, options = {}) {
72
72
  }
73
73
  let callerDiscovery, verifyCallers;
74
74
  if (options.callerScopePaths !== undefined) {
75
- const callers = await captureCallers(snapshot.root, snapshot.files, options.callerScopePaths, options);
75
+ const callers = await (type==='git'?captureCallers:captureSvnCallers)(snapshot.root, snapshot.files, options.callerScopePaths, options);
76
76
  verifyCallers = callers.verify;
77
77
  callerDiscovery = { ...callers.discovery, capturedPaths: [] };
78
78
  for (const item of callers.context) {
@@ -0,0 +1,48 @@
1
+ import {svnCallerIndex} from './svn-caller-index.mjs';
2
+ import {svnCallerNode,svnCallerProbes} from './svn-caller-node.mjs';
3
+
4
+ import {callerCandidates} from './caller-candidates.mjs';
5
+ import {readLocal} from './content.mjs';
6
+ import {isSecretPath} from './secret-path.mjs';
7
+
8
+ // Called only by preview capture, never by a model retrieval request.
9
+ export async function captureSvnCallers(root, files, scopes, options={}) {
10
+ const deadline=AbortSignal.timeout(30000);
11
+ const signal=options.signal?AbortSignal.any([options.signal,deadline]):deadline;
12
+ const bounded={...options,signal,maxFileBytes:Math.min(options.maxFileBytes??256*1024,256*1024)};
13
+ const index=await svnCallerIndex(root,scopes,bounded);
14
+ const changed=new Set(files.flatMap(f=>[f.path,...(f.oldPath?[f.oldPath]:[])]));
15
+ const rules=new Set(options.rulePaths??[]),scanned=[],skipped=[];
16
+ let scannedBytes=0;
17
+ for(const name of index.paths){
18
+ signal.throwIfAborted();
19
+ if(changed.has(name)||rules.has(name)||isSecretPath(name)) {skipped.push({path:name,reason:'Excluded from caller discovery'});continue;}
20
+ if(!/\.(mjs|cjs|js|jsx|ts|tsx|c|cc|cpp|cxx|h|hh|hpp|hxx|shader|hlsl|glsl|cginc|hlsli|compute)$/i.test(name)){skipped.push({path:name,reason:'Unsupported caller language'});continue;}
21
+ if(scanned.length>=128)throw new Error('Caller scan file limit exceeded');
22
+ // Read failures are fatal: unread bytes are not negative search evidence.
23
+ const identity=await svnCallerNode(root,name,bounded);
24
+ const content=await readLocal(root,name,bounded);
25
+ scannedBytes+=content.bytes;
26
+ if(scannedBytes>1024*1024)throw new Error('Caller scan byte limit exceeded');
27
+ scanned.push({path:name,...content,nodeFingerprint:identity.fingerprint,revision:identity.revision});
28
+ }
29
+ const requested=callerCandidates(files,scanned,[]).probePaths;
30
+ const probes=await svnCallerProbes(root,requested,bounded);
31
+ const plan=callerCandidates(files,scanned,probes.paths);
32
+ let verifiedBytes=0;
33
+ const verify=async()=>{
34
+ for(const source of scanned){
35
+ const identity=await svnCallerNode(root,source.path,bounded);
36
+ if(identity.fingerprint!==source.nodeFingerprint)throw new Error('SVN caller node changed during capture');
37
+ const again=await readLocal(root,source.path,bounded);verifiedBytes+=again.bytes;
38
+ if(verifiedBytes>2*1024*1024)throw new Error('Caller verification byte limit exceeded');
39
+ if(again.hash!==source.hash)throw new Error('Caller scan source changed during capture');
40
+ }
41
+ if((await svnCallerIndex(root,scopes,bounded)).fingerprint!==index.fingerprint)throw new Error('Caller index changed during capture');
42
+ if((await svnCallerProbes(root,requested,bounded)).fingerprint!==probes.fingerprint)throw new Error('Caller resolution changed during capture');
43
+ signal.throwIfAborted();
44
+ };
45
+ await verify();
46
+ const paths=new Set(plan.candidates.map(c=>c.fromPath));
47
+ return {verify,context:scanned.filter(s=>paths.has(s.path)),discovery:{scopes:index.scopes,indexFingerprint:index.fingerprint,probeFingerprint:probes.fingerprint,scanned:scanned.map(({path,hash,bytes,nodeFingerprint,revision})=>({path,hash,bytes,nodeFingerprint,revision})),scannedBytes,skipped,candidates:plan.candidates,notice:plan.notice,incomplete:true,limitations:['Only supported literal references inside approved scopes were searched; no complete caller or semantic coverage.']}};
48
+ }
@@ -0,0 +1,10 @@
1
+ import {svnCallerAncestors} from './svn-caller-safety.mjs';
2
+ import {checked} from './process.mjs';
3
+ import {xml} from './svn.mjs';
4
+ import {relativePath,hash} from './content.mjs';
5
+ import {validateCallerScopes} from './caller-index.mjs';
6
+ import {lstat} from 'node:fs/promises';
7
+ import path from 'node:path';
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
+ // 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'||s['@_props']!=='none'||['@_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})))};}
@@ -0,0 +1,11 @@
1
+ import {checked} from './process.mjs';
2
+ import {xml} from './svn.mjs';
3
+ import {relativePath,hash} from './content.mjs';
4
+ import {lstat} from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import {svnCallerAncestors} from './svn-caller-safety.mjs';
7
+ const canonical=v=>Array.isArray(v)?v.map(canonical):v&&typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,canonical(v[k])])):v;
8
+ // Body authorization requires regular local node and offline SVN identity/properties.
9
+ export async function svnCallerNode(root,name,options={}){relativePath(name);const parent=path.posix.dirname(name);if(parent!=='.')await svnCallerAncestors(root,parent,options);const stat=await lstat(path.join(root,name));if(stat.isSymbolicLink()||!stat.isFile())throw new Error('SVN caller source is not a regular file');const run=async args=>xml(await checked('svn',['--non-interactive',...args,'--',name+'@'],{...options,cwd:root,maxBytes:64*1024}));const entries=(await run(['status','--xml','--verbose','--ignore-externals','--depth','empty'])).status?.target?.flatMap(t=>t.entry??[])??[];if(entries.length!==1||entries[0]['@_path'].replaceAll('\\','/')!==name)throw new Error('SVN caller source status missing');const s=entries[0]['wc-status'];if(s?.['@_item']!=='normal'||!['none','normal'].includes(s['@_props'])||['@_switched','@_copied','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true'))throw new Error('Unsafe SVN caller source state');const info=(await run(['info','--xml'])).info?.entry?.[0];const rootInfo=xml(await checked('svn',['--non-interactive','info','--xml','--','.@'],{...options,cwd:root,maxBytes:64*1024})).info?.entry?.[0];if(info?.['@_kind']!=='file'||info['wc-info']?.['copy-from-url']||info.repository?.uuid!==rootInfo.repository?.uuid||info['wc-info']?.['wcroot-abspath']!==rootInfo['wc-info']?.['wcroot-abspath'])throw new Error('Unsafe SVN caller source identity');const expected=rootInfo.url.replace(/\/$/,'')+'/'+name.split('/').map(encodeURIComponent).join('/');if(info.url!==expected)throw new Error('Unsafe SVN caller switched source URL');const props=(await run(['proplist','--xml','--verbose'])).properties?.target?.[0]?.property??[];if(props.some(p=>['svn:special','svn:keywords','svn:externals'].includes(p['@_name'])))throw new Error('Unsafe SVN caller source properties');return {revision:s['@_revision'],fingerprint:hash(JSON.stringify(canonical({status:entries[0],info,props})))};}
10
+ // Existing untracked/linked/directory paths compete too; absence grants no body read.
11
+ export async function svnCallerProbes(root,names,options={}){if(!Array.isArray(names)||names.length>512||new Set(names).size!==names.length)throw new Error('Invalid SVN caller probes');const existing=[],identities=[];for(const name of [...names].sort()){relativePath(name);if(name.split('/').length>32)throw new Error('SVN caller probe depth limit exceeded');options.signal?.throwIfAborted();let cursor=root,identity=[];for(const part of name.split('/')){cursor=path.join(cursor,part);try{const s=await lstat(cursor);identity.push({part,kind:s.isSymbolicLink()?'link':s.isDirectory()?'dir':s.isFile()?'file':'other'});if(s.isSymbolicLink()){existing.push(name);break;}if(cursor===path.join(root,name))existing.push(name);if(!s.isDirectory())break;}catch(e){if(e.code!=='ENOENT'&&e.code!=='ENOTDIR')throw e;identity.push({part,kind:'missing'});break;}}identities.push({name,identity});if(Buffer.byteLength(JSON.stringify(identities))>1024*1024)throw new Error('SVN caller probe metadata limit exceeded');}return {paths:existing,fingerprint:hash(JSON.stringify(identities))};}
@@ -0,0 +1,8 @@
1
+ import {checked} from './process.mjs';
2
+ import {xml} from './svn.mjs';
3
+ import {relativePath,hash} from './content.mjs';
4
+ import {lstat,realpath} from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ const canonicalValue=v=>Array.isArray(v)?v.map(canonicalValue):v&&typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,canonicalValue(v[k])])):v;
7
+ // Offline metadata on each explicit ancestor. Never follows external/switched roots.
8
+ export async function svnCallerAncestors(root,scope,options={}){relativePath(scope);if(scope.split('/').length>32)throw new Error('SVN caller ancestor depth limit exceeded');const canonical=await realpath(root),names=['.',...scope.split('/').map((_,i)=>scope.split('/').slice(0,i+1).join('/'))],identities=[];let bytes=0,rootInfo;for(const name of names){options.signal?.throwIfAborted();const local=name==='.'?canonical:path.join(canonical,name);const stat=await lstat(local);if(stat.isSymbolicLink()||!stat.isDirectory())throw new Error('SVN caller scope requires real directories');const run=async args=>{const b=await checked('svn',['--non-interactive',...args,'--',name+'@'],{...options,cwd:canonical,maxBytes:64*1024});bytes+=b.length;if(bytes>256*1024)throw new Error('SVN caller ancestor metadata limit exceeded');return xml(b);};const status=await run(['status','--xml','--verbose','--ignore-externals','--depth','empty']);const entries=(status.status?.target??[]).flatMap(t=>t.entry??[]);if(entries.length!==1||entries[0]['@_path'].replaceAll('\\','/')!==name)throw new Error('SVN caller ancestor status missing');const s=entries[0]['wc-status'];if(s?.['@_item']!=='normal'||!['none','normal'].includes(s['@_props'])||['@_switched','@_copied','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true'))throw new Error('Unsafe SVN caller ancestor state');const info=(await run(['info','--xml'])).info?.entry?.[0];if(!info||info['@_kind']!=='dir'||info['wc-info']?.['copy-from-url'])throw new Error('Unsafe SVN caller ancestor identity');rootInfo??=info;if(info.repository?.uuid!==rootInfo.repository?.uuid||info['wc-info']?.['wcroot-abspath']!==rootInfo['wc-info']?.['wcroot-abspath'])throw new Error('SVN caller ancestor external working root');if(name!=='.'){const expected=rootInfo.url.replace(/\/$/,'')+'/'+name.split('/').map(encodeURIComponent).join('/');if(info.url!==expected)throw new Error('Unsafe SVN caller switched ancestor URL');}const properties=await run(['proplist','--xml','--verbose']);const props=properties.properties?.target?.[0]?.property??[];if(props.some(p=>['svn:externals','svn:special'].includes(p['@_name'])))throw new Error('Unsafe SVN caller ancestor properties');identities.push({path:name,status:entries[0],info,properties:props});}return {bytes,fingerprint:hash(JSON.stringify(canonicalValue(identities)))};}