@feng0531/dsh-code-review 0.87.0 → 0.87.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 +7 -0
- package/RELEASE-NOTES.md +10 -16
- package/package.json +1 -1
- package/src/primary-input.mjs +2 -14
- package/src/review-change-map.mjs +17 -0
- package/src/review-resume.mjs +95 -9
- package/src/review-stage-state.mjs +7 -2
- package/src/review.mjs +25 -8
- package/src/stage-generation-cache.mjs +22 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
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.87.1 — 修复阶段续审缓存中断
|
|
6
|
+
|
|
7
|
+
- 主审、窗口归因和续审导入从同一批准原文重建包络坐标;包络仍不表示精确编辑或回归因果。
|
|
8
|
+
- 阶段生成先校验再进入缓存,单条拒绝或容量不足不污染已保存记录、不再在文件收尾中止整任务;当前发现与独立复核保留。
|
|
9
|
+
- 组成员、组级和宿主限制按组合后的有界数量保存,单次模型响应仍最多50条;续审不继承旧复核回执。
|
|
10
|
+
- 续审导出总容量不足时仅保存适配的完整记录并披露省略数量,不中止当前审查;非法导入、取消及真实落盘错误不被容量退化吞掉。
|
|
11
|
+
|
|
5
12
|
## 0.87.0 — 连续取证、迁移契约与阶段续审
|
|
6
13
|
|
|
7
14
|
- 当前阶段按批准来源重新投递有限相关正文,交互风险可内联;未送入模型不计证据,缓存仍扣字节,独立复核使用新回执。
|
package/RELEASE-NOTES.md
CHANGED
|
@@ -1,27 +1,21 @@
|
|
|
1
|
-
# v0.87.
|
|
1
|
+
# v0.87.1 发布说明
|
|
2
2
|
|
|
3
3
|
使用方式见 [README](README.md),历史变化见 [变更记录](CHANGELOG.md),发布门禁见 [开发说明](DEVELOPMENT.md)。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 修复阶段续审中断
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
修复 v0.87.0 中主审已接受的结果被续审缓存再次拒绝,并在文件收尾扩大为整个任务中止的问题。主审、窗口归因及续审校验现从同一批准原文重建大文件包络坐标;包络仍只是保守区域,不代表精确编辑或回归因果。
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
组成员与组级限制合并后按宿主有界结果保存;模型成员及组级响应各自最多50条的限制不变。阶段生成先校验再入缓存,拒绝或容量不足不污染此前有效记录,也不省略当前候选的独立复核。不能继续的候选明确保持未完成,不通过重新生成掩盖失败。
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## 续审容量与安全边界
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
续审导出超过总字节或节点容量时,按完整记录保留适配部分,披露未保存数量;不截断发现或假装已保存全部进度。省略主审或完整文件链时不继承相关继续状态。未保存部分在后续新授权审查中重新执行。
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
批准快照、身份与原始坐标校验仍严格。非法导入、伪造窗口和属性、访问器等不安全数据继续拒绝;旧复核回执不作为新证据。真实落盘错误及用户取消不被容量退化吞掉。
|
|
16
16
|
|
|
17
|
-
##
|
|
17
|
+
## 验证与交付
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
新增实际执行回归覆盖包络窗口开启/关闭阶段继续、非空候选、51/101条合法合并限制、缓存拒绝后继续处理其他文件、断点复用主审但重新独立复核,以及容量、取消和落盘失败。修复冻结验收完整回归1249项:1245通过、0失败、4跳过;关键组合66/66通过,宿主离线smoke通过。这些不是业务项目重跑或真实模型质量、速度保证。
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
## 验证与发布边界
|
|
24
|
-
|
|
25
|
-
受控离线夹具覆盖开关实际调用对照、原生/文本预算、无效可选通道回退、新回执、取消回滚及续审配置绑定。它们验证机制,不是用户业务重跑,也不证明真实模型召回、速度或成本。增量多轮早停、多轮/窗口组主审、推理记忆压缩和安全检索模式仍未实现,见 [能力清单](CORE-ALIGNMENT-TASKS.md)。
|
|
26
|
-
|
|
27
|
-
正式交付仍须完成冻结标签 Ubuntu/Windows × Node.js22/24 四矩阵及 Actions、npm、GitHub Release 原始安装包逐字节与校验清单核验。本说明不是已发布或已安装声明;发布不会自动升级或重启当前 DSH。
|
|
21
|
+
正式发布还须通过该版本冻结标签 Ubuntu/Windows × Node.js22/24 四矩阵,并核验 Actions、npm 与 GitHub Release 的同一原始安装包及校验清单。发布不会自动安装、升级或重启当前 DSH。
|
package/package.json
CHANGED
package/src/primary-input.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { sourceSegments } from './source-segments.mjs';
|
|
2
2
|
import { primaryContextFocus } from './primary-context.mjs';
|
|
3
|
-
import { changeRegion } from './change-region.mjs';
|
|
4
3
|
import { CONTEXT_SIDE_NOTICE } from './context-identity.mjs';
|
|
5
4
|
import { approvedCallerBindings } from './caller-binding-input.mjs';
|
|
6
5
|
import { REQUIREMENT_NOTICE } from './business-requirement.mjs';
|
|
@@ -12,7 +11,7 @@ import { inferContextRelations } from './context-relations.mjs';
|
|
|
12
11
|
import { prepareWindowSynthesis } from './window-synthesis.mjs';
|
|
13
12
|
import { planWindowBatches } from './window-batches.mjs';
|
|
14
13
|
import { changeWindows } from './change-windows.mjs';
|
|
15
|
-
import {
|
|
14
|
+
import { reviewChangeMap } from './review-change-map.mjs';
|
|
16
15
|
import { initialInputBudget } from './input-budget.mjs';
|
|
17
16
|
import { changeScopeNotice } from './git-change-scope.mjs';
|
|
18
17
|
import { chooseRiskStrategy, riskPlanningMode as validateRiskPlanningMode } from './risk-strategy.mjs';
|
|
@@ -25,18 +24,7 @@ const optional = h => h.confidence === 'namespace-member-syntax-only' || h.confi
|
|
|
25
24
|
export function preparePrimaryInput(input, file, { instructions, scope, grouping, maxInputBytes, riskPlanningMode, enableRiskPlanning, skipSmallRiskPlans, round, focusPrimaryContext = false }) {
|
|
26
25
|
validateRiskPlanningMode(riskPlanningMode);
|
|
27
26
|
if (typeof focusPrimaryContext !== 'boolean') throw new Error('focusPrimaryContext must be boolean');
|
|
28
|
-
|
|
29
|
-
if (changes.status === 'limited') {
|
|
30
|
-
const region = changeRegion(file.left.text, file.right.text);
|
|
31
|
-
if (region.status === 'changed') {
|
|
32
|
-
changes = {
|
|
33
|
-
status: 'changed', precision: 'envelope',
|
|
34
|
-
reason: 'Exact diff unavailable; broad region includes potentially unchanged lines, not exact edits or causal proof.',
|
|
35
|
-
edits: [{ id: 'envelope1', old: region.old, new: region.new }],
|
|
36
|
-
hunks: [{ id: 'envelope-h1', old: region.old, new: region.new, editIds: ['envelope1'] }],
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
}
|
|
27
|
+
const changes = reviewChangeMap(file.left.text, file.right.text);
|
|
40
28
|
|
|
41
29
|
let base = {
|
|
42
30
|
...(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.' } : {}),
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { changeMap } from './change-map.mjs';
|
|
2
|
+
import { changeRegion } from './change-region.mjs';
|
|
3
|
+
|
|
4
|
+
// Canonical review transport coordinates, recomputed solely from approved source.
|
|
5
|
+
// An envelope is a bounded broad region, never an exact edit or causal proof.
|
|
6
|
+
export function reviewChangeMap(before, after) {
|
|
7
|
+
const changes = changeMap(before, after);
|
|
8
|
+
if (changes.status !== 'limited') return changes;
|
|
9
|
+
const region = changeRegion(before, after);
|
|
10
|
+
if (region.status !== 'changed') return changes;
|
|
11
|
+
return {
|
|
12
|
+
status: 'changed', precision: 'envelope',
|
|
13
|
+
reason: 'Exact diff unavailable; broad region includes potentially unchanged lines, not exact edits or causal proof.',
|
|
14
|
+
edits: [{ id: 'envelope1', old: region.old, new: region.new }],
|
|
15
|
+
hunks: [{ id: 'envelope-h1', old: region.old, new: region.new, editIds: ['envelope1'] }],
|
|
16
|
+
};
|
|
17
|
+
}
|
package/src/review-resume.mjs
CHANGED
|
@@ -9,11 +9,18 @@ const digest = value => hash(JSON.stringify(canonical(value)));
|
|
|
9
9
|
const MAX_BYTES = 8 * 1024 * 1024;
|
|
10
10
|
const stageEnabled = options => options?.execution?.enableStageContinuation === true || options?.enableStageContinuation === true;
|
|
11
11
|
const invalid = () => { throw new Error('Invalid stage resume checkpoint'); };
|
|
12
|
+
// Private export-only sentinel: never infer capacity from an arbitrary error/code.
|
|
13
|
+
class ResumeStorageCapacityError extends Error {
|
|
14
|
+
constructor() { super('Resume checkpoint exceeds storage capacity'); this.code = 'RESUME_STORAGE_CAPACITY_EXCEEDED'; }
|
|
15
|
+
}
|
|
16
|
+
const capacity = mode => { if (mode === 'export') throw new ResumeStorageCapacityError(); if (mode !== 'scan') invalid(); };
|
|
17
|
+
export const RESUME_CHECKPOINT_CAPACITY_LIMITATION = '续审检查点容量受限:仅保存适配容量的完整历史单元,未保存部分将重新执行;不影响本次发现或授予证据权限。';
|
|
12
18
|
const exact = (value, keys) => { if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !keys.includes(k))) invalid(); };
|
|
13
19
|
const RECEIPT_KEYS = new Set(['receiptId', 'receipts', 'retrievalAudit', 'retrieved', 'resolveEvidence']);
|
|
14
|
-
function privateClone(value, { stripReceipts = false } = {}, seen = new Set(), depth = 0, budget = { nodes: 0, bytes: 0 }) {
|
|
15
|
-
if (
|
|
16
|
-
if (
|
|
20
|
+
function privateClone(value, { stripReceipts = false, capacityMode } = {}, seen = new Set(), depth = 0, budget = { nodes: 0, bytes: 0 }) {
|
|
21
|
+
if (depth > 32) invalid();
|
|
22
|
+
if (++budget.nodes > 300000) capacity(capacityMode);
|
|
23
|
+
if (typeof value === 'string') { budget.bytes += Buffer.byteLength(value); if (budget.bytes > MAX_BYTES) capacity(capacityMode); return value; }
|
|
17
24
|
if (value === null || typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value)) return value;
|
|
18
25
|
if (!value || typeof value !== 'object' || seen.has(value) || ![Object.prototype, Array.prototype, null].includes(Object.getPrototypeOf(value))) invalid();
|
|
19
26
|
if (Array.isArray(value) && (value.length > 100000 || Object.keys(value).length !== value.length)) invalid();
|
|
@@ -26,11 +33,57 @@ function privateClone(value, { stripReceipts = false } = {}, seen = new Set(), d
|
|
|
26
33
|
// Imported checkpoints remain JSON-only and reject undefined explicitly.
|
|
27
34
|
if (stripReceipts && !Array.isArray(value) && d.value === undefined) continue;
|
|
28
35
|
if (RECEIPT_KEYS.has(key)) { if (stripReceipts) continue; invalid(); }
|
|
29
|
-
out[key] = privateClone(d.value, { stripReceipts }, seen, depth + 1, budget);
|
|
36
|
+
out[key] = privateClone(d.value, { stripReceipts, capacityMode }, seen, depth + 1, budget);
|
|
30
37
|
}
|
|
31
38
|
seen.delete(value);return out;
|
|
32
39
|
}
|
|
33
|
-
function bounded(value) { if (Buffer.byteLength(JSON.stringify(value)) > MAX_BYTES)
|
|
40
|
+
function bounded(value, capacityMode) { if (Buffer.byteLength(JSON.stringify(value)) > MAX_BYTES) capacity(capacityMode); return value; }
|
|
41
|
+
|
|
42
|
+
function exportWithinCapacity(value) {
|
|
43
|
+
const cloned = bounded(privateClone(value, { stripReceipts: true, capacityMode: 'export' }), 'export');
|
|
44
|
+
// Accepted-stage import has a stricter aggregate node budget than the wrapper.
|
|
45
|
+
const stageBudget = { nodes: 0, bytes: 0 };
|
|
46
|
+
privateClone(cloned.stageGenerations, { capacityMode: 'export' }, new Set(), 0, stageBudget);
|
|
47
|
+
if (stageBudget.nodes > 200000) throw new ResumeStorageCapacityError();
|
|
48
|
+
return cloned;
|
|
49
|
+
}
|
|
50
|
+
function capacityFits(value) {
|
|
51
|
+
try { exportWithinCapacity(value); return true; }
|
|
52
|
+
catch (error) { if (error instanceof ResumeStorageCapacityError) return false; throw error; }
|
|
53
|
+
}
|
|
54
|
+
function boundedExport(result, report, snapshot) {
|
|
55
|
+
// Every unit has already passed structural and semantic checks before admission.
|
|
56
|
+
// Only the serialized storage/node aggregate may degrade; unknown errors escape.
|
|
57
|
+
if (capacityFits(result)) return prepareV2(exportWithinCapacity(result), snapshot);
|
|
58
|
+
const next = { schemaVersion: 2, snapshotId: result.snapshotId, identityHash: result.identityHash, completed: [], interactions: [], stageGenerations: [] };
|
|
59
|
+
exportWithinCapacity(next); // Even an unusable identity shell must fail normally.
|
|
60
|
+
const omitted = { completed: 0, stageGenerations: 0, interactions: 0, continuation: 0, businessGrouping: 0 };
|
|
61
|
+
for (const row of result.completed) {
|
|
62
|
+
const candidate = { ...next, completed: [...next.completed, row] };
|
|
63
|
+
if (capacityFits(candidate)) next.completed.push(row); else omitted.completed++;
|
|
64
|
+
}
|
|
65
|
+
if (result.businessGrouping !== undefined) {
|
|
66
|
+
if (capacityFits({ ...next, businessGrouping: result.businessGrouping })) next.businessGrouping = result.businessGrouping; else omitted.businessGrouping++;
|
|
67
|
+
}
|
|
68
|
+
for (const row of result.stageGenerations) {
|
|
69
|
+
if (capacityFits({ ...next, stageGenerations: [...next.stageGenerations, row] })) next.stageGenerations.push(row); else omitted.stageGenerations++;
|
|
70
|
+
}
|
|
71
|
+
if (result.continuation !== undefined) {
|
|
72
|
+
// Historical continuation cannot imply completion for a dropped primary unit.
|
|
73
|
+
if (!omitted.stageGenerations && !omitted.completed && capacityFits({ ...next, continuation: result.continuation })) next.continuation = result.continuation; else omitted.continuation++;
|
|
74
|
+
}
|
|
75
|
+
const kept = new Set(next.completed.map(row => row.fileId));
|
|
76
|
+
for (const row of result.interactions) {
|
|
77
|
+
if (row.fileIds.every(id => kept.has(id)) && capacityFits({ ...next, interactions: [...next.interactions, row] })) next.interactions.push(row); else omitted.interactions++;
|
|
78
|
+
}
|
|
79
|
+
const limitation = { code: 'RESUME_CHECKPOINT_CAPACITY_EXCEEDED', text: RESUME_CHECKPOINT_CAPACITY_LIMITATION, omitted };
|
|
80
|
+
report.resumeLimitations ??= [];
|
|
81
|
+
const previous = report.resumeLimitations.findIndex(row => row.code === limitation.code);
|
|
82
|
+
if (previous < 0) report.resumeLimitations.push(limitation); else report.resumeLimitations[previous] = limitation;
|
|
83
|
+
report.limitations ??= [];
|
|
84
|
+
if (!report.limitations.some(row => row.text === limitation.text)) report.limitations.push({ text: limitation.text });
|
|
85
|
+
return prepareV2(exportWithinCapacity(next), snapshot);
|
|
86
|
+
}
|
|
34
87
|
function continuationState(snapshot, wrapper) {
|
|
35
88
|
exact(wrapper, ['options', 'state']);
|
|
36
89
|
const o = wrapper.options, cp = wrapper.state;
|
|
@@ -60,7 +113,28 @@ const safeFile = file => !file?.stageFailures?.length && !file?.reviewPasses?.so
|
|
|
60
113
|
// neither source authority nor model approval. Exact whole-snapshot binding is
|
|
61
114
|
// deliberately conservative: any changed context/rule/source invalidates reuse.
|
|
62
115
|
export function buildResumeState(snapshot, options, report) {
|
|
63
|
-
if (typeof snapshot?.id !== 'string' || !Array.isArray(snapshot.files) || !report ||
|
|
116
|
+
if (typeof snapshot?.id !== 'string' || !Array.isArray(snapshot.files) || !report || typeof report !== 'object') throw new Error('Invalid resume checkpoint');
|
|
117
|
+
if (stageEnabled(options)) {
|
|
118
|
+
// Scan the entire export payload before any filtering/size admission. Unlike
|
|
119
|
+
// imports this scan records no storage entitlement, but never executes hooks.
|
|
120
|
+
if (![Object.prototype, null].includes(Object.getPrototypeOf(report))) invalid();
|
|
121
|
+
const noticeDescriptor = Object.getOwnPropertyDescriptor(report, 'resumeLimitations');
|
|
122
|
+
if (noticeDescriptor && (!noticeDescriptor.enumerable || !Object.hasOwn(noticeDescriptor, 'value') || !Array.isArray(noticeDescriptor.value))) invalid();
|
|
123
|
+
const keys = ['files', 'findings', 'limitations', 'interactions', 'businessGrouping', 'stageGenerations', 'continuationOptions', 'continuation', 'resumeLimitations'];
|
|
124
|
+
const candidate = {};
|
|
125
|
+
for (const key of keys) {
|
|
126
|
+
const descriptor = Object.getOwnPropertyDescriptor(report, key);
|
|
127
|
+
if (descriptor) { if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) invalid(); candidate[key] = descriptor.value; }
|
|
128
|
+
}
|
|
129
|
+
const safe = privateClone(candidate, { stripReceipts: true, capacityMode: 'scan' });
|
|
130
|
+
// Preserve mutation target only for explicit capacity notices below.
|
|
131
|
+
return buildStageResumeState(snapshot, options, safe, report);
|
|
132
|
+
}
|
|
133
|
+
return buildStageResumeState(snapshot, options, report, report);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function buildStageResumeState(snapshot, options, report, noticeTarget) {
|
|
137
|
+
if (!Array.isArray(report.files) || !Array.isArray(report.findings)) throw new Error('Invalid resume checkpoint');
|
|
64
138
|
const approved = new Map(snapshot.files.filter(file => file.eligibility === 'reviewable').map(file => [file.id, file]));
|
|
65
139
|
const completed = [];
|
|
66
140
|
for (const state of report.files) {
|
|
@@ -81,9 +155,21 @@ export function buildResumeState(snapshot, options, report) {
|
|
|
81
155
|
const result = { schemaVersion: 1, snapshotId: snapshot.id, identityHash: identity, completed, ...(businessGrouping ? { businessGrouping } : {}), interactions };
|
|
82
156
|
if (stageEnabled(options)) {
|
|
83
157
|
result.schemaVersion = 2;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
158
|
+
const stageKeys = new Set();
|
|
159
|
+
result.stageGenerations = (report.stageGenerations ?? []).flatMap(record => {
|
|
160
|
+
const [row] = prepareStageGenerations(snapshot, [record]);
|
|
161
|
+
if (stageKeys.has(row.key)) invalid(); stageKeys.add(row.key);
|
|
162
|
+
return row.phase === 'primary' && !completed.some(item => item.fileId === row.fileId) ? [row] : [];
|
|
163
|
+
});
|
|
164
|
+
if (report.continuation !== undefined) result.continuation = continuationState(snapshot, { options: report.continuationOptions, state: report.continuation });
|
|
165
|
+
// Check every completed chain, including units later omitted for capacity.
|
|
166
|
+
for (const item of completed) {
|
|
167
|
+
const file = approved.get(item.fileId);
|
|
168
|
+
if (!file || !safeFile(item.state) || item.state.fileId !== item.fileId || item.state.path !== item.path ||
|
|
169
|
+
!Array.isArray(item.findings) || item.findings.some(f => f?.fileId !== item.fileId || f.verification?.status !== 'completed') ||
|
|
170
|
+
!Array.isArray(item.limitations) || item.limitations.some(row => row?.fileId !== item.fileId)) throw new Error('Invalid completed resume chain');
|
|
171
|
+
}
|
|
172
|
+
return boundedExport(result, noticeTarget, snapshot);
|
|
87
173
|
}
|
|
88
174
|
if (Buffer.byteLength(JSON.stringify(result)) > 8 * 1024 * 1024) throw new Error('Resume checkpoint exceeds storage limit');
|
|
89
175
|
return result;
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { hash } from './content.mjs';
|
|
2
2
|
import { changeMap } from './change-map.mjs';
|
|
3
|
+
import { reviewChangeMap } from './review-change-map.mjs';
|
|
3
4
|
import { changeWindows } from './change-windows.mjs';
|
|
4
5
|
import { sourceSegments } from './source-segments.mjs';
|
|
5
6
|
import { validateWindowFindings } from './window-findings.mjs';
|
|
6
7
|
import { validateAttribution } from './attribution.mjs';
|
|
7
8
|
|
|
8
9
|
const LIMIT = 8 * 1024 * 1024;
|
|
10
|
+
// Host-only accepted generation: member model 50 + group model 50 + host notes 2.
|
|
11
|
+
// This is checkpoint/report metadata, never a wider model response or evidence grant.
|
|
12
|
+
export const MAX_STAGE_LIMITATIONS = 102;
|
|
9
13
|
const PHASES = ['primary', 'window-synthesis', 'file-interaction'];
|
|
10
14
|
const DROP = ['verification', 'regression', 'priorRun', 'retrievalAudit', 'receipts', 'receiptId'];
|
|
11
15
|
const fail = () => { throw Object.assign(new Error('Invalid accepted stage generation'), { code: 'INVALID_STAGE_GENERATION' }); };
|
|
@@ -71,6 +75,7 @@ function normalizeFinding(raw, file, changes, phase, files) {
|
|
|
71
75
|
if (raw.attribution?.status === 'missing' && Object.keys(raw.attribution).some(k => !['status', 'causality'].includes(k))) fail();
|
|
72
76
|
attribution = validateAttribution(value, { ...file, properties: file.properties ?? [] }, changes);
|
|
73
77
|
if (raw.attribution?.status !== undefined && raw.attribution.status !== attribution.status) fail();
|
|
78
|
+
for (const key of ['precision', 'notice', 'causality']) if (raw.attribution?.[key] !== undefined && raw.attribution[key] !== attribution[key]) fail();
|
|
74
79
|
}
|
|
75
80
|
const result = { fileId: file.id, path: file.path, severity: raw.severity, anchor, attribution };
|
|
76
81
|
for (const key of ['title', 'evidence', 'trigger', 'impact', 'suggestion']) result[key] = raw[key];
|
|
@@ -120,8 +125,8 @@ export function prepareStageGenerations(snapshot, records, { maxBytes = LIMIT, v
|
|
|
120
125
|
seen.add(key);
|
|
121
126
|
exact(record.generation, ['findings', 'limitations', ...DROP]);
|
|
122
127
|
const g = record.generation;
|
|
123
|
-
if (!Array.isArray(g.findings) || g.findings.length > 50 || !Array.isArray(g.limitations) || g.limitations.length >
|
|
124
|
-
const changes =
|
|
128
|
+
if (!Array.isArray(g.findings) || g.findings.length > 50 || !Array.isArray(g.limitations) || g.limitations.length > MAX_STAGE_LIMITATIONS || g.limitations.some(s => !text(s))) fail();
|
|
129
|
+
const changes = reviewChangeMap(file.left.text, file.right.text);
|
|
125
130
|
const findings = g.findings.map(raw => {
|
|
126
131
|
if (raw.reviewRound !== undefined && raw.reviewRound !== round) fail();
|
|
127
132
|
return normalizeFinding(raw, file, changes, phase, files);
|
package/src/review.mjs
CHANGED
|
@@ -36,7 +36,9 @@ import { createEvidenceContext } from './evidence-context.mjs';
|
|
|
36
36
|
import { prepareInteractionRisk, consumeInteractionRisks } from './interaction-risk.mjs';
|
|
37
37
|
import { reportSummaryMarkdown } from './report-summary.mjs';
|
|
38
38
|
import { validateGroupInteraction } from './group-interaction.mjs';
|
|
39
|
-
import { stageKey
|
|
39
|
+
import { stageKey } from './review-stage-state.mjs';
|
|
40
|
+
import { createStageGenerationCache } from './stage-generation-cache.mjs';
|
|
41
|
+
import { reviewChangeMap } from './review-change-map.mjs';
|
|
40
42
|
import { createStageContinuation } from './stage-continuation.mjs';
|
|
41
43
|
import { buildMigrationContracts } from './migration-contracts.mjs';
|
|
42
44
|
|
|
@@ -54,7 +56,7 @@ function validateResponse(response, file, knownChanges) {
|
|
|
54
56
|
if (Buffer.byteLength(typeof response === 'string' ? response : JSON.stringify(response) ?? '') > 128 * 1024) throw new Error('Review response too large');
|
|
55
57
|
if (typeof response === 'string') response = JSON.parse(response);
|
|
56
58
|
if (!response || !Array.isArray(response.findings) || response.findings.length > 50 || !Array.isArray(response.limitations) || response.limitations.length > 50 || response.limitations.some(s => !nonempty(s))) throw protocolError('MODEL_INVALID_REVIEW_RESPONSE', 'Invalid review response');
|
|
57
|
-
const changes = knownChanges ??
|
|
59
|
+
const changes = knownChanges ?? reviewChangeMap(file.left.text, file.right.text);
|
|
58
60
|
const findings = response.findings.map(raw => {
|
|
59
61
|
if (!raw || raw.fileId !== file.id) throw protocolError('MODEL_INVALID_FINDING_IDENTITY', 'Invalid finding identity or severity');
|
|
60
62
|
if (!['critical', 'high', 'medium', 'low'].includes(raw.severity)) throw protocolError('MODEL_INVALID_FINDING_SEVERITY', 'Invalid finding identity or severity');
|
|
@@ -189,11 +191,12 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
189
191
|
} });
|
|
190
192
|
};
|
|
191
193
|
const groupPrepared = new Map(), groupResults = new Map(), groupStarted = new Map(), groupInteractions = new Map();
|
|
192
|
-
const
|
|
194
|
+
const generationCache = createStageGenerationCache(input, options.resumeState?.stageGenerations ?? []);
|
|
195
|
+
const acceptedStages = new Map(generationCache.records().filter(record => record.phase === 'primary').map(record => [record.key, { generation: record.generation, record, restored: true }]));
|
|
193
196
|
const deferredStages = [], yieldedUnits = new Set(), fileDeadlines = new Map(), primaryStageKeys = new Map();
|
|
194
197
|
const checkpointStages = () => {
|
|
195
198
|
if (!options.enableStageContinuation) return;
|
|
196
|
-
report.stageGenerations =
|
|
199
|
+
report.stageGenerations = generationCache.records().filter(record => record.phase === 'primary');
|
|
197
200
|
};
|
|
198
201
|
let readerAllocation;
|
|
199
202
|
const stageScope = id => readerAllocation
|
|
@@ -357,9 +360,10 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
357
360
|
const key = stageKey(file.id, { phase: batch.interaction ? 'file-interaction' : batch.synthesis ? 'window-synthesis' : 'primary', round: batch.round, windowIds: batch.windowIds ?? [], ...(batch.interaction ? { groupId: batch.interaction.id } : {}) });
|
|
358
361
|
const priorStage = acceptedStages.get(key);
|
|
359
362
|
if (retry && priorStage?.completed) return;
|
|
363
|
+
let cacheRejected = false;
|
|
360
364
|
const allocationId = batch.interaction ? 'interaction:' + batch.interaction.id : batch.synthesis ? 'synthesis:' + file.id : 'primary:' + file.id;
|
|
361
365
|
const defer = (error, unitId = allocationId) => {
|
|
362
|
-
if (!options.enableStageContinuation || !readerAllocation || !['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED'].includes(error?.code)) return false;
|
|
366
|
+
if (cacheRejected || !options.enableStageContinuation || !readerAllocation || !['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED'].includes(error?.code)) return false;
|
|
363
367
|
if (!yieldedUnits.has(unitId)) { readerAllocation.yield(unitId, { reason: error.code }); yieldedUnits.add(unitId); }
|
|
364
368
|
let pending = deferredStages.find(item => item.key === key);
|
|
365
369
|
if (pending && pending.allocationId !== unitId) { const previousUnit = pending.allocationId; pending.allocationId = unitId; if (!deferredStages.some(other => other !== pending && !other.done && other.allocationId === previousUnit)) { yieldedUnits.delete(previousUnit); readerAllocation.release(previousUnit); } }
|
|
@@ -521,8 +525,15 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
521
525
|
if(rounds>1)generated.findings.forEach(f=>{f.reviewRound=batch.round;});
|
|
522
526
|
if (options.enableStageContinuation && !priorStage && !state.retrievalLimited) {
|
|
523
527
|
const record = { key, fileId: file.id, phase, round: batch.round, windowIds: batch.windowIds ?? [], ...(batch.interaction ? { groupId: batch.interaction.id } : {}), generation: { findings: structuredClone(generated.findings), limitations: structuredClone(generated.limitations) } };
|
|
524
|
-
|
|
525
|
-
|
|
528
|
+
const admission = generationCache.admit(record);
|
|
529
|
+
if (admission.accepted) {
|
|
530
|
+
acceptedStages.set(key, { generation: generated, record: admission.record, completed: false });
|
|
531
|
+
checkpointStages();
|
|
532
|
+
} else {
|
|
533
|
+
cacheRejected = true;
|
|
534
|
+
(state.stageCacheLimits ??= []).push({ phase, round: batch.round, code: admission.code });
|
|
535
|
+
result.limitations.push({ fileId: file.id, text: '阶段生成未纳入续审缓存:' + admission.code + ';保留本次有效审查结果和独立复核,不复用未缓存生成、不扩大容量。' });
|
|
536
|
+
}
|
|
526
537
|
}
|
|
527
538
|
for (const finding of generated.findings) { const index = priorStage ? result.findings.findIndex(existing => existing.id === finding.id) : -1; if (index < 0) result.findings.push(finding); else result.findings[index] = finding; }
|
|
528
539
|
if (generated.findings.some(f => f.attribution.status === 'missing')) result.limitations.push({ fileId: file.id, text: '部分发现缺少变更归因,仅校验了定位,尚不能确认属于本次回归' });
|
|
@@ -530,7 +541,13 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
530
541
|
if (!groupedGeneration && !scheduled.slice(batchIndex + 1).some(next => !next.synthesis && !next.interaction)) releaseStage('primary:' + file.id);
|
|
531
542
|
if (options.enableStageContinuation && !batch.interaction && !batch.synthesis && state.windowCoverage && batch.round === 1) { state.windowCoverage.completed = [...new Set([...state.windowCoverage.completed, ...batch.windowIds])]; state.windowCoverage.pending = state.windowCoverage.pending.filter(id => !batch.windowIds.includes(id)); }
|
|
532
543
|
if (options.enableVerification === true && generated.findings.length && !priorStage?.completed) {
|
|
533
|
-
if (yieldedUnits.has('verification:' + file.id)) {
|
|
544
|
+
if (yieldedUnits.has('verification:' + file.id)) {
|
|
545
|
+
if (!defer(Object.assign(new Error('Verification allocation is waiting for released capacity'), { code: 'READER_CALL_BUDGET_EXCEEDED' }), 'verification:' + file.id)) {
|
|
546
|
+
for (const finding of generated.findings) finding.verification = { status: 'incomplete', causality: 'unverified' };
|
|
547
|
+
result.limitations.push({ fileId: file.id, text: '复核额度等待释放且生成未缓存,不能继续本阶段;候选仍未复核,不重新生成或标为完成。' });
|
|
548
|
+
}
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
534
551
|
for (const finding of generated.findings) finding.verification = { status: 'pending', causality: 'unverified' };
|
|
535
552
|
try {
|
|
536
553
|
activeStage = 'verification';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { prepareStageGenerations } from './review-stage-state.mjs';
|
|
2
|
+
|
|
3
|
+
// Only host-validated generations enter this cache. Failed admission leaves the
|
|
4
|
+
// last good set unchanged; imported checkpoints still fail closed in the validator.
|
|
5
|
+
export function createStageGenerationCache(snapshot, records = [], { maxBytes = 8 * 1024 * 1024 } = {}) {
|
|
6
|
+
let saved = prepareStageGenerations(snapshot, records, { maxBytes });
|
|
7
|
+
return {
|
|
8
|
+
records() { return structuredClone(saved); },
|
|
9
|
+
admit(record) {
|
|
10
|
+
let normalized;
|
|
11
|
+
try { [normalized] = prepareStageGenerations(snapshot, [record]); }
|
|
12
|
+
catch { return { accepted: false, code: 'STAGE_CACHE_RECORD_REJECTED' }; }
|
|
13
|
+
const next = saved.filter(row => row.key !== normalized.key);
|
|
14
|
+
next.push(normalized);
|
|
15
|
+
let checked;
|
|
16
|
+
try { checked = prepareStageGenerations(snapshot, next, { maxBytes }); }
|
|
17
|
+
catch { return { accepted: false, code: 'STAGE_CACHE_CAPACITY_EXCEEDED' }; }
|
|
18
|
+
saved = checked;
|
|
19
|
+
return { accepted: true, record: structuredClone(normalized) };
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|