@feng0531/dsh-code-review 0.85.0 → 0.85.4
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 +19 -0
- package/DEVELOPMENT.md +20 -2
- package/README.md +1 -1
- package/RELEASE-NOTES.md +19 -11
- package/index.mjs +2 -1
- package/package.json +1 -1
- package/src/anchor-correction.mjs +1 -1
- package/src/class-bindings.mjs +3 -0
- package/src/content.mjs +19 -4
- package/src/cpp-relations.mjs +1 -1
- package/src/csharp-context.mjs +5 -1
- package/src/dsh-model.mjs +77 -34
- package/src/git-history.mjs +1 -1
- package/src/git.mjs +1 -1
- package/src/import-call-sites.mjs +4 -1
- package/src/model-diagnostics.mjs +62 -0
- package/src/model-json.mjs +4 -2
- package/src/process.mjs +11 -2
- package/src/project-execution.mjs +1 -1
- package/src/project-review-service.mjs +2 -2
- package/src/project-snapshot.mjs +1 -1
- package/src/review-jobs.mjs +6 -3
- package/src/review.mjs +3 -3
- package/src/source-segments.mjs +1 -0
- package/src/svn-history-callers.mjs +2 -1
- package/src/svn-working-special.mjs +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
本文件介绍对使用者有意义的变化。安装与参数见 [README](README.md),本次发布正文见 [发布说明](RELEASE-NOTES.md)。历史提交和安装包保留在 [GitHub Releases](https://github.com/f-e-n-g-0531/dsh-code-review/releases);本文不把开发日志当作功能介绍。
|
|
4
4
|
|
|
5
|
+
## 0.85.4 — 审查正确性、失败恢复与全项目提速
|
|
6
|
+
|
|
7
|
+
`v0.85.2` 因发布说明缺少导航链接、`v0.85.3` 因 Windows 临时目录短路径别名未被新增测试 mock 规范化而未通过 CI,均未发布 npm 或 GitHub Release。本版本补齐链接、规范化测试匹配路径并重新执行完整门禁,不覆盖旧冻结标签、不削弱拒绝断言。
|
|
8
|
+
|
|
9
|
+
- 本地文件打开改为规范路径,核对最初检查、打开前检查和文件句柄的设备号/inode,并复核祖先身份;SVN special 读取后复核祖先身份。这是竞态检测加固,不是原子目录相对打开,不能保证抵御恶意进程精确交换并恢复路径;高对抗场景应使用隔离、不可并发修改的快照。
|
|
10
|
+
- Git 仓库根只去掉结尾换行,不再 `trim()` 掉路径首尾空格。进程在 SIGTERM 后仍不退出时补发 SIGKILL,超时和取消不再永久等待。
|
|
11
|
+
- 超界行锚点不再误判为精确匹配,仍允许按原有证据规则重定位;具名导入允许单个尾随逗号但拒绝空绑定;注册表元数据及工件网络异常按原次数重试。
|
|
12
|
+
- 模型流兼容完整文本块:终态块替换对应增量而非重复拼接,完整块也计入输出预算;模型拒绝值为 null/undefined 时保留已有文件结果。
|
|
13
|
+
- 实例方法提示拒绝构造器名称遮蔽和原型替换;C# 竞争类型按正文而不只按文件名阻止错误唯一性;C++ include 支持关键字后无空白的合法引用。
|
|
14
|
+
- 后台持久化队列失败不再阻断终态保存;成功落盘后释放已结束任务的内存,失败时保留诊断并唤醒等待者。分段参数拒绝非正整数,SVN 历史调用方预算不再信任低报的正文大小。
|
|
15
|
+
- 全项目默认并发从 3 提到允许上限 8。批内主审、检索和复核仍串行,模型调用次数不减少;提供方限流时把 `projectConcurrency` 调低。
|
|
16
|
+
|
|
17
|
+
## 0.85.1 — 模型失败诊断与定向纠正
|
|
18
|
+
|
|
19
|
+
- 修复宿主 error 终态详情读取位置,保留实际 `finish.reason.failure` 中的已知错误码、HTTP 状态和有效重试间隔,便于区分服务失败类别;不新增对鉴权、额度或取消的盲目重试。
|
|
20
|
+
- 非法 JSON 记录固定语法分类、可用的行列位置及响应指纹,按最近一次分类定向请求重新生成;不回放错误原文,不自动修补 JSON,最多两次纠正且继续遵循原调用预算。
|
|
21
|
+
- 新增诊断不保存模型响应片段或提供方原始 message,未知错误码和请求 ID 仅保留哈希;隔离进度回调对纠正诊断的修改,保持异常身份和 CR/LF/CRLF 定位一致。
|
|
22
|
+
- 当前宿主没有原生 JSON mode/response schema 接口,本版不声称保证模型始终输出合法 JSON,也不以离线验证替代真实模型失败率复测。
|
|
23
|
+
|
|
5
24
|
## 0.85.0 — 审查失败恢复与全项目提速
|
|
6
25
|
|
|
7
26
|
- 全项目独立批次默认 3 路并行,插件配置 projectConcurrency 支持 1~8;预览/报告显示并发数,结果仍按原批次顺序合并,保持共享调用上限和取消后等待全部清理。批内主审与复核不跳过,预算竞争时未完成文件仍待处理。
|
package/DEVELOPMENT.md
CHANGED
|
@@ -20,9 +20,27 @@
|
|
|
20
20
|
|
|
21
21
|
## 全项目并发与离线速度对照
|
|
22
22
|
|
|
23
|
-
插件配置 `projectConcurrency` 默认为
|
|
23
|
+
插件配置 `projectConcurrency` 默认为 8,范围 1~8;1 为串行回退。仅独立批准批次并行,各批仍有独立检索来源和账本,所有调用与 JSON 纠正共享原执行计数。该上限按单次执行生效,不是全宿主 RPM/TPM 限流器;提供方不支持并发或遇到 429 时应调低。取消/致命检查点错误必须等待全部在途模型清理后释放会话锁,后台 checkpoint 继续使用原串行持久化队列。
|
|
24
24
|
|
|
25
|
-
运行 `node scripts/review-speed-benchmark.mjs` 比较相同 12 个 mock
|
|
25
|
+
运行 `node scripts/review-speed-benchmark.mjs` 比较相同 12 个 mock 批次在串行、旧默认 3 路与新默认 8 路并发下的耗时;脚本模拟每次模型调用 40ms 延迟,不访问网络、不证明真实模型速度或质量。核对工作量、调用数、完成数和输出顺序一致,不把降低覆盖作为加速。
|
|
26
|
+
|
|
27
|
+
### 当前工作树捕获的安全边界
|
|
28
|
+
|
|
29
|
+
本地捕获校验符号链接、规范路径、文件句柄身份及祖先目录身份。Node 的跨平台公开 API 没有原子 beneath/no-symlink 目录相对打开机制,因此这些前后检查只能检测已观察到的替换,不能证明抵御并发恶意进程交换后恢复路径的攻击。需要严格隔离时,应先在不允许并发修改的独立快照中审核;不要把 inode 检查描述为彻底修复 TOCTOU。
|
|
30
|
+
|
|
31
|
+
### 本次全库审核的实测瓶颈
|
|
32
|
+
|
|
33
|
+
407 个文件、34 个批次、3 路并发的历史任务耗时 40 分 37.208 秒,调用模型 778 次,其中 263 次为额外 JSON 重生成(33.8%),另有 9 条阶段响应纠正事件;共 19 个文件失败。471 条 protocol-correction 检查点包含不同纠正阶段和完成事件,不能当成 471 次额外调用。上述数据不提供逐调用耗时,不能据此声称磁盘 I/O 或某一提供方适配层占用了确定比例。新默认并发与完整块兼容修复尚需真实提供方复测,离线模拟只验证调度开销、调用量和结果顺序。
|
|
34
|
+
|
|
35
|
+
## 模型响应失败与诊断
|
|
36
|
+
|
|
37
|
+
当前核验的 DSH `llm.stream` 接口没有原生 JSON mode/response schema 参数;插件不发送会被忽略的 `responseFormat`,也不把工具参数 schema 当作结构化输出保证。先要求纯 JSON,再做本地解析和阶段 schema、证据校验。
|
|
38
|
+
|
|
39
|
+
正常 stop 后的非法 JSON 最多重新生成两次,共享原调用预算与取消信号;纠正请求只带固定语法分类和可用的行列位置,不回放错误响应、不自动修补 JSON、不删减发现来换取通过。语法位置相对于去掉外层空白及完整代码围栏后的 JSON 正文,偏移单位为 UTF-16 code unit;未知运行时错误文本回退到通用分类,不推测位置。
|
|
40
|
+
|
|
41
|
+
失败结果的 `protocolDiagnostic` 保留语法分类、行列位置(可用时)和响应 SHA-256 指纹,不存储响应片段或原生解析异常文本。宿主服务错误从 `finish.reason.failure` 读取,保留已知错误码、HTTP 状态、有效重试间隔和请求 ID 的哈希;未知码只存分类及哈希,不存提供方 message、原始 ID、URL、headers 或 body。已有工具链直接抛出的异常仍保持身份,不新增自动网络重试;AUTH、QUOTA、取消及 error 终态不能按格式错误重发。指纹只是对照线索,不是原文备份,也不构成跨模型确定性保证。
|
|
42
|
+
|
|
43
|
+
执行 `node --test test/model-diagnostics.test.mjs test/model-json-correction.test.mjs test/model-protocol-diagnostics.test.mjs test/dsh-model.test.mjs` 可验证本地纠正、失败归类和脱敏;宿主检查另覆盖真实 ToolRuntime 的纠正和服务失败事件,均为受控离线模型,不证明真实服务失败率下降。
|
|
26
44
|
|
|
27
45
|
## 审查预算
|
|
28
46
|
|
package/README.md
CHANGED
|
@@ -256,7 +256,7 @@ Git / SVN 历史审查也能使用这些参数;依赖和调用方按对应版
|
|
|
256
256
|
|
|
257
257
|
清单上限10000节点,单次选择最多1000节点/16MiB,12节点一批,单文件256KiB;超限明确拒绝,缩小选择后必须重新预览。单次采集共享8MiB元数据内存保护,不按命令条数、插件秒表或模型调用次数中断;耗时受宿主/模型提供方限制,用户可随时取消。SVN为本地已观察清单,稀疏/外部/链接及不支持属性显示限制;不证明仓库远端完整覆盖。
|
|
258
258
|
|
|
259
|
-
全项目审查默认最多 **
|
|
259
|
+
全项目审查默认最多 **8 个独立批次并行**,每批内主审、检索、复核仍按依赖顺序执行;预览和报告的 `execution.batchConcurrency` 显示实际配置。插件配置 `projectConcurrency` 可设为 1~8,设 1 回退串行;遇到模型提供方并发限制或 429,应降低并发。该设置不是 preview 参数,不扩大来源或共享模型调用预算,也不减少模型调用量或承诺 token 成本下降。仅一个批次时不会获得批间加速;实际提速受提供方吞吐、批次均衡及检查点写入影响。
|
|
260
260
|
|
|
261
261
|
对外只交付一份统一最终报告,给出总数、completed、excluded、blocked、pending、去重后的发现及未完成综合阶段;内部批次快照仅供证据追溯,不需要用户拼接报告。completed只表示所选捕获文件已处理,不代表代码正确;排除与阻断从不算完成。代码或清单变化会拒绝旧预览,运行后无法复核则标记outdated。
|
|
262
262
|
|
package/RELEASE-NOTES.md
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
|
-
# v0.85.
|
|
1
|
+
# v0.85.4 发布说明
|
|
2
|
+
|
|
3
|
+
使用方式见 [README](README.md),详细变化见 [变更记录](CHANGELOG.md),发布门禁见 [开发说明](DEVELOPMENT.md)。
|
|
4
|
+
|
|
5
|
+
`v0.85.2` 因发布说明缺少导航链接、`v0.85.3` 因 Windows runner 临时目录短路径别名未被两个新增测试 mock 规范化而未通过 CI,均未发布 npm 或 GitHub Release。本版本保留旧冻结标签,补齐文档链接并修正测试路径匹配后重新执行完整门禁,不削弱原有拒绝断言。
|
|
6
|
+
|
|
7
|
+
## 审查正确性与失败恢复
|
|
8
|
+
|
|
9
|
+
- 模型流现在支持合法的完整文本块;完整块替换对应增量而不是重复拼接,文本和推理块继续受输出预算限制。真正重复或非法的 JSON 仍会拒绝,不靠截断响应通过。
|
|
10
|
+
- 后台保存队列在一次失败后仍可保存终态;成功落盘后释放已结束任务的内存,最终保存失败也会唤醒等待者并保留诊断。
|
|
11
|
+
- 修复模型拒绝值为 null/undefined 时丢失报告、非法分段参数导致死循环、SVN 历史正文大小低报影响累计预算等问题。
|
|
12
|
+
- 补全进程终止的 SIGKILL 回退及清理、注册表网络异常重试、Git 根路径空格保留、锚点越界重定位、具名导入尾随逗号,以及 JavaScript/C#/C++ 导航边界。
|
|
2
13
|
|
|
3
14
|
## 全项目审核提速
|
|
4
15
|
|
|
5
|
-
|
|
16
|
+
默认独立批次并发从 3 提高到 8,可用插件配置 projectConcurrency 在 1~8 内调整。保留主审、检索、证据复核及共享调用预算;遇到提供方并发限制或 429 时请调低。
|
|
6
17
|
|
|
7
|
-
|
|
18
|
+
相同 12 个批次、每次模拟模型延迟 40ms 的离线对照中,旧默认 3 路约 182ms,新默认 8 路约 92ms,约快 1.98 倍;调用数、完成数和结果顺序一致。这不是实际模型吞吐承诺,不减少必需调用或承诺 token 成本下降。
|
|
8
19
|
|
|
9
|
-
|
|
20
|
+
本次历史审核的 778 次模型调用中,263 次为额外 JSON 重生成。完整块兼容修复可避免对应场景的无谓纠正,但尚未证明它解释历史全部格式失败;真实提供方效果需要更新后复测。
|
|
10
21
|
|
|
11
|
-
|
|
12
|
-
- 主审文件身份、严重性、必填字段或行证据校验失败时,最多请求一次完整响应纠正,仍执行严格校验,不自动更改身份、映射严重性或编造证据。
|
|
13
|
-
- 全项目行号错位仅在同文件当前侧存在唯一精确文本时修正,保留原候选供审计;不跨文件、跨版本或模糊匹配,窗口限制继续生效。
|
|
14
|
-
- 模型可见共享检索预算余量;整批动态范围与预算先预检,实际输出超限不部分发布结果或审计。预算耗尽后只允许一次最终答复,不扩大读取权限;已有有效候选保留,文件仍未完成,不以空 findings 视为通过。
|
|
22
|
+
## 验证与安全边界
|
|
15
23
|
|
|
16
|
-
|
|
24
|
+
本地完整测试 685 项:684 通过、1 项 Windows 平台跳过、0 失败、0 取消;另对新增流式、持久化及导航回归进行独立验证。正式发布必须经过冻结标签提交的 Linux/Windows × Node 22/24 四组 CI,以及隔离安装和注册表工件一致性门禁。
|
|
17
25
|
|
|
18
|
-
|
|
26
|
+
文件及祖先身份检查是路径竞态检测加固,不是原子目录相对打开;不能保证抵御恶意进程精确交换并恢复路径。严格隔离场景应在不可并发修改的独立快照中审核。
|
|
19
27
|
|
|
20
|
-
|
|
28
|
+
本版本不自动安装、升级或重启 DSH;需更新并重新加载插件后生效。
|
package/index.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { registerBackgroundReview } from './src/background-review.mjs';
|
|
2
2
|
import { createReviewService } from './src/service.mjs';
|
|
3
|
+
import { DEFAULT_PROJECT_CONCURRENCY } from './src/project-execution.mjs';
|
|
3
4
|
import { reviewToolOutput } from './src/tool-view.mjs';
|
|
4
5
|
|
|
5
6
|
export const name = 'dsh-code-review';
|
|
@@ -13,7 +14,7 @@ const EXECUTE_PARAMETERS = { type: 'object', additionalProperties: false, proper
|
|
|
13
14
|
export function apply(ctx, config = {}) {
|
|
14
15
|
// This entry runs only when the host invokes the registered tool.
|
|
15
16
|
// Host tool policy owns permission; do not request a second approval here.
|
|
16
|
-
const service = createReviewService(ctx.llm, { allowModelSending: true, enableRiskPlanning: true, enableBusinessGrouping: true, enableAnchorCorrection: true, projectConcurrency: config.projectConcurrency ??
|
|
17
|
+
const service = createReviewService(ctx.llm, { allowModelSending: true, enableRiskPlanning: true, enableBusinessGrouping: true, enableAnchorCorrection: true, projectConcurrency: config.projectConcurrency ?? DEFAULT_PROJECT_CONCURRENCY, authorize: async () => true });
|
|
17
18
|
ctx.tools.register({
|
|
18
19
|
name: 'code_review_preview', description: PREVIEW_DESCRIPTION, parameters: PREVIEW_PARAMETERS, output: reviewToolOutput,
|
|
19
20
|
async execute(args, exec) { const preview = await service.preview(args, exec); return { json: JSON.stringify(preview), markdown: '' }; },
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ export function correctAnchor(raw, file, changes, {signal}={}) {
|
|
|
5
5
|
if(!raw||raw.fileId!==file.id||a?.kind!=='line'||!['old','new'].includes(a.side)||!Number.isSafeInteger(a.start)||!Number.isSafeInteger(a.end)||a.start<1||a.end<a.start||typeof a.snippet!=='string'||!a.snippet.trim()||a.snippet.length>8000)return result('not-applicable','invalid-anchor');
|
|
6
6
|
const text=(a.side==='old'?file.left:file.right)?.text;if(typeof text!=='string'||Buffer.byteLength(text)>256*1024)return result('not-applicable','source-limit');
|
|
7
7
|
const source=text.replaceAll('\r\n','\n').split('\n'),snippet=a.snippet.replaceAll('\r\n','\n').split('\n');
|
|
8
|
-
if(source.slice(a.start-1,a.end).join('\n')===snippet.join('\n'))return result('unchanged','already-exact');
|
|
8
|
+
if(a.end<=source.length&&source.slice(a.start-1,a.end).join('\n')===snippet.join('\n'))return result('unchanged','already-exact');
|
|
9
9
|
const currentSource = file.reviewMode === 'project' && a.side === 'new';
|
|
10
10
|
if(file.reviewMode === 'project' && !currentSource)return result('rejected','project-requires-new-side');
|
|
11
11
|
if(!currentSource&&(changes?.status!=='changed'||changes.precision!=='exact'||!Array.isArray(raw.attribution?.editIds)||!raw.attribution.editIds.length))return result('rejected','missing-exact-edit');
|
package/src/class-bindings.mjs
CHANGED
|
@@ -121,6 +121,9 @@ export function instanceMethodBindings(lines, importLine, targetText) {
|
|
|
121
121
|
if (assign === null || inst === member || inst === klass || inst === local) continue;
|
|
122
122
|
const newCount = [...caller.matchAll(new RegExp('(^|[^A-Za-z0-9_$])new\\s+' + escapeDollar(local) + '\\s*\\(', 'g'))].length;
|
|
123
123
|
if (newCount !== 1) continue;
|
|
124
|
+
// Without lexical scope resolution only the import and construction may name the class.
|
|
125
|
+
const localToken = boundaryPattern(local, '(?=[^A-Za-z0-9_$]|$)', 'g');
|
|
126
|
+
if (lines.some((line,i) => i + 1 !== importLine && i + 1 !== assign && [...line.matchAll(localToken)].length)) continue;
|
|
124
127
|
const instToken = boundaryPattern(inst, '(?=[^A-Za-z0-9_$]|$)', 'g');
|
|
125
128
|
const instLines = [];
|
|
126
129
|
for (let i = 0; i < lines.length; i++) {
|
package/src/content.mjs
CHANGED
|
@@ -16,6 +16,7 @@ export function decode(bytes) {
|
|
|
16
16
|
if (/[\x00-\x08\x0e-\x1f]/.test(text)) throw new Error('Binary content');
|
|
17
17
|
return { text, hash: hash(bytes), encoding, bytes: bytes.length };
|
|
18
18
|
}
|
|
19
|
+
|
|
19
20
|
export async function readLocal(root, name, { maxFileBytes = 256 * 1024, signal } = {}) {
|
|
20
21
|
relativePath(name);
|
|
21
22
|
assertNonSecretPath(name);
|
|
@@ -24,19 +25,29 @@ export async function readLocal(root, name, { maxFileBytes = 256 * 1024, signal
|
|
|
24
25
|
const canonicalRoot = await realpath(root);
|
|
25
26
|
const filename = path.resolve(canonicalRoot, ...name.split('/'));
|
|
26
27
|
let cursor = canonicalRoot;
|
|
28
|
+
const ancestors = [];
|
|
29
|
+
let expected;
|
|
27
30
|
for (const part of name.split('/')) {
|
|
28
31
|
cursor = path.join(cursor, part);
|
|
29
32
|
const stat = await lstat(cursor);
|
|
30
33
|
if (stat.isSymbolicLink()) throw new Error('Symbolic links and junctions are excluded');
|
|
31
|
-
if (cursor === filename
|
|
34
|
+
if (cursor === filename) {
|
|
35
|
+
if (!stat.isFile()) throw new Error('Not a regular file');
|
|
36
|
+
expected = stat;
|
|
37
|
+
} else {
|
|
38
|
+
if (!stat.isDirectory()) throw new Error('Not a directory');
|
|
39
|
+
ancestors.push({ path: cursor, stat });
|
|
40
|
+
}
|
|
32
41
|
}
|
|
33
42
|
const canonical = await realpath(filename);
|
|
34
43
|
const rel = path.relative(canonicalRoot, canonical);
|
|
35
44
|
if (rel.startsWith('..' + path.sep) || rel === '..' || path.isAbsolute(rel)) throw new Error('Path escapes repository');
|
|
36
|
-
const
|
|
45
|
+
const opened = await lstat(filename);
|
|
46
|
+
if (opened.isSymbolicLink() || !opened.isFile()) throw new Error(opened.isSymbolicLink() ? 'Symbolic links and junctions are excluded' : 'Not a regular file');
|
|
47
|
+
const handle = await open(canonical, 'r');
|
|
37
48
|
try {
|
|
38
49
|
const before = await handle.stat();
|
|
39
|
-
if (!before.isFile()) throw new Error('
|
|
50
|
+
if (!before.isFile() || before.dev !== expected.dev || before.ino !== expected.ino || before.dev !== opened.dev || before.ino !== opened.ino) throw new Error('File changed during capture');
|
|
40
51
|
if (before.size > maxFileBytes) throw new Error('File size limit exceeded');
|
|
41
52
|
const buffer = Buffer.alloc(maxFileBytes + 1);
|
|
42
53
|
let length = 0;
|
|
@@ -47,8 +58,12 @@ export async function readLocal(root, name, { maxFileBytes = 256 * 1024, signal
|
|
|
47
58
|
length += bytesRead;
|
|
48
59
|
}
|
|
49
60
|
if (length > maxFileBytes) throw new Error('File size limit exceeded');
|
|
61
|
+
for (const ancestor of ancestors) {
|
|
62
|
+
const now = await lstat(ancestor.path);
|
|
63
|
+
if (now.isSymbolicLink() || !now.isDirectory() || now.dev !== ancestor.stat.dev || now.ino !== ancestor.stat.ino || now.ctimeMs !== ancestor.stat.ctimeMs) throw new Error('Ancestor changed during capture');
|
|
64
|
+
}
|
|
50
65
|
const after = await handle.stat();
|
|
51
|
-
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || canonical !== await realpath(filename)) throw new Error('File changed during capture');
|
|
66
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || before.ino !== after.ino || canonical !== await realpath(filename)) throw new Error('File changed during capture');
|
|
52
67
|
return decode(buffer.subarray(0, length));
|
|
53
68
|
} finally { await handle.close(); }
|
|
54
69
|
}
|
package/src/cpp-relations.mjs
CHANGED
|
@@ -22,7 +22,7 @@ export function inferCppRelations(files) {
|
|
|
22
22
|
// Conditional/macro includes are not resolved without a build configuration.
|
|
23
23
|
if (lines.some(line => /^\s*#\s*(?:if|ifdef|ifndef|elif|else|endif|define|undef)\b/.test(line))) continue;
|
|
24
24
|
for (let i=0;i<lines.length;i++) {
|
|
25
|
-
const match = /^\s*#\s*include\s
|
|
25
|
+
const match = /^\s*#\s*include\s*"([^"\\]+)"\s*(?:\/\/.*)?$/.exec(lines[i]);
|
|
26
26
|
if (!match || match[1].startsWith('/') || match[1].includes(':')) continue;
|
|
27
27
|
const targetPath = path.posix.normalize(path.posix.join(path.posix.dirname(file.path),match[1]));
|
|
28
28
|
if (targetPath.startsWith('../') || targetPath === '..') continue;
|
package/src/csharp-context.mjs
CHANGED
|
@@ -20,7 +20,11 @@ export function inferCsharpContext(input, primary) {
|
|
|
20
20
|
if (lines.some((line,j) => j !== i && token.test(line))) continue;
|
|
21
21
|
// Unsupported declarations still compete; filtering them out must not create uniqueness.
|
|
22
22
|
const competitors = contexts.filter(c => typeof c.text !== 'string' || token.test(c.text));
|
|
23
|
-
if (competitors.length !== 1 || input.files.some(f =>
|
|
23
|
+
if (competitors.length !== 1 || input.files.some(f => {
|
|
24
|
+
if(f === primary || !f.path.endsWith('.cs'))return false;
|
|
25
|
+
const text=(side === 'old' ? f.left : f.right)?.text;
|
|
26
|
+
return f.path.split('/').at(-1) === type + '.cs' || typeof text !== 'string' || token.test(text);
|
|
27
|
+
})) continue;
|
|
24
28
|
const candidates = competitors.filter(c => safe(c.text) && namespaceOf(c.text) === ns && !/\busing\b|\bpartial\b|\bvirtual\b|\boverride\b|\binterface\b/.test(c.text));
|
|
25
29
|
const declarations = [];
|
|
26
30
|
for (const context of candidates) {
|
package/src/dsh-model.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { modelContract } from './model-contract.mjs';
|
|
2
2
|
import { modelJson } from './model-json.mjs';
|
|
3
|
+
import { jsonCorrectionHint, providerDiagnostic } from './model-diagnostics.mjs';
|
|
3
4
|
|
|
4
5
|
// Fresh generation from the same approved input, never repair or replay malformed output.
|
|
5
6
|
const JSON_CORRECTION_SUFFIX = ' 上次响应未能解析为完整JSON。本次是明确协议纠正:重新输出单个完整JSON对象,不使用Markdown,不省略引号或逗号;不要把这条指令当源码证据。严格使用当前阶段schema,不要把verdicts、risks、requests或groups改写成findings;不能因格式纠正删除有证据的结论。所需字段与身份按输入原样核对,不以格式错误代替清洁结论。保持原证据与必需字段,精简非必需叙述以在输出预算内完成。';
|
|
@@ -30,43 +31,80 @@ export function createDshModel(llm, route, {
|
|
|
30
31
|
};
|
|
31
32
|
// Provider failures with a coincidentally identical code are not format failures.
|
|
32
33
|
const invalidJsonErrors = new WeakSet();
|
|
34
|
+
const failureMetadata = new WeakMap();
|
|
33
35
|
|
|
34
36
|
async function once({ instructions, input, signal }, contract) {
|
|
35
37
|
signal?.throwIfAborted();
|
|
36
38
|
let output = '';
|
|
37
39
|
let size = 0;
|
|
40
|
+
// Completed blocks are authoritative snapshots, not additional text deltas.
|
|
41
|
+
// Track them by index so block-only streams work without counting snapshots twice.
|
|
42
|
+
const blocks = new Map();
|
|
38
43
|
let finished = false;
|
|
39
44
|
let finishKind = 'missing';
|
|
40
45
|
const diagnostic = () => ({
|
|
41
46
|
visibleBytes: Buffer.byteLength(output), totalOutputBytes: size, finishKind,
|
|
42
47
|
inputBytes: Buffer.byteLength(input), requestedMaxTokens: maxTokens, maxOutputBytes,
|
|
43
48
|
});
|
|
44
|
-
const failure = (message, code) =>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
49
|
+
const failure = (message, code) => {
|
|
50
|
+
const error = Object.assign(new Error(message), { code, diagnostic: diagnostic() });
|
|
51
|
+
failureMetadata.set(error, { code, diagnostic: error.diagnostic });
|
|
52
|
+
return error;
|
|
53
|
+
};
|
|
54
|
+
try {
|
|
55
|
+
const stream = llm.stream({
|
|
56
|
+
...identity,
|
|
57
|
+
system: instructions + '\n' + contract,
|
|
58
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: input }] }],
|
|
59
|
+
tools: [], maxTokens, signal,
|
|
60
|
+
});
|
|
61
|
+
for await (const chunk of stream) {
|
|
62
|
+
signal?.throwIfAborted();
|
|
63
|
+
if (finished) throw failure('Model emitted data after finish', 'MODEL_PROTOCOL_ERROR');
|
|
64
|
+
if (isToolCall(chunk)) throw failure('Review model attempted a tool call', 'MODEL_PROTOCOL_ERROR');
|
|
65
|
+
const delta = chunk.type === 'text-delta' || chunk.type === 'reasoning-delta';
|
|
66
|
+
const completed = chunk.type === 'block-end' &&
|
|
67
|
+
(chunk.block.type === 'text' || chunk.block.type === 'reasoning');
|
|
68
|
+
if (delta || completed) {
|
|
69
|
+
const type = completed ? chunk.block.type : chunk.type === 'text-delta' ? 'text' : 'reasoning';
|
|
70
|
+
// Unindexed delta-only streams remain supported for older callers.
|
|
71
|
+
const key = chunk.index ?? type;
|
|
72
|
+
const previous = blocks.get(key);
|
|
73
|
+
if (previous && previous.type !== type) throw failure('Model changed block type', 'MODEL_PROTOCOL_ERROR');
|
|
74
|
+
// Like the host BlockAssembler, first close wins; never append stragglers.
|
|
75
|
+
if (previous?.closed) continue;
|
|
76
|
+
const text = completed ? chunk.block.text : (previous?.text ?? '') + chunk.text;
|
|
77
|
+
const bytes = Buffer.byteLength(text);
|
|
78
|
+
// A shorter final snapshot must not refund previously consumed output budget.
|
|
79
|
+
const chargedBytes = Math.max(previous?.chargedBytes ?? 0, bytes);
|
|
80
|
+
size += chargedBytes - (previous?.chargedBytes ?? 0);
|
|
81
|
+
if (size > maxOutputBytes) throw failure('Model output limit exceeded', 'MODEL_OUTPUT_LIMIT');
|
|
82
|
+
blocks.set(key, { type, text, chargedBytes, closed: completed });
|
|
83
|
+
output = [...blocks.values()].filter(block => block.type === 'text').map(block => block.text).join('');
|
|
84
|
+
}
|
|
85
|
+
if (chunk.type === 'finish') {
|
|
86
|
+
finishKind = chunk.reason.kind;
|
|
87
|
+
if (finishKind !== 'stop') {
|
|
88
|
+
const error = failure('Model did not complete: ' + finishKind, 'MODEL_INCOMPLETE_OUTPUT');
|
|
89
|
+
Object.assign(error.diagnostic, providerDiagnostic(chunk.reason));
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
finished = true;
|
|
67
93
|
}
|
|
68
|
-
finished = true;
|
|
69
94
|
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
// Keep thrown provider/cancellation identity, including frozen and primitive values.
|
|
97
|
+
// Only add safe structured metadata when the error permits it.
|
|
98
|
+
if (error && (typeof error === 'object' || typeof error === 'function') && !failureMetadata.has(error)) {
|
|
99
|
+
const safeDiagnostic = { ...diagnostic(), ...providerDiagnostic(error) };
|
|
100
|
+
failureMetadata.set(error, { code: safeDiagnostic.providerCode, diagnostic: safeDiagnostic });
|
|
101
|
+
try {
|
|
102
|
+
if (Object.isExtensible(error) && !Object.hasOwn(error, 'diagnostic')) {
|
|
103
|
+
Object.defineProperty(error, 'diagnostic', { value: structuredClone(safeDiagnostic), configurable: true, enumerable: true, writable: true });
|
|
104
|
+
}
|
|
105
|
+
} catch { /* Diagnostic accessors/proxies must not replace the primary failure. */ }
|
|
106
|
+
}
|
|
107
|
+
throw error;
|
|
70
108
|
}
|
|
71
109
|
// Cancellation during iterator cleanup/end must not become an invalid-JSON correction.
|
|
72
110
|
signal?.throwIfAborted();
|
|
@@ -76,6 +114,7 @@ export function createDshModel(llm, route, {
|
|
|
76
114
|
} catch (error) {
|
|
77
115
|
error.diagnostic = { ...diagnostic(), ...error.diagnostic };
|
|
78
116
|
invalidJsonErrors.add(error);
|
|
117
|
+
failureMetadata.set(error, { code: error.code, diagnostic: error.diagnostic });
|
|
79
118
|
throw error;
|
|
80
119
|
}
|
|
81
120
|
}
|
|
@@ -90,24 +129,28 @@ export function createDshModel(llm, route, {
|
|
|
90
129
|
let result;
|
|
91
130
|
try {
|
|
92
131
|
result = await once({ ...original, instructions: original.instructions +
|
|
93
|
-
(attempt > 1 ? JSON_CORRECTION_SUFFIX + ' 纠正次数:' + (attempt - 1) + '/' + correctionLimit + '。' : '') }, contract);
|
|
132
|
+
(attempt > 1 ? JSON_CORRECTION_SUFFIX + jsonCorrectionHint(attempts.at(-1)?.diagnostic) + ' 纠正次数:' + (attempt - 1) + '/' + correctionLimit + '。' : '') }, contract);
|
|
94
133
|
} catch (error) {
|
|
95
|
-
|
|
134
|
+
const metadata = failureMetadata.get(error) ?? {};
|
|
135
|
+
failureMetadata.delete(error);
|
|
136
|
+
attempts.push({ attempt, ...structuredClone(metadata) });
|
|
96
137
|
const invalidJson = invalidJsonErrors.delete(error);
|
|
97
138
|
if (!invalidJson || attempt > correctionLimit) {
|
|
98
|
-
|
|
99
|
-
error.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
'
|
|
139
|
+
try {
|
|
140
|
+
if (attempts.length > 1 && error && typeof error === 'object' && Object.isExtensible(error)) {
|
|
141
|
+
Object.defineProperty(error, 'attempts', { value: structuredClone(attempts), configurable: true, enumerable: true, writable: true });
|
|
142
|
+
if (invalidJson) {
|
|
143
|
+
error.message = 'Model response is not complete JSON after ' + (attempt - 1) +
|
|
144
|
+
' explicit JSON correction(s) (' + attempt + ' model calls); correction budget exhausted; no malformed output accepted';
|
|
145
|
+
}
|
|
103
146
|
}
|
|
104
|
-
}
|
|
147
|
+
} catch { /* Preserve provider identity even with hostile accessors/proxies. */ }
|
|
105
148
|
throw error;
|
|
106
149
|
}
|
|
107
150
|
original.signal?.throwIfAborted();
|
|
108
151
|
// Exactly one reservation per extra call; a thrown budget/cancellation veto is final.
|
|
109
152
|
await request.onProtocolCorrection?.({
|
|
110
|
-
|
|
153
|
+
...structuredClone(metadata), attempts: structuredClone(attempts),
|
|
111
154
|
action: 'explicit-json-correction', attempt: attempt + 1,
|
|
112
155
|
correction: attempt, maxJsonCorrections: correctionLimit,
|
|
113
156
|
notice: 'Additional model response requested; original output not repaired or accepted.',
|
|
@@ -119,7 +162,7 @@ export function createDshModel(llm, route, {
|
|
|
119
162
|
// Out-of-band audit only: never inject metadata into stage-specific model schemas.
|
|
120
163
|
await request.onProtocolEvent?.({
|
|
121
164
|
action: 'json-correction-complete', attempt, corrections: attempt - 1,
|
|
122
|
-
attempts: [...attempts, { attempt, diagnostic: result.diagnostic }],
|
|
165
|
+
attempts: structuredClone([...attempts, { attempt, diagnostic: result.diagnostic }]),
|
|
123
166
|
notice: 'Fresh complete JSON accepted; malformed outputs discarded; extra model calls occurred.',
|
|
124
167
|
});
|
|
125
168
|
original.signal?.throwIfAborted();
|
package/src/git-history.mjs
CHANGED
|
@@ -68,7 +68,7 @@ export async function captureGitHistory(cwd, options = {}) {
|
|
|
68
68
|
if (!Number.isSafeInteger(maxFiles) || maxFiles < 1 || maxFiles > 200 || !Number.isSafeInteger(maxFileBytes) || maxFileBytes < 1 || maxFileBytes > 256 * 1024 || !Number.isSafeInteger(maxSnapshotBytes) || maxSnapshotBytes < 1 || maxSnapshotBytes > 4 * 1024 * 1024) throw new Error('Invalid historical capture limits');
|
|
69
69
|
if (selectedPaths !== undefined && (!Array.isArray(selectedPaths) || selectedPaths.some(p => relativePath(p) !== p))) throw new Error('Invalid selected paths');
|
|
70
70
|
|
|
71
|
-
const root = await realpath(utf8(await gitAt(cwd, ['rev-parse', '--show-toplevel'], options)).
|
|
71
|
+
const root = await realpath(utf8(await gitAt(cwd, ['rev-parse', '--show-toplevel'], options)).replace(/\r?\n$/, ''));
|
|
72
72
|
|
|
73
73
|
// Resolve the two endpoints: commit compares against its sole parent; range uses explicit endpoints.
|
|
74
74
|
let base, target;
|
package/src/git.mjs
CHANGED
|
@@ -10,7 +10,7 @@ const git = (cwd, args, options) => checked('git', [...flags, ...args], { ...opt
|
|
|
10
10
|
const text = bytes => new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
11
11
|
|
|
12
12
|
async function resolveRoot(cwd, options) {
|
|
13
|
-
const top = text(await git(cwd, ['rev-parse', '--show-toplevel'], options)).
|
|
13
|
+
const top = text(await git(cwd, ['rev-parse', '--show-toplevel'], options)).replace(/\r?\n$/, '');
|
|
14
14
|
return realpath(top);
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -17,7 +17,10 @@ export function importedCallSites(lines, importLine, callIndex = indexStandalone
|
|
|
17
17
|
const identifier = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
18
18
|
if (identifier.test(match[1])) names.push(match[1]);
|
|
19
19
|
else if (match[1].startsWith('{') && match[1].endsWith('}')) {
|
|
20
|
-
|
|
20
|
+
const parts = match[1].slice(1, -1).split(',');
|
|
21
|
+
// A single trailing comma is valid; leading or interior holes are not.
|
|
22
|
+
if (!parts.at(-1).trim()) parts.pop();
|
|
23
|
+
for (const part of parts) {
|
|
21
24
|
const binding = /^\s*([A-Za-z_$][A-Za-z0-9_$]*)(?:\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*))?\s*$/.exec(part);
|
|
22
25
|
if (!binding) return [];
|
|
23
26
|
names.push(binding[2] ?? binding[1]);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// Never persist provider messages, response excerpts, headers, URLs or raw request IDs.
|
|
4
|
+
const digest = text => createHash('sha256').update(text).digest('hex');
|
|
5
|
+
const own = (value, key) => {
|
|
6
|
+
try { const d = value != null ? Object.getOwnPropertyDescriptor(value, key) : undefined; return d && 'value' in d ? d.value : undefined; }
|
|
7
|
+
catch { return undefined; }
|
|
8
|
+
};
|
|
9
|
+
const PROVIDER_CODES = new Set(['RATE_LIMIT','CONTEXT_WINDOW_EXCEEDED','QUOTA','ACCOUNT_QUOTA','AUTH','AUTHENTICATION','INVALID_CREDENTIAL','MISSING_CREDENTIAL','PERMISSION','TIMEOUT','NETWORK','SERVER_ERROR','SERVER','SERVICE_UNAVAILABLE','INVALID_ARGS','INVALID_REQUEST','BAD_REQUEST','EMPTY_RESPONSE','UNKNOWN','NO_ADAPTER','ABORTED','CANCELLED']);
|
|
10
|
+
export function providerDiagnostic(value) {
|
|
11
|
+
const carried = own(value, 'failure');
|
|
12
|
+
const code = own(carried, 'code') ?? own(value, 'code');
|
|
13
|
+
const status = own(carried, 'status') ?? own(value, 'status');
|
|
14
|
+
const retry = own(carried, 'providerRetryAfterMs') ?? own(value, 'providerRetryAfterMs');
|
|
15
|
+
const requestId = own(carried, 'requestId') ?? own(value, 'requestId');
|
|
16
|
+
return {
|
|
17
|
+
...(typeof code === 'string' && code.length > 0 && code.length <= 256 ? { providerCode: PROVIDER_CODES.has(code) ? code : 'UNRECOGNIZED', ...(PROVIDER_CODES.has(code) ? {} : { providerCodeHash: digest(code) }) } : {}),
|
|
18
|
+
...(Number.isInteger(status) && status >= 100 && status <= 599 ? { providerStatus: status } : {}),
|
|
19
|
+
...(Number.isFinite(retry) && retry > 0 && retry <= 86400000 ? { providerRetryAfterMs: retry } : {}),
|
|
20
|
+
...(typeof requestId === 'string' && requestId.length > 0 && requestId.length <= 512 ? { providerRequestIdHash: digest(requestId) } : {}),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const SYNTAX_HINTS = {
|
|
25
|
+
'property-name': '对象属性名必须使用双引号,不允许单引号、裸键或尾逗号。',
|
|
26
|
+
colon: '属性名与值之间必须有冒号。',
|
|
27
|
+
separator: '相邻属性或数组元素之间必须有逗号,结束处不可多加逗号。',
|
|
28
|
+
'invalid-escape': 'JSON 字符串中的反斜杠必须合法转义;换行、引号和路径须按 JSON 编码。',
|
|
29
|
+
'control-character': 'JSON 字符串中不能包含裸换行或控制字符,必须正确转义。',
|
|
30
|
+
'unterminated-string': '字符串必须闭合;不要用多行裸文本或截断字符串。',
|
|
31
|
+
'unexpected-end': '只输出一个语法完整且闭合的对象,不省略结尾。',
|
|
32
|
+
'trailing-content': 'JSON 对象结束后不得附加解释、第二个对象或 Markdown。',
|
|
33
|
+
syntax: '逐项检查双引号、转义、冒号、逗号和括号,输出完整严格 JSON。',
|
|
34
|
+
};
|
|
35
|
+
export function jsonSyntaxDiagnostic(body, error) {
|
|
36
|
+
const message = error instanceof SyntaxError ? error.message : '';
|
|
37
|
+
let category = 'syntax';
|
|
38
|
+
if (/^Expected (?:property name|double-quoted property name)/.test(message)) category = 'property-name';
|
|
39
|
+
else if (/^Expected ':'/.test(message)) category = 'colon';
|
|
40
|
+
else if (/^Expected ','/.test(message)) category = 'separator';
|
|
41
|
+
else if (/^Bad (?:escaped character|Unicode escape)/.test(message)) category = 'invalid-escape';
|
|
42
|
+
else if (/^Bad control character/.test(message)) category = 'control-character';
|
|
43
|
+
else if (/^Unterminated string/.test(message)) category = 'unterminated-string';
|
|
44
|
+
else if (/^Unexpected end of JSON input/.test(message)) category = 'unexpected-end';
|
|
45
|
+
else if (/^Unexpected non-whitespace character after JSON/.test(message)) category = 'trailing-content';
|
|
46
|
+
const match = / at position ([0-9]+)(?: \(line [0-9]+ column [0-9]+\))?$/.exec(message);
|
|
47
|
+
const offset = match ? Number(match[1]) : category === 'unexpected-end' ? body.length : undefined;
|
|
48
|
+
let location = {};
|
|
49
|
+
if (Number.isSafeInteger(offset) && offset >= 0 && offset <= body.length) {
|
|
50
|
+
const before = body.slice(0, offset);
|
|
51
|
+
const lines = before.split(/\r\n|\r|\n/);
|
|
52
|
+
location = { offset, line: lines.length, column: lines.at(-1).length + 1 };
|
|
53
|
+
}
|
|
54
|
+
return { syntaxCategory: category, ...location, offsetUnit: 'utf16-code-unit-in-json-body', responseHash: digest(body) };
|
|
55
|
+
}
|
|
56
|
+
export function jsonCorrectionHint(diagnostic) {
|
|
57
|
+
const category = diagnostic?.syntaxCategory;
|
|
58
|
+
if (typeof category !== 'string' || !Object.hasOwn(SYNTAX_HINTS, category)) return '';
|
|
59
|
+
const { line, column } = diagnostic;
|
|
60
|
+
const location = Number.isSafeInteger(line) && line > 0 && Number.isSafeInteger(column) && column > 0 ? ',JSON正文第' + line + '行第' + column + '列' : '';
|
|
61
|
+
return ' 本地解析诊断(不含原响应):' + category + location + '。' + SYNTAX_HINTS[category];
|
|
62
|
+
}
|
package/src/model-json.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { jsonSyntaxDiagnostic } from './model-diagnostics.mjs';
|
|
2
|
+
|
|
1
3
|
// Accept one complete JSON value, optionally inside one exact Markdown fence.
|
|
2
4
|
// This parser never repairs bytes or invokes a model; orchestration owns corrections.
|
|
3
5
|
export function modelJson(text) {
|
|
@@ -7,12 +9,12 @@ export function modelJson(text) {
|
|
|
7
9
|
const body = fence ? fence[1] : trimmed;
|
|
8
10
|
try {
|
|
9
11
|
JSON.parse(body);
|
|
10
|
-
} catch {
|
|
12
|
+
} catch (parseError) {
|
|
11
13
|
const envelope = fence ? 'json-fence' : 'plain';
|
|
12
14
|
const shape = !trimmed ? 'empty' : trimmed.startsWith('```') && !fence ? 'non-exact-fence' : /^[{\[]/.test(body) ? 'json-like-invalid' : 'non-json-leading';
|
|
13
15
|
const error = new Error('Model response is not complete JSON (bytes=' + bytes + ', envelope=' + envelope + ', shape=' + shape + '); no repair or retry performed by JSON parser');
|
|
14
16
|
error.code = 'MODEL_INVALID_JSON';
|
|
15
|
-
error.diagnostic = { jsonBytes: bytes, envelope, shape };
|
|
17
|
+
error.diagnostic = { jsonBytes: bytes, envelope, shape, ...jsonSyntaxDiagnostic(body, parseError) };
|
|
16
18
|
throw error;
|
|
17
19
|
}
|
|
18
20
|
return body;
|
package/src/process.mjs
CHANGED
|
@@ -14,13 +14,21 @@ export function run(command, args, { cwd, signal, timeoutMs = 0, maxBytes = 8 *
|
|
|
14
14
|
env: childEnv,
|
|
15
15
|
});
|
|
16
16
|
const stdout = [], stderr = [];
|
|
17
|
-
let bytes = 0, failure;
|
|
18
|
-
const stop = error => {
|
|
17
|
+
let bytes = 0, failure, killer, stopped = false;
|
|
18
|
+
const stop = error => {
|
|
19
|
+
failure ??= error;
|
|
20
|
+
if (stopped) return;
|
|
21
|
+
stopped = true;
|
|
22
|
+
child.kill();
|
|
23
|
+
killer = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); }, 1000);
|
|
24
|
+
killer.unref?.();
|
|
25
|
+
};
|
|
19
26
|
const abort = () => stop(signal.reason ?? new Error('Cancelled'));
|
|
20
27
|
signal?.addEventListener('abort', abort, { once: true });
|
|
21
28
|
if (signal?.aborted) abort();
|
|
22
29
|
const timer = timeoutMs>0?setTimeout(() => stop(new Error('Command timeout')), timeoutMs):undefined;
|
|
23
30
|
const capture = chunks => data => {
|
|
31
|
+
if (stopped) return;
|
|
24
32
|
bytes += data.length;
|
|
25
33
|
if (bytes > maxBytes) stop(new Error('Command output limit exceeded'));
|
|
26
34
|
else chunks.push(data);
|
|
@@ -30,6 +38,7 @@ export function run(command, args, { cwd, signal, timeoutMs = 0, maxBytes = 8 *
|
|
|
30
38
|
child.on('error', error => { failure ??= error; });
|
|
31
39
|
child.on('close', code => {
|
|
32
40
|
clearTimeout(timer);
|
|
41
|
+
clearTimeout(killer);
|
|
33
42
|
signal?.removeEventListener('abort', abort);
|
|
34
43
|
if (failure) reject(failure);
|
|
35
44
|
else resolve({ code, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { reviewSnapshot } from './review.mjs';
|
|
2
2
|
|
|
3
|
-
export const DEFAULT_PROJECT_CONCURRENCY =
|
|
3
|
+
export const DEFAULT_PROJECT_CONCURRENCY = 8;
|
|
4
4
|
export function projectConcurrency(value = DEFAULT_PROJECT_CONCURRENCY) {
|
|
5
5
|
if (!Number.isSafeInteger(value) || value < 1 || value > 8) throw new Error('Project concurrency must be an integer from 1 to 8');
|
|
6
6
|
return value;
|
|
@@ -5,7 +5,7 @@ import { hash } from './content.mjs';
|
|
|
5
5
|
import { resolveWorkspaceRepository } from './workspace-repository.mjs';
|
|
6
6
|
import { detectVcs } from './snapshot.mjs';
|
|
7
7
|
import { projectInventory, captureProjectBatch } from './project-snapshot.mjs';
|
|
8
|
-
import { reviewProjectBatches, projectConcurrency as validateProjectConcurrency } from './project-execution.mjs';
|
|
8
|
+
import { reviewProjectBatches, projectConcurrency as validateProjectConcurrency, DEFAULT_PROJECT_CONCURRENCY } from './project-execution.mjs';
|
|
9
9
|
import { createDshModel } from './dsh-model.mjs';
|
|
10
10
|
|
|
11
11
|
const PREVIEW_NOTICE_SUFFIX = ' 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. Independent approved batches run with bounded concurrency; per-batch evidence and global model-call budgets remain enforced. Show scope and model before confirmed execute.';
|
|
@@ -66,7 +66,7 @@ async function captureProject(cwd, args, signal) {
|
|
|
66
66
|
return { ...result, id: hash(JSON.stringify(result)) };
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
export function createProjectReviewService(llm, { allowModelSending = false, authorize = async () => false, now = Date.now, ttlMs = 300000, maxPreviews = 8, projectMaxCalls = Number.MAX_SAFE_INTEGER, projectConcurrency =
|
|
69
|
+
export function createProjectReviewService(llm, { allowModelSending = false, authorize = async () => false, now = Date.now, ttlMs = 300000, maxPreviews = 8, projectMaxCalls = Number.MAX_SAFE_INTEGER, projectConcurrency = DEFAULT_PROJECT_CONCURRENCY } = {}) {
|
|
70
70
|
for (const n of [ttlMs, maxPreviews]) if (!Number.isSafeInteger(n) || n < 1) throw new Error('Invalid project preview limit');
|
|
71
71
|
if (!Number.isSafeInteger(projectMaxCalls) || projectMaxCalls < 1) throw new Error('Invalid project model call limit');
|
|
72
72
|
|
package/src/project-snapshot.mjs
CHANGED
|
@@ -13,7 +13,7 @@ const INVENTORY_NOTICE = 'Tracked current working files only, including unchange
|
|
|
13
13
|
|
|
14
14
|
async function inventoryGit(root, options) {
|
|
15
15
|
const args = ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false'];
|
|
16
|
-
const actual = text(await checked('git', [...args, 'rev-parse', '--show-toplevel'], { ...options, cwd: root })).
|
|
16
|
+
const actual = text(await checked('git', [...args, 'rev-parse', '--show-toplevel'], { ...options, cwd: root })).replace(/\r?\n$/, '');
|
|
17
17
|
if (await realpath(actual) !== root) throw new Error('Full project requires explicit repository root');
|
|
18
18
|
const index = await checked('git', [...args, 'ls-files', '--stage', '-z'], { ...options, cwd: root, maxBytes: 2 * 1024 * 1024 });
|
|
19
19
|
const records = [];
|
package/src/review-jobs.mjs
CHANGED
|
@@ -16,12 +16,15 @@ export function createReviewJobs({directory}={}){
|
|
|
16
16
|
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));changed(job.id);}
|
|
17
17
|
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;}
|
|
18
18
|
return {
|
|
19
|
-
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;}
|
|
19
|
+
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;}
|
|
20
|
+
// Keep the serialization tail usable after failure, but return the original
|
|
21
|
+
// rejecting operation so checkpoint callers still observe durability failures.
|
|
22
|
+
const enqueue=fn=>{const operation=job.pending.then(fn);job.pending=operation.catch(()=>{});return operation;};
|
|
20
23
|
// Own cancellation explicitly. The short start RPC ending is NOT task cancellation.
|
|
21
|
-
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
|
|
24
|
+
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));jobs.delete(id);}catch(error){job.record.status='failed';job.record.persistenceError=error?.message??String(error);}finally{changed(id);}}})();return {jobId:id,status:'queued',nextAction:'Call code_review_wait until terminal status; do not finish the user request at start.',notice:'Background job accepted. Poll status; start is not a completed review. Explicit cancel or plugin disposal stops it.'};
|
|
22
25
|
},
|
|
23
26
|
async status(id,exec,page){const r=structuredClone(await owned(id,exec));r.recovery=recoveryCoverage(r);r.progress=jobProgress(r);r.waitRevision=r.checkpoints.length;r.nextAction=['queued','running'].includes(r.status)?'Call code_review_wait; do not end the user review request while task runs.':r.result?.previewId?'Show preview and request user confirmation before execute.':'Collect final report and unfinished scope, then provide one final answer.';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,unfinishedStages:r.recovery.unfinishedStages.slice(offset,offset+limit),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}: {})};},
|
|
24
|
-
async wait(id,exec,{after,timeoutMs=25000,signal}={}){const r=await owned(id,exec);if(!Number.isSafeInteger(timeoutMs)||timeoutMs<1||timeoutMs>60000)throw new Error('Invalid wait duration');if(after!==undefined&&(!Number.isSafeInteger(after)||after<0))throw new Error('Invalid wait revision');const revision=r.checkpoints.length;if(!['queued','running'].includes(r.status)||(after!==undefined&&after!==revision))return {changed:true};signal?.throwIfAborted();await new Promise((resolve,reject)=>{let timer;const cleanup=()=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);listeners.get(id)?.delete(done);};const done=()=>{cleanup();resolve();},abort=()=>{cleanup();reject(signal.reason);};if(!listeners.has(id))listeners.set(id,new Set());listeners.get(id).add(done);signal?.addEventListener('abort',abort,{once:true});timer=setTimeout(done,timeoutMs);const latest=jobs.get(id)?.record;if(latest
|
|
27
|
+
async wait(id,exec,{after,timeoutMs=25000,signal}={}){const r=await owned(id,exec);if(!Number.isSafeInteger(timeoutMs)||timeoutMs<1||timeoutMs>60000)throw new Error('Invalid wait duration');if(after!==undefined&&(!Number.isSafeInteger(after)||after<0))throw new Error('Invalid wait revision');const revision=r.checkpoints.length;if(!['queued','running'].includes(r.status)||(after!==undefined&&after!==revision))return {changed:true};signal?.throwIfAborted();await new Promise((resolve,reject)=>{let timer;const cleanup=()=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);const waiting=listeners.get(id);waiting?.delete(done);if(waiting?.size===0)listeners.delete(id);};const done=()=>{cleanup();resolve();},abort=()=>{cleanup();reject(signal.reason);};if(!listeners.has(id))listeners.set(id,new Set());listeners.get(id).add(done);signal?.addEventListener('abort',abort,{once:true});timer=setTimeout(done,timeoutMs);const latest=jobs.get(id)?.record;if(!latest||latest.checkpoints.length!==revision||!['queued','running'].includes(latest.status))done();if(signal?.aborted)abort();});return {changed:true};},
|
|
25
28
|
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};},
|
|
26
29
|
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));}
|
|
27
30
|
};
|
package/src/review.mjs
CHANGED
|
@@ -326,9 +326,9 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
326
326
|
} catch (error) {
|
|
327
327
|
state.status = signal?.aborted ? 'cancelled' : error?.code === 'PROJECT_MODEL_CALL_LIMIT' ? 'pending' : 'failed';
|
|
328
328
|
state.reason = error?.message ?? String(error);
|
|
329
|
-
if(error
|
|
330
|
-
if(error
|
|
331
|
-
if(error
|
|
329
|
+
if(error?.attempts)state.protocolAttempts=error.attempts;
|
|
330
|
+
if(error?.responseAttempts)state.responseAttempts=error.responseAttempts;
|
|
331
|
+
if(error?.diagnostic)state.protocolDiagnostic=error.diagnostic;
|
|
332
332
|
for(const pass of state.reviewPasses??[])if(pass.status==='running')pass.status=signal?.aborted?'cancelled':'failed';
|
|
333
333
|
state.failureCode = signal?.aborted ? 'CANCELLED' : ['MODEL_INVALID_JSON','MODEL_INCOMPLETE_OUTPUT','MODEL_TIMEOUT','MODEL_INVALID_READ_REQUEST','MODEL_OUTPUT_LIMIT','MODEL_PROTOCOL_ERROR','MODEL_INVALID_REVIEW_RESPONSE','MODEL_INVALID_FINDING_IDENTITY','MODEL_INVALID_FINDING_SEVERITY','MODEL_MISSING_FINDING_FIELD','MODEL_INVALID_ANCHOR','MODEL_ANCHOR_MISMATCH','READER_CALL_BUDGET_EXCEEDED','READER_OUTPUT_BUDGET_EXCEEDED','RETRIEVAL_ROUND_BUDGET_EXCEEDED','RETRIEVAL_FINAL_RESPONSE_REQUIRED','PROJECT_MODEL_CALL_LIMIT'].includes(error?.code) ? error.code : 'REVIEW_EXECUTION_FAILED';
|
|
334
334
|
}
|
package/src/source-segments.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Linear segmentation of approved text. Coordinates remain original one-based lines.
|
|
2
2
|
export function sourceSegments(windows,{maxBytes=12000,maxLines=100}={}){
|
|
3
|
+
if(!Number.isSafeInteger(maxBytes)||maxBytes<1||!Number.isSafeInteger(maxLines)||maxLines<1)throw new Error('Invalid source segment limits');
|
|
3
4
|
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
5
|
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
6
|
if(!i)out.push(w);
|
|
@@ -24,7 +24,8 @@ export async function svnHistoryCallers(files,history,scopes,context,{index,look
|
|
|
24
24
|
if(typeof value?.content?.text!=='string'||value.kind!=='file'||value.source?.operativeRevision!==revision)throw new Error('Invalid fixed SVN caller body');
|
|
25
25
|
const bodyBytes=value.content.bytes??Buffer.byteLength(value.content.text);
|
|
26
26
|
if(!Number.isSafeInteger(bodyBytes)||bodyBytes<0||bodyBytes>256*1024||Buffer.byteLength(value.content.text)>256*1024)throw new Error('Invalid fixed SVN caller size');
|
|
27
|
-
|
|
27
|
+
// Raw encoding bytes may differ from UTF-8; never charge less than the text retained.
|
|
28
|
+
scanBytes+=Math.max(bodyBytes,Buffer.byteLength(value.content.text));
|
|
28
29
|
if(scanBytes>1024*1024)throw new Error('SVN historical caller scan source limit exceeded');
|
|
29
30
|
const source={path:name,...value.content,svnSource:value.source,side,revision};sources.push(source);scanned.push(source);
|
|
30
31
|
}
|
|
@@ -8,13 +8,15 @@ import {svnSpecialData} from './svn-special.mjs';
|
|
|
8
8
|
export async function readWorkingSvnSpecial(root,name,properties,options={}){
|
|
9
9
|
relativePath(name);assertNonSecretPath(name);options.signal?.throwIfAborted();
|
|
10
10
|
const canonical=await realpath(root);let cursor=canonical;const parts=name.split('/');
|
|
11
|
-
|
|
11
|
+
const ancestors=[];
|
|
12
|
+
for(const part of parts.slice(0,-1)){cursor=path.join(cursor,part);const stat=await lstat(cursor);if(stat.isSymbolicLink()||!stat.isDirectory())throw new Error('SVN special ancestor must be a real directory');ancestors.push({path:cursor,dev:stat.dev,ino:stat.ino,ctimeMs:stat.ctimeMs});}
|
|
12
13
|
const filename=path.join(cursor,parts.at(-1)),before=await lstat(filename);let content;
|
|
13
14
|
if(before.isSymbolicLink()){
|
|
14
15
|
const target=await readlink(filename,{encoding:'buffer'});if(target.length>16384)throw new Error('SVN link target byte limit exceeded');
|
|
15
16
|
const bytes=Buffer.concat([Buffer.from('link '),target]);if(bytes.length>(options.maxFileBytes??256*1024))throw new Error('SVN special file size limit exceeded');
|
|
16
17
|
content=decode(bytes);const after=await lstat(filename);const again=await readlink(filename,{encoding:'buffer'});
|
|
17
|
-
if(
|
|
18
|
+
for(const ancestor of ancestors){const now=await lstat(ancestor.path);if(now.isSymbolicLink()||!now.isDirectory()||now.dev!==ancestor.dev||now.ino!==ancestor.ino||now.ctimeMs!==ancestor.ctimeMs)throw new Error('SVN special ancestor changed during capture');}
|
|
19
|
+
if(!after.isSymbolicLink()||before.ino!==after.ino||before.dev!==after.dev||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs||!again.equals(target))throw new Error('SVN link changed during capture');
|
|
18
20
|
}else{
|
|
19
21
|
if(!before.isFile())throw new Error('SVN special working node is neither link nor regular link record');
|
|
20
22
|
content=await readLocal(canonical,name,options);
|