@feng0531/dsh-code-review 0.31.1 → 0.33.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 +12 -0
- package/README.md +5 -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/context-identity.mjs +22 -0
- package/src/context-relations.mjs +1 -1
- package/src/csharp-context.mjs +1 -1
- package/src/git-history.mjs +16 -3
- package/src/interaction-input.mjs +2 -1
- package/src/primary-input.mjs +2 -1
- package/src/retrieval-scope.mjs +5 -6
- package/src/review.mjs +6 -0
- package/src/service.mjs +5 -3
- package/src/snapshot.mjs +27 -1
- package/src/verification-loop.mjs +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# 变更记录
|
|
2
2
|
|
|
3
|
+
## 0.33.0
|
|
4
|
+
|
|
5
|
+
- 新增Git历史显式oldContextPaths,baseline对象只读捕获及版本/OID/hash身份;不伪造目标正文,不绕过所选变更边界。
|
|
6
|
+
- reader与导入/C#关系消费者按侧分派;schema/service预览显示旧侧元数据,生成/综合/复核提示纳入共享输入预算。
|
|
7
|
+
- 链接/submodule/凭据、重复及重叠路径、root无baseline、字节/槽/快照预算拒绝;真实Git零发送预览、工作树隔离、证据receipt验收。311离线测试通过;候选发布门禁待验证。
|
|
8
|
+
|
|
9
|
+
## 0.32.0
|
|
10
|
+
|
|
11
|
+
- 新增显式callerScopePaths工作区Git限定字面引用方捕获;预览显示扫描身份和候选,非匹配正文不发送。
|
|
12
|
+
- 元数据/正文/探针/生命周期硬预算,所有扫描hash及索引与竞争路径重核,调用者与既有上下文共享名额;历史/SVN明确拒绝。
|
|
13
|
+
- 报告导航限制,不冒充完整caller或符号绑定;302离线测试通过。标签CI四矩阵通过;npm处理延迟导致原发布验证404,恢复37898945312使用原工件完成正式Release;npm/Release原字节85750及双hash匹配。
|
|
14
|
+
|
|
3
15
|
## 0.31.1
|
|
4
16
|
|
|
5
17
|
- 最终报告保留业务需求hash/bytes与不可信状态,不重印正文,预览发送告知补全。
|
package/README.md
CHANGED
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
## 当前状态
|
|
6
6
|
|
|
7
|
-
当前代码版本为 0.
|
|
7
|
+
当前代码版本为 0.33.0(候选版本,远端发布门禁待验证;已验收正式版本为0.32.0)
|
|
8
|
+
|
|
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
|
+
|
|
11
|
+
预览可选callerScopePaths,最多4个非根、非重叠相对目录,仅Git工作区。预览读取目录内tracked JS/TS/C-family候选正文(含非匹配),仅匹配引用方作为上下文发送模型;128扫描文件/1MiB正文/256索引记录/64KiB索引/512竞争路径探针,发现与重核共享30秒,复核累计2MiB,仍共享20上下文及4MiB快照。绑定所有扫描hash、索引及竞争探针;execute重新捕获。历史/SVN明确不支持,不静默忽略。字面引用仅导航,不证明函数调用或完整覆盖,报告保留partial限制。
|
|
8
12
|
|
|
9
13
|
最终报告记录业务需求hash/bytes用于溯源,不重复需求正文;真实Git当前/历史捕获及服务预览执行预算集成已验收。
|
|
10
14
|
|
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: { 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 } } },
|
|
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
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { hash, relativePath } from './content.mjs';
|
|
2
|
+
import { assertNonSecretPath } from './secret-path.mjs';
|
|
3
|
+
|
|
4
|
+
export const CONTEXT_SIDE_NOTICE = 'context.text属于批准当前或target侧;oldText/oldRevision与context-old属于baseline旧侧。oldOnly:true表示只捕获旧侧,没有target正文,不证明目标路径不存在。不得互换版本或用旧侧上下文证明新侧行为;没有证据须保留uncertain。';
|
|
5
|
+
|
|
6
|
+
// Validate approved snapshot data only. This does not authorize any capture.
|
|
7
|
+
export function contextSources(item, history) {
|
|
8
|
+
if (!item || relativePath(item.path) !== item.path) throw new Error('Invalid context path');
|
|
9
|
+
assertNonSecretPath(item.path);
|
|
10
|
+
if (item.oldOnly !== undefined && item.oldOnly !== true) throw new Error('Invalid old-only context marker');
|
|
11
|
+
if (item.oldOnly === true) {
|
|
12
|
+
if (!history || !history.base || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(history.base) || item.oldRevision !== history.base || typeof item.oldText !== 'string' || item.text !== undefined || item.revision !== undefined || item.hash !== undefined || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(item.oldBlobOid ?? '') || item.oldHash !== hash(item.oldText)) throw new Error('Invalid old-only historical context provenance');
|
|
13
|
+
return [{side:'context-old',text:item.oldText,revision:item.oldRevision,blobOid:item.oldBlobOid,hash:item.oldHash}];
|
|
14
|
+
}
|
|
15
|
+
if (typeof item.text !== 'string') throw new Error('Missing current context text');
|
|
16
|
+
const sources=[{side:'context',text:item.text,...(item.revision ? {revision:item.revision} : {})}];
|
|
17
|
+
if (item.oldText !== undefined) {
|
|
18
|
+
if (!history || !history.base || item.oldRevision !== history.base || item.revision !== history.target || typeof item.oldText !== 'string') throw new Error('Invalid historical context provenance');
|
|
19
|
+
sources.push({side:'context-old',text:item.oldText,revision:item.oldRevision});
|
|
20
|
+
}
|
|
21
|
+
return sources;
|
|
22
|
+
}
|
|
@@ -7,7 +7,7 @@ export function inferContextRelations(input, file) {
|
|
|
7
7
|
if (input.history) {
|
|
8
8
|
const relations=[];
|
|
9
9
|
for (const side of ['old','new']) {
|
|
10
|
-
const contexts=(input.context ?? []).filter(c=>side==='new'
|
|
10
|
+
const contexts=(input.context ?? []).filter(c=>side==='new'?typeof c.text==='string':typeof c.oldText==='string').map(c=>({...c,text:side==='old'?c.oldText:c.text}));
|
|
11
11
|
const text=side==='old'?file.left.text:file.right.text;
|
|
12
12
|
// Infer each version independently: never bind an old import to target definitions.
|
|
13
13
|
const one={...file,left:{...file.left,text:''},right:{...file.right,text}};
|
package/src/csharp-context.mjs
CHANGED
|
@@ -6,7 +6,7 @@ export function inferCsharpContext(input, primary) {
|
|
|
6
6
|
const approvedContexts = (input.context ?? []).filter(c => c.path.endsWith('.cs') && !input.files.some(f => f.path === c.path));
|
|
7
7
|
const result = [];
|
|
8
8
|
for (const side of ['old', 'new']) {
|
|
9
|
-
const contexts = input.history ? approvedContexts.map(c=>({...c,text:side==='old'?c.oldText:c.text})) : approvedContexts;
|
|
9
|
+
const contexts = input.history ? approvedContexts.filter(c=>side==='new'?!c.oldOnly:true).map(c=>({...c,text:side==='old'?c.oldText:c.text})) : approvedContexts;
|
|
10
10
|
const source = (side === 'old' ? primary.left : primary.right)?.text;
|
|
11
11
|
if (!safe(source) || /\busing\b|\bdynamic\b/.test(source)) continue;
|
|
12
12
|
const ns = namespaceOf(source); if (!ns) continue;
|
package/src/git-history.mjs
CHANGED
|
@@ -28,16 +28,29 @@ export async function captureGitHistory(cwd, options={}) {
|
|
|
28
28
|
if(names.length>maxFiles)throw new Error('Historical change count limit exceeded');if(selectedPaths?.some(p=>!names.includes(p)))throw new Error('Selected path is not a historical change');
|
|
29
29
|
const files=[];for(const name of names){signal?.throwIfAborted();const l=left.get(name),r=right.get(name),item={id:hash(name),path:name,rawStatus:!l?'A':!r?'D':'M',eligibility:'reviewable',properties:[]};files.push(item);if(isSecretPath(name)){item.eligibility='excluded';item.reason='Credential path excluded';continue;}if(selectedPaths&&!selectedPaths.includes(name)){item.eligibility='excluded';item.reason='Not explicitly selected';continue;}try{for(const e of [l,r].filter(Boolean))if(e.type!=='blob'||!['100644','100755'].includes(e.mode))throw new Error('Historical link or submodule excluded');const read=async e=>decode(e?await git(root,['cat-file','blob',e.oid],maxFileBytes):Buffer.alloc(0));item.left=await read(l);item.right=await read(r);item.rightExists=!!r;if(l&&r&&item.left.hash===item.right.hash){item.eligibility='excluded';item.reason='Mode-only historical change not analyzed';}}catch(error){signal?.throwIfAborted();item.eligibility='blocked';item.reason=error.message;delete item.left;delete item.right;}if(Buffer.byteLength(JSON.stringify(files))>maxSnapshotBytes)throw new Error('Historical snapshot limit exceeded');}
|
|
30
30
|
const rulePaths=options.rulePaths ?? [];
|
|
31
|
-
const contextPaths=options.contextPaths ?? [], maxContextFiles=options.maxContextFiles ?? 20;
|
|
31
|
+
const contextPaths=options.contextPaths ?? [], oldContextPaths=options.oldContextPaths ?? [], maxContextFiles=options.maxContextFiles ?? 20;
|
|
32
|
+
if(!Array.isArray(oldContextPaths)||oldContextPaths.length>20||new Set(oldContextPaths).size!==oldContextPaths.length)throw new Error('Invalid old context paths');
|
|
33
|
+
for(const name of oldContextPaths){relativePath(name);assertNonSecretPath(name);}
|
|
34
|
+
if(oldContextPaths.length&&!base)throw new Error('Old context requires nonempty historical baseline');
|
|
35
|
+
if(!Array.isArray(contextPaths)||!Array.isArray(rulePaths))throw new Error('Invalid historical context or rule paths');
|
|
36
|
+
if(oldContextPaths.some(p=>contextPaths.includes(p)||rulePaths.includes(p)))throw new Error('Old context paths must be separate');
|
|
37
|
+
if(contextPaths.length+oldContextPaths.length>maxContextFiles)throw new Error('Historical context file limit exceeded');
|
|
32
38
|
if(!Array.isArray(contextPaths)||!Number.isSafeInteger(maxContextFiles)||maxContextFiles<1||maxContextFiles>20||contextPaths.length>maxContextFiles)throw new Error('Historical context file limit exceeded');
|
|
33
39
|
if(options.autoContext!==undefined&&typeof options.autoContext!=='boolean')throw new Error('Invalid autoContext option');
|
|
34
40
|
if(!Array.isArray(rulePaths)||rulePaths.length>4||rulePaths.some(p=>contextPaths.includes(p)))throw new Error('Rule paths must be separate from context paths');
|
|
35
|
-
await lookup(target,right,[...contextPaths,...rulePaths]);await lookup(base,left,contextPaths);
|
|
41
|
+
await lookup(target,right,[...contextPaths,...rulePaths]);await lookup(base,left,[...contextPaths,...oldContextPaths]);
|
|
36
42
|
const context=[], regular=e=>e?.type==='blob'&&['100644','100755'].includes(e.mode);
|
|
37
43
|
const captureContext=async name=>{relativePath(name);assertNonSecretPath(name);const changed=files.find(f=>f.path===name);if(changed){if(changed.eligibility!=='reviewable'||changed.rightExists===false)throw new Error('Historical context is excluded blocked or deleted');return {path:name,...changed.right};}const entry=right.get(name);if(!regular(entry))throw new Error('Historical context not a target regular blob');const content=decode(await git(root,['cat-file','blob',entry.oid],maxFileBytes));const previous=left.get(name);return {path:name,...content,revision:target,...(regular(previous)?{oldText:decode(await git(root,['cat-file','blob',previous.oid],maxFileBytes)).text,oldRevision:base}:{})};};
|
|
38
44
|
for(const name of [...new Set(contextPaths)].sort())context.push(await captureContext(name));
|
|
45
|
+
for(const name of [...oldContextPaths].sort()){
|
|
46
|
+
const changed=files.find(f=>f.path===name);
|
|
47
|
+
if(changed){if(changed.eligibility!=='reviewable'||!left.has(name))throw new Error('Old context conflicts with excluded blocked or absent old change');continue;}
|
|
48
|
+
const entry=left.get(name);if(!regular(entry))throw new Error('Old context not a baseline regular blob');
|
|
49
|
+
const content=decode(await git(root,['cat-file','blob',entry.oid],maxFileBytes));
|
|
50
|
+
context.push({path:name,oldOnly:true,oldText:content.text,oldHash:hash(content.text),oldBlobOid:entry.oid,oldRevision:base});
|
|
51
|
+
}
|
|
39
52
|
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));}});
|
|
40
53
|
let autoContext;
|
|
41
|
-
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()]);const combined=new Map(plan.candidates.map(c=>[c.path,{...c,sourceSides:['new']}]));for(const c of oldPlan.candidates){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)&&!rulePaths.includes(c.path)),capturedPaths:[]};for(const c of autoContext.candidates){signal?.throwIfAborted();if(context.length>=maxContextFiles){c.status='blocked';c.reason='Context file limit exceeded';continue;}try{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;}}}
|
|
54
|
+
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()]);const combined=new Map(plan.candidates.map(c=>[c.path,{...c,sourceSides:['new']}]));for(const c of oldPlan.candidates){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();if(context.length>=maxContextFiles){c.status='blocked';c.reason='Context file limit exceeded';continue;}try{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;}}}
|
|
42
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)};
|
|
43
56
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CONTEXT_SIDE_NOTICE } from './context-identity.mjs';
|
|
1
2
|
import { REQUIREMENT_NOTICE } from './business-requirement.mjs';
|
|
2
3
|
import { inferContextRelations } from './context-relations.mjs';
|
|
3
4
|
import { defectGuidance } from './defect-guidance.mjs';
|
|
@@ -16,7 +17,7 @@ export function prepareInteractionInput(snapshot, group, measure) {
|
|
|
16
17
|
if (group.fileIds.filter(id => { const file = snapshot.files.find(f => f.id === id); return file.left.text !== file.right.text; }).length < 2) return { status: 'not-applicable', fileIds: [...group.fileIds] };
|
|
17
18
|
if (files.some(f => f.unavailable)) return { status: 'blocked', reason: 'exact-edit-analysis-unavailable', fileIds: [...group.fileIds] };
|
|
18
19
|
const contextRelations = group.fileIds.flatMap(id=>inferContextRelations(snapshot,snapshot.files.find(f=>f.id===id)).map(relation=>({primaryFileId:id,...relation})));
|
|
19
|
-
const payload = JSON.stringify({ ...(snapshot.businessRequirement!==undefined?{businessRequirement:snapshot.businessRequirement,requirementNotice:REQUIREMENT_NOTICE}:{}), contextRelations, contextRelationNotice:'Approved-context import/include and call binding hints only; not a call graph, complete caller coverage, semantic binding or causal evidence. Historical contextSide/contextVersion must not be interchanged.', snapshotId: snapshot.id, sourceMode: 'file-interaction', groupId: group.id, files, context: snapshot.context ?? [], rules: snapshot.rules ?? [], notice: 'Only combined defects across at least two listed files; directories are navigation not evidence. Read referenced edit source sides from approved catalog in this generation. Return ordinary findings with fileId of one listed primary and exact original line anchor; attribution.editIds references only that primary. Add interaction:{editRefs:[{fileId,editId}]} including all involved edits across at least two files. This sourceMode overrides single-primary input instructions. Retain original primary anchor and file-qualified edit references. Unsupported conclusions become limitations; no repeated single-file issues. Relations and read coverage are not causal proof.' });
|
|
20
|
+
const payload = JSON.stringify({ ...(snapshot.history ? {contextSideNotice:CONTEXT_SIDE_NOTICE} : {}), ...(snapshot.businessRequirement!==undefined?{businessRequirement:snapshot.businessRequirement,requirementNotice:REQUIREMENT_NOTICE}:{}), contextRelations, contextRelationNotice:'Approved-context import/include and call binding hints only; not a call graph, complete caller coverage, semantic binding or causal evidence. Historical contextSide/contextVersion must not be interchanged.', snapshotId: snapshot.id, sourceMode: 'file-interaction', groupId: group.id, files, context: snapshot.context ?? [], rules: snapshot.rules ?? [], notice: 'Only combined defects across at least two listed files; directories are navigation not evidence. Read referenced edit source sides from approved catalog in this generation. Return ordinary findings with fileId of one listed primary and exact original line anchor; attribution.editIds references only that primary. Add interaction:{editRefs:[{fileId,editId}]} including all involved edits across at least two files. This sourceMode overrides single-primary input instructions. Retain original primary anchor and file-qualified edit references. Unsupported conclusions become limitations; no repeated single-file issues. Relations and read coverage are not causal proof.' });
|
|
20
21
|
const budget = measure(payload);
|
|
21
22
|
return { status: budget.fits ? 'ready' : 'blocked', reason: budget.fits ? undefined : 'input-budget', fileIds: [...group.fileIds], payload, budget };
|
|
22
23
|
}
|
package/src/primary-input.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CONTEXT_SIDE_NOTICE } from './context-identity.mjs';
|
|
1
2
|
import { REQUIREMENT_NOTICE } from './business-requirement.mjs';
|
|
2
3
|
import { defectGuidance } from './defect-guidance.mjs';
|
|
3
4
|
import { cppCallSites } from './cpp-call-sites.mjs';
|
|
@@ -13,7 +14,7 @@ import { initialInputBudget } from './input-budget.mjs';
|
|
|
13
14
|
// Pure preparation over already captured data. No model calls or new reads.
|
|
14
15
|
export function preparePrimaryInput(input, file, { instructions, scope, grouping, maxInputBytes }) {
|
|
15
16
|
const changes = changeMap(file.left.text, file.right.text);
|
|
16
|
-
const base = { ...(input.businessRequirement!==undefined?{businessRequirement:input.businessRequirement,requirementNotice:REQUIREMENT_NOTICE}:{}), defectGuidance: defectGuidance(file.path, file.rightExists === false ? file.left.text : file.right.text), snapshotId: input.id, file, changes, context: input.context ?? [], rules: input.rules ?? [] };
|
|
17
|
+
const base = { ...(input.history ? {contextSideNotice:CONTEXT_SIDE_NOTICE} : {}), ...(input.businessRequirement!==undefined?{businessRequirement:input.businessRequirement,requirementNotice:REQUIREMENT_NOTICE}:{}), defectGuidance: defectGuidance(file.path, file.rightExists === false ? file.left.text : file.right.text), snapshotId: input.id, file, changes, context: input.context ?? [], rules: input.rules ?? [] };
|
|
17
18
|
const contextRelations = inferContextRelations(input, file);
|
|
18
19
|
if (contextRelations.length) {
|
|
19
20
|
base.contextRelations = contextRelations;
|
package/src/retrieval-scope.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { contextSources } from './context-identity.mjs';
|
|
1
2
|
import { assertNonSecretPath } from './secret-path.mjs';
|
|
2
3
|
import { createSnapshotReader } from './snapshot-reader.mjs';
|
|
3
4
|
|
|
@@ -21,18 +22,16 @@ export function createRetrievalScope(snapshot, options) {
|
|
|
21
22
|
}
|
|
22
23
|
const seenContext = new Set();
|
|
23
24
|
for (const item of snapshot.context) {
|
|
24
|
-
if (!item || typeof item.path !== 'string' || !item.path || seenContext.has(item.path)
|
|
25
|
+
if (!item || typeof item.path !== 'string' || !item.path || seenContext.has(item.path)) throw new Error('Invalid retrieval context');
|
|
25
26
|
seenContext.add(item.path);
|
|
26
27
|
const changed = paths.get(item.path);
|
|
28
|
+
const sources = contextSources(item, snapshot.history);
|
|
27
29
|
if (changed) {
|
|
30
|
+
if (item.oldOnly) throw new Error('Context conflicts with selected changes');
|
|
28
31
|
if (changed.eligibility !== 'reviewable' || changed.rightExists === false || changed.right.text !== item.text) throw new Error('Context conflicts with selected changes');
|
|
29
32
|
continue;
|
|
30
33
|
}
|
|
31
|
-
add(item.path,
|
|
32
|
-
if (item.oldText !== undefined) {
|
|
33
|
-
if (!snapshot.history || item.oldRevision !== snapshot.history.base || item.revision !== snapshot.history.target || typeof item.oldText !== 'string') throw new Error('Invalid historical context provenance');
|
|
34
|
-
add(item.path, 'context-old', item.oldText);
|
|
35
|
-
}
|
|
34
|
+
for (const source of sources) add(item.path, source.side, source.text);
|
|
36
35
|
}
|
|
37
36
|
const reader = createSnapshotReader(snapshot.id, entries, options);
|
|
38
37
|
return Object.freeze({ ...reader, catalog: () => structuredClone(catalog) });
|
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','oldContextPaths'].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
|
-
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
|
+
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');
|
|
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), ...(options.oldContextPaths !== undefined ? {oldContextPaths:options.oldContextPaths,oldContextSources:snapshot.context.filter(c=>c.oldOnly).map(c=>({path:c.path,side:'context-old',revision:c.oldRevision,blobOid:c.oldBlobOid,hash:c.oldHash}))} : {}), ...(snapshot.callerDiscovery ? {callerDiscovery:snapshot.callerDiscovery} : {}), ...(snapshot.autoContext ? { autoContext: snapshot.autoContext } : {}), rules: (snapshot.rules ?? []).map(({ path, hash }) => ({ path, hash })), notice: (options.oldContextPaths !== undefined ? 'oldContextPaths仅为baseline旧侧正文,无目标侧替代;选中变更复用原old身份,旧侧不能证明新侧行为。' : '') + (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,12 @@ 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);
|
|
37
|
+
if (options.oldContextPaths !== undefined && (!Array.isArray(options.oldContextPaths) || options.oldContextPaths.length > 20 || new Set(options.oldContextPaths).size !== options.oldContextPaths.length)) throw new Error('Invalid old context paths');
|
|
38
|
+
for (const name of options.oldContextPaths ?? []) relativePath(name);
|
|
34
39
|
const type = await detectVcs(cwd);
|
|
40
|
+
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');
|
|
35
42
|
if (['commit','baseRevision','targetRevision'].some(k => options[k] !== undefined)) {
|
|
36
43
|
if (type !== 'git') throw new Error('Git revision review is unsupported on SVN');
|
|
37
44
|
return bindBusinessRequirement(await captureGitHistory(cwd, options),options.businessRequirement);
|
|
@@ -63,13 +70,32 @@ export async function captureSnapshot(cwd, options = {}) {
|
|
|
63
70
|
context.push({ path: name, ...changed.right });
|
|
64
71
|
} else context.push({ path: name, ...await readLocal(snapshot.root, name, options) });
|
|
65
72
|
}
|
|
73
|
+
let callerDiscovery, verifyCallers;
|
|
74
|
+
if (options.callerScopePaths !== undefined) {
|
|
75
|
+
const callers = await captureCallers(snapshot.root, snapshot.files, options.callerScopePaths, options);
|
|
76
|
+
verifyCallers = callers.verify;
|
|
77
|
+
callerDiscovery = { ...callers.discovery, capturedPaths: [] };
|
|
78
|
+
for (const item of callers.context) {
|
|
79
|
+
const present = context.find(c => c.path === item.path);
|
|
80
|
+
const matches = callerDiscovery.candidates.filter(c => c.fromPath === item.path);
|
|
81
|
+
if (present && present.hash !== item.hash) throw new Error('Caller context changed during capture');
|
|
82
|
+
if (!present && context.length >= Math.min(maxContextFiles,20)) {
|
|
83
|
+
for (const candidate of matches) { candidate.status='blocked'; candidate.captureReason='Context file limit exceeded'; }
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!present) context.push(item);
|
|
87
|
+
callerDiscovery.capturedPaths.push(item.path);
|
|
88
|
+
for (const candidate of matches) candidate.status='captured';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
66
91
|
// Re-capture verifies both selected sides while collecting explicit context.
|
|
67
92
|
if ((await capture(cwd, options)).id !== snapshot.id) throw new Error('Snapshot changed while collecting context');
|
|
68
93
|
for (const item of context) if ((await readLocal(snapshot.root, item.path, options)).hash !== item.hash) throw new Error('Context changed during capture');
|
|
69
94
|
if (index && (await (type === 'git' ? gitContextIndex : svnContextIndex)(snapshot.root, indexOptions)).fingerprint !== index.fingerprint) throw new Error('Tracked context index changed during capture');
|
|
70
95
|
const verifiedRules = await captureProjectRules(snapshot.root, rulePaths, { signal: options.signal, files: snapshot.files });
|
|
71
96
|
if (JSON.stringify(verifiedRules) !== JSON.stringify(rules)) throw new Error('Rules changed during capture');
|
|
72
|
-
|
|
97
|
+
if (verifyCallers) await verifyCallers();
|
|
98
|
+
const result = { ...snapshot, context, ...(callerDiscovery ? {callerDiscovery} : {}), ...(autoContext ? { autoContext } : {}), ...(rules.length ? { rules } : {}) };
|
|
73
99
|
delete result.id;
|
|
74
100
|
const serialized = JSON.stringify(result);
|
|
75
101
|
if (Buffer.byteLength(serialized) > maxSnapshotBytes) throw new Error('Snapshot size limit exceeded');
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CONTEXT_SIDE_NOTICE } from './context-identity.mjs';
|
|
1
2
|
import { defectGuidance } from './defect-guidance.mjs';
|
|
2
3
|
import { createEvidenceReceipts } from './evidence-receipts.mjs';
|
|
3
4
|
import { retrievalLoop } from './retrieval-loop.mjs';
|
|
@@ -15,7 +16,7 @@ export async function verificationLoop(model, request, candidates, scope, option
|
|
|
15
16
|
const ids = candidates.map((_, i) => 'c' + (i + 1));
|
|
16
17
|
const envelope = request.input ? JSON.parse(request.input) : {};
|
|
17
18
|
const rules = envelope.rules ?? [];
|
|
18
|
-
const input = JSON.stringify({ ...(envelope.businessRequirement!==undefined?{businessRequirement:envelope.businessRequirement,requirementNotice:envelope.requirementNotice}:{}), defectGuidance: [...new Set(candidates.map(f => scope.catalog().find(s => s.fileId === f.fileId)?.path).filter(Boolean))].map(path => envelope.file?.path === path && envelope.defectGuidance ? envelope.defectGuidance : envelope.files?.find(f=>f.path===path)?.defectGuidance ?? defectGuidance(path)), rules, candidates: candidates.map((finding, i) => ({ candidateId: ids[i], finding })) });
|
|
19
|
+
const input = JSON.stringify({ contextSideNotice:CONTEXT_SIDE_NOTICE, ...(envelope.businessRequirement!==undefined?{businessRequirement:envelope.businessRequirement,requirementNotice:envelope.requirementNotice}:{}), defectGuidance: [...new Set(candidates.map(f => scope.catalog().find(s => s.fileId === f.fileId)?.path).filter(Boolean))].map(path => envelope.file?.path === path && envelope.defectGuidance ? envelope.defectGuidance : envelope.files?.find(f=>f.path===path)?.defectGuidance ?? defectGuidance(path)), rules, candidates: candidates.map((finding, i) => ({ candidateId: ids[i], finding })) });
|
|
19
20
|
const receipts = createEvidenceReceipts(scope.read);
|
|
20
21
|
const retrievalScope = { ...scope, read: receipts.read };
|
|
21
22
|
const response = await retrievalLoop(model, { ...request, input, instructions: VERIFICATION_INSTRUCTIONS }, retrievalScope, options);
|