@feng0531/dsh-code-review 0.81.0 → 0.82.1
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 +8 -0
- package/DEVELOPMENT.md +7 -7
- package/README.md +17 -2
- package/RELEASE-NOTES.md +15 -11
- package/index.mjs +2 -0
- package/package.json +1 -1
- package/src/attribution.mjs +1 -1
- package/src/background-review.mjs +14 -0
- package/src/caller-capture.mjs +2 -2
- package/src/capture-signal.mjs +2 -0
- package/src/dependency-chain.mjs +2 -1
- package/src/dsh-model.mjs +3 -2
- package/src/history-caller-capture.mjs +2 -1
- package/src/history-dependency-chain.mjs +2 -1
- package/src/primary-input.mjs +7 -3
- package/src/process.mjs +3 -3
- package/src/project-review-service.mjs +10 -8
- package/src/recovery-coverage.mjs +2 -0
- package/src/review-jobs.mjs +26 -0
- package/src/review.mjs +13 -6
- package/src/runtime-identity.mjs +4 -0
- package/src/service.mjs +5 -3
- package/src/source-segments.mjs +7 -0
- package/src/svn-caller-capture.mjs +2 -2
- package/src/svn-capture-budget.mjs +3 -2
- package/src/svn-dependency-chain.mjs +2 -1
- package/src/svn-explicit-context.mjs +2 -1
- package/src/svn-history.mjs +3 -2
- package/src/svn-navigation-budget.mjs +2 -1
- package/src/svn-working-mergeinfo.mjs +3 -2
- package/src/window-batches.mjs +2 -2
- package/src/window-synthesis.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
本文件介绍对使用者有意义的变化。安装与参数见 [README](README.md),本次发布正文见 [发布说明](RELEASE-NOTES.md)。历史提交和安装包保留在 [GitHub Releases](https://github.com/f-e-n-g-0531/dsh-code-review/releases);本文不把开发日志当作功能介绍。
|
|
4
4
|
|
|
5
|
+
## 0.82.0 — 后台审查、持久进度与大文件回退
|
|
6
|
+
|
|
7
|
+
长任务先返回ID,查询/取消/分页取回已保存结果;重启后保留记录但不恢复旧授权。大hunk分段与粗粒度包络精度披露;JSON明确纠正并计数。预览显示运行版本位置,按工作区诊断。
|
|
8
|
+
|
|
9
|
+
## 0.81.1 — 取消默认内部超时和计数配额
|
|
10
|
+
|
|
11
|
+
修复外层延长仍被采集/导航30秒、模型120秒等内部截止终止的问题。默认不限制采集命令数或总模型调用数,遵循用户/宿主取消。保留来源、内存与模型输入保护,不声称解除宿主/提供方限制。
|
|
12
|
+
|
|
5
13
|
## 0.81.0 — 多会话独立审查与全项目当前源码
|
|
6
14
|
|
|
7
15
|
不同会话不再被全局busy阻塞,预览/锁/取消/模型/预算独立。新增reviewMode=project,跟踪当前源码分批检查并保持准确覆盖;不是差异审查,不把空旧侧当历史基线。项目模式有明确总量和共享调用限制,未完成不冒充成功。
|
package/DEVELOPMENT.md
CHANGED
|
@@ -16,19 +16,19 @@
|
|
|
16
16
|
|
|
17
17
|
服务按DSH session对象隔离缓存和锁,同一会话changes/project两种模式共用互斥锁;不同会话可并行,模型调用与取消由各请求生命周期约束。宿主dispose终止全部会话服务。全项目是跟踪文件的当前源码分批,不是多仓库汇总或跨批次语义分析。
|
|
18
18
|
|
|
19
|
-
项目捕获最多10000清单节点、1000选择节点、16MiB
|
|
19
|
+
项目捕获最多10000清单节点、1000选择节点、16MiB,不设默认总模型调用次数或执行秒表,初次捕获/复核均遵循宿主与用户取消。SVN项目模式拒绝不支持节点属性,不隐式继承固定历史审查的网络权限。
|
|
20
20
|
|
|
21
21
|
## 审查预算
|
|
22
22
|
|
|
23
23
|
除 README 中的文件和输入上限外:
|
|
24
24
|
|
|
25
|
-
-
|
|
25
|
+
- 采集、导航、命令及模型审查默认不设插件内部秒表;内部API调用者仍可显式指定timeoutMs(0表示无内部截止)。用户/宿主取消仍传播并等待清理。
|
|
26
26
|
- 每阶段最多三轮检索;全局最多 50 次读取请求,返回内容累计 256 KiB,单次最多 64 KiB,读取最多 200 行。
|
|
27
|
-
- Git 工作区依赖链最多三层、512 个精确路径查询、20 个候选、手动入口与自动依赖共享 1 MiB
|
|
28
|
-
- SVN
|
|
29
|
-
- SVN 依赖链最多三层,以精确本地路径和安全正常节点捕获;512 路径、20 候选和上下文。工作副本显式上下文、自动依赖、调用方扫描(含非命中)及关键字BASE核验共享1 MiB
|
|
30
|
-
- Git 历史依赖链同样最多三层,两侧共享 512 个路径、20 个候选和上下文、1 MiB
|
|
31
|
-
- 引用方发现最多四个不重叠的非根目录;128 个候选正文共 1 MiB,目录索引最多 256 条/64 KiB,竞争路径最多 512
|
|
27
|
+
- Git 工作区依赖链最多三层、512 个精确路径查询、20 个候选、手动入口与自动依赖共享 1 MiB 正文;文件读取与最终复核共享宿主/用户取消信号。
|
|
28
|
+
- SVN工作副本全采集记录命令次数、共享1MiB元数据内存保护,不设默认命令次数或时间截止,含初始/重复采集、导航、属性和末尾验证;cat输出另受16MiB总硬上限约束。历史采集也有独立的元数据与正文内存保护,不以命令次数或时间中断。
|
|
29
|
+
- SVN 依赖链最多三层,以精确本地路径和安全正常节点捕获;512 路径、20 候选和上下文。工作副本显式上下文、自动依赖、调用方扫描(含非命中)及关键字BASE核验共享1 MiB来源内存账本与宿主/用户取消信号,不能切换阶段重新获得额度;切换、外部与不支持属性节点拒绝。
|
|
30
|
+
- Git 历史依赖链同样最多三层,两侧共享 512 个路径、20 个候选和上下文、1 MiB 正文内存保护和取消信号;只读取固定对象。
|
|
31
|
+
- 引用方发现最多四个不重叠的非根目录;128 个候选正文共 1 MiB,目录索引最多 256 条/64 KiB,竞争路径最多 512 个。发现与复核共享宿主/用户取消信号。
|
|
32
32
|
|
|
33
33
|
达到上限必须阻断或说明未完成,不能静默扩大范围、丢弃证据或重新计时。
|
|
34
34
|
|
package/README.md
CHANGED
|
@@ -204,6 +204,21 @@ Git / SVN 历史审查也能使用这些参数;依赖和调用方按对应版
|
|
|
204
204
|
|
|
205
205
|
`repositoryPath` 相对于会话工作目录,必须指向一个实际仓库根目录;其余文件路径再相对于该仓库。每个仓库分别预览和执行,不使用绝对路径、`..` 或链接目录。
|
|
206
206
|
|
|
207
|
+
## 长任务:后台预览、审查与取回结果
|
|
208
|
+
|
|
209
|
+
全项目或慢仓库建议使用后台工具,不让一次外层调用等待整个项目。仍先预览,再由你确认外发;启动返回jobId不等于审核完成。
|
|
210
|
+
|
|
211
|
+
1. 调用code_review_start,phase设为preview,previewArgs填原预览参数(例如reviewMode=project)。
|
|
212
|
+
2. 用code_review_status和jobId查询;完成后读取resultSummary中的预览范围、模型和previewId。
|
|
213
|
+
3. 展示预览,确认后调用code_review_start,phase设为execute,传previewId与confirmed=true。
|
|
214
|
+
4. 查询审查任务;offset/limit分页取回检查点、文件状态和发现;单个检查点内的大清单用checkpointIndex指定其零基索引,再用offset/limit取回内部全部条目。可调用code_review_cancel取消。
|
|
215
|
+
|
|
216
|
+
报告保存在宿主profile的data/dsh-code-review/jobs目录;没有profile环境时使用用户主目录.dsh下的数据目录,不写入业务仓库。内容可能含源码片段,应按源码敏感级别保护。本次启动调用结束不自动取消后台任务;明确取消、停用插件或宿主退出会停止它。
|
|
217
|
+
|
|
218
|
+
重启后可按原jobId读回保存记录,原运行标记interrupted,不自动继续外发。recovery给出已观察文件/窗口与未完成路径;它不是完整分母或新授权,补审必须重新预览确认。新预览的runtime包含实际加载版本和模块位置,排查不同工作区时分别核对,不根据另一台机器推断。
|
|
219
|
+
|
|
220
|
+
大文件超出单次输入时按原行号分段。精确差异算不出时使用明确标注的包络,可能包含未变行,不声称精确归因。模型JSON错误会请求一次明确纠正,记录额外调用;不会给错误输出补括号、猜字段或当成无缺陷。
|
|
221
|
+
|
|
207
222
|
## 多个会话同时审查
|
|
208
223
|
|
|
209
224
|
可以同时打开多个DSH Agent会话:会话A审项目A,会话B审项目B,也可以分别审查同一项目。各会话的预览、执行锁、模型路由、取消和预算独立;A正在执行不会让B收到全局busy。一个会话内部仍串行,不能拿另一个会话的previewId执行。宿主停用插件会结束全部服务,单次取消只影响当前请求。
|
|
@@ -225,7 +240,7 @@ Git / SVN 历史审查也能使用这些参数;依赖和调用方按对应版
|
|
|
225
240
|
|
|
226
241
|
项目模式只接受reviewMode、repositoryPath和可选selectedPaths,不与历史revision、autoContext、规则或调用方参数混用。默认全部跟踪节点;不读取未跟踪/忽略文件、子模块、外部仓库及链接目标。目录只作清单记录,源码按批次审查;跨批次不宣称完整调用图或全程序语义分析。
|
|
227
242
|
|
|
228
|
-
清单上限10000节点,单次选择最多1000节点/16MiB,12节点一批,单文件256KiB
|
|
243
|
+
清单上限10000节点,单次选择最多1000节点/16MiB,12节点一批,单文件256KiB;超限明确拒绝,缩小选择后必须重新预览。单次采集共享8MiB元数据内存保护,不按命令条数、插件秒表或模型调用次数中断;耗时受宿主/模型提供方限制,用户可随时取消。SVN为本地已观察清单,稀疏/外部/链接及不支持属性显示限制;不证明仓库远端完整覆盖。
|
|
229
244
|
|
|
230
245
|
报告分别给出总数、completed、excluded、blocked、pending和每批快照。completed只表示所选捕获文件已处理,不代表代码正确;排除与阻断从不算完成。代码或清单变化会拒绝旧预览,运行后无法复核则标记outdated。
|
|
231
246
|
|
|
@@ -285,7 +300,7 @@ Git / SVN 历史审查也能使用这些参数;依赖和调用方按对应版
|
|
|
285
300
|
| 全部快照 | 4 MiB |
|
|
286
301
|
| 额外上下文 | 手动与自动来源共 20 个文件 |
|
|
287
302
|
| 单次模型输入 | 约 96 KiB |
|
|
288
|
-
|
|
|
303
|
+
| 内部时间/总调用次数 | 不设默认截止;遵循宿主与用户取消 |
|
|
289
304
|
|
|
290
305
|
大变更可按保留原行号的差异窗口分批分析;无法容纳的部分会明确标为未完成。建议先按功能或模块选择一小批文件。详细读取和时间预算见 [开发与发布指南](DEVELOPMENT.md)。
|
|
291
306
|
|
package/RELEASE-NOTES.md
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
|
-
# v0.
|
|
1
|
+
# v0.82.1 发布说明
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
v0.82.0冻结标签的Windows CI因临时目录规范路径别名误拒绝失败,未发布;改为固定规范目录身份并继续拒绝链接,原标签不移动。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## 后台长任务与保存结果
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
新增code_review_start/status/cancel:后台预览或已确认执行先返回任务ID,逐文件、逐窗口保存进度,结果分页取回。避免一次外层600秒中断后没有已完成成果。任务按会话/目录/模型/推理强度归属校验;宿主profile保存原始结果,不修改被审仓库。
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
停用插件/宿主退出会中断任务,已落盘检查点可读回。不会自动跨重启继续外发或恢复旧批准;补审需新预览与确认。任务completed仅表示函数返回,必须看实际report.status及覆盖。
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
- 分批保留来源哈希,全部批次共享本会话100次模型调用和10分钟。清单最多10000节点,单次选择最多1000节点/16MiB,超限明确拒绝;单文件上限256KiB。
|
|
13
|
-
- 报告区分完成、排除、阻断及未完成路径;没有完成的批次保留pending/partial,来源变化拒绝旧预览。
|
|
14
|
-
- 不包含未跟踪/忽略/子模块/外部内容或链接目标;SVN是本地已观察范围,不证明远端完整清单。跨批次不提供全程序语义调用图。
|
|
15
|
-
- 项目模式仅支持reviewMode、repositoryPath、selectedPaths,不与历史、规则或自动上下文参数混用。不支持的SVN状态和属性明确阻断。
|
|
11
|
+
## 大文件与模型协议
|
|
16
12
|
|
|
17
|
-
|
|
13
|
+
过大hunk按原行号分段,精确差异受限时退回保守包络并披露精度,不能用其证明精确编辑或回归因果。去掉默认20批调度上限,不用批次数代替完成条件。仍有单次模型输入/内存保护,超长单行无法容纳时明确未完成。
|
|
14
|
+
|
|
15
|
+
JSON格式错误时明确请求一次新响应,不修改或伪造旧响应;额外调用计数并披露纠正。仍不能解析或字段错误时保留失败,不冒充无缺陷。
|
|
16
|
+
|
|
17
|
+
## 诊断与安全
|
|
18
|
+
|
|
19
|
+
预览返回实际加载版本、模块位置及耗时,后台显示阶段。不同工作区单独诊断,不用一台机器结果外推。独立项目工厂补充TTL/预览数校验。用户取消、只读、来源哈希与批准绑定保持。报告含证据片段,应按源码保护并由用户管理保存目录。
|
|
20
|
+
|
|
21
|
+
用法见 [README](README.md)。本版本没有自动安装/重启宿主,也未声称反馈中的各私有工作区已验收恢复。
|
package/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {registerBackgroundReview} from './src/background-review.mjs';
|
|
1
2
|
import { createReviewService } from './src/service.mjs';
|
|
2
3
|
export const name = 'dsh-code-review';
|
|
3
4
|
export const inject = ['tools', 'llm'];
|
|
@@ -22,5 +23,6 @@ export function apply(ctx, config = {}) {
|
|
|
22
23
|
output,
|
|
23
24
|
async execute(args, exec) { const result = await service.execute(args, exec); return { json: JSON.stringify(result.report), markdown: result.markdown }; },
|
|
24
25
|
});
|
|
26
|
+
registerBackgroundReview(ctx,service);
|
|
25
27
|
ctx.on('dispose', () => service.dispose());
|
|
26
28
|
}
|
package/package.json
CHANGED
package/src/attribution.mjs
CHANGED
|
@@ -12,5 +12,5 @@ export function validateAttribution(value, file, changes = changeMap(file.left.t
|
|
|
12
12
|
if (value.editIds.some(id => typeof id !== 'string' || !ids.has(id)) || value.properties.some(name => typeof name !== 'string' || !properties.has(name))) throw new Error('Unknown change reference');
|
|
13
13
|
if (new Set(value.editIds).size !== value.editIds.length || new Set(value.properties).size !== value.properties.length) throw new Error('Duplicate change reference');
|
|
14
14
|
if (!value.editIds.length && !value.properties.length) throw new Error('Attribution requires a change reference');
|
|
15
|
-
return { status: 'references-validated', causality: 'unverified', editIds: [...value.editIds], properties: [...value.properties], beforeBehavior: value.beforeBehavior, afterBehavior: value.afterBehavior, reason: value.reason };
|
|
15
|
+
return { status: 'references-validated', causality: 'unverified', ...(changes.precision==='envelope'?{precision:'envelope',notice:'References are broad region coordinates, not exact edit evidence.'}:{}), editIds: [...value.editIds], properties: [...value.properties], beforeBehavior: value.beforeBehavior, afterBehavior: value.afterBehavior, reason: value.reason };
|
|
16
16
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import {createReviewJobs} from './review-jobs.mjs';
|
|
4
|
+
|
|
5
|
+
export function registerBackgroundReview(ctx,service,{directory}={}){
|
|
6
|
+
const root=directory??path.join(process.env.DSH_PROFILE_DIR??path.join(os.homedir(),'.dsh'),'data','dsh-code-review','jobs');
|
|
7
|
+
const jobs=createReviewJobs({directory:root});
|
|
8
|
+
const output={schema:{type:'object',additionalProperties:false,properties:{json:{type:'string'},markdown:{type:'string'}},required:['json','markdown']},render:(_a,v)=>[{type:'text',text:v.markdown||v.json}]};
|
|
9
|
+
const respond=value=>({json:JSON.stringify(value),markdown:''});
|
|
10
|
+
ctx.tools.register({name:'code_review_start',description:'Start a durable background preview or confirmed review. Returns a job ID immediately. Preview phase never sends source. For execute supply the approved previewId and confirmed=true. Use code_review_status to collect progress/results; code_review_cancel to stop. Does not automatically resume after host restart or grant new source approval.',parameters:{type:'object',additionalProperties:false,properties:{phase:{type:'string',enum:['preview','execute']},previewArgs:{type:'object',description:'Same parameters as code_review_preview; do not include execute fields.'},previewId:{type:'string'},confirmed:{type:'boolean'}},required:['phase']},output,async execute(args,exec){if(!args||Object.keys(args).some(k=>!['phase','previewArgs','previewId','confirmed'].includes(k))||!['preview','execute'].includes(args.phase))throw new Error('Invalid background phase');if(args.phase==='execute'&&(args.confirmed!==true||typeof args.previewId!=='string'||args.previewArgs!==undefined))throw new Error('Confirmed preview required');if(args.phase==='preview'&&(args.previewId!==undefined||args.confirmed!==undefined))throw new Error('Preview cannot confirm execution');return respond(await jobs.start(exec,async({signal,checkpoint})=>{const original=exec.agent,sessionId=original.session.header.id??original.session.id,detached={...exec,agent:original,signal,reviewCheckpoint:async value=>{const active=exec.agent;if(active!==original||(active.session.header.id??active.session.id)!==sessionId){throw new Error('Background Agent/session changed');}await checkpoint(value);}};await checkpoint({stage:args.phase==='preview'?'capturing-preview':'revalidating-approved-source'});return args.phase==='preview'?service.preview(args.previewArgs??{},detached):service.execute({previewId:args.previewId,confirmed:true},detached);}));}});
|
|
11
|
+
ctx.tools.register({name:'code_review_status',description:'Read a background review task owned by this session, directory and model. Returns saved progress or final preview/report. completed task means the tool returned; inspect nested report coverage/status. Interrupted tasks retain checkpoints but require new preview and approval to resend.',parameters:{type:'object',additionalProperties:false,properties:{jobId:{type:'string'},offset:{type:'integer',minimum:0},checkpointIndex:{type:'integer',minimum:0,description:'Read one checkpoint nested files/findings page; offset/limit apply within it.'},limit:{type:'integer',minimum:1,maximum:100}},required:['jobId']},output,async execute(args,exec){return respond(await jobs.status(args.jobId,exec,{offset:args.offset??0,limit:args.limit??20,checkpointIndex:args.checkpointIndex}));}});
|
|
12
|
+
ctx.tools.register({name:'code_review_cancel',description:'Cancel a background review task owned by this session. Does not discard saved checkpoints. No source is sent by cancellation.',parameters:{type:'object',additionalProperties:false,properties:{jobId:{type:'string'}},required:['jobId']},output,async execute(args,exec){return respond(await jobs.cancel(args.jobId,exec));}});
|
|
13
|
+
ctx.on('dispose',()=>jobs.dispose());return jobs;
|
|
14
|
+
}
|
package/src/caller-capture.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {captureSignal} from './capture-signal.mjs';
|
|
1
2
|
import {gitCallerIndex} from './caller-index.mjs';
|
|
2
3
|
import {gitCallerProbes} from './caller-probes.mjs';
|
|
3
4
|
import {callerCandidates} from './caller-candidates.mjs';
|
|
@@ -6,8 +7,7 @@ import {isSecretPath} from './secret-path.mjs';
|
|
|
6
7
|
|
|
7
8
|
// Called only by preview capture, never by a model retrieval request.
|
|
8
9
|
export async function captureCallers(root, files, scopes, options={}) {
|
|
9
|
-
const
|
|
10
|
-
const signal=options.signal?AbortSignal.any([options.signal,deadline]):deadline;
|
|
10
|
+
const signal=captureSignal(options.signal);
|
|
11
11
|
const bounded={...options,signal,maxFileBytes:Math.min(options.maxFileBytes??256*1024,256*1024)};
|
|
12
12
|
const index=await gitCallerIndex(root,scopes,bounded);
|
|
13
13
|
const changed=new Set(files.flatMap(f=>[f.path,...(f.oldPath?[f.oldPath]:[])]));
|
package/src/dependency-chain.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import {captureSignal} from './capture-signal.mjs';
|
|
1
2
|
import {contextCandidates} from './context-candidates.mjs';
|
|
2
3
|
import {gitContextIndex} from './tracked-context.mjs';
|
|
3
4
|
import {readLocal} from './content.mjs';
|
|
4
5
|
// Fixed-depth literal navigation, not symbol binding. Never enumerate the repository.
|
|
5
6
|
export async function captureDependencyChain(root,files,options={}){
|
|
6
|
-
const signal=
|
|
7
|
+
const signal=captureSignal(options.signal);
|
|
7
8
|
const scoped={...options,maxFileBytes:Math.min(options.maxFileBytes??256*1024,256*1024),signal},probes=new Set(),visited=new Set(files.map(f=>f.path)),context=[],candidates=[];
|
|
8
9
|
const reserved=new Set([...(options.contextPaths??[]),...(options.rulePaths??[])]),limit=Math.min(options.maxContextFiles??20,20);
|
|
9
10
|
let frontier=[...files],index,bytes=0,truncated=false,depth=0;
|
package/src/dsh-model.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { modelJson } from './model-json.mjs';
|
|
2
2
|
/** One-shot host LLM call. No agent loop and no executable tools. */
|
|
3
|
-
export function createDshModel(llm, route, { maxTokens = 4096, maxOutputBytes = 128 * 1024 } = {}) {
|
|
3
|
+
export function createDshModel(llm, route, { maxTokens = 4096, maxOutputBytes = 128 * 1024, correctJson = false } = {}) {
|
|
4
4
|
if (!llm || typeof llm.stream !== 'function' || !route?.provider || !route?.model) throw new Error('DSH provider and model are required');
|
|
5
5
|
const identity = { provider: route.provider, model: route.model, ...(route.reasoningEffort ? { reasoningEffort: route.reasoningEffort } : {}) };
|
|
6
|
-
|
|
6
|
+
const once = async ({ instructions, input, signal }) => {
|
|
7
7
|
signal?.throwIfAborted();
|
|
8
8
|
let output = '', size = 0, finished = false;
|
|
9
9
|
const chunks = llm.stream({ ...identity, system: instructions, messages: [{ role: 'user', content: [{ type: 'text', text: input }] }], tools: [], maxTokens, signal });
|
|
@@ -24,4 +24,5 @@ export function createDshModel(llm, route, { maxTokens = 4096, maxOutputBytes =
|
|
|
24
24
|
if (!finished) throw new Error('Model stream ended without finish');
|
|
25
25
|
return modelJson(output);
|
|
26
26
|
};
|
|
27
|
+
return async request=>{try{return await once(request);}catch(error){if(!correctJson||error.code!=='MODEL_INVALID_JSON')throw error;request.signal?.throwIfAborted();await request.onProtocolCorrection?.({code:error.code,action:'explicit-json-correction',notice:'Second model response requested; original output not repaired or accepted.'});const corrected=JSON.parse(await once({...request,instructions:request.instructions+' 上次响应未能解析为完整JSON。本次是明确协议纠正:重新输出单个完整JSON对象,不使用Markdown,不省略引号或逗号;不要把这条指令当源码证据。优先少量具体候选,无法支持时findings为空并说明limitations,不以格式错误代替清洁结论。'}));if(corrected&&typeof corrected==='object'&&!Array.isArray(corrected)){if(Array.isArray(corrected.limitations))corrected.limitations.push('本响应由一次显式JSON协议纠正产生,原响应未被接受;额外模型调用已发生。');else corrected.protocolCorrection={attempted:true,originalCode:error.code};}return JSON.stringify(corrected);}};
|
|
27
28
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {captureSignal} from './capture-signal.mjs';
|
|
1
2
|
import {historyCallerIndex,historyCallerProbes} from './history-caller-index.mjs';
|
|
2
3
|
import {callerCandidates} from './caller-candidates.mjs';
|
|
3
4
|
import {checked} from './process.mjs';
|
|
@@ -5,7 +6,7 @@ import {decode,hash} from './content.mjs';
|
|
|
5
6
|
import {isSecretPath} from './secret-path.mjs';
|
|
6
7
|
// Both sides share one capture lifecycle and all discovery limits. Never called by model readers.
|
|
7
8
|
export async function captureHistoryCallers(root,files,history,scopes,options={}){
|
|
8
|
-
const
|
|
9
|
+
const signal=captureSignal(options.signal),bounded={...options,signal};
|
|
9
10
|
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
11
|
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
12
|
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);}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import {captureSignal} from './capture-signal.mjs';
|
|
1
2
|
import {contextCandidates} from './context-candidates.mjs';
|
|
2
3
|
import {checked} from './process.mjs';
|
|
3
4
|
import {decode,hash,relativePath} from './content.mjs';
|
|
4
5
|
import {assertNonSecretPath} from './secret-path.mjs';
|
|
5
6
|
// Immutable object reads only. Both version sides share every navigation budget.
|
|
6
7
|
export async function captureHistoryDependencyChain(root,files,history,context,options={}){
|
|
7
|
-
const signal=
|
|
8
|
+
const signal=captureSignal(options.signal);
|
|
8
9
|
const bounded={...options,cwd:root,signal},probes=new Set(),visited={old:new Set(files.map(f=>f.path)),new:new Set(files.map(f=>f.path))};
|
|
9
10
|
const maps={old:new Map(),new:new Map()},candidates=new Map(),reserved=new Set([...(options.rulePaths??[]),...(options.oldContextPaths??[]),...(options.contextPaths??[])]);let bytes=0,truncated=false;
|
|
10
11
|
const regular=e=>e?.type==='blob'&&['100644','100755'].includes(e.mode);
|
package/src/primary-input.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {sourceSegments} from './source-segments.mjs';
|
|
2
|
+
import {changeRegion} from './change-region.mjs';
|
|
1
3
|
import { CONTEXT_SIDE_NOTICE } from './context-identity.mjs';
|
|
2
4
|
import { approvedCallerBindings } from './caller-binding-input.mjs';
|
|
3
5
|
import { REQUIREMENT_NOTICE } from './business-requirement.mjs';
|
|
@@ -14,7 +16,8 @@ import { initialInputBudget } from './input-budget.mjs';
|
|
|
14
16
|
|
|
15
17
|
// Pure preparation over already captured data. No model calls or new reads.
|
|
16
18
|
export function preparePrimaryInput(input, file, { instructions, scope, grouping, maxInputBytes }) {
|
|
17
|
-
|
|
19
|
+
let changes = changeMap(file.left.text, file.right.text);
|
|
20
|
+
if(changes.status==='limited'){const region=changeRegion(file.left.text,file.right.text);if(region.status==='changed')changes={status:'changed',precision:'envelope',reason:'Exact diff unavailable; broad region includes potentially unchanged lines, not exact edits or causal proof.',edits:[{id:'envelope1',old:region.old,new:region.new}],hunks:[{id:'envelope-h1',old:region.old,new:region.new,editIds:['envelope1']}]};}
|
|
18
21
|
const base = { ...(input.projectScope?{projectScope:input.projectScope,reviewNotice:'Full current-file review; empty left is a sentinel, not a historical baseline. changes are transport coordinates only, do not infer introduced regression.'}:{}), ...(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 ?? [] };
|
|
19
22
|
const contextRelations = inferContextRelations(input, file);
|
|
20
23
|
if (contextRelations.length) {
|
|
@@ -70,7 +73,8 @@ export function preparePrimaryInput(input, file, { instructions, scope, grouping
|
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
75
|
if (!measure(payload).fits && scope) {
|
|
73
|
-
const
|
|
76
|
+
const exactWindows=changeWindows(file,changes);
|
|
77
|
+
const windows=exactWindows?sourceSegments(exactWindows):null;
|
|
74
78
|
if (windows) {
|
|
75
79
|
const compactFile = { ...file, left: { ...file.left, text: undefined }, right: { ...file.right, text: undefined } };
|
|
76
80
|
const contextCatalog = scope.catalog().filter(source => ['context', 'context-old'].includes(source.side));
|
|
@@ -80,7 +84,7 @@ export function preparePrimaryInput(input, file, { instructions, scope, grouping
|
|
|
80
84
|
windowBase.cppCallSites = base.cppCallSites.map(({line,expression,side})=>({line,expression,side}));
|
|
81
85
|
windowBase.cppCallNotice = '最多40条旧/新侧零参语法导航;统一confidence=syntax-only-unresolved,可能为宏/重载/动态分派,无绑定、可达性或顺序保证,不是证据或完整覆盖。原文仍须按批准catalog读取。';
|
|
82
86
|
}
|
|
83
|
-
const compact = JSON.stringify({ ...windowBase, context: [], contextCatalog, contextMode: 'approved-retrieval', contextNotice: '上下文全文仍在同一批准快照,仅目录进入窗口初始输入;必须按sourceId和版本侧read原文,不把目录视为证据或完整覆盖。', file: compactFile, windows, sourceMode: 'change-windows', windowNotice: '
|
|
87
|
+
const compact = JSON.stringify({ ...windowBase, context: [], contextCatalog, contextMode: 'approved-retrieval', contextNotice: '上下文全文仍在同一批准快照,仅目录进入窗口初始输入;必须按sourceId和版本侧read原文,不把目录视为证据或完整覆盖。', file: compactFile, windows, sourceMode: 'change-windows', windowNotice: '窗口可能为精确hunk的分段或粗粒度包络,精度以changes.precision为准;片段两侧不保证行对齐,不以片段证明精确编辑或回归因果。start/count为原始源码行号,不得重编号。完整源码未随初始输入发送,可在catalog批准范围内检索;缺少上下文时说明限制。' });
|
|
84
88
|
if (!measure(compact).fits) {
|
|
85
89
|
const envelope = JSON.parse(compact);
|
|
86
90
|
const plan = planWindowBatches(windows, subset => JSON.stringify({ ...envelope, windows: subset, windowNotice: '本批仅审查windows包含的编辑,其他hunk另批;行号为原始行号,跨片段上下文不足说明限制。' }), measure);
|
package/src/process.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
|
|
3
3
|
/** Run only adapter-owned commands. No shell, prompts, or unbounded capture. */
|
|
4
|
-
export function run(command, args, { cwd, signal, timeoutMs =
|
|
4
|
+
export function run(command, args, { cwd, signal, timeoutMs = 0, maxBytes = 8 * 1024 * 1024, env = {} } = {}) {
|
|
5
5
|
return new Promise((resolve, reject) => {
|
|
6
6
|
signal?.throwIfAborted();
|
|
7
|
-
|
|
7
|
+
if(!Number.isSafeInteger(timeoutMs)||timeoutMs<0||!Number.isSafeInteger(maxBytes)||maxBytes<1)throw new Error('Invalid command limit');
|
|
8
8
|
const childEnv = { ...process.env, ...env };
|
|
9
9
|
for (const key of Object.keys(childEnv)) if (/^GIT_/i.test(key)) delete childEnv[key];
|
|
10
10
|
Object.assign(childEnv, { GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_NO_LAZY_FETCH: '1' });
|
|
@@ -19,7 +19,7 @@ export function run(command, args, { cwd, signal, timeoutMs = 30_000, maxBytes =
|
|
|
19
19
|
const abort = () => stop(signal.reason ?? new Error('Cancelled'));
|
|
20
20
|
signal?.addEventListener('abort', abort, { once: true });
|
|
21
21
|
if (signal?.aborted) abort();
|
|
22
|
-
const timer = setTimeout(() => stop(new Error('Command timeout')), timeoutMs);
|
|
22
|
+
const timer = timeoutMs>0?setTimeout(() => stop(new Error('Command timeout')), timeoutMs):undefined;
|
|
23
23
|
const capture = chunks => data => {
|
|
24
24
|
bytes += data.length;
|
|
25
25
|
if (bytes > maxBytes) stop(new Error('Command output limit exceeded'));
|
|
@@ -7,11 +7,12 @@ import {projectInventory,captureProjectBatch} from './project-snapshot.mjs';
|
|
|
7
7
|
import {reviewSnapshot,markdownReport} from './review.mjs';
|
|
8
8
|
import {createDshModel} from './dsh-model.mjs';
|
|
9
9
|
const owner=exec=>{const a=exec?.agent;if(!a?.session?.header?.cwd||!a.options?.provider||!a.options?.model)throw new Error('Active Agent with cwd and model required');return {agent:a,cwd:a.session.header.cwd,route:{provider:a.options.provider,model:a.options.model,...(a.options.reasoningEffort?{reasoningEffort:a.options.reasoningEffort}:{})}};};
|
|
10
|
-
export function createProjectReviewService(llm,{allowModelSending=false,authorize=async()=>false,now=Date.now,ttlMs=300000,maxPreviews=8,projectMaxCalls=
|
|
11
|
-
if(!Number.isSafeInteger(
|
|
10
|
+
export function createProjectReviewService(llm,{allowModelSending=false,authorize=async()=>false,now=Date.now,ttlMs=300000,maxPreviews=8,projectMaxCalls=Number.MAX_SAFE_INTEGER}={}){
|
|
11
|
+
for(const n of [ttlMs,maxPreviews])if(!Number.isSafeInteger(n)||n<1)throw new Error('Invalid project preview limit');
|
|
12
|
+
if(!Number.isSafeInteger(projectMaxCalls)||projectMaxCalls<1)throw new Error('Invalid project model call limit');
|
|
12
13
|
const previews=new Map(),lifetime=new AbortController();let busy=false;
|
|
13
14
|
async function capture(cwd,args,signal){
|
|
14
|
-
let commands=0,metadataBytes=0;const captureBudget={signal,reserve(){signal.throwIfAborted();
|
|
15
|
+
let commands=0,metadataBytes=0;const captureBudget={signal,reserve(){signal.throwIfAborted();commands++;},charge(size){metadataBytes+=size;if(metadataBytes>8*1024*1024)throw Object.assign(new Error('Project shared metadata budget exceeded'),{code:'PROJECT_CAPTURE_BUDGET'});}};
|
|
15
16
|
const captureOptions={signal,svnCaptureBudget:captureBudget};
|
|
16
17
|
const root=await realpath(await resolveWorkspaceRepository(cwd,args.repositoryPath)),vcs=await detectVcs(root),inventory=await projectInventory(root,vcs,captureOptions);
|
|
17
18
|
let paths=inventory.records.map(r=>r.path);if(args.selectedPaths!==undefined){if(!Array.isArray(args.selectedPaths)||args.selectedPaths.length>1000||new Set(args.selectedPaths).size!==args.selectedPaths.length||args.selectedPaths.some(p=>!paths.includes(p)))throw new Error('Invalid tracked project selection');paths=paths.filter(p=>args.selectedPaths.includes(p));}
|
|
@@ -21,12 +22,13 @@ export function createProjectReviewService(llm,{allowModelSending=false,authoriz
|
|
|
21
22
|
for(const b of batches)if((await captureProjectBatch(verified,b.files.map(f=>f.path),captureOptions)).id!==b.id)throw new Error('Project source changed during capture');
|
|
22
23
|
const result={root,vcs,inventoryId:inventory.id,inventoryComplete:inventory.vcs==='git',totalTrackedNodes:inventory.records.length,selectedFiles:paths.length,selectionComplete:paths.length===inventory.records.length,notice:inventory.notice,batches};return {...result,id:hash(JSON.stringify(result))};
|
|
23
24
|
}
|
|
24
|
-
const signalFor=
|
|
25
|
+
const signalFor=exec=>AbortSignal.any([lifetime.signal,...(exec.signal?[exec.signal]:[])]);
|
|
25
26
|
return {owns:id=>previews.has(id),
|
|
26
|
-
async preview(args,exec){if(busy)throw new Error('Review service busy');if(!args||typeof args!=='object')throw new Error('Project preview parameters required');if(Object.keys(args).some(k=>!['reviewMode','repositoryPath','selectedPaths'].includes(k))||args.reviewMode!=='project')throw new Error('Project mode supports only reviewMode/repositoryPath/selectedPaths; change/history context parameters are not project parameters');const current=owner(exec);busy=true;try{const s=await capture(current.cwd,args,signalFor(exec
|
|
27
|
-
async execute(args,exec){if(busy)throw new Error('Review service busy');if(!allowModelSending)throw new Error('Model sending disabled');if(args.confirmed!==true||typeof args.previewId!=='string'||Object.keys(args).some(k=>!['previewId','confirmed'].includes(k)))throw new Error('Confirmed preview required');const p=previews.get(args.previewId),current=owner(exec);if(!p||p.expires<=now()||p.agent!==current.agent||p.cwd!==current.cwd||JSON.stringify(p.route)!==JSON.stringify(current.route))throw new Error('Preview missing expired or owner/model/directory changed');previews.delete(args.previewId);busy=true;const signal=signalFor(exec
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
async preview(args,exec){if(busy)throw new Error('Review service busy');if(!args||typeof args!=='object')throw new Error('Project preview parameters required');if(Object.keys(args).some(k=>!['reviewMode','repositoryPath','selectedPaths'].includes(k))||args.reviewMode!=='project')throw new Error('Project mode supports only reviewMode/repositoryPath/selectedPaths; change/history context parameters are not project parameters');const current=owner(exec);busy=true;try{const s=await capture(current.cwd,args,signalFor(exec));for(const [id,p]of previews)if(p.expires<=now())previews.delete(id);while(previews.size>=maxPreviews)previews.delete(previews.keys().next().value);const previewId=randomUUID();previews.set(previewId,{...current,args:structuredClone(args),snapshotId:s.id,expires:now()+ttlMs});return {previewId,snapshotId:s.id,reviewMode:'project',repositoryRoot:s.root,vcs:s.vcs,model:current.route,modelSendingEnabled:allowModelSending,totalTrackedNodes:s.totalTrackedNodes,selectedFiles:s.selectedFiles,selectionComplete:s.selectionComplete,inventoryComplete:s.inventoryComplete,batches:s.batches.length,files:s.batches.flatMap(b=>b.files.map(f=>({path:f.path,eligibility:f.eligibility,reason:f.reason}))),notice:s.notice+' Full current source audit, not regression-only diff. Shared session execution budget: no internal elapsed-time or model-call cutoff, 16MiB capture memory protection. Blocked/excluded/pending files are not reviewed successfully. No untracked/ignored/external bodies; no complete semantic call graph. Show scope and model before confirmed execute.'};}finally{busy=false;}},
|
|
28
|
+
async execute(args,exec){if(busy)throw new Error('Review service busy');if(!allowModelSending)throw new Error('Model sending disabled');if(args.confirmed!==true||typeof args.previewId!=='string'||Object.keys(args).some(k=>!['previewId','confirmed'].includes(k)))throw new Error('Confirmed preview required');const p=previews.get(args.previewId),current=owner(exec);if(!p||p.expires<=now()||p.agent!==current.agent||p.cwd!==current.cwd||JSON.stringify(p.route)!==JSON.stringify(current.route))throw new Error('Preview missing expired or owner/model/directory changed');previews.delete(args.previewId);busy=true;const signal=signalFor(exec);try{const s=await capture(current.cwd,p.args,signal);if(s.id!==p.snapshotId)throw new Error('Project changed since preview');if(await authorize({snapshot:s,route:p.route,exec})!==true)throw new Error('Model sending denied');if(owner(exec).cwd!==p.cwd||JSON.stringify(owner(exec).route)!==JSON.stringify(p.route))throw new Error('Agent changed during approval');signal.throwIfAborted();
|
|
29
|
+
await exec.reviewCheckpoint?.({stage:'approved-manifest',snapshotId:s.id,files:s.batches.flatMap(b=>b.files.map(f=>({fileId:f.id,path:f.path,status:f.eligibility==='reviewable'?'pending':f.eligibility})))});
|
|
30
|
+
let calls=0;const adapter=createDshModel(llm,p.route,{correctJson:true}),model=request=>{if(calls>=projectMaxCalls)throw new Error('Shared project model call limit exceeded');calls++;return adapter({...request,onProtocolCorrection:async info=>{if(calls>=projectMaxCalls)throw new Error('Shared project model call limit exceeded');calls++;await request.onProtocolCorrection?.(info);}});};const results=[];
|
|
31
|
+
for(const b of s.batches){if(signal.aborted||calls>=projectMaxCalls){results.push({snapshotId:b.id,status:'partial',files:b.files.map(f=>({fileId:f.id,path:f.path,status:f.eligibility==='reviewable'?'pending':f.eligibility,reason:'Session project budget exhausted or cancelled'})),findings:[],limitations:[]});continue;}results.push(await reviewSnapshot(b,model,{signal,maxCalls:projectMaxCalls-calls,enableRetrieval:true,enableVerification:true,onProgress:exec.reviewCheckpoint}));}
|
|
30
32
|
const files=results.flatMap(r=>r.files.map(f=>({...f,batchSnapshotId:r.snapshotId}))),coverage={total:s.selectedFiles,completed:files.filter(f=>f.status==='completed').length,excluded:files.filter(f=>f.status==='excluded').length,blocked:files.filter(f=>f.status==='blocked').length,pending:files.filter(f=>!['completed','excluded','blocked'].includes(f.status)).length};const findings=results.flatMap(r=>r.findings.map(f=>({...f,batchSnapshotId:r.snapshotId})));const report={schemaVersion:1,mode:'project',snapshotId:s.id,status:signal.aborted?'cancelled':coverage.total===0||coverage.completed===0?'partial':results.every(r=>r.status==='completed')&&files.every(f=>['completed','excluded'].includes(f.status))?'completed':'partial',repositoryRoot:s.root,vcs:s.vcs,model:p.route,modelCalls:calls,maxModelCalls:projectMaxCalls,totalTrackedNodes:s.totalTrackedNodes,selectionComplete:s.selectionComplete,inventoryComplete:s.inventoryComplete,coverage,files,findings,batches:results,notice:s.notice+' Coverage counts processing of selected tracked files only, not proof of correctness. Excluded and blocked are never completed. No cross-batch whole-program semantic coverage is claimed.'};
|
|
31
33
|
try{report.outdated=(await capture(current.cwd,p.args,signal)).id!==s.id;}catch{report.outdated=true;}
|
|
32
34
|
const lines=['# 全项目代码审查报告','','状态:'+report.status,'已完成:'+coverage.completed+'/'+coverage.total,'排除:'+coverage.excluded+';阻断:'+coverage.blocked+';未完成:'+coverage.pending,'',report.notice];for(let i=0;i<results.length;i++)lines.push('', '## 批次 '+(i+1),markdownReport({...results[i],coverage:results[i].coverage??{},interactions:results[i].interactions??[]}));if(report.outdated)lines.push('注意:项目已变化或无法复核,报告仅对应原批准快照。');return {report,markdown:lines.join(String.fromCharCode(10))};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// Saved evidence is a ledger, not renewed source authority or automatic resume.
|
|
2
|
+
export function recoveryCoverage(record){const states=new Map(),windows=new Map();for(const c of record.checkpoints??[]){if(c.stage==='approved-manifest')for(const f of c.files??[])states.set(f.path,{path:f.path,status:f.status,snapshotId:c.snapshotId});if(c.stage==='file-started')states.set(c.path,{path:c.path,status:c.status==='excluded'?'excluded':'pending',snapshotId:c.snapshotId});if(c.stage==='file-complete'&&c.file)states.set(c.file.path,{path:c.file.path,status:c.file.status,snapshotId:c.snapshotId});if(c.stage==='batch-complete')for(const f of c.files??[])states.set(f.path,{path:f.path,status:f.status,snapshotId:c.snapshotId});if(c.stage==='window-complete')windows.set(c.path,{path:c.path,snapshotId:c.snapshotId,...c.windowCoverage});}const final=record.result?.report;if(final?.files)for(const f of final.files)states.set(f.path,{path:f.path,status:f.status,snapshotId:f.batchSnapshotId??final.snapshotId});const files=[...states.values()];return {manifestSaved:(record.checkpoints??[]).some(c=>c.stage==='approved-manifest'),files,windows:[...windows.values()],suggestedPaths:files.filter(f=>!['completed','excluded'].includes(f.status)).map(f=>f.path),notice:'Saved approved manifest (when present) plus checkpoints; before that, only observed scope, not a complete denominator. Completed windows are not a completed file. New source sends require fresh preview and confirmation; no approval is restored from this ledger.'};}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {recoveryCoverage} from './recovery-coverage.mjs';
|
|
2
|
+
import {randomUUID} from 'node:crypto';
|
|
3
|
+
import {mkdir,writeFile,rename,readFile,lstat,realpath} from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
// Report storage is host-owned, never inside a reviewed working copy.
|
|
7
|
+
export function createReviewJobs({directory}={}){
|
|
8
|
+
if(typeof directory!=='string'||!path.isAbsolute(directory))throw new Error('Absolute host report directory required');
|
|
9
|
+
const jobs=new Map();let disposed=false,canonicalDirectory;
|
|
10
|
+
const identity=exec=>{const a=exec?.agent,s=a?.session;if(!s||!a.options?.provider||!a.options?.model||!s.header?.cwd)throw new Error('Review job requires session identity');const id=s.header.id??s.id;if(typeof id!=='string'||!id)throw new Error('Durable session id required');return {sessionId:id,cwd:s.header.cwd,provider:a.options.provider,model:a.options.model,reasoningEffort:a.options.reasoningEffort??null};};
|
|
11
|
+
const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
|
|
12
|
+
const pageCheckpoint=(c,offset,limit)=>({...c,...(c.files?{files:c.files.slice(offset,offset+limit),totalFiles:c.files.length}:{}),...(c.findings?{findings:c.findings.slice(offset,offset+limit),totalFindings:c.findings.length}: {})});
|
|
13
|
+
const filename=id=>{if(typeof id!=='string'||!/^[a-f0-9-]{36}$/.test(id))throw new Error('Invalid review job id');return path.join(directory,id+'.json');};
|
|
14
|
+
async function safeDirectory(){await mkdir(directory,{recursive:true,mode:0o700});const stat=await lstat(directory),resolved=await realpath(directory);if(stat.isSymbolicLink()||!stat.isDirectory()||(canonicalDirectory!==undefined&&resolved!==canonicalDirectory))throw new Error('Unsafe report directory');canonicalDirectory??=resolved;}
|
|
15
|
+
async function save(job){await safeDirectory();const tmp=path.join(directory,job.id+'.'+randomUUID()+'.tmp');await writeFile(tmp,JSON.stringify(job.record),{mode:0o600,flag:'wx'});await rename(tmp,filename(job.id));}
|
|
16
|
+
async function owned(id,exec){const who=identity(exec),job=jobs.get(id);if(job){if(!same(who,job.record.owner))throw new Error('Foreign review job');return job.record;}await safeDirectory();const name=filename(id),stat=await lstat(name);if(stat.isSymbolicLink()||!stat.isFile())throw new Error('Unsafe report file');const r=JSON.parse(await readFile(name,'utf8'));if(!same(who,r.owner))throw new Error('Foreign review job');if(['queued','running'].includes(r.status)){r.status='interrupted';r.notice='Host restarted or plugin disposed. Saved checkpoints are available; resending requires a fresh preview and approval.';}return r;}
|
|
17
|
+
return {
|
|
18
|
+
async start(exec,work){if(disposed)throw new Error('Review job service disposed');exec.signal?.throwIfAborted();const who=identity(exec);const relative=path.relative(path.resolve(who.cwd),directory);if(relative===''||(!relative.startsWith('..'+path.sep)&&relative!=='..'&&!path.isAbsolute(relative)))throw new Error('Report store must be outside reviewed workspace');if([...jobs.values()].some(j=>j.record.owner.sessionId===who.sessionId&&['queued','running'].includes(j.record.status)))throw new Error('Session review job already active');const id=randomUUID(),controller=new AbortController(),job={id,controller,record:{jobId:id,owner:who,status:'queued',stage:'queued',checkpoints:[],createdAt:new Date().toISOString()},pending:Promise.resolve()};jobs.set(id,job);try{await save(job);}catch(error){jobs.delete(id);throw error;}const enqueue=fn=>{job.pending=job.pending.then(fn);return job.pending;};
|
|
19
|
+
// Own cancellation explicitly. The short start RPC ending is NOT task cancellation.
|
|
20
|
+
job.done=(async()=>{await Promise.resolve();try{job.record.status='running';await enqueue(()=>save(job));const result=await work({signal:controller.signal,checkpoint:async value=>enqueue(async()=>{job.record.stage=value.stage??'reviewing';job.record.checkpoints.push(structuredClone(value));await save(job);})});job.record.result=result;job.record.status=controller.signal.aborted?'cancelled':'completed';}catch(error){job.record.status=controller.signal.aborted?'cancelled':'failed';job.record.error=error?.message??String(error);}finally{job.record.finishedAt=new Date().toISOString();try{await enqueue(()=>save(job));}catch(error){job.record.status='failed';job.record.persistenceError=error.message;}}})();return {jobId:id,status:'queued',notice:'Background job accepted. Poll status; start is not a completed review. Explicit cancel or plugin disposal stops it.'};
|
|
21
|
+
},
|
|
22
|
+
async status(id,exec,page){const r=structuredClone(await owned(id,exec));r.recovery=recoveryCoverage(r);if(!page)return r;const {offset=0,limit=20,checkpointIndex}=page;if(!Number.isSafeInteger(offset)||offset<0||!Number.isSafeInteger(limit)||limit<1||limit>100)throw new Error('Invalid result page');if(checkpointIndex!==undefined){if(!Number.isSafeInteger(checkpointIndex)||checkpointIndex<0||checkpointIndex>=r.checkpoints.length)throw new Error('Invalid checkpoint index');return {jobId:r.jobId,status:r.status,checkpointIndex,checkpoint:pageCheckpoint(r.checkpoints[checkpointIndex],offset,limit),page:{offset,limit}};}const result=r.result;const report=result?.report;return {...r,result:undefined,recovery:{...r.recovery,files:r.recovery.files.slice(offset,offset+limit),windows:r.recovery.windows.slice(offset,offset+limit),suggestedPaths:r.recovery.suggestedPaths.slice(offset,offset+limit),totalObservedFiles:r.recovery.files.length},checkpoints:r.checkpoints.slice(offset,offset+limit).map(c=>pageCheckpoint(c,0,limit)),checkpointPage:{offset,limit,total:r.checkpoints.length},...(result?{resultSummary:report?{status:report.status,coverage:report.coverage,model:report.model,snapshotId:report.snapshotId}:({...result,files:result.files?.slice(offset,offset+limit),filePage:result.files?{offset,limit,total:result.files.length}:undefined}),resultPage:report?{files:report.files?.slice(offset,offset+limit),findings:report.findings?.slice(offset,offset+limit),totalFiles:report.files?.length,totalFindings:report.findings?.length}:undefined}: {})};},
|
|
23
|
+
async cancel(id,exec){await owned(id,exec);const j=jobs.get(id);if(j)j.controller.abort(new Error('User cancelled review job'));return {jobId:id,cancelRequested:!!j};},
|
|
24
|
+
async dispose(){disposed=true;for(const j of jobs.values())j.controller.abort(new Error('Review plugin disposed'));await Promise.all([...jobs.values()].map(j=>j.done));}
|
|
25
|
+
};
|
|
26
|
+
}
|
package/src/review.mjs
CHANGED
|
@@ -64,7 +64,7 @@ async function invoke(model, request, timeoutMs, parentSignal) {
|
|
|
64
64
|
const abort = () => controller.abort(parentSignal.reason);
|
|
65
65
|
parentSignal?.addEventListener('abort', abort, { once: true });
|
|
66
66
|
if (parentSignal?.aborted) abort();
|
|
67
|
-
const timer = setTimeout(() => controller.abort(Object.assign(new Error('Model timeout'), { code: 'MODEL_TIMEOUT' })), timeoutMs);
|
|
67
|
+
const timer = timeoutMs>0?setTimeout(() => controller.abort(Object.assign(new Error('Model timeout'), { code: 'MODEL_TIMEOUT' })), timeoutMs):undefined;
|
|
68
68
|
try {
|
|
69
69
|
controller.signal.throwIfAborted();
|
|
70
70
|
// Executors must honor cancellation; never release ownership before cleanup settles.
|
|
@@ -79,9 +79,10 @@ async function invoke(model, request, timeoutMs, parentSignal) {
|
|
|
79
79
|
|
|
80
80
|
export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
81
81
|
const rounds = reviewRounds(options.reviewRounds);
|
|
82
|
-
const { signal, maxInputBytes = 96 * 1024, maxCalls =
|
|
82
|
+
const { signal, maxInputBytes = 96 * 1024, maxCalls = Number.MAX_SAFE_INTEGER, timeoutMs = 0 } = options;
|
|
83
83
|
if (typeof model !== 'function') throw new Error('A model executor is required');
|
|
84
|
-
for
|
|
84
|
+
for(const n of [maxInputBytes,maxCalls])if(!Number.isSafeInteger(n)||n<1)throw new Error('Invalid review limit');
|
|
85
|
+
if(!Number.isSafeInteger(timeoutMs)||timeoutMs<0)throw new Error('Invalid review limit');
|
|
85
86
|
// Clone once: callers cannot change the input while a model call is pending.
|
|
86
87
|
const input = structuredClone(snapshot);
|
|
87
88
|
const instructions=input.projectScope?PROJECT_REVIEW_INSTRUCTIONS:REVIEW_INSTRUCTIONS;
|
|
@@ -118,8 +119,10 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
118
119
|
const interactionPlans = scope && report.grouping ? report.grouping.groups.filter(g => g.fileIds.length > 1).map(group => ({ group, prepared: prepareInteractionInput(input, group, payload => initialInputBudget({ instructions, input: payload }, scope, maxInputBytes)) })) : [];
|
|
119
120
|
for (const plan of interactionPlans.filter(p => p.prepared.status !== 'not-applicable')) report.interactions.push({ groupId: plan.group.id, fileIds: plan.prepared.fileIds, status: plan.prepared.status === 'ready' ? 'pending' : 'blocked', reason: plan.prepared.reason, lifecycle: 'shares-last-primary-timeout' });
|
|
120
121
|
for (const file of input.files) {
|
|
122
|
+
if(report.files.length)await options.onProgress?.({stage:'file-complete',snapshotId:input.id,file:report.files.at(-1),findings:report.findings.filter(f=>f.fileId===report.files.at(-1).fileId)});
|
|
121
123
|
const state = { fileId: file.id, path: file.path, status: file.eligibility === 'reviewable' ? 'pending' : file.eligibility, reason: file.reason, ...(file.blockCode ? {blockCode:file.blockCode} : {}) };
|
|
122
124
|
report.files.push(state);
|
|
125
|
+
await options.onProgress?.({stage:'file-started',snapshotId:input.id,fileId:file.id,path:file.path,status:state.status});
|
|
123
126
|
if (state.status !== 'pending') continue;
|
|
124
127
|
if (signal?.aborted) { state.status = 'cancelled'; state.reason = '用户取消'; continue; }
|
|
125
128
|
if (report.modelCalls >= maxCalls) { state.reason = '模型调用预算耗尽'; continue; }
|
|
@@ -130,6 +133,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
130
133
|
if (prepared.synthesis) state.synthesisStatus = prepared.synthesis.budget.fits ? 'pending' : 'blocked';
|
|
131
134
|
if (prepared.synthesis && !prepared.synthesis.budget.fits) report.limitations.push({ fileId: file.id, text: '跨窗口综合输入超限,未发送;单窗口完成不表示交互影响已检查' });
|
|
132
135
|
if (state.sourceMode === 'change-windows') state.windowCoverage = { completed: [], pending: [...state.windowIds] };
|
|
136
|
+
if(changes.precision==='envelope')report.limitations.push({fileId:file.id,text:'精确差异不可得,按保守包络分段检查;包络包含可能未变的行,不证明精确编辑或回归因果。'});
|
|
133
137
|
if (changes.status === 'limited') report.limitations.push({ fileId: file.id, text: '精确变更分析受限:' + changes.reason + ';仍按完整两侧内容审查' });
|
|
134
138
|
if (state.groupFallback) report.limitations.push({ fileId: file.id, text: '关联组上下文超过输入预算,回退单文件审查' });
|
|
135
139
|
if (state.initialInputBytes > maxInputBytes) { state.status = 'blocked'; state.reason = '输入超过预算,未截断或提交模型'; continue; }
|
|
@@ -138,14 +142,15 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
138
142
|
if (report.modelCalls >= maxCalls) throw new Error('Model call budget exceeded');
|
|
139
143
|
report.modelCalls++;
|
|
140
144
|
};
|
|
145
|
+
const trackedModel=request=>model({...request,onProtocolCorrection:async info=>{beforeCall();await request.onProtocolCorrection?.(info);await options.onProgress?.({stage:'protocol-correction',fileId:file.id,path:file.path,...info});}});
|
|
141
146
|
const executor = scope
|
|
142
|
-
? request => retrievalLoop(
|
|
147
|
+
? request => retrievalLoop(trackedModel, request, scope, { maxInputBytes, beforeCall, onRetrieved: records => {
|
|
143
148
|
(state.retrievalAudit ??= []).push(...records);
|
|
144
149
|
}, onTruncated: () => {
|
|
145
150
|
if (!state.retrievalTruncated) report.limitations.push({ fileId: file.id, text: '检索结果达到数量上限,部分匹配未提供给模型' });
|
|
146
151
|
state.retrievalTruncated = true;
|
|
147
152
|
} })
|
|
148
|
-
: request => { beforeCall(); return
|
|
153
|
+
: request => { beforeCall(); return trackedModel(request); };
|
|
149
154
|
await invoke(async enclosingRequest => {
|
|
150
155
|
if (options.enableBusinessGrouping === true && !report.businessGrouping && prepareBusinessGroups(input.files)) {
|
|
151
156
|
report.businessGrouping = { status: 'pending' };
|
|
@@ -240,7 +245,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
240
245
|
if (options.enableVerification === true && generated.findings.length) {
|
|
241
246
|
for (const finding of generated.findings) finding.verification = { status: 'pending', causality: 'unverified' };
|
|
242
247
|
try {
|
|
243
|
-
const verdicts = await verificationLoop(
|
|
248
|
+
const verdicts = await verificationLoop(trackedModel, request, generated.findings, scope, {
|
|
244
249
|
maxInputBytes, beforeCall,
|
|
245
250
|
onRetrieved: records => { (state.retrievalAudit ??= []).push(...records); },
|
|
246
251
|
onTruncated: () => { report.limitations.push({ fileId: file.id, text: '复核检索结果达到数量上限' }); },
|
|
@@ -266,6 +271,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
266
271
|
state.windowCoverage.pending = state.windowCoverage.pending.filter(id => !batch.windowIds.includes(id));
|
|
267
272
|
}
|
|
268
273
|
if(rounds>1)state.reviewPasses[batchIndex].status='completed';
|
|
274
|
+
await options.onProgress?.({stage:'window-complete',snapshotId:input.id,fileId:file.id,path:file.path,windowIds:batch.windowIds,round:batch.round,findings:generated.findings,windowCoverage:state.windowCoverage});
|
|
269
275
|
}
|
|
270
276
|
}, { instructions, input: payload }, timeoutMs, signal);
|
|
271
277
|
if (signal?.aborted) throw signal.reason;
|
|
@@ -279,6 +285,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
279
285
|
state.failureCode = signal?.aborted ? 'CANCELLED' : ['MODEL_INVALID_JSON','MODEL_TIMEOUT','MODEL_INVALID_READ_REQUEST'].includes(error?.code) ? error.code : 'REVIEW_EXECUTION_FAILED';
|
|
280
286
|
}
|
|
281
287
|
}
|
|
288
|
+
await options.onProgress?.({stage:'batch-complete',snapshotId:input.id,files:report.files,findings:report.findings,limitations:report.limitations});
|
|
282
289
|
for (const interaction of report.interactions) if (interaction.status !== 'completed') report.limitations.push({ text: '跨文件综合未完成:' + interaction.groupId + ' (' + interaction.status + ')' });
|
|
283
290
|
if (report.grouping?.links.some(l => l.split)) report.limitations.push({ text: '跨组关系未综合覆盖;关联分组不代表全部跨文件交互覆盖' });
|
|
284
291
|
const reviewableCount = input.files.filter(f => f.eligibility === 'reviewable').length;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import {readFileSync} from 'node:fs';
|
|
2
|
+
import {fileURLToPath} from 'node:url';
|
|
3
|
+
const manifestUrl=new URL('../package.json',import.meta.url);
|
|
4
|
+
export const REVIEW_RUNTIME=Object.freeze({version:JSON.parse(readFileSync(manifestUrl,'utf8')).version,modulePath:fileURLToPath(new URL('../index.mjs',import.meta.url)),defaultInternalDeadline:false});
|
package/src/service.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {REVIEW_RUNTIME} from './runtime-identity.mjs';
|
|
1
2
|
import {createProjectReviewService} from './project-review-service.mjs';
|
|
2
3
|
import {svnDiagnostic} from './svn-diagnostics.mjs';
|
|
3
4
|
import { reviewRounds as validateReviewRounds } from './review-rounds.mjs';
|
|
@@ -51,7 +52,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
51
52
|
while (previews.size >= maxPreviews) previews.delete(previews.keys().next().value);
|
|
52
53
|
const previewId = randomUUID();
|
|
53
54
|
previews.set(previewId, { ...current, target, options, rounds, snapshotId: snapshot.id, expires: now() + ttlMs });
|
|
54
|
-
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.workingScope?{workingScope:snapshot.workingScope}:{}), ...(snapshot.history ? { history: snapshot.history } : {}), ...(snapshot.mergeSources ? {mergeSources:snapshot.mergeSources} : {}),...(snapshot.effectiveMergeSources?{effectiveMergeSources:snapshot.effectiveMergeSources}:{}), model: current.route, modelSendingEnabled: allowModelSending, files: snapshot.files.map(f => ({ path: f.path,...(f.workingSource?{workingSource:f.workingSource}:{}),...(f.replacementSource?{replacementSource:f.replacementSource,contentNotice:f.contentNotice}:{}), eligibility: f.eligibility, reason: f.reason,...(f.specialData?{specialData:f.specialData,contentNotice:f.contentNotice}:{}), ...(f.eolMapping?{eolMapping:f.eolMapping,textNotice:f.textNotice}:{}), ...(f.keywordMapping?{keywordMapping:f.keywordMapping,textNotice:f.textNotice}:{}), ...(f.rawStatus==='replaced'&&(snapshot.history?.vcs==='svn'||f.replacementSource)?{oldKind:f.oldKind,newKind:f.newKind,nodeNotice:f.nodeNotice}:{}), ...(f.blockCode ? {blockCode:f.blockCode,...(svnDiagnostic(f.blockCode)?{diagnostic:svnDiagnostic(f.blockCode)}:{})} : {}), ...(f.copySource ? {copySource:f.copySource,copyNotice:f.copyNotice} : {}) })), contextPaths: snapshot.context.map(c => c.path), ...(snapshot.context.some(c=>c.keywordMapping)?{contextKeywordMappings:snapshot.context.filter(c=>c.keywordMapping).map(c=>({path:c.path,keywordMapping:c.keywordMapping}))}:{}), ...(snapshot.context.some(c=>c.eolMapping)?{contextEolMappings:snapshot.context.filter(c=>c.eolMapping).map(c=>({path:c.path,eolMapping:c.eolMapping}))}:{}), ...(snapshot.history?.vcs==='svn'?{contextSources:snapshot.context.map(c=>({path:c.path,...(c.svnSource?{new:c.svnSource}:{}),...(c.oldSvnSource?{old:c.oldSvnSource}:{})}))}:{}), ...(options.oldContextPaths !== undefined ? {oldContextPaths:options.oldContextPaths,oldContextSources:snapshot.context.filter(c=>c.oldOnly).map(c=>({path:c.path,side:'context-old',revision:c.oldRevision,...(c.oldSvnSource?{svnSource:c.oldSvnSource}:{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: (snapshot.history?.vcs==='svn'&&options.autoContext===true?'SVN历史autoContext为精确依赖定位读取固定版本父目录有界清单,包含同目录非匹配名字元数据;仅匹配捕获正文发送模型,不扫描全仓,不用工作树替代历史版本。':'') + (options.oldContextPaths !== undefined ? 'oldContextPaths仅为baseline旧侧正文,无目标侧替代;选中变更复用原old身份,旧侧不能证明新侧行为。' : '') + (snapshot.callerDiscovery ? 'callerScopePaths已授权预览读取限定目录内tracked候选正文,包括不匹配文件;非匹配正文不发送模型,匹配捕获上下文会发送。仅字面引用导航,不是函数调用或完整调用覆盖。' : '') + (enableRiskPlanning ? '执行按plan阈值决定风险假设规划,小变更可跳过独立规划但仍主审并复核;计划不是证据,所有阶段共享原预算。' : '') + '执行会向上述模型提供方发送可审查文件两侧内容、预览列出的显式/自动上下文、所选项目规则全文及预览业务需求文本。模型可在该快照范围内多轮只读检索,不读取范围外文件;候选生成后会进行证据与反证复核,两阶段各最多3
|
|
55
|
+
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.workingScope?{workingScope:snapshot.workingScope}:{}), ...(snapshot.history ? { history: snapshot.history } : {}), ...(snapshot.mergeSources ? {mergeSources:snapshot.mergeSources} : {}),...(snapshot.effectiveMergeSources?{effectiveMergeSources:snapshot.effectiveMergeSources}:{}), model: current.route, modelSendingEnabled: allowModelSending, files: snapshot.files.map(f => ({ path: f.path,...(f.workingSource?{workingSource:f.workingSource}:{}),...(f.replacementSource?{replacementSource:f.replacementSource,contentNotice:f.contentNotice}:{}), eligibility: f.eligibility, reason: f.reason,...(f.specialData?{specialData:f.specialData,contentNotice:f.contentNotice}:{}), ...(f.eolMapping?{eolMapping:f.eolMapping,textNotice:f.textNotice}:{}), ...(f.keywordMapping?{keywordMapping:f.keywordMapping,textNotice:f.textNotice}:{}), ...(f.rawStatus==='replaced'&&(snapshot.history?.vcs==='svn'||f.replacementSource)?{oldKind:f.oldKind,newKind:f.newKind,nodeNotice:f.nodeNotice}:{}), ...(f.blockCode ? {blockCode:f.blockCode,...(svnDiagnostic(f.blockCode)?{diagnostic:svnDiagnostic(f.blockCode)}:{})} : {}), ...(f.copySource ? {copySource:f.copySource,copyNotice:f.copyNotice} : {}) })), contextPaths: snapshot.context.map(c => c.path), ...(snapshot.context.some(c=>c.keywordMapping)?{contextKeywordMappings:snapshot.context.filter(c=>c.keywordMapping).map(c=>({path:c.path,keywordMapping:c.keywordMapping}))}:{}), ...(snapshot.context.some(c=>c.eolMapping)?{contextEolMappings:snapshot.context.filter(c=>c.eolMapping).map(c=>({path:c.path,eolMapping:c.eolMapping}))}:{}), ...(snapshot.history?.vcs==='svn'?{contextSources:snapshot.context.map(c=>({path:c.path,...(c.svnSource?{new:c.svnSource}:{}),...(c.oldSvnSource?{old:c.oldSvnSource}:{})}))}:{}), ...(options.oldContextPaths !== undefined ? {oldContextPaths:options.oldContextPaths,oldContextSources:snapshot.context.filter(c=>c.oldOnly).map(c=>({path:c.path,side:'context-old',revision:c.oldRevision,...(c.oldSvnSource?{svnSource:c.oldSvnSource}:{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: (snapshot.history?.vcs==='svn'&&options.autoContext===true?'SVN历史autoContext为精确依赖定位读取固定版本父目录有界清单,包含同目录非匹配名字元数据;仅匹配捕获正文发送模型,不扫描全仓,不用工作树替代历史版本。':'') + (options.oldContextPaths !== undefined ? 'oldContextPaths仅为baseline旧侧正文,无目标侧替代;选中变更复用原old身份,旧侧不能证明新侧行为。' : '') + (snapshot.callerDiscovery ? 'callerScopePaths已授权预览读取限定目录内tracked候选正文,包括不匹配文件;非匹配正文不发送模型,匹配捕获上下文会发送。仅字面引用导航,不是函数调用或完整调用覆盖。' : '') + (enableRiskPlanning ? '执行按plan阈值决定风险假设规划,小变更可跳过独立规划但仍主审并复核;计划不是证据,所有阶段共享原预算。' : '') + '执行会向上述模型提供方发送可审查文件两侧内容、预览列出的显式/自动上下文、所选项目规则全文及预览业务需求文本。模型可在该快照范围内多轮只读检索,不读取范围外文件;候选生成后会进行证据与反证复核,两阶段各最多3轮检索,不设插件内部默认时间或总模型调用次数截止,遵循宿主/用户取消;输入字节和检索轮次保护仍保留;复核不能证明缺陷成立。plan.reviewRounds为独立审查工作量,非模型reasoning effort;后续轮不带首轮风险计划,所有轮共享原超时与预算。plan仅预检初始输入,ready不代表已审查;minimumCalls不含后续检索/复核/动态业务综合;多文件可先进行业务分组,分组失败保留原组并记录限制,动态综合数执行前未知,实际预算可能不足。请先向用户展示范围,获得确认后执行。' };
|
|
55
56
|
} finally { busy = false; }
|
|
56
57
|
},
|
|
57
58
|
async execute(args, exec) {
|
|
@@ -74,7 +75,8 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
74
75
|
if (await authorize({ snapshot, route: preview.route, exec }) !== true) throw new Error('Model sending approval denied or unavailable');
|
|
75
76
|
exec.signal?.throwIfAborted();
|
|
76
77
|
if (owner(exec).cwd !== current.cwd || JSON.stringify(owner(exec).route) !== JSON.stringify(preview.route)) throw new Error('Agent changed during approval');
|
|
77
|
-
|
|
78
|
+
await exec.reviewCheckpoint?.({stage:'approved-manifest',snapshotId:snapshot.id,files:snapshot.files.map(f=>({fileId:f.id,path:f.path,status:f.eligibility==='reviewable'?'pending':f.eligibility}))});
|
|
79
|
+
const report = await reviewSnapshot(snapshot, createDshModel(llm, preview.route,{correctJson:true}), { signal: exec.signal, enableRetrieval: true, enableGrouping: true, enableVerification: true, enableRiskPlanning, skipSmallRiskPlans, reviewRounds:preview.rounds, enableBusinessGrouping, enableAnchorCorrection, onProgress:exec.reviewCheckpoint });
|
|
78
80
|
report.model = preview.route;
|
|
79
81
|
const latest = await resolveWorkspaceRepository(current.cwd, preview.options.repositoryPath).then(target => target === preview.target ? capture(target, { ...preview.options, signal: exec.signal }) : null).catch(() => null);
|
|
80
82
|
report.outdated = !latest || latest.id !== snapshot.id || owner(exec).cwd !== current.cwd;
|
|
@@ -88,5 +90,5 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
88
90
|
export function createReviewService(llm,options={}){
|
|
89
91
|
const sessions=new Map();let disposed=false;
|
|
90
92
|
const get=exec=>{if(disposed)throw new Error('Review service disposed');const agent=exec?.agent,key=agent?.session??agent;if(!key||typeof key!=='object')throw new Error('Active Agent required');if(!sessions.has(key)){const changes=createSessionReviewService(llm,options),project=createProjectReviewService(llm,options);let locked=false;const call=async(kind,args,exec)=>{if(locked)throw new Error('Review service busy');locked=true;try{return await (kind==='preview'?(args?.reviewMode==='project'?project.preview(args,exec):changes.preview(args?.reviewMode==='changes'?Object.fromEntries(Object.entries(args).filter(([key])=>key!=='reviewMode')):args,exec)):(project.owns(args?.previewId)?project.execute(args,exec):changes.execute(args,exec)));}finally{locked=false;}};sessions.set(key,{preview:(a,e)=>call('preview',a,e),execute:(a,e)=>call('execute',a,e),dispose(){changes.dispose();project.dispose();}});}return sessions.get(key);};
|
|
91
|
-
return {preview:async(args,exec)=>get(exec).preview(args,exec),execute:async(args,exec)=>get(exec).execute(args,exec),dispose(){disposed=true;for(const service of sessions.values())service.dispose();sessions.clear();}};
|
|
93
|
+
return {preview:async(args,exec)=>{const started=Date.now();await exec?.reviewCheckpoint?.({stage:'preview-started',runtime:REVIEW_RUNTIME});const result=await get(exec).preview(args,exec);return {...result,runtime:REVIEW_RUNTIME,elapsedMs:Date.now()-started};},execute:async(args,exec)=>get(exec).execute(args,exec),dispose(){disposed=true;for(const service of sessions.values())service.dispose();sessions.clear();}};
|
|
92
94
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Linear segmentation of approved text. Coordinates remain original one-based lines.
|
|
2
|
+
export function sourceSegments(windows,{maxBytes=12000,maxLines=100}={}){
|
|
3
|
+
const out=[];for(const w of windows){if(w.old.count<=maxLines&&w.new.count<=maxLines&&Buffer.byteLength(w.old.text)<=maxBytes&&Buffer.byteLength(w.new.text)<=maxBytes){out.push(w);continue;}const sides={old:w.old.text.match(/[^\n]*\n|[^\n]+$/g)??[],new:w.new.text.match(/[^\n]*\n|[^\n]+$/g)??[]},pos={old:0,new:0};let i=0;
|
|
4
|
+
while(pos.old<sides.old.length||pos.new<sides.new.length){const item={id:w.id+'-s'+(++i),editIds:[...w.editIds]};for(const side of ['old','new']){const start=pos[side],parts=[];let bytes=0;while(pos[side]<sides[side].length&&parts.length<maxLines){const line=sides[side][pos[side]],n=Buffer.byteLength(line);if(parts.length&&bytes+n>maxBytes)break;parts.push(line);bytes+=n;pos[side]++;if(bytes>=maxBytes)break;}item[side]={start:w[side].start+start,count:parts.length,text:parts.join('')};}out.push(item);}
|
|
5
|
+
if(!i)out.push(w);
|
|
6
|
+
}return out;
|
|
7
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {captureSignal} from './capture-signal.mjs';
|
|
1
2
|
import {svnCallerIndex} from './svn-caller-index.mjs';
|
|
2
3
|
import {svnCallerNode,svnCallerProbes} from './svn-caller-node.mjs';
|
|
3
4
|
|
|
@@ -9,8 +10,7 @@ import {createSvnNavigationBudget} from './svn-navigation-budget.mjs';
|
|
|
9
10
|
|
|
10
11
|
// Called only by preview capture, never by a model retrieval request.
|
|
11
12
|
export async function captureSvnCallers(root, files, scopes, options={}) {
|
|
12
|
-
const
|
|
13
|
-
const signal=options.signal?AbortSignal.any([options.signal,deadline]):deadline;
|
|
13
|
+
const signal=captureSignal(options.signal);
|
|
14
14
|
const bounded={...options,signal,svnNavigationBudget:options.svnNavigationBudget??createSvnNavigationBudget(signal),maxFileBytes:Math.min(options.maxFileBytes??256*1024,256*1024)};
|
|
15
15
|
const index=await svnCallerIndex(root,scopes,bounded);
|
|
16
16
|
const changed=new Set(files.flatMap(f=>[f.path,...(f.oldPath?[f.oldPath]:[])]));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import {captureSignal} from './capture-signal.mjs';
|
|
1
2
|
// Shared across initial capture, navigation, mergeinfo and final verification.
|
|
2
3
|
export function createSvnCaptureBudget(signal){
|
|
3
|
-
const deadline=
|
|
4
|
+
const deadline=captureSignal(signal);let commands=0,metadataBytes=0,bodyBytes=0;
|
|
4
5
|
const error=message=>Object.assign(new Error(message),{code:'SVN_CAPTURE_BUDGET'});
|
|
5
|
-
return Object.freeze({signal:deadline,reserve(){deadline.throwIfAborted();
|
|
6
|
+
return Object.freeze({signal:deadline,reserve(){deadline.throwIfAborted();commands++;},charge(size,body=false){deadline.throwIfAborted();if(!Number.isSafeInteger(size)||size<0)throw error('Invalid SVN capture byte charge');if(body){bodyBytes+=size;if(bodyBytes>16*1024*1024)throw error('SVN capture shared verification body budget exceeded');}else{metadataBytes+=size;if(metadataBytes>1024*1024)throw error('SVN capture shared metadata budget exceeded');}},usage(){return {commands,metadataBytes,bodyBytes};}});
|
|
6
7
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {captureSignal} from './capture-signal.mjs';
|
|
1
2
|
import {contextCandidates} from './context-candidates.mjs';
|
|
2
3
|
import {svnCallerNode,svnCallerProbes} from './svn-caller-node.mjs';
|
|
3
4
|
import {captureSvnExplicitContext} from './svn-explicit-context.mjs';
|
|
@@ -5,7 +6,7 @@ import {createSvnNavigationBudget} from './svn-navigation-budget.mjs';
|
|
|
5
6
|
|
|
6
7
|
// Exact local path probes only. Every body requires safe offline SVN node identity.
|
|
7
8
|
export async function captureSvnDependencyChain(root,files,options={}){
|
|
8
|
-
const signal=
|
|
9
|
+
const signal=captureSignal(options.signal),bounded={...options,signal,svnNavigationBudget:options.svnNavigationBudget??createSvnNavigationBudget(signal),maxFileBytes:Math.min(options.maxFileBytes??256*1024,256*1024)};
|
|
9
10
|
const visited=new Set(files.map(f=>f.path)),reserved=new Set([...(options.contextPaths??[]),...(options.rulePaths??[])]),probes=new Set(),context=[],candidates=[],nodes=[];let frontier=[...files],bytes=0,truncated=false,index;
|
|
10
11
|
const limit=Math.min(options.maxContextFiles??20,20);if(new Set(options.contextPaths??[]).size>limit)throw new Error('Context file limit exceeded');
|
|
11
12
|
const capture=async name=>{const value=await captureSvnExplicitContext(root,[name],bounded);bytes+=value.sourceBytes;if(bytes>1024*1024)throw new Error('SVN dependency source byte limit exceeded');return {item:value.context[0],node:value.identities[0]};};
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import {captureSignal} from './capture-signal.mjs';
|
|
1
2
|
import {svnCallerNode} from './svn-caller-node.mjs';
|
|
2
3
|
import {readLocal,decode} from './content.mjs';
|
|
3
4
|
import {checked} from './process.mjs';
|
|
4
5
|
import {svnKeywordMapping} from './svn-keyword-mapping.mjs';
|
|
5
6
|
import {svnEolMapping} from './svn-eol-mapping.mjs';
|
|
6
7
|
export async function captureSvnExplicitContext(root,names,options={}){
|
|
7
|
-
const signal=
|
|
8
|
+
const signal=captureSignal(options.signal),bounded={...options,allowExplicitKeywords:true,signal,maxFileBytes:Math.min(options.maxFileBytes??256*1024,256*1024)};
|
|
8
9
|
if(!Array.isArray(names)||names.length>20||new Set(names).size!==names.length)throw new Error('Invalid SVN explicit context selection');
|
|
9
10
|
const context=[],identities=[];let bytes=0;
|
|
10
11
|
for(const name of names){
|
package/src/svn-history.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {captureSignal} from './capture-signal.mjs';
|
|
1
2
|
import {sameSvnUrl,canonicalSvnUrl} from './svn-url.mjs';
|
|
2
3
|
import {svnHistoryEndpoints} from './svn-history-endpoints.mjs';
|
|
3
4
|
import {realpath} from 'node:fs/promises';
|
|
@@ -62,11 +63,11 @@ export async function captureSvnHistory(cwd,options={}) {
|
|
|
62
63
|
for(const [value,cap]of [[maxFiles,200],[maxFileBytes,256*1024],[maxSnapshotBytes,4*1024*1024]])if(!Number.isSafeInteger(value)||value<1||value>cap)throw new Error('Invalid SVN history limit');
|
|
63
64
|
if(selectedPaths!==undefined && (!Array.isArray(selectedPaths)||selectedPaths.length>200||new Set(selectedPaths).size!==selectedPaths.length))throw new Error('Invalid SVN history selection');
|
|
64
65
|
for(const name of selectedPaths??[])if(name!=='.')relativePath(name);
|
|
65
|
-
const signal=
|
|
66
|
+
const signal=captureSignal(options.signal);
|
|
66
67
|
let commands=0,metadataBytes=0,sourceBytes=0;
|
|
67
68
|
const budgetError = message => Object.assign(new Error(message), {code:'SVN_HISTORY_BUDGET'});
|
|
68
69
|
const run=async(args,body=false)=>{
|
|
69
|
-
signal.throwIfAborted();if(
|
|
70
|
+
signal.throwIfAborted();commands++;if(metadataBytes>1024*1024 || sourceBytes>maxSnapshotBytes)throw budgetError('SVN historical shared budget exceeded');
|
|
70
71
|
const bytes=await checked('svn',['--non-interactive',...args],{cwd,signal,maxBytes:body?maxFileBytes:1024*1024});
|
|
71
72
|
if(body){sourceBytes+=bytes.length;if(sourceBytes>maxSnapshotBytes)throw budgetError('SVN historical source budget exceeded');}
|
|
72
73
|
else{metadataBytes+=bytes.length;if(metadataBytes>1024*1024)throw budgetError('SVN historical metadata budget exceeded');}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import {captureSignal} from './capture-signal.mjs';
|
|
1
2
|
// One source ledger/deadline per working-copy navigation capture, never per stage.
|
|
2
3
|
export function createSvnNavigationBudget(signal){
|
|
3
|
-
const sharedSignal=
|
|
4
|
+
const sharedSignal=captureSignal(signal);let bytes=0;
|
|
4
5
|
return Object.freeze({signal:sharedSignal,charge(size){sharedSignal.throwIfAborted();if(!Number.isSafeInteger(size)||size<0)throw new Error('Invalid SVN navigation source size');bytes+=size;if(bytes>1024*1024)throw Object.assign(new Error('SVN shared navigation source byte limit exceeded'),{code:'SVN_NAVIGATION_BUDGET'});},usage(){return bytes;}});
|
|
5
6
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {captureSignal} from './capture-signal.mjs';
|
|
1
2
|
import {checked} from './process.mjs';
|
|
2
3
|
import {xml} from './svn.mjs';
|
|
3
4
|
import {hash} from './content.mjs';
|
|
@@ -5,8 +6,8 @@ import {svnWorkingIdentity} from './svn-working-identity.mjs';
|
|
|
5
6
|
import {captureEffectiveMergeinfo} from './svn-effective-mergeinfo.mjs';
|
|
6
7
|
|
|
7
8
|
export async function workingEffectiveMergeinfo(root,rootInfo,files,options={}){
|
|
8
|
-
const signal=
|
|
9
|
-
const run=async args=>{signal.throwIfAborted();
|
|
9
|
+
const signal=captureSignal(options.signal);let commands=0,bytes=0;const cache=new Map();
|
|
10
|
+
const run=async args=>{signal.throwIfAborted();commands++;const data=await checked('svn',['--non-interactive',...args],{...options,cwd:root,signal,maxBytes:256*1024});bytes+=data.length;if(bytes>1024*1024)throw new Error('SVN working mergeinfo metadata budget exceeded');return xml(data);};
|
|
10
11
|
const read=async(name,side,revision)=>{
|
|
11
12
|
const key=JSON.stringify([name,side]);if(cache.has(key)){const value=cache.get(key);if(side==='old'&&value.source.revision!==revision)throw new Error('Mixed BASE revisions cannot prove effective mergeinfo');return value;}
|
|
12
13
|
const node=(await run(['info','--xml','--',name+'@'])).info?.entry?.[0];const identity=svnWorkingIdentity(node,rootInfo,name);
|
package/src/window-batches.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Bounded pure planner. Every window is either in one batch or explicitly blocked.
|
|
2
|
-
export function planWindowBatches(windows, serialize, measure, { maxBatches =
|
|
3
|
-
if (!Array.isArray(windows) || windows.length >
|
|
2
|
+
export function planWindowBatches(windows, serialize, measure, { maxBatches = Number.MAX_SAFE_INTEGER } = {}) {
|
|
3
|
+
if (!Array.isArray(windows) || windows.length > 100000 || typeof serialize !== 'function' || typeof measure !== 'function' || !Number.isSafeInteger(maxBatches) || maxBatches < 1) throw new Error('Invalid window batch plan');
|
|
4
4
|
const ids = new Set();
|
|
5
5
|
for (const window of windows) {
|
|
6
6
|
if (!window || typeof window.id !== 'string' || !window.id || ids.has(window.id)) throw new Error('Invalid window identity');
|
package/src/window-synthesis.mjs
CHANGED
|
@@ -4,7 +4,7 @@ export function prepareWindowSynthesis(batchPayloads, measure) {
|
|
|
4
4
|
const batches = batchPayloads.map(payload => JSON.parse(payload));
|
|
5
5
|
const base = batches[0];
|
|
6
6
|
const windows = batches.flatMap(batch => batch.windows);
|
|
7
|
-
if (batches.some(batch => batch.snapshotId !== base.snapshotId || batch.file.id !== base.file.id || batch.sourceMode !== 'change-windows') || windows.length >
|
|
7
|
+
if (batches.some(batch => batch.snapshotId !== base.snapshotId || batch.file.id !== base.file.id || batch.sourceMode !== 'change-windows') || windows.length > 100000 || new Set(windows.map(w => w.id)).size !== windows.length) throw new Error('Inconsistent synthesis windows');
|
|
8
8
|
const windowIndex = windows.map(w => ({ id: w.id, editIds: w.editIds, old: { start: w.old.start, count: w.old.count }, new: { start: w.new.start, count: w.new.count } }));
|
|
9
9
|
const payload = JSON.stringify({ ...base, windows: undefined, windowIndex, sourceMode: 'window-synthesis', windowNotice: '本请求综合检查所有窗口的共同影响,不重报单窗口问题;windowIndex仅为原始定位目录,不含源码,必须在批准catalog中读取相关旧新片段,不能仅凭目录认定缺陷。输出仍仅主file的发现,归因至少关联两个不同窗口的编辑;证据不足明确限制。' });
|
|
10
10
|
const budget = measure(payload);
|