@feng0531/dsh-code-review 0.86.0 → 0.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/CORE-ALIGNMENT-TASKS.md +19 -4
- package/CORE-SOURCE-INVENTORY.md +6 -6
- package/DEVELOPMENT.md +15 -1
- package/README.md +22 -5
- package/RELEASE-NOTES.md +14 -14
- package/index.mjs +1 -1
- package/package.json +1 -1
- package/src/background-review.mjs +36 -0
- package/src/candidate-verification.mjs +1 -1
- package/src/coverage-plan.mjs +20 -6
- package/src/cpp-migration-bindings.mjs +149 -0
- package/src/evidence-context.mjs +96 -0
- package/src/group-interaction.mjs +48 -0
- package/src/group-primary.mjs +14 -3
- package/src/interaction-risk.mjs +71 -0
- package/src/migration-contracts.mjs +160 -0
- package/src/migration-regression.mjs +103 -0
- package/src/model-contract.mjs +3 -3
- package/src/model-tools.mjs +6 -4
- package/src/primary-seeds.mjs +6 -2
- package/src/project-review-service.mjs +2 -2
- package/src/prompts.mjs +1 -0
- package/src/regression-assessment.mjs +8 -2
- package/src/report-summary.mjs +59 -0
- package/src/retrieval-loop.mjs +17 -4
- package/src/review-artifacts.mjs +1 -1
- package/src/review-diagnostics.mjs +2 -1
- package/src/review-jobs.mjs +1 -1
- package/src/review-reader-allocation.mjs +117 -0
- package/src/review-reader-policy.mjs +1 -1
- package/src/review-resume.mjs +67 -2
- package/src/review-stage-state.mjs +143 -0
- package/src/review.mjs +221 -52
- package/src/service.mjs +8 -8
- package/src/snapshot-reader.mjs +62 -22
- package/src/stage-continuation.mjs +74 -0
- package/src/verification-loop.mjs +9 -4
- package/src/verification-seeds.mjs +18 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { retrievalRequest } from './retrieval-loop.mjs';
|
|
2
|
+
import { modelWireBudget, nativeWireEnabled } from './model-wire-budget.mjs';
|
|
3
|
+
|
|
4
|
+
const positive = value => Number.isSafeInteger(value) && value > 0;
|
|
5
|
+
const contains = (outer, inner) => positive(outer?.start) && positive(outer?.count) && outer.start <= inner.start && outer.start + outer.count >= inner.start + inner.count;
|
|
6
|
+
const capacity = () => Object.assign(new Error('Optional evidence context unavailable'), { code: 'EVIDENCE_CONTEXT_CAPACITY' });
|
|
7
|
+
const optional = error => ['EVIDENCE_CONTEXT_CAPACITY', 'READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED'].includes(error?.code);
|
|
8
|
+
|
|
9
|
+
function supplied(payload, source, range) {
|
|
10
|
+
if (source.side === 'context' || source.side === 'context-old') return (Array.isArray(payload.context) ? payload.context : []).some(item => item?.path === source.path && typeof item[source.side === 'context' ? 'text' : 'oldText'] === 'string');
|
|
11
|
+
if (!['old', 'new'].includes(source.side)) return false;
|
|
12
|
+
const files = [payload.file, ...(Array.isArray(payload.relatedFiles) ? payload.relatedFiles : []), ...(Array.isArray(payload.reviews) ? payload.reviews.flatMap(review => [review.file, ...(Array.isArray(review.relatedFiles) ? review.relatedFiles : [])]) : [])];
|
|
13
|
+
if (files.some(file => file?.id === source.fileId && file.path === source.path && typeof (source.side === 'old' ? file.left?.text : file.right?.text) === 'string')) return true;
|
|
14
|
+
return payload.file?.id === source.fileId && payload.file.path === source.path && payload.sourceMode === 'change-windows' && (payload.windows ?? []).some(window => typeof window?.[source.side]?.text === 'string' && contains(window[source.side], range));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A review-lifetime navigation index, NOT evidence, authority, or a receipt store.
|
|
18
|
+
// remember() must be called only for reads admitted to an actual model input.
|
|
19
|
+
// seed() never audits or remembers its own results; the sender does that after
|
|
20
|
+
// its normal fit/beforeCall/cancellation checks, just like ordinary retrieval.
|
|
21
|
+
export function createEvidenceContext(snapshotId, { maxEntries = 128 } = {}) {
|
|
22
|
+
if (typeof snapshotId !== 'string' || !snapshotId || snapshotId.length > 500 || !positive(maxEntries) || maxEntries > 512) throw new Error('Invalid evidence context options');
|
|
23
|
+
const entries = new Map();
|
|
24
|
+
return Object.freeze({
|
|
25
|
+
size: () => entries.size,
|
|
26
|
+
remember(records) {
|
|
27
|
+
if (!Array.isArray(records) || records.length > 256) throw new Error('Invalid evidence context records');
|
|
28
|
+
for (const record of records) {
|
|
29
|
+
if (record?.kind !== 'read' || record.snapshotId !== snapshotId || record.omitted === true || typeof record.sourceId !== 'string' || !record.sourceId || record.sourceId.length > 200 || typeof record.hash !== 'string' || !/^[a-f0-9]{64}$/.test(record.hash) || !positive(record.start) || !positive(record.count) || record.count > 200 || !Number.isSafeInteger(record.start + record.count)) continue;
|
|
30
|
+
const item = { sourceId: record.sourceId, hash: record.hash, start: record.start, count: record.count };
|
|
31
|
+
const key = JSON.stringify(item);
|
|
32
|
+
if (entries.has(key)) continue;
|
|
33
|
+
if (entries.size >= maxEntries) entries.delete(entries.keys().next().value);
|
|
34
|
+
entries.set(key, item);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
seed(scope, request, { sourceIds = [], ranges = [], existing = [], maxReads = 4, maxLines = 80, maxBytes = 16384, maxInputBytes = 98304, reserveCalls = 2, reserveBytes = 4096, signal = request?.signal, buildRequest = records => retrievalRequest(request, scope, records) } = {}) {
|
|
38
|
+
if (!Array.isArray(sourceIds) || sourceIds.length > 200 || sourceIds.some(id => typeof id !== 'string' || !id || id.length > 200) || !Array.isArray(ranges) || ranges.length > 200 || ranges.some(range => typeof range?.sourceId !== 'string' || !positive(range.start) || !positive(range.count) || !Number.isSafeInteger(range.start + range.count)) || !Array.isArray(existing) || existing.length > 50 || !Number.isSafeInteger(maxReads) || maxReads < 0 || maxReads > 8 || !positive(maxLines) || maxLines > 200 || !positive(maxBytes) || maxBytes > 65536 || !positive(maxInputBytes) || !Number.isSafeInteger(reserveCalls) || reserveCalls < 0 || reserveCalls > 100 || !Number.isSafeInteger(reserveBytes) || reserveBytes < 0 || reserveBytes > 1048576 || typeof buildRequest !== 'function' || signal !== undefined && !(signal instanceof AbortSignal) || request?.signal !== undefined && !(request.signal instanceof AbortSignal)) throw new Error('Invalid evidence context seed options');
|
|
39
|
+
const check = () => { request?.signal?.throwIfAborted(); signal?.throwIfAborted(); };
|
|
40
|
+
check();
|
|
41
|
+
const seeds = [];
|
|
42
|
+
if (!maxReads || !entries.size || !sourceIds.length && !ranges.length) return seeds;
|
|
43
|
+
if (typeof request?.input !== 'string' || typeof request.instructions !== 'string') throw new Error('Invalid evidence context request');
|
|
44
|
+
const payload = JSON.parse(request.input);
|
|
45
|
+
if (payload?.snapshotId !== snapshotId || typeof scope?.transaction !== 'function' || typeof scope.preflight !== 'function' || typeof scope.catalog !== 'function' || typeof scope.read !== 'function' || typeof scope.budget !== 'function') return seeds;
|
|
46
|
+
const catalog = scope.catalog(), selected = new Set(sourceIds);
|
|
47
|
+
const attempted = new Set();
|
|
48
|
+
let attempts = 0;
|
|
49
|
+
try {
|
|
50
|
+
return scope.transaction(() => {
|
|
51
|
+
for (const entry of entries.values()) {
|
|
52
|
+
check();
|
|
53
|
+
if (attempts >= maxReads) break;
|
|
54
|
+
const sources = catalog.filter(source => source.id === entry.sourceId);
|
|
55
|
+
if (sources.length !== 1) continue;
|
|
56
|
+
const source = sources[0];
|
|
57
|
+
if (!positive(source.lineCount) || entry.start > source.lineCount || source.hash !== undefined && source.hash !== entry.hash) continue;
|
|
58
|
+
const overlap = ranges.find(range => range.sourceId === entry.sourceId && range.start < entry.start + entry.count && entry.start < range.start + range.count);
|
|
59
|
+
if (!selected.has(entry.sourceId) && !overlap) continue;
|
|
60
|
+
const start = overlap ? Math.max(entry.start, overlap.start) : entry.start;
|
|
61
|
+
const count = Math.min(maxLines, entry.start + entry.count - start, source.lineCount - start + 1, overlap ? overlap.start + overlap.count - start : Infinity);
|
|
62
|
+
const operation = { kind: 'read', id: source.id, start, count };
|
|
63
|
+
const key = JSON.stringify(operation);
|
|
64
|
+
if (attempted.has(key) || supplied(payload, source, operation)) continue;
|
|
65
|
+
if ([...existing, ...seeds].some(item => item?.request?.kind === 'read' && item.result?.snapshotId === snapshotId && item.result.sourceId === source.id && item.result.hash === entry.hash && typeof item.result.text === 'string' && contains(item.result, operation))) continue;
|
|
66
|
+
attempted.add(key); attempts++;
|
|
67
|
+
check();
|
|
68
|
+
const budget = scope.budget(), cost = scope.preflight([operation]);
|
|
69
|
+
const cached = budget.reusableResults === true && cost?.calls === 0;
|
|
70
|
+
if ((!cached && budget.remainingCalls <= reserveCalls) || budget.remainingOutputBytes <= reserveBytes) throw capacity();
|
|
71
|
+
const result = scope.read(operation);
|
|
72
|
+
check();
|
|
73
|
+
if (result.snapshotId !== snapshotId || result.sourceId !== source.id || result.hash !== entry.hash || result.start !== start || result.count !== count || typeof result.text !== 'string' || result.receiptId !== undefined || result.omitted === true) throw capacity();
|
|
74
|
+
const proposed = [...seeds, { request: operation, result }];
|
|
75
|
+
if (Buffer.byteLength(JSON.stringify(proposed)) > maxBytes) throw capacity();
|
|
76
|
+
const next = buildRequest([...existing, ...proposed]);
|
|
77
|
+
if (typeof next?.input !== 'string' || typeof next.instructions !== 'string') throw new Error('Invalid evidence context request builder');
|
|
78
|
+
const fits = nativeWireEnabled(request, scope) ? modelWireBudget({ ...next, nativeOutputTools: true }, maxInputBytes).fits : Buffer.byteLength(next.input) + Buffer.byteLength(next.instructions) <= maxInputBytes;
|
|
79
|
+
check();
|
|
80
|
+
const remaining = scope.budget();
|
|
81
|
+
if (!fits || (!cached && remaining.remainingCalls < reserveCalls) || remaining.remainingOutputBytes < reserveBytes) throw capacity();
|
|
82
|
+
seeds.push({ request: operation, result });
|
|
83
|
+
}
|
|
84
|
+
check();
|
|
85
|
+
return seeds;
|
|
86
|
+
});
|
|
87
|
+
} catch (error) {
|
|
88
|
+
check();
|
|
89
|
+
if (!optional(error)) throw error;
|
|
90
|
+
// One rejected item abandons the entire optional batch: previously read
|
|
91
|
+
// siblings must not consume bytes/calls when no seed array is returned.
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { validateInteractionFindings } from './interaction-findings.mjs';
|
|
3
|
+
import { validateSynthesisReads } from './synthesis-evidence.mjs';
|
|
4
|
+
|
|
5
|
+
export const GROUP_INTERACTION_INSTRUCTIONS = ' GROUP_INTERACTIONS: 本次精确完整组主审可在根结果附带可选 interactions:{groupId:"原groupId",findings:[],limitations:[]}。不要放在reviews成员内。只有在本次生成通过批准read实际读取组内所有精确编辑至少一侧后才可声明交互结果;完整正文、历史audit、风险或search不是该read证据。交互finding必须包含普通finding字段、主文件精确anchor/attribution以及跨至少两个不同成员的interaction.editRefs:[{fileId,editId}]。缺少或无法满足证据时可省略interactions,宿主保留独立交互阶段;不能以空数组伪造已检查。本开关覆盖前述“专门交互阶段仍独立校验”的传输安排:宿主仅在本channel全部验收成功后省去匹配的专门交互生成,缺失或失败仍独立执行;候选复核绝不省略。风险不是证据,分组不证明因果,独立候选复核仍需执行。';
|
|
6
|
+
|
|
7
|
+
// Call independently AFTER validateGroupPrimary. Optional channel failure never
|
|
8
|
+
// discards accepted primary findings, grants evidence, or triggers hidden retries.
|
|
9
|
+
export function validateGroupInteraction(response, prepared, snapshot, reads = [], catalog = []) {
|
|
10
|
+
const groupId = prepared?.groupId;
|
|
11
|
+
const fallback = reason => ({ status: 'fallback', groupId, reason, findings: [], limitations: [] });
|
|
12
|
+
if (prepared?.groupInteraction !== true) return { status: 'disabled', groupId, findings: [], limitations: [] };
|
|
13
|
+
try {
|
|
14
|
+
const serialized = typeof response === 'string' ? response : JSON.stringify(response);
|
|
15
|
+
if (typeof serialized !== 'string' || Buffer.byteLength(serialized) > 128 * 1024) return fallback('invalid-channel');
|
|
16
|
+
const value = typeof response === 'string' ? JSON.parse(response) : response;
|
|
17
|
+
if (!value || !Object.hasOwn(value, 'interactions')) return fallback('missing-channel');
|
|
18
|
+
const channel = value.interactions;
|
|
19
|
+
if (!channel || Array.isArray(channel) || Object.keys(channel).sort().join(',') !== 'findings,groupId,limitations' || channel.groupId !== groupId) return fallback('invalid-channel');
|
|
20
|
+
const ids = prepared.fileIds;
|
|
21
|
+
if (!Array.isArray(ids) || ids.length < 2 || ids.length > 4 || new Set(ids).size !== ids.length || !Array.isArray(value.reviews) || value.reviews.length !== ids.length || new Set(value.reviews.map(row => row?.fileId)).size !== ids.length || value.reviews.some(row => !ids.includes(row?.fileId) || row.findings?.some(finding => finding?.interaction !== undefined))) return fallback('invalid-members');
|
|
22
|
+
const original = JSON.parse(prepared.payload);
|
|
23
|
+
if (original.snapshotId !== snapshot.id || original.sourceMode !== 'group-primary' || original.groupInteraction?.groupId !== groupId || !Array.isArray(original.groupInteraction.fileIds) || original.groupInteraction.fileIds.length !== ids.length || original.groupInteraction.fileIds.some((id, index) => id !== ids[index]) || !Array.isArray(original.reviews) || original.reviews.length !== ids.length || ids.some(id => { const captured = original.reviews.find(row => row.fileId === id)?.file; const current = snapshot.files.find(file => file.id === id); return !captured || !current || captured.path !== current.path || captured.left?.text !== current.left?.text || captured.right?.text !== current.right?.text; })) return fallback('stale-preparation');
|
|
24
|
+
if (!Array.isArray(reads) || !Array.isArray(catalog) || new Set(catalog.map(source => source.id)).size !== catalog.length) return fallback('invalid-evidence');
|
|
25
|
+
// Only successful current-generation source/hash identities participate.
|
|
26
|
+
const audit = reads.filter(read => {
|
|
27
|
+
const source = catalog.find(source => source.id === read?.sourceId);
|
|
28
|
+
const file = snapshot.files.find(file => file.id === source?.fileId && file.path === source.path);
|
|
29
|
+
const text = source?.side === 'old' ? file?.left?.text : source?.side === 'new' ? file?.right?.text : undefined;
|
|
30
|
+
return typeof text === 'string' && read.kind === 'read' && read.snapshotId === snapshot.id && read.omitted !== true && read.hash === createHash('sha256').update(text).digest('hex') && (source.hash === undefined || source.hash === read.hash);
|
|
31
|
+
});
|
|
32
|
+
// Replacing an entire dedicated interaction requires current-generation
|
|
33
|
+
// evidence for EVERY member/edit, not just a selected subset of findings.
|
|
34
|
+
for (const id of ids) {
|
|
35
|
+
const changes = prepared.changes?.get(id);
|
|
36
|
+
if (changes?.precision !== 'exact' || changes.status !== 'changed' || !changes.edits.length) return fallback('inexact-members');
|
|
37
|
+
validateSynthesisReads([{ attribution: { editIds: changes.edits.map(edit => edit.id) } }], changes, audit, catalog, id, snapshot.id);
|
|
38
|
+
}
|
|
39
|
+
const validated = validateInteractionFindings(channel, snapshot, { id: groupId, fileIds: ids }, audit, catalog);
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
for (const finding of validated.findings) {
|
|
42
|
+
const key = JSON.stringify([finding.fileId, finding.anchor, finding.interaction.editRefs.map(ref => [ref.fileId, ref.editId]).sort()]);
|
|
43
|
+
if (seen.has(key)) return fallback('duplicate-findings');
|
|
44
|
+
seen.add(key);
|
|
45
|
+
}
|
|
46
|
+
return { status: 'accepted', groupId, findings: validated.findings, limitations: validated.limitations };
|
|
47
|
+
} catch { return fallback('invalid-evidence-or-findings'); }
|
|
48
|
+
}
|
package/src/group-primary.mjs
CHANGED
|
@@ -2,10 +2,12 @@ import { preparePrimaryInput } from './primary-input.mjs';
|
|
|
2
2
|
import { initialInputBudget } from './input-budget.mjs';
|
|
3
3
|
import { INLINE_RISK_INSTRUCTIONS } from './prompts.mjs';
|
|
4
4
|
import { validateRiskPlan } from './risk-plan.mjs';
|
|
5
|
+
import { GROUP_INTERACTION_INSTRUCTIONS } from './group-interaction.mjs';
|
|
5
6
|
|
|
6
7
|
export const GROUP_PRIMARY_INSTRUCTIONS = '批准的group-primary输入包含多个完整主文件。逐个审核所有reviews成员的新旧正文,并结合组内其他文件分析;不能仅审核一个文件。返回 {"reviews":[{"fileId":"原成员身份","findings":[],"limitations":[]}],"limitations":[]},每个成员恰好一项,包括没有发现的成员。各finding仍需原审查指令全部字段、精确原行anchor及该fileId本次变化归因;不能用其他成员的身份或坐标替代。缺证据写limitations,不声明跨文件综合已完成;专门交互阶段仍独立校验。可按批准检索协议先返回requests。';
|
|
7
8
|
|
|
8
|
-
export function prepareGroupPrimary(snapshot, group, { instructions, scope, maxInputBytes, focusPrimaryContext = false, enableRiskPlanning = false } = {}) {
|
|
9
|
+
export function prepareGroupPrimary(snapshot, group, { instructions, scope, maxInputBytes, focusPrimaryContext = false, enableRiskPlanning = false, enableGroupInteractions = false, migrationContracts = [] } = {}) {
|
|
10
|
+
if (typeof enableGroupInteractions !== 'boolean') throw new Error('Invalid group interaction option');
|
|
9
11
|
if (!scope || snapshot.projectScope || !Array.isArray(group?.fileIds) || group.fileIds.length < 2 || group.fileIds.length > 4 || new Set(group.fileIds).size !== group.fileIds.length) return null;
|
|
10
12
|
const members = group.fileIds.map(id => snapshot.files.find(file => file.id === id));
|
|
11
13
|
if (members.some(file => !file || file.eligibility !== 'reviewable')) return null;
|
|
@@ -15,8 +17,17 @@ export function prepareGroupPrimary(snapshot, group, { instructions, scope, maxI
|
|
|
15
17
|
const context = [...new Map(reviews.flatMap(r => r.context ?? []).map(item => [JSON.stringify(item), item])).values()];
|
|
16
18
|
for (const item of reviews) { delete item.context; delete item.rules; delete item.businessRequirement; delete item.requirementNotice; }
|
|
17
19
|
const value = { snapshotId: snapshot.id, sourceMode: 'group-primary', groupId: group.id, reviews, context, rules: snapshot.rules ?? [], ...(snapshot.businessRequirement !== undefined ? { businessRequirement: snapshot.businessRequirement } : {}) };
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
let payload = JSON.stringify(value);
|
|
21
|
+
let combined = instructions + '\n' + GROUP_PRIMARY_INSTRUCTIONS + (enableRiskPlanning ? '\n各成员可附带risks;组风险可引用所有成员精确编辑,仍只是假设,不增加规划调用。' + INLINE_RISK_INSTRUCTIONS.replace('Final JSON requires findings/limitations;', 'Each reviews member requires findings/limitations;') : '');
|
|
22
|
+
if (!initialInputBudget({ input: payload, instructions: combined }, scope, maxInputBytes).fits) return null;
|
|
23
|
+
const contracts = migrationContracts.filter(contract => contract.candidateFileIds?.some(id => group.fileIds.includes(id)));
|
|
24
|
+
if (contracts.length) { const proposed = JSON.stringify({ ...JSON.parse(payload), migrationContracts: contracts, migrationNotice: 'Host navigation hypotheses only. Compare the old public gate, new entry and callers under the same trigger. Neither semantics nor causality are established; independent verification is required.' }); if (initialInputBudget({ input: proposed, instructions: combined }, scope, maxInputBytes).fits) payload = proposed; }
|
|
25
|
+
let groupInteraction = false;
|
|
26
|
+
if (enableGroupInteractions && typeof group.id === 'string' && group.id.length > 0 && group.id.length <= 200 && prepared.every(p => p.changes.status === 'changed' && p.changes.edits.length)) {
|
|
27
|
+
const proposed = JSON.stringify({ ...JSON.parse(payload), groupInteraction: { groupId: group.id, fileIds: [...group.fileIds] } });
|
|
28
|
+
if (initialInputBudget({ input: proposed, instructions: combined + GROUP_INTERACTION_INSTRUCTIONS }, scope, maxInputBytes).fits) { payload = proposed; combined += GROUP_INTERACTION_INSTRUCTIONS; groupInteraction = true; }
|
|
29
|
+
}
|
|
30
|
+
return { payload, instructions: combined, fileIds: [...group.fileIds], inlineRisk: enableRiskPlanning, changes: new Map(members.map((f, i) => [f.id, prepared[i].changes])), ...(enableGroupInteractions ? { groupId: group.id, groupInteraction, ...(!groupInteraction ? { groupInteractionFallback: 'input-budget-or-inexact' } : {}) } : {}) };
|
|
20
31
|
}
|
|
21
32
|
|
|
22
33
|
export function validateGroupPrimary(response, prepared, snapshot, validate, catalog = []) {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { prepareInteractionInput } from './interaction-input.mjs';
|
|
3
|
+
import { riskPlanningDecision, validateRiskPlan } from './risk-plan.mjs';
|
|
4
|
+
import { initialInputBudget } from './input-budget.mjs';
|
|
5
|
+
import { INLINE_RISK_INSTRUCTIONS } from './prompts.mjs';
|
|
6
|
+
|
|
7
|
+
// Reuse the existing optional-risks schema. Do not add the planning-only schema
|
|
8
|
+
// which forbids findings and requests. Interaction references remain file-qualified.
|
|
9
|
+
export const INTERACTION_RISK_INSTRUCTIONS = 'INLINE_INTERACTION_RISKS ' + INLINE_RISK_INSTRUCTIONS +
|
|
10
|
+
' Interaction risks must reference real edits in at least two distinct files from this interaction group. The supplied files[].edits cover the complete exact group edits, not source evidence. Source bodies must still be read in this generation; hypotheses never replace interaction evidence or independent verification. Missing or invalid optional risks do not invalidate findings and must not trigger another planning call.';
|
|
11
|
+
|
|
12
|
+
function canonicalInteraction(snapshot, group) {
|
|
13
|
+
const prepared = prepareInteractionInput(snapshot, group, () => ({ fits: true }));
|
|
14
|
+
return prepared.status === 'ready' ? JSON.parse(prepared.payload) : undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Pure first-generation decision; no reads, receipts, state mutation or model sends. */
|
|
18
|
+
export function prepareInteractionRisk(snapshot, group, prepared, options) {
|
|
19
|
+
const { instructions, scope, maxInputBytes } = options;
|
|
20
|
+
if (options.inlineInteractionRisks !== undefined && typeof options.inlineInteractionRisks !== 'boolean') throw new Error('inlineInteractionRisks must be boolean');
|
|
21
|
+
const files = group.fileIds.map(id => snapshot.files.find(file => file.id === id));
|
|
22
|
+
const decision = options.enableRiskPlanning === true
|
|
23
|
+
? options.skipSmallRiskPlans === true ? riskPlanningDecision(files) : { required: true, reason: 'unconditional' }
|
|
24
|
+
: { required: false, reason: 'disabled' };
|
|
25
|
+
const fallback = fallbackReason => ({ mode: decision.required ? 'separate' : 'disabled', decision,
|
|
26
|
+
instructions, planningCalls: decision.required ? 1 : 0, fallbackReason });
|
|
27
|
+
if (!decision.required) return fallback(options.enableRiskPlanning === true ? 'smallchange' : 'disabled');
|
|
28
|
+
if (options.inlineInteractionRisks !== true) return fallback('option-disabled');
|
|
29
|
+
if (snapshot.projectScope || files.some(file => file?.reviewMode === 'project')) return fallback('project');
|
|
30
|
+
if ((options.round ?? 1) !== 1) return fallback('laterround');
|
|
31
|
+
if (!scope) return fallback('approved-reader-required');
|
|
32
|
+
if (prepared?.status !== 'ready' || prepared.budget?.fits !== true || prepared.batches !== undefined) return fallback('incomplete-edit-scope');
|
|
33
|
+
let canonical, payload;
|
|
34
|
+
try {
|
|
35
|
+
canonical = canonicalInteraction(snapshot, group);
|
|
36
|
+
payload = JSON.parse(prepared.payload);
|
|
37
|
+
} catch { return fallback('incomplete-edit-scope'); }
|
|
38
|
+
if (!canonical || !payload || payload.snapshotId !== snapshot.id || payload.groupId !== group.id ||
|
|
39
|
+
payload.sourceMode !== 'file-interaction' || !isDeepStrictEqual(prepared.fileIds, group.fileIds) ||
|
|
40
|
+
!isDeepStrictEqual(payload.files, canonical.files)) return fallback('incomplete-edit-scope');
|
|
41
|
+
const combined = instructions + '\n' + INTERACTION_RISK_INSTRUCTIONS;
|
|
42
|
+
// Exactly the same retrieval envelope and native/text wire policy as execution.
|
|
43
|
+
const budget = initialInputBudget({ instructions: combined, input: prepared.payload,
|
|
44
|
+
...(options.nativeOutputTools !== undefined ? { nativeOutputTools: options.nativeOutputTools } : {}) }, scope, maxInputBytes);
|
|
45
|
+
return budget.fits ? { mode: 'inline', decision, instructions: combined, budget, planningCalls: 0 }
|
|
46
|
+
: fallback('input-budget');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Separate optional hypotheses from the unchanged final findings envelope.
|
|
50
|
+
* Malformed stage JSON still throws; missing/invalid optional risks never do.
|
|
51
|
+
* No arbitrary exception message or untrusted hypothesis text enters limitations.
|
|
52
|
+
*/
|
|
53
|
+
export function consumeInteractionRisks(response, snapshot, group, catalog = []) {
|
|
54
|
+
// The stage transport already bounds the complete response. Do not reject a
|
|
55
|
+
// sound findings envelope just because optional risks exceed their own limit.
|
|
56
|
+
const decoded = typeof response === 'string' ? JSON.parse(response) : structuredClone(response);
|
|
57
|
+
if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) throw new Error('Invalid interaction response');
|
|
58
|
+
const { risks, ...rest } = decoded;
|
|
59
|
+
if (!Object.hasOwn(decoded, 'risks')) return { response: rest, risk: { status: 'absent', method: 'inline', risks: [],
|
|
60
|
+
reason: 'optional-risks-missing', limitation: '交互内联风险未返回;保留可独立校验的发现,未补发规划。' } };
|
|
61
|
+
try {
|
|
62
|
+
const canonical = canonicalInteraction(snapshot, group);
|
|
63
|
+
if (!canonical) throw new Error('Inexact interaction');
|
|
64
|
+
const hypotheses = validateRiskPlan({ risks }, canonical.files, catalog);
|
|
65
|
+
if (hypotheses.some(risk => new Set(risk.editRefs.map(ref => ref.fileId)).size < 2)) throw new Error('Single-file hypothesis');
|
|
66
|
+
return { response: rest, risk: { status: 'completed', method: 'inline', reason: 'inline-interaction', risks: hypotheses } };
|
|
67
|
+
} catch {
|
|
68
|
+
return { response: rest, risk: { status: 'invalid', method: 'inline', risks: [], reason: 'optional-risks-invalid',
|
|
69
|
+
limitation: '交互内联风险无效;保留可独立校验的发现,风险不作为证据,未补发规划。' } };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { changeMap } from './change-map.mjs';
|
|
2
|
+
import { createRetrievalScope } from './retrieval-scope.mjs';
|
|
3
|
+
import { inferImportRelations } from './import-relations.mjs';
|
|
4
|
+
import { inferCppRelations } from './cpp-relations.mjs';
|
|
5
|
+
import { buildReviewGroups } from './review-groups.mjs';
|
|
6
|
+
import { cppMigrationBindings } from './cpp-migration-bindings.mjs';
|
|
7
|
+
|
|
8
|
+
const NOTICE = 'Conservative syntax navigation only; not semantic migration, call reachability, authorization, read coverage or causality proof.';
|
|
9
|
+
const cmp = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
10
|
+
|
|
11
|
+
// Deliberately reject unsupported lexical constructs instead of treating comments,
|
|
12
|
+
// strings or overloaded/template syntax as executable bindings.
|
|
13
|
+
function functions(text, cpp) {
|
|
14
|
+
if (typeof text !== 'string' || Buffer.byteLength(text) > 256 * 1024 || /[\x60]|\/\*|\\\r?\n/.test(text)) return [];
|
|
15
|
+
const lines = text.split('\n'), found = [];
|
|
16
|
+
if (cpp && lines.some(line => /^\s*#\s*(?:if|ifdef|ifndef|elif|else|endif|define|undef)\b/.test(line))) return [];
|
|
17
|
+
for (let i = 0; i < lines.length; i++) {
|
|
18
|
+
const match = cpp ? /^\s*(?:(?:inline|static)\s+)*(?:void|bool|int|auto)\s+([A-Za-z_]\w*)\s*\([^;{}]*\)\s*\{\s*$/.exec(lines[i])
|
|
19
|
+
: /^\s*export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\([^;{}]*\)\s*\{\s*$/.exec(lines[i]);
|
|
20
|
+
if (!match) continue;
|
|
21
|
+
let depth = 1, end;
|
|
22
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
23
|
+
// Braces inside strings/comments must not fabricate an enclosing function.
|
|
24
|
+
const plain = lines[j].replace(/\/\/.*$/, '');
|
|
25
|
+
if (/['"\x60\/]/.test(plain)) break;
|
|
26
|
+
for (const char of plain) { if (char === '{') depth++; else if (char === '}') depth--; }
|
|
27
|
+
if (depth === 0) { end = j + 1; break; }
|
|
28
|
+
if (depth < 0) break;
|
|
29
|
+
}
|
|
30
|
+
if (end) found.push({ symbol: match[1], start: i + 1, end });
|
|
31
|
+
}
|
|
32
|
+
return found.filter(f => found.filter(other => other.symbol === f.symbol).length === 1).slice(0, 100);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Pure host navigation over one approved fixed snapshot. No I/O or model calls. */
|
|
36
|
+
export function buildMigrationContracts(snapshot, { maxContracts = 32, maxGroupFiles = 4 } = {}) {
|
|
37
|
+
if (!snapshot || typeof snapshot.id !== 'string' || !Array.isArray(snapshot.files) || snapshot.files.length > 200 ||
|
|
38
|
+
!Number.isSafeInteger(maxContracts) || maxContracts < 1 || maxContracts > 100 ||
|
|
39
|
+
!Number.isSafeInteger(maxGroupFiles) || maxGroupFiles < 1 || maxGroupFiles > 20) throw new Error('Invalid migration contract input');
|
|
40
|
+
const result = { contracts: [], groups: [], links: [], limitations: [NOTICE] };
|
|
41
|
+
if (snapshot.projectScope || snapshot.files.some(f => f.reviewMode === 'project')) { result.limitations.push('Project comparison has no approved old/new migration baseline.'); return result; }
|
|
42
|
+
const catalog = createRetrievalScope(snapshot).catalog(), byId = new Map(snapshot.files.map(f => [f.id, f]));
|
|
43
|
+
const contexts = catalog.filter(s => !s.fileId).map(s => ({ id: '@migration-context:' + s.id, source: s,
|
|
44
|
+
item: snapshot.context.find(c => c.path === s.path) }));
|
|
45
|
+
if (contexts.some(c => byId.has(c.id))) throw new Error('Migration context identity collision');
|
|
46
|
+
const contextIds = new Map(contexts.map(c => [c.id, c]));
|
|
47
|
+
const maps = new Map(snapshot.files.filter(f => f.eligibility === 'reviewable').map(f => [f.id, changeMap(f.left.text, f.right.text)]));
|
|
48
|
+
const source = (id, side) => contextIds.get(id)?.source ?? catalog.find(s => s.fileId === id && s.side === side);
|
|
49
|
+
const graph = { old: [], new: [] }, definitions = new Map();
|
|
50
|
+
const unresolved = new Set();
|
|
51
|
+
for (const side of ['old', 'new']) {
|
|
52
|
+
// Distinct path indices: an old import must never resolve using the new path.
|
|
53
|
+
const entries = snapshot.files.map(f => ({ ...f, path: side === 'old' ? f.oldPath ?? f.path : f.path, oldPath: undefined,
|
|
54
|
+
left: { text: '' }, right: { text: (side === 'old' ? f.left : f.right)?.text ?? '' } }));
|
|
55
|
+
const sideContexts = contexts.filter(c => side === 'old' ? c.source.side === 'context-old' : c.source.side === 'context');
|
|
56
|
+
if (entries.length + sideContexts.length <= 200) for (const c of sideContexts) entries.push({ id: c.id, path: c.source.path,
|
|
57
|
+
eligibility: 'reviewable', left: { text: '' }, right: { text: side === 'old' ? c.item.oldText : c.item.text } });
|
|
58
|
+
else unresolved.add('Context caller navigation exceeds relation budget; selected-file chains only.');
|
|
59
|
+
const paths = new Map();
|
|
60
|
+
for (const f of entries) { const row = paths.get(f.path) ?? []; row.push(f); paths.set(f.path, row); }
|
|
61
|
+
const unique = entries.map(f => ({ ...f, eligibility: paths.get(f.path).length > 1 ? 'blocked' : f.eligibility }));
|
|
62
|
+
// Duplicate paths cannot be passed to the relation helpers; retain a blocked competitor.
|
|
63
|
+
const indexed = [...paths.values()].map(rows => rows.length === 1 ? unique.find(f => f.id === rows[0].id) : { ...rows[0], eligibility: 'blocked' });
|
|
64
|
+
for (const f of unique.filter(f => f.eligibility === 'reviewable')) {
|
|
65
|
+
const cpp = /\.(?:c|cc|cpp|cxx|h|hh|hpp|hxx)$/.test(f.path);
|
|
66
|
+
definitions.set(side + ':' + f.id, functions(f.right.text, cpp));
|
|
67
|
+
}
|
|
68
|
+
const classNavigation = cppMigrationBindings(indexed.map(f => ({ id: f.id, path: f.path, eligibility: f.eligibility,
|
|
69
|
+
text: f.right.text, source: source(f.id, side) ?? { id: 'unavailable', side } })));
|
|
70
|
+
for (const def of classNavigation.definitions) definitions.get(side + ':' + def.fileId)?.push(def);
|
|
71
|
+
graph[side].push(...classNavigation.bindings);
|
|
72
|
+
for (const limitation of classNavigation.limitations) unresolved.add(limitation);
|
|
73
|
+
const imports = inferImportRelations(indexed).filter(e => e.side === 'new' && e.bindingHints?.length);
|
|
74
|
+
for (const edge of imports) for (const binding of edge.bindingHints) {
|
|
75
|
+
if (binding.declarationKind !== 'function') continue;
|
|
76
|
+
graph[side].push({ from: edge.from, to: edge.to, importLine: binding.importLine, callLine: binding.callLine, symbol: binding.imported, declarationLine: binding.declarationLine });
|
|
77
|
+
}
|
|
78
|
+
const cppIncludes = inferCppRelations(indexed).filter(e => e.side === 'new' && e.specifier);
|
|
79
|
+
for (const f of indexed.filter(f => f.eligibility === 'reviewable' && /\.(?:c|cc|cpp|cxx|h|hh|hpp|hxx)$/.test(f.path))) {
|
|
80
|
+
const defs = definitions.get(side + ':' + f.id) ?? [];
|
|
81
|
+
if (!defs.length) continue;
|
|
82
|
+
const lines = f.right.text.split('\n');
|
|
83
|
+
for (const [i, line] of lines.entries()) {
|
|
84
|
+
const call = /^\s*([A-Za-z_]\w*)\s*\([^;"'{}\/]*\)\s*;\s*$/.exec(line);
|
|
85
|
+
if (!call || defs.some(d => d.symbol === call[1]) || !defs.some(d => i + 1 > d.start && i + 1 < d.end)) continue;
|
|
86
|
+
// A parameter/local alias/using declaration can shadow the included name.
|
|
87
|
+
// Keep only direct calls as its other token occurrences, never guess scope.
|
|
88
|
+
const token = new RegExp('(^|[^A-Za-z0-9_])' + call[1] + '(?=[^A-Za-z0-9_]|$)');
|
|
89
|
+
if (lines.some(line => token.test(line) && !/^\s*#\s*include\b/.test(line) &&
|
|
90
|
+
!new RegExp('^\\s*' + call[1] + '\\s*\\([^;\"\'{}\\/]*\\)\\s*;\\s*$').test(line))) continue;
|
|
91
|
+
const targets = cppIncludes.filter(e => e.from === f.id).flatMap(e => (definitions.get(side + ':' + e.to) ?? [])
|
|
92
|
+
.filter(d => d.symbol === call[1]).map(d => ({ edge: e, def: d })));
|
|
93
|
+
if (targets.length !== 1) { unresolved.add('Unresolved C++ direct call identity at ' + f.path + ':L' + (i + 1) + ' (' + side + ').'); continue; }
|
|
94
|
+
const { edge, def } = targets[0];
|
|
95
|
+
graph[side].push({ from: f.id, to: edge.to, importLine: edge.line, callLine: i + 1, symbol: def.symbol, declarationLine: def.start });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const role = (id, side, def) => {
|
|
100
|
+
const file = byId.get(id), map = maps.get(id), src = source(id, side);
|
|
101
|
+
if (!file || !src || map?.precision !== 'exact' || !def) return undefined;
|
|
102
|
+
const edits = map.edits.filter(e => e[side].count > 0 && e[side].start <= def.end && e[side].start + e[side].count - 1 >= def.start);
|
|
103
|
+
if (!edits.length || edits.length > 50) return undefined;
|
|
104
|
+
return { fileId: id, sourceId: src.id, side, path: side === 'old' ? file.oldPath ?? file.path : file.path,
|
|
105
|
+
sourcePath: src.path, symbol: def.symbol, line: Math.max(def.start, edits[0][side].start), declarationLine: def.start,
|
|
106
|
+
editId: edits[0].id, editIds: edits.map(e => e.id) };
|
|
107
|
+
};
|
|
108
|
+
const caller = (edge, side) => {
|
|
109
|
+
const src = source(edge.from, side), file = byId.get(edge.from), map = maps.get(edge.from);
|
|
110
|
+
return { ...(file ? { fileId: edge.from } : {}), sourceId: src.id, side: src.side, sourcePath: src.path,
|
|
111
|
+
path: file ? side === 'old' ? file.oldPath ?? file.path : file.path : src.path,
|
|
112
|
+
line: edge.callLine, callLine: edge.callLine, importLine: edge.importLine, targetFileId: edge.to, targetSymbol: edge.symbol,
|
|
113
|
+
editIds: map?.precision === 'exact' ? map.edits.filter(e => e[side].count > 0 && edge.callLine >= e[side].start && edge.callLine < e[side].start + e[side].count).map(e => e.id) : [] };
|
|
114
|
+
};
|
|
115
|
+
for (const side of ['old', 'new']) if (graph[side].length > 2000) {
|
|
116
|
+
graph[side] = graph[side].slice(0, 2000);
|
|
117
|
+
unresolved.add('Migration call navigation limit reached; remaining bindings unresolved.');
|
|
118
|
+
}
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
for (const bridge of graph.new) {
|
|
121
|
+
const enclosing = (definitions.get('new:' + bridge.from) ?? []).filter(d => bridge.callLine > d.start && bridge.callLine < d.end);
|
|
122
|
+
if (enclosing.length !== 1) continue;
|
|
123
|
+
// A new entry calls the existing public entry, or the old entry now delegates
|
|
124
|
+
// to a new entry. Neither direction proves where a guard moved.
|
|
125
|
+
for (const [oldId, newId, newDef, publicSymbol] of [[bridge.to, bridge.from, enclosing[0], bridge.symbol],
|
|
126
|
+
[bridge.from, bridge.to, (definitions.get('new:' + bridge.to) ?? []).find(d => d.start === bridge.declarationLine), enclosing[0].symbol]]) {
|
|
127
|
+
const oldCalls = graph.old.filter(e => e.to === oldId && e.symbol === publicSymbol && e.from !== newId);
|
|
128
|
+
for (const oldCall of oldCalls) {
|
|
129
|
+
const oldDef = (definitions.get('old:' + oldId) ?? []).find(d => d.start === oldCall.declarationLine);
|
|
130
|
+
const oldRole = role(oldId, 'old', oldDef), newRole = role(newId, 'new', newDef);
|
|
131
|
+
if (!oldRole || !newRole || oldId === newId) continue;
|
|
132
|
+
const key = JSON.stringify([oldId, oldRole.symbol, newId, newRole.symbol]);
|
|
133
|
+
if (seen.has(key)) continue;
|
|
134
|
+
seen.add(key);
|
|
135
|
+
if (result.contracts.length >= maxContracts) { unresolved.add('Migration contract limit reached; additional chains remain unresolved.'); continue; }
|
|
136
|
+
const oldCallerPath = source(oldCall.from, 'old')?.path;
|
|
137
|
+
const newCalls = graph.new.filter(e => e.to === oldId && e.symbol === publicSymbol && (e.from === oldCall.from ||
|
|
138
|
+
contextIds.has(e.from) && contextIds.has(oldCall.from) && source(e.from, 'new')?.path === oldCallerPath));
|
|
139
|
+
const callers = [caller(oldCall, 'old'), ...newCalls.slice(0, 10).map(e => caller(e, 'new')), caller(bridge, 'new')];
|
|
140
|
+
const pending = newCalls.length ? [] : ['Old caller has no independently resolved new-side binding.'];
|
|
141
|
+
result.contracts.push({ id: 'mc' + (result.contracts.length + 1), snapshotId: snapshot.id, candidateFileIds: [oldId, newId],
|
|
142
|
+
old: [oldRole], new: [newRole], callers, sourceIds: [...new Set([oldRole.sourceId, newRole.sourceId, ...callers.map(c => c.sourceId)])],
|
|
143
|
+
status: 'navigation-only', causality: 'unverified', unresolved: pending,
|
|
144
|
+
navigationBindings: [...new Map([...(oldDef.navigation ? [oldDef.navigation] : []),
|
|
145
|
+
...(newDef.navigation ? [newDef.navigation] : []), ...(oldCall.navigationBindings ?? []),
|
|
146
|
+
...newCalls.flatMap(e => e.navigationBindings ?? []), ...(bridge.navigationBindings ?? [])]
|
|
147
|
+
.map(row => [JSON.stringify(row), row])).values()] });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
for (const contract of result.contracts) contract.sourceIds = [...new Set([...contract.sourceIds, ...contract.navigationBindings.map(row => row.sourceId)])];
|
|
152
|
+
const edges = result.contracts.flatMap(c => [{ from: c.old[0].fileId, to: c.new[0].fileId, reason: 'migration-contract:' + c.id },
|
|
153
|
+
...c.callers.filter(row => row.fileId && row.fileId !== row.targetFileId).map(row => ({ from: row.fileId, to: row.targetFileId, reason: 'migration-caller:' + c.id }))]);
|
|
154
|
+
const involved = new Set(edges.flatMap(e => [e.from, e.to]));
|
|
155
|
+
const grouped = buildReviewGroups(snapshot.files.filter(f => involved.has(f.id)), edges, { maxFiles: maxGroupFiles });
|
|
156
|
+
result.groups = grouped.groups; result.links = grouped.links;
|
|
157
|
+
result.limitations.push(...[...unresolved].sort(cmp).slice(0, 100));
|
|
158
|
+
if (!result.contracts.length) result.limitations.push('No complete, unambiguous, changed old-entry/new-entry/caller syntax chain resolved.');
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { changeMap } from './change-map.mjs';
|
|
2
|
+
import { contextSources } from './context-identity.mjs';
|
|
3
|
+
import { hash } from './content.mjs';
|
|
4
|
+
|
|
5
|
+
const contexts = new WeakSet();
|
|
6
|
+
const positive = value => Number.isSafeInteger(value) && value > 0;
|
|
7
|
+
const overlap = (ref, range) => range?.count > 0 && ref.start < range.start + range.count && range.start < ref.start + ref.count;
|
|
8
|
+
const fail = () => { throw new Error('Invalid approved migration regression contract'); };
|
|
9
|
+
const freeze = value => { if (value && typeof value === 'object') { for (const item of Object.values(value)) freeze(item); Object.freeze(value); } return value; };
|
|
10
|
+
|
|
11
|
+
// Compile host navigation against the exact approved snapshot. No model-provided
|
|
12
|
+
// contract object or copied input metadata can replace this invocation capability.
|
|
13
|
+
export function prepareMigrationRegression(snapshot, scope, contracts, candidates) {
|
|
14
|
+
if (!snapshot || typeof snapshot.id !== 'string' || snapshot.projectScope || snapshot.files?.some(file => file.reviewMode === 'project') || !Array.isArray(snapshot.files) || !Array.isArray(contracts) || contracts.length > 100 || !Array.isArray(candidates)) fail();
|
|
15
|
+
const catalog = scope.catalog();
|
|
16
|
+
const role = (value, expectedSide) => {
|
|
17
|
+
if (!value || typeof value.sourceId !== 'string') fail();
|
|
18
|
+
const matches = catalog.filter(source => source.id === value.sourceId);
|
|
19
|
+
if (matches.length !== 1) fail();
|
|
20
|
+
const source = matches[0];
|
|
21
|
+
if (source.path !== (value.sourcePath ?? value.path) || source.side !== value.side || source.fileId !== value.fileId || expectedSide && !(expectedSide === 'old' ? ['old', 'context-old'] : ['new', 'context']).includes(source.side)) fail();
|
|
22
|
+
let body, edits = [];
|
|
23
|
+
if (source.fileId) {
|
|
24
|
+
const file = snapshot.files.find(file => file.id === source.fileId && file.path === source.path && file.eligibility === 'reviewable');
|
|
25
|
+
if (!file || !['old', 'new'].includes(source.side) || value.path !== (source.side === 'old' ? file.oldPath ?? file.path : file.path)) fail();
|
|
26
|
+
body = (source.side === 'old' ? file.left : file.right)?.text;
|
|
27
|
+
if (!Array.isArray(value.editIds) || value.editIds.length > 50 || new Set(value.editIds).size !== value.editIds.length || expectedSide && !value.editIds.length) fail();
|
|
28
|
+
if (value.editIds.length) {
|
|
29
|
+
const changes = changeMap(file.left.text, file.right.text);
|
|
30
|
+
const side = expectedSide ?? source.side;
|
|
31
|
+
if (changes.status !== 'changed' || changes.precision !== 'exact') fail();
|
|
32
|
+
edits = value.editIds.map(id => {
|
|
33
|
+
const edit = changes.edits.find(edit => edit.id === id);
|
|
34
|
+
if (!edit || edit[side].count < 1) fail();
|
|
35
|
+
return { fileId: file.id, editId: edit.id, range: edit[side] };
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
const item = snapshot.context?.find(item => item.path === source.path);
|
|
40
|
+
if (!item || value.path !== source.path || !Array.isArray(value.editIds) || value.editIds.length) fail();
|
|
41
|
+
body = contextSources(item, snapshot.history).find(item => item.side === source.side)?.text;
|
|
42
|
+
}
|
|
43
|
+
const line = value.line ?? value.callLine;
|
|
44
|
+
if (typeof body !== 'string' || !positive(line) || line > source.lineCount || value.importLine !== undefined && (!positive(value.importLine) || value.importLine > source.lineCount) || edits.length && !edits.some(edit => line >= edit.range.start && line < edit.range.start + edit.range.count)) fail();
|
|
45
|
+
return { sourceId: source.id, side: source.side, path: value.path, sourcePath: source.path, ...(source.fileId ? { fileId: source.fileId } : {}), line, hash: hash(body), edits };
|
|
46
|
+
};
|
|
47
|
+
const ids = new Set(), compiled = [];
|
|
48
|
+
for (const contract of contracts) {
|
|
49
|
+
if (!contract || typeof contract.id !== 'string' || !contract.id || contract.id.length > 200 || ids.has(contract.id) || contract.snapshotId !== snapshot.id || contract.status !== 'navigation-only' || contract.causality !== 'unverified' || !Array.isArray(contract.callers) || contract.callers.length > 32) fail();
|
|
50
|
+
ids.add(contract.id);
|
|
51
|
+
if (!Array.isArray(contract.old) || contract.old.length !== 1 || !Array.isArray(contract.new) || contract.new.length !== 1 || !Array.isArray(contract.candidateFileIds)) fail();
|
|
52
|
+
const oldValue = contract.old[0], newValue = contract.new[0];
|
|
53
|
+
const old = role(oldValue, 'old'), current = role(newValue, 'new');
|
|
54
|
+
if (!old.fileId || !current.fileId || old.fileId === current.fileId || old.sourceId === current.sourceId || !old.edits.length || !current.edits.length) fail();
|
|
55
|
+
const callers = contract.callers.map(caller => {
|
|
56
|
+
const result = role(caller);
|
|
57
|
+
if (!['old', 'new', 'context-old', 'context'].includes(result.side) || caller.targetFileId !== oldValue.fileId && caller.targetFileId !== newValue.fileId) fail();
|
|
58
|
+
return result;
|
|
59
|
+
});
|
|
60
|
+
// The navigation chain has an explicit old-entry caller; a new-role source
|
|
61
|
+
// alone cannot manufacture a migration relation between arbitrary files.
|
|
62
|
+
if (!contract.callers.some(caller => caller.targetFileId === oldValue.fileId && ['old', 'context-old'].includes(caller.side))) fail();
|
|
63
|
+
const fileIds = [...new Set([old.fileId, current.fileId].filter(Boolean))];
|
|
64
|
+
if (new Set(contract.candidateFileIds).size !== contract.candidateFileIds.length || contract.candidateFileIds.length !== fileIds.length || contract.candidateFileIds.some(id => !fileIds.includes(id))) fail();
|
|
65
|
+
compiled.push({ id: contract.id, snapshotId: snapshot.id, old, new: current, callers, candidateFileIds: fileIds });
|
|
66
|
+
}
|
|
67
|
+
const context = freeze({ snapshotId: snapshot.id, contracts: compiled, candidates: structuredClone(candidates) });
|
|
68
|
+
contexts.add(context);
|
|
69
|
+
return context;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function migrationRegressionMetadata(context) {
|
|
73
|
+
if (!contexts.has(context)) fail();
|
|
74
|
+
return context.contracts.map(contract => ({ ...structuredClone(contract), old: [structuredClone(contract.old)], new: [structuredClone(contract.new)], status: 'navigation-only', causality: 'unverified' }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function validateMigrationRegression(value, evidence, fileId, context, candidateId) {
|
|
78
|
+
if (!contexts.has(context) || typeof value.migrationContractId !== 'string') fail();
|
|
79
|
+
const contract = context.contracts.find(contract => contract.id === value.migrationContractId && contract.candidateFileIds.includes(fileId));
|
|
80
|
+
const candidate = context.candidates.find((candidate, index) => candidate.fileId === fileId && (!candidateId || candidateId === 'c' + (index + 1)));
|
|
81
|
+
if (!contract || !candidate || candidate.attribution?.status !== 'references-validated' || !Array.isArray(candidate.attribution.editIds)) fail();
|
|
82
|
+
const ownRoles = [contract.old, contract.new].filter(role => role.fileId === fileId);
|
|
83
|
+
if (!ownRoles.some(role => role.edits.some(edit => candidate.attribution.editIds.includes(edit.editId) && candidate.anchor?.kind === 'line' && candidate.anchor.side === (role.side === 'old' ? 'old' : 'new') && overlap({ start: candidate.anchor.start, count: candidate.anchor.end - candidate.anchor.start + 1 }, edit.range)))) fail();
|
|
84
|
+
let actualEdit = false;
|
|
85
|
+
for (const [field, edge, sides] of [['oldEvidence', contract.old, ['old', 'context-old']], ['newEvidence', contract.new, ['new', 'context']]]) {
|
|
86
|
+
const indices = value[field];
|
|
87
|
+
if (!Array.isArray(indices) || indices.length > 8 || new Set(indices).size !== indices.length) fail();
|
|
88
|
+
let entry = false;
|
|
89
|
+
for (const index of indices) {
|
|
90
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= evidence.length) fail();
|
|
91
|
+
const ref = evidence[index];
|
|
92
|
+
const binding = [edge, ...contract.callers.filter(role => sides.includes(role.side))].find(role => role.sourceId === ref.sourceId && sides.includes(role.side));
|
|
93
|
+
if (!binding || ref.status !== 'references-validated' || ref.snapshotId !== context.snapshotId || ref.hash !== binding.hash || !positive(ref.start) || !positive(ref.count) || binding !== edge && !(ref.start <= binding.line && binding.line < ref.start + ref.count)) fail();
|
|
94
|
+
if (binding === edge && ref.start <= edge.line && edge.line < ref.start + ref.count) {
|
|
95
|
+
entry = true;
|
|
96
|
+
if (edge.edits.some(edit => overlap(ref, edit.range))) actualEdit = true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (value.classification !== 'uncertain' && !entry) throw new Error('Migration comparison requires both explicit entry roles');
|
|
100
|
+
}
|
|
101
|
+
if (value.classification !== 'uncertain' && !actualEdit) throw new Error('Migration comparison requires actual changed-role evidence');
|
|
102
|
+
return { ...value, oldEvidence: [...value.oldEvidence], newEvidence: [...value.newEvidence], migrationContractId: contract.id, migrationSnapshotId: context.snapshotId, status: 'comparison-references-validated', causality: 'unverified' };
|
|
103
|
+
}
|
package/src/model-contract.mjs
CHANGED
|
@@ -9,11 +9,11 @@ const INTERACTION_CONTRACT = '本阶段返回 {"findings":[],"limitations":[]}
|
|
|
9
9
|
const FINDINGS_CONTRACT = '本阶段最终返回 {"findings":[],"limitations":[]}。每项finding必须含fileId、severity(critical/high/medium/low)、title、evidence、trigger、impact、suggestion和anchor;fileId原样复制file.id,不用文件名替代。行定位写成 anchor:{kind:"line",side:"new",start:1,end:1,snippet:"逐行原文"}(仅为形状示例,不是证据);start/end用原来源一基行号而非窗口内序号,精确匹配原行;old/new按当前阶段允许侧选择,示例不改变授权;仅当没有retrievalFinalization且仍有检索余量时,需要更多证据可按检索协议先返回requests。';
|
|
10
10
|
|
|
11
11
|
function stageContract(p, instructions) {
|
|
12
|
-
if (Array.isArray(p.candidates)) return VERDICT_CONTRACT;
|
|
12
|
+
if (Array.isArray(p.candidates)) return VERDICT_CONTRACT + (Array.isArray(p.migrationContracts) && p.migrationContracts.length ? ' 若明确按宿主migrationContracts跨文件比较,regression仅在原classification/reason/oldEvidence/newEvidence之外附带migrationContractId;必须逐字来自本次宿主contract.id,不允许模型声明新的contract、source授权或角色。缺边证据保留uncertain;未启用时仍按原同主文件规则。' : '');
|
|
13
13
|
if (/返回\{groups:/.test(instructions)) return GROUPS_CONTRACT;
|
|
14
14
|
if (/仅制定审查计划/.test(instructions)) return instructions.includes(RISK_PLAN_SCHEMA) ? '本阶段严格按上述risks schema返回,禁止requests/findings;身份逐项核对输入。' : RISK_CONTRACT;
|
|
15
|
-
if (p.sourceMode === 'group-primary') return '本阶段返回{reviews:[{fileId,findings:[],limitations:[],risks:可选数组}],limitations:[]};每项fileId仅来自reviews[].file.id,按文件原始坐标审查,不将分组自动视为交互证据。可按批准检索协议单独返回requests。';
|
|
16
|
-
if (p.sourceMode === 'file-interaction') return INTERACTION_CONTRACT;
|
|
15
|
+
if (p.sourceMode === 'group-primary') return '本阶段返回{reviews:[{fileId,findings:[],limitations:[],risks:可选数组}],limitations:[]};每项fileId仅来自reviews[].file.id,按文件原始坐标审查,不将分组自动视为交互证据。可按批准检索协议单独返回requests。' + (p.groupInteraction ? ' 根结果可附带可选interactions:{groupId,findings:[],limitations:[]},groupId严格来自groupInteraction.groupId;只有本次实际read覆盖各成员编辑并通过交互校验才替代独立交互阶段。缺失/非法交互不抹主审结果且保留独立阶段;reviews成员不能夹带interaction发现。' : '');
|
|
16
|
+
if (p.sourceMode === 'file-interaction') return INTERACTION_CONTRACT + (instructions.includes('INLINE_INTERACTION_RISKS') ? ' 可同时返回risks数组,风险schema与原指令一致;风险是假设而非已确认发现,不代替交互证据。' : '');
|
|
17
17
|
if (p.file) return FINDINGS_CONTRACT;
|
|
18
18
|
return '';
|
|
19
19
|
}
|
package/src/model-tools.mjs
CHANGED
|
@@ -34,11 +34,13 @@ export function modelResultStage(input, instructions = '') {
|
|
|
34
34
|
export function modelResultTools(input, instructions = '') {
|
|
35
35
|
const stage = modelResultStage(input, instructions);
|
|
36
36
|
if (!stage) return [];
|
|
37
|
+
let p; try { p = JSON.parse(input); } catch { return []; }
|
|
38
|
+
const migrationIds = Array.isArray(p.migrationContracts) ? p.migrationContracts.map(contract => contract?.id).filter(id => typeof id === 'string' && id.length > 0 && id.length <= 200).slice(0, 128) : [];
|
|
39
|
+
const regression = migrationIds.length ? object({ classification: { enum: ['introduced', 'preexisting', 'uncertain'] }, reason: text, oldEvidence: array({ type: 'integer', minimum: 0 }), newEvidence: array({ type: 'integer', minimum: 0 }), migrationContractId: { type: 'string', maxLength: 200, enum: [...new Set(migrationIds)] } }, ['classification', 'reason', 'oldEvidence', 'newEvidence']) : { type: 'object' };
|
|
37
40
|
const final = stage === 'findings' ? review : stage === 'risks' ? object({ risks: array(risk) }) :
|
|
38
41
|
stage === 'groups' ? object({ groups: array(object({ fileIds: strings, reason: { type: 'string' } })) }) :
|
|
39
|
-
stage === 'reviews' ? object({ reviews: array(object({ fileId: { type: 'string' }, findings, limitations: strings, risks: array(risk) }, ['fileId', 'findings', 'limitations'])), limitations: strings }) :
|
|
40
|
-
object({ verdicts: array(object({ candidateId: text, verdict: { enum: ['supported', 'refuted', 'uncertain'] }, reason: text, evidence: array({ type: 'object' }), regression
|
|
41
|
-
let p; try { p = JSON.parse(input); } catch { return []; }
|
|
42
|
+
stage === 'reviews' ? object({ reviews: array(object({ fileId: { type: 'string' }, findings, limitations: strings, risks: array(risk) }, ['fileId', 'findings', 'limitations'])), limitations: strings, ...(p.groupInteraction ? { interactions: object({ groupId: { const: p.groupInteraction.groupId }, findings, limitations: strings }) } : {}) }, ['reviews', 'limitations']) :
|
|
43
|
+
object({ verdicts: array(object({ candidateId: text, verdict: { enum: ['supported', 'refuted', 'uncertain'] }, reason: text, evidence: array({ type: 'object' }), regression }, ['candidateId', 'verdict', 'reason', 'evidence'])) });
|
|
42
44
|
const parameters = p.retrievalFinalization || stage === 'risks' || stage === 'groups' ? final : { type: 'object', oneOf: [final, requests] };
|
|
43
45
|
return [{ name: RESULT_TOOL_NAME, description: 'Submit only the current approved review stage result or approved retrieval requests as JSON data. Does not execute actions.', parameters }];
|
|
44
46
|
}
|
|
@@ -52,7 +54,7 @@ export function validateNativeResult(text, input, instructions) {
|
|
|
52
54
|
const p = JSON.parse(input);
|
|
53
55
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
54
56
|
if (Object.hasOwn(value, 'requests')) return !p.retrievalFinalization && !['risks', 'groups'].includes(stage) && Object.keys(value).length === 1 && Array.isArray(value.requests);
|
|
55
|
-
const allowed = stage === 'findings' ? ['findings', 'limitations', 'risks'] : stage === 'reviews' ? ['reviews', 'limitations'] : [stage];
|
|
57
|
+
const allowed = stage === 'findings' ? ['findings', 'limitations', 'risks'] : stage === 'reviews' ? ['reviews', 'limitations', ...(p.groupInteraction ? ['interactions'] : [])] : [stage];
|
|
56
58
|
const required = stage === 'findings' ? ['findings', 'limitations'] : stage === 'reviews' ? ['reviews', 'limitations'] : [stage];
|
|
57
59
|
return !!stage && Object.keys(value).every(k => allowed.includes(k)) && required.every(k => Array.isArray(value[k]));
|
|
58
60
|
}
|
package/src/primary-seeds.mjs
CHANGED
|
@@ -65,13 +65,17 @@ export function primarySeeds(scope, request, { maxInputBytes = 96 * 1024, onRetr
|
|
|
65
65
|
if (attempted.size >= MAX_READS) break;
|
|
66
66
|
if (seeds.some(item => item.request.id === source.id && item.result.start <= coordinate && coordinate < item.result.start + item.result.count)) continue;
|
|
67
67
|
const budget = scope.budget();
|
|
68
|
-
if (budget.
|
|
68
|
+
if (budget.remainingOutputBytes <= RESERVE_BYTES) break;
|
|
69
69
|
const operation = { kind: 'read', id: source.id, start: Math.max(1, coordinate - 4), count: MAX_LINES };
|
|
70
70
|
const key = JSON.stringify(operation);
|
|
71
71
|
if (attempted.has(key)) continue;
|
|
72
72
|
attempted.add(key);
|
|
73
73
|
let item;
|
|
74
74
|
try {
|
|
75
|
+
// A cached raw delivery costs no new materialization call, but still uses
|
|
76
|
+
// output bytes and must pass the exact operation's approved preflight.
|
|
77
|
+
const cached = budget.reusableResults === true && scope.preflight([operation])?.calls === 0 && scope.canServe?.([operation]) === true;
|
|
78
|
+
if (!cached && budget.remainingCalls <= RESERVE_CALLS) continue;
|
|
75
79
|
item = scope.transaction(() => {
|
|
76
80
|
checkSignal();
|
|
77
81
|
scope.preflight([operation]);
|
|
@@ -79,7 +83,7 @@ export function primarySeeds(scope, request, { maxInputBytes = 96 * 1024, onRetr
|
|
|
79
83
|
checkSignal();
|
|
80
84
|
const next = retrievalRequest(request, scope, [...seeds, { request: operation, result }]);
|
|
81
85
|
const remaining = scope.budget();
|
|
82
|
-
if (result.start + result.count <= coordinate || remaining.remainingCalls < RESERVE_CALLS || remaining.remainingOutputBytes < RESERVE_BYTES || (request.nativeOutputTools === true ? !modelWireBudget(next, maxInputBytes).fits : Buffer.byteLength(next.input) + Buffer.byteLength(next.instructions) > maxInputBytes)) throw optionalError();
|
|
86
|
+
if (result.start + result.count <= coordinate || (!cached && remaining.remainingCalls < RESERVE_CALLS) || remaining.remainingOutputBytes < RESERVE_BYTES || (request.nativeOutputTools === true ? !modelWireBudget(next, maxInputBytes).fits : Buffer.byteLength(next.input) + Buffer.byteLength(next.instructions) > maxInputBytes)) throw optionalError();
|
|
83
87
|
return { request: operation, result };
|
|
84
88
|
});
|
|
85
89
|
} catch (error) {
|