@feng0531/dsh-code-review 0.31.1 → 0.32.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 +6 -0
- package/README.md +3 -1
- package/index.mjs +1 -1
- package/package.json +1 -1
- package/src/caller-candidates.mjs +32 -0
- package/src/caller-capture.mjs +44 -0
- package/src/caller-index.mjs +41 -0
- package/src/caller-probes.mjs +28 -0
- package/src/review.mjs +6 -0
- package/src/service.mjs +4 -2
- package/src/snapshot.mjs +24 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# 变更记录
|
|
2
2
|
|
|
3
|
+
## 0.32.0
|
|
4
|
+
|
|
5
|
+
- 新增显式callerScopePaths工作区Git限定字面引用方捕获;预览显示扫描身份和候选,非匹配正文不发送。
|
|
6
|
+
- 元数据/正文/探针/生命周期硬预算,所有扫描hash及索引与竞争路径重核,调用者与既有上下文共享名额;历史/SVN明确拒绝。
|
|
7
|
+
- 报告导航限制,不冒充完整caller或符号绑定;302离线测试通过。发布门禁尚在执行。
|
|
8
|
+
|
|
3
9
|
## 0.31.1
|
|
4
10
|
|
|
5
11
|
- 最终报告保留业务需求hash/bytes与不可信状态,不重印正文,预览发送告知补全。
|
package/README.md
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
## 当前状态
|
|
6
6
|
|
|
7
|
-
当前代码版本为 0.
|
|
7
|
+
当前代码版本为 0.32.0(本批发布门禁进行中)
|
|
8
|
+
|
|
9
|
+
预览可选callerScopePaths,最多4个非根、非重叠相对目录,仅Git工作区。预览读取目录内tracked JS/TS/C-family候选正文(含非匹配),仅匹配引用方作为上下文发送模型;128扫描文件/1MiB正文/256索引记录/64KiB索引/512竞争路径探针,发现与重核共享30秒,复核累计2MiB,仍共享20上下文及4MiB快照。绑定所有扫描hash、索引及竞争探针;execute重新捕获。历史/SVN明确不支持,不静默忽略。字面引用仅导航,不证明函数调用或完整覆盖,报告保留partial限制。
|
|
8
10
|
|
|
9
11
|
最终报告记录业务需求hash/bytes用于溯源,不重复需求正文;真实Git当前/历史捕获及服务预览执行预算集成已验收。
|
|
10
12
|
|
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: { 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: { 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 } } },
|
|
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
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { contextCandidates } from './context-candidates.mjs';
|
|
2
|
+
import { relativePath } from './content.mjs';
|
|
3
|
+
import { isSecretPath } from './secret-path.mjs';
|
|
4
|
+
|
|
5
|
+
// Pure reverse navigation; resolutionPaths must contain every competitor.
|
|
6
|
+
export function callerCandidates(files, scanned, resolutionPaths) {
|
|
7
|
+
if (!Array.isArray(files) || files.length > 200 || !Array.isArray(scanned) || scanned.length > 128 || !Array.isArray(resolutionPaths) || resolutionPaths.length > 512) throw new Error('Caller navigation limit exceeded');
|
|
8
|
+
for (const name of resolutionPaths) relativePath(name);
|
|
9
|
+
const changed = new Set(), targets = new Set();
|
|
10
|
+
for (const file of files) {
|
|
11
|
+
relativePath(file.path); changed.add(file.path);
|
|
12
|
+
if (file.oldPath) { relativePath(file.oldPath); changed.add(file.oldPath); }
|
|
13
|
+
if (file.eligibility === 'reviewable' && file.rightExists !== false && !isSecretPath(file.path)) targets.add(file.path);
|
|
14
|
+
}
|
|
15
|
+
const candidates = [], probes = new Set(), seen = new Set();
|
|
16
|
+
for (const source of scanned) {
|
|
17
|
+
relativePath(source.path);
|
|
18
|
+
if (seen.has(source.path)) throw new Error('Duplicate caller source');
|
|
19
|
+
seen.add(source.path);
|
|
20
|
+
if (changed.has(source.path) || isSecretPath(source.path)) continue;
|
|
21
|
+
if (!/\.(mjs|cjs|js|jsx|ts|tsx|c|cc|cpp|cxx|h|hh|hpp|hxx|shader|hlsl|glsl|cginc|hlsli|compute)$/i.test(source.path)) continue;
|
|
22
|
+
if (typeof source.text !== 'string' || Buffer.byteLength(source.text) > 256 * 1024) throw new Error('Invalid caller source content');
|
|
23
|
+
const plan = contextCandidates([{path:source.path,eligibility:'reviewable',right:{text:source.text}}], resolutionPaths, {onProbe:paths=>{
|
|
24
|
+
for (const name of paths) probes.add(name);
|
|
25
|
+
if (probes.size > 512) throw new Error('Caller resolution probe limit exceeded');
|
|
26
|
+
}});
|
|
27
|
+
if (plan.truncated) throw new Error('Caller navigation result limit exceeded');
|
|
28
|
+
for (const target of plan.candidates) if (targets.has(target.path)) candidates.push({fromPath:source.path,targetPath:target.path,reason:target.reasons[0],confidence:'navigation-only'});
|
|
29
|
+
}
|
|
30
|
+
const compare=(a,b)=>a<b?-1:a>b?1:0;
|
|
31
|
+
return {candidates:candidates.sort((a,b)=>compare(a.fromPath,b.fromPath)||compare(a.targetPath,b.targetPath)),probePaths:[...probes].sort(),notice:'Literal module/include references only; not function calls, reachability, semantic binding or complete caller coverage.'};
|
|
32
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {gitCallerIndex} from './caller-index.mjs';
|
|
2
|
+
import {gitCallerProbes} from './caller-probes.mjs';
|
|
3
|
+
import {callerCandidates} from './caller-candidates.mjs';
|
|
4
|
+
import {readLocal} from './content.mjs';
|
|
5
|
+
import {isSecretPath} from './secret-path.mjs';
|
|
6
|
+
|
|
7
|
+
// Called only by preview capture, never by a model retrieval request.
|
|
8
|
+
export async function captureCallers(root, files, scopes, options={}) {
|
|
9
|
+
const deadline=AbortSignal.timeout(30000);
|
|
10
|
+
const signal=options.signal?AbortSignal.any([options.signal,deadline]):deadline;
|
|
11
|
+
const bounded={...options,signal,maxFileBytes:Math.min(options.maxFileBytes??256*1024,256*1024)};
|
|
12
|
+
const index=await gitCallerIndex(root,scopes,bounded);
|
|
13
|
+
const changed=new Set(files.flatMap(f=>[f.path,...(f.oldPath?[f.oldPath]:[])]));
|
|
14
|
+
const rules=new Set(options.rulePaths??[]),scanned=[],skipped=[];
|
|
15
|
+
let scannedBytes=0;
|
|
16
|
+
for(const name of index.paths){
|
|
17
|
+
signal.throwIfAborted();
|
|
18
|
+
if(changed.has(name)||rules.has(name)||isSecretPath(name)) {skipped.push({path:name,reason:'Excluded from caller discovery'});continue;}
|
|
19
|
+
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;}
|
|
20
|
+
if(scanned.length>=128)throw new Error('Caller scan file limit exceeded');
|
|
21
|
+
// Read failures are fatal: unread bytes are not negative search evidence.
|
|
22
|
+
const content=await readLocal(root,name,bounded);
|
|
23
|
+
scannedBytes+=content.bytes;
|
|
24
|
+
if(scannedBytes>1024*1024)throw new Error('Caller scan byte limit exceeded');
|
|
25
|
+
scanned.push({path:name,...content});
|
|
26
|
+
}
|
|
27
|
+
const requested=callerCandidates(files,scanned,[]).probePaths;
|
|
28
|
+
const probes=await gitCallerProbes(root,requested,bounded);
|
|
29
|
+
const plan=callerCandidates(files,scanned,probes.paths);
|
|
30
|
+
let verifiedBytes=0;
|
|
31
|
+
const verify=async()=>{
|
|
32
|
+
for(const source of scanned){
|
|
33
|
+
const again=await readLocal(root,source.path,bounded);verifiedBytes+=again.bytes;
|
|
34
|
+
if(verifiedBytes>2*1024*1024)throw new Error('Caller verification byte limit exceeded');
|
|
35
|
+
if(again.hash!==source.hash)throw new Error('Caller scan source changed during capture');
|
|
36
|
+
}
|
|
37
|
+
if((await gitCallerIndex(root,scopes,bounded)).fingerprint!==index.fingerprint)throw new Error('Caller index changed during capture');
|
|
38
|
+
if((await gitCallerProbes(root,requested,bounded)).fingerprint!==probes.fingerprint)throw new Error('Caller resolution changed during capture');
|
|
39
|
+
signal.throwIfAborted();
|
|
40
|
+
};
|
|
41
|
+
await verify();
|
|
42
|
+
const paths=new Set(plan.candidates.map(c=>c.fromPath));
|
|
43
|
+
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})=>({path,hash,bytes})),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.']}};
|
|
44
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { checked } from './process.mjs';
|
|
2
|
+
import { relativePath, hash } from './content.mjs';
|
|
3
|
+
import { assertNonSecretPath } from './secret-path.mjs';
|
|
4
|
+
|
|
5
|
+
export function validateCallerScopes(scopes) {
|
|
6
|
+
if (!Array.isArray(scopes) || scopes.length < 1 || scopes.length > 4) throw new Error('Invalid caller scopes');
|
|
7
|
+
for (const scope of scopes) {
|
|
8
|
+
relativePath(scope); assertNonSecretPath(scope);
|
|
9
|
+
if (/[?*\[\]]/.test(scope)) throw new Error('Caller scope must be literal');
|
|
10
|
+
}
|
|
11
|
+
const sorted = [...scopes].sort();
|
|
12
|
+
for (let i = 0; i < sorted.length; i++) for (let j = i + 1; j < sorted.length; j++) {
|
|
13
|
+
if (sorted[j] === sorted[i] || sorted[j].startsWith(sorted[i] + '/')) throw new Error('Overlapping caller scopes');
|
|
14
|
+
}
|
|
15
|
+
return sorted;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Explicit discovery scopes, never an implicit repository-wide index.
|
|
19
|
+
export async function gitCallerIndex(root, scopes, options = {}) {
|
|
20
|
+
const approved = validateCallerScopes(scopes);
|
|
21
|
+
const chunks = [], entries = new Map();
|
|
22
|
+
let bytes = 0, records = 0;
|
|
23
|
+
for (const scope of approved) {
|
|
24
|
+
const chunk = await checked('git', ['--no-optional-locks', '--literal-pathspecs', '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', 'ls-files', '--stage', '-z', '--', scope], { ...options, cwd: root, maxBytes: 64 * 1024 });
|
|
25
|
+
bytes += chunk.length;
|
|
26
|
+
if (bytes > 64 * 1024) throw new Error('Caller index byte limit exceeded');
|
|
27
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(chunk);
|
|
28
|
+
if (text && !text.endsWith('\0')) throw new Error('Incomplete caller index');
|
|
29
|
+
for (const record of text.split('\0').filter(Boolean)) {
|
|
30
|
+
if (++records > 256) throw new Error('Caller index path limit exceeded');
|
|
31
|
+
const match = /^(\d{6}) ([a-f0-9]{40}|[a-f0-9]{64}) ([0-3])\t([\s\S]+)$/.exec(record);
|
|
32
|
+
if (!match) throw new Error('Invalid caller index record');
|
|
33
|
+
const name = relativePath(match[4]);
|
|
34
|
+
if (!name.startsWith(scope + '/')) throw new Error('Caller scope is not a directory or index escaped scope');
|
|
35
|
+
const regular = match[3] === '0' && ['100644', '100755'].includes(match[1]);
|
|
36
|
+
entries.set(name, entries.has(name) ? false : regular);
|
|
37
|
+
}
|
|
38
|
+
chunks.push(chunk);
|
|
39
|
+
}
|
|
40
|
+
return { scopes: approved, paths: [...entries].filter(([, valid]) => valid).map(([name]) => name).sort(), fingerprint: hash(Buffer.concat(chunks)), records, bytes };
|
|
41
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { checked } from './process.mjs';
|
|
2
|
+
import { relativePath, hash } from './content.mjs';
|
|
3
|
+
|
|
4
|
+
// Metadata only: a directory, conflicted file or symlink is a competitor too.
|
|
5
|
+
export async function gitCallerProbes(root, probePaths, options = {}) {
|
|
6
|
+
if (!Array.isArray(probePaths) || probePaths.length > 512 || new Set(probePaths).size !== probePaths.length) throw new Error('Invalid caller probes');
|
|
7
|
+
const chunks = [], existing = [];
|
|
8
|
+
let bytes = 0;
|
|
9
|
+
for (const name of [...probePaths].sort()) {
|
|
10
|
+
relativePath(name);
|
|
11
|
+
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});
|
|
12
|
+
bytes += chunk.length;
|
|
13
|
+
if (bytes > 1024*1024) throw new Error('Caller probe metadata limit exceeded');
|
|
14
|
+
const text = new TextDecoder('utf-8',{fatal:true}).decode(chunk);
|
|
15
|
+
if (text && !text.endsWith('\0')) throw new Error('Incomplete caller probe');
|
|
16
|
+
const records=text.split('\0').filter(Boolean);
|
|
17
|
+
for (const record of records) {
|
|
18
|
+
const match=/^(\d{6}) ([a-f0-9]{40}|[a-f0-9]{64}) ([0-3])\t([\s\S]+)$/.exec(record);
|
|
19
|
+
if (!match) throw new Error('Invalid caller probe record');
|
|
20
|
+
relativePath(match[4]);
|
|
21
|
+
if (match[4] !== name && !match[4].startsWith(name+'/')) throw new Error('Caller probe escaped literal path');
|
|
22
|
+
}
|
|
23
|
+
if (records.length) existing.push(name);
|
|
24
|
+
// Include missing probe names in identity, not only returned records.
|
|
25
|
+
chunks.push(Buffer.from(name+'\0'),chunk);
|
|
26
|
+
}
|
|
27
|
+
return {paths:existing,fingerprint:hash(Buffer.concat(chunks)),bytes};
|
|
28
|
+
}
|
package/src/review.mjs
CHANGED
|
@@ -93,6 +93,11 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
93
93
|
const report = { schemaVersion: 1, snapshotId: input.id, vcs: input.vcs, status: 'completed', files: [], findings: [], limitations: [], modelCalls: 0 };
|
|
94
94
|
validateBusinessRequirement(input.businessRequirement);
|
|
95
95
|
if(input.businessRequirement!==undefined)report.businessRequirement={hash:hash(input.businessRequirement),bytes:Buffer.byteLength(input.businessRequirement),status:'untrusted-constraint'};
|
|
96
|
+
if (input.callerDiscovery) {
|
|
97
|
+
report.callerDiscovery = structuredClone(input.callerDiscovery);
|
|
98
|
+
report.limitations.push({text:'调用者发现仅限批准目录内字面引用,不证明函数调用或完整覆盖;仅已捕获上下文可用于证据。'});
|
|
99
|
+
if (input.callerDiscovery.candidates.some(c=>c.status==='blocked')) report.limitations.push({text:'部分引用方因上下文名额限制未捕获。'});
|
|
100
|
+
}
|
|
96
101
|
if (input.autoContext) {
|
|
97
102
|
report.autoContext = structuredClone(input.autoContext);
|
|
98
103
|
if (input.autoContext.truncated || input.autoContext.candidates.some(c => c.status === 'blocked')) report.limitations.push({ text: '自动上下文未完全捕获:存在截断或阻塞,仅实际捕获路径可用于证据。' });
|
|
@@ -268,6 +273,7 @@ const safe = text => Array.from(String(text ?? '')).map(c => {
|
|
|
268
273
|
}).join('');
|
|
269
274
|
export function markdownReport(report) {
|
|
270
275
|
const output = ['# Code Review 报告', '', '状态:' + report.status, '快照:' + report.snapshotId, '', '## 覆盖情况'];
|
|
276
|
+
if (report.callerDiscovery) output.push('- 引用方发现:扫描 '+report.callerDiscovery.scanned.length+' 个文件,捕获 '+report.callerDiscovery.capturedPaths.length+' 个上下文;仅批准目录字面引用导航,不证明函数调用或完整覆盖。');
|
|
271
277
|
if(report.businessRequirement)output.push('- 业务需求:SHA256 '+safe(report.businessRequirement.hash)+' · '+report.businessRequirement.bytes+' bytes;不可信约束,不是证据或权限,全文不在报告重复。');
|
|
272
278
|
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,仅定位不证明因果');
|
|
273
279
|
if (report.history) output.push('- Git历史审查:' + safe(report.history.mode) + ' · ' + safe(report.history.base ?? 'empty') + ' → ' + safe(report.history.target) + ';精确端点树差异,重命名按增删,不含工作树');
|
package/src/service.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateCallerScopes } from './caller-index.mjs';
|
|
1
2
|
import { validateBusinessRequirement } from './business-requirement.mjs';
|
|
2
3
|
import { hash } from './content.mjs';
|
|
3
4
|
import { resolveWorkspaceRepository } from './workspace-repository.mjs';
|
|
@@ -28,10 +29,11 @@ export function createReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
28
29
|
exec = bind(exec);
|
|
29
30
|
exec.signal.throwIfAborted();
|
|
30
31
|
if (busy) throw new Error('Review service busy');
|
|
31
|
-
if (!args || Object.keys(args).some(k => !['repositoryPath', 'selectedPaths', 'contextPaths', 'rulePaths', 'autoContext', 'commit', 'baseRevision', 'targetRevision','businessRequirement'].includes(k))) throw new Error('Unknown preview argument');
|
|
32
|
+
if (!args || Object.keys(args).some(k => !['repositoryPath', 'selectedPaths', 'contextPaths', 'rulePaths', 'autoContext', 'commit', 'baseRevision', 'targetRevision','businessRequirement','callerScopePaths'].includes(k))) throw new Error('Unknown preview argument');
|
|
32
33
|
if (args.autoContext !== undefined && typeof args.autoContext !== 'boolean') throw new Error('Invalid autoContext option');
|
|
33
34
|
for (const key of ['selectedPaths', 'contextPaths', 'rulePaths']) if (args[key] !== undefined && (!Array.isArray(args[key]) || args[key].length > (key === 'rulePaths' ? 4 : 200) || args[key].some(p => typeof p !== 'string'))) throw new Error('Invalid path list');
|
|
34
35
|
validateBusinessRequirement(args.businessRequirement);
|
|
36
|
+
if (args.callerScopePaths !== undefined) validateCallerScopes(args.callerScopePaths);
|
|
35
37
|
const current = owner(exec);
|
|
36
38
|
busy = true;
|
|
37
39
|
try {
|
|
@@ -45,7 +47,7 @@ export function createReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
45
47
|
while (previews.size >= maxPreviews) previews.delete(previews.keys().next().value);
|
|
46
48
|
const previewId = randomUUID();
|
|
47
49
|
previews.set(previewId, { ...current, target, options, snapshotId: snapshot.id, expires: now() + ttlMs });
|
|
48
|
-
return { ...(options.businessRequirement!==undefined?{businessRequirement:{text:options.businessRequirement,hash:hash(options.businessRequirement),bytes:Buffer.byteLength(options.businessRequirement)}}:{}), previewId, plan, snapshotId: snapshot.id, repositoryRoot: snapshot.root, vcs: snapshot.vcs, ...(snapshot.history ? { history: snapshot.history } : {}), model: current.route, modelSendingEnabled: allowModelSending, files: snapshot.files.map(f => ({ path: f.path, eligibility: f.eligibility, reason: f.reason })), contextPaths: snapshot.context.map(c => c.path), ...(snapshot.autoContext ? { autoContext: snapshot.autoContext } : {}), rules: (snapshot.rules ?? []).map(({ path, hash }) => ({ path, hash })), notice: (enableRiskPlanning ? '执行先制定风险假设计划,再审查并复核;计划不是证据,所有阶段共享原预算。' : '') + '执行会向上述模型提供方发送可审查文件两侧内容、预览列出的显式/自动上下文、所选项目规则全文及预览业务需求文本。模型可在该快照范围内多轮只读检索,不读取范围外文件;候选生成后会进行证据与反证复核,两阶段各最多3轮检索,共享每文件120秒和总模型调用100次预算;复核不能证明缺陷成立。plan仅预检初始输入,ready不代表已审查;minimumCalls不含后续检索/复核/动态业务综合;多文件可先进行业务分组,分组失败保留原组并记录限制,动态综合数执行前未知,实际预算可能不足。请先向用户展示范围,获得确认后执行。' };
|
|
50
|
+
return { ...(options.businessRequirement!==undefined?{businessRequirement:{text:options.businessRequirement,hash:hash(options.businessRequirement),bytes:Buffer.byteLength(options.businessRequirement)}}:{}), previewId, plan, snapshotId: snapshot.id, repositoryRoot: snapshot.root, vcs: snapshot.vcs, ...(snapshot.history ? { history: snapshot.history } : {}), model: current.route, modelSendingEnabled: allowModelSending, files: snapshot.files.map(f => ({ path: f.path, eligibility: f.eligibility, reason: f.reason })), contextPaths: snapshot.context.map(c => c.path), ...(snapshot.callerDiscovery ? {callerDiscovery:snapshot.callerDiscovery} : {}), ...(snapshot.autoContext ? { autoContext: snapshot.autoContext } : {}), rules: (snapshot.rules ?? []).map(({ path, hash }) => ({ path, hash })), notice: (snapshot.callerDiscovery ? 'callerScopePaths已授权预览读取限定目录内tracked候选正文,包括不匹配文件;非匹配正文不发送模型,匹配捕获上下文会发送。仅字面引用导航,不是函数调用或完整调用覆盖。' : '') + (enableRiskPlanning ? '执行先制定风险假设计划,再审查并复核;计划不是证据,所有阶段共享原预算。' : '') + '执行会向上述模型提供方发送可审查文件两侧内容、预览列出的显式/自动上下文、所选项目规则全文及预览业务需求文本。模型可在该快照范围内多轮只读检索,不读取范围外文件;候选生成后会进行证据与反证复核,两阶段各最多3轮检索,共享每文件120秒和总模型调用100次预算;复核不能证明缺陷成立。plan仅预检初始输入,ready不代表已审查;minimumCalls不含后续检索/复核/动态业务综合;多文件可先进行业务分组,分组失败保留原组并记录限制,动态综合数执行前未知,实际预算可能不足。请先向用户展示范围,获得确认后执行。' };
|
|
49
51
|
} finally { busy = false; }
|
|
50
52
|
},
|
|
51
53
|
async execute(args, exec) {
|
package/src/snapshot.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { captureCallers } from './caller-capture.mjs';
|
|
2
|
+
import { validateCallerScopes } from './caller-index.mjs';
|
|
1
3
|
import { bindBusinessRequirement, validateBusinessRequirement } from './business-requirement.mjs';
|
|
2
4
|
import { gitContextIndex, svnContextIndex } from './tracked-context.mjs';
|
|
3
5
|
import { contextCandidates } from './context-candidates.mjs';
|
|
@@ -31,7 +33,9 @@ export async function captureSnapshot(cwd, options = {}) {
|
|
|
31
33
|
if (!Array.isArray(contextPaths) || contextPaths.length > maxContextFiles) throw new Error('Context file limit exceeded');
|
|
32
34
|
for (const name of contextPaths) relativePath(name);
|
|
33
35
|
if (options.autoContext !== undefined && typeof options.autoContext !== 'boolean') throw new Error('Invalid autoContext option');
|
|
36
|
+
if (options.callerScopePaths !== undefined) validateCallerScopes(options.callerScopePaths);
|
|
34
37
|
const type = await detectVcs(cwd);
|
|
38
|
+
if (options.callerScopePaths !== undefined && (type !== 'git' || ['commit','baseRevision','targetRevision'].some(k => options[k] !== undefined))) throw new Error('Caller discovery currently requires Git working review');
|
|
35
39
|
if (['commit','baseRevision','targetRevision'].some(k => options[k] !== undefined)) {
|
|
36
40
|
if (type !== 'git') throw new Error('Git revision review is unsupported on SVN');
|
|
37
41
|
return bindBusinessRequirement(await captureGitHistory(cwd, options),options.businessRequirement);
|
|
@@ -63,13 +67,32 @@ export async function captureSnapshot(cwd, options = {}) {
|
|
|
63
67
|
context.push({ path: name, ...changed.right });
|
|
64
68
|
} else context.push({ path: name, ...await readLocal(snapshot.root, name, options) });
|
|
65
69
|
}
|
|
70
|
+
let callerDiscovery, verifyCallers;
|
|
71
|
+
if (options.callerScopePaths !== undefined) {
|
|
72
|
+
const callers = await captureCallers(snapshot.root, snapshot.files, options.callerScopePaths, options);
|
|
73
|
+
verifyCallers = callers.verify;
|
|
74
|
+
callerDiscovery = { ...callers.discovery, capturedPaths: [] };
|
|
75
|
+
for (const item of callers.context) {
|
|
76
|
+
const present = context.find(c => c.path === item.path);
|
|
77
|
+
const matches = callerDiscovery.candidates.filter(c => c.fromPath === item.path);
|
|
78
|
+
if (present && present.hash !== item.hash) throw new Error('Caller context changed during capture');
|
|
79
|
+
if (!present && context.length >= Math.min(maxContextFiles,20)) {
|
|
80
|
+
for (const candidate of matches) { candidate.status='blocked'; candidate.captureReason='Context file limit exceeded'; }
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!present) context.push(item);
|
|
84
|
+
callerDiscovery.capturedPaths.push(item.path);
|
|
85
|
+
for (const candidate of matches) candidate.status='captured';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
66
88
|
// Re-capture verifies both selected sides while collecting explicit context.
|
|
67
89
|
if ((await capture(cwd, options)).id !== snapshot.id) throw new Error('Snapshot changed while collecting context');
|
|
68
90
|
for (const item of context) if ((await readLocal(snapshot.root, item.path, options)).hash !== item.hash) throw new Error('Context changed during capture');
|
|
69
91
|
if (index && (await (type === 'git' ? gitContextIndex : svnContextIndex)(snapshot.root, indexOptions)).fingerprint !== index.fingerprint) throw new Error('Tracked context index changed during capture');
|
|
70
92
|
const verifiedRules = await captureProjectRules(snapshot.root, rulePaths, { signal: options.signal, files: snapshot.files });
|
|
71
93
|
if (JSON.stringify(verifiedRules) !== JSON.stringify(rules)) throw new Error('Rules changed during capture');
|
|
72
|
-
|
|
94
|
+
if (verifyCallers) await verifyCallers();
|
|
95
|
+
const result = { ...snapshot, context, ...(callerDiscovery ? {callerDiscovery} : {}), ...(autoContext ? { autoContext } : {}), ...(rules.length ? { rules } : {}) };
|
|
73
96
|
delete result.id;
|
|
74
97
|
const serialized = JSON.stringify(result);
|
|
75
98
|
if (Buffer.byteLength(serialized) > maxSnapshotBytes) throw new Error('Snapshot size limit exceeded');
|