@feng0531/dsh-code-review 0.83.0 → 0.84.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 +8 -0
- package/CORE-ALIGNMENT-TASKS.md +1 -1
- package/DEVELOPMENT.md +1 -1
- package/README.md +16 -2
- package/RELEASE-NOTES.md +8 -6
- package/index.mjs +11 -12
- package/package.json +1 -1
- package/src/background-review.mjs +84 -12
- package/src/business-groups.mjs +1 -1
- package/src/class-bindings.mjs +45 -12
- package/src/defect-guidance.mjs +158 -16
- package/src/dsh-model.mjs +107 -15
- package/src/git-change-scope.mjs +39 -0
- package/src/git-history.mjs +202 -56
- package/src/git.mjs +192 -50
- package/src/import-bindings.mjs +74 -65
- package/src/job-progress.mjs +21 -2
- package/src/model-contract.mjs +17 -1
- package/src/primary-input.mjs +87 -26
- package/src/project-report.mjs +22 -5
- package/src/project-review-service.mjs +177 -37
- package/src/project-snapshot.mjs +101 -40
- package/src/prompts.mjs +9 -0
- package/src/protocol-guidance.mjs +13 -1
- package/src/recovery-coverage.mjs +25 -1
- package/src/retrieval-loop.mjs +66 -28
- package/src/review.mjs +39 -33
- package/src/risk-plan.mjs +1 -1
- package/src/service.mjs +140 -6
- package/src/snapshot-reader.mjs +18 -5
- package/src/snapshot.mjs +75 -42
- package/src/svn-capture-budget.mjs +22 -5
- package/src/svn-effective-mergeinfo.mjs +59 -37
- package/src/svn-eol-mapping.mjs +22 -10
- package/src/svn-history-endpoints.mjs +18 -12
- package/src/svn-history-lookup.mjs +34 -26
- package/src/svn-keyword-config.mjs +11 -8
- package/src/svn-keyword-mapping.mjs +67 -36
- package/src/svn-mergeinfo.mjs +34 -22
- package/src/svn-navigation-budget.mjs +15 -4
- package/src/svn-special.mjs +13 -8
- package/src/svn-url.mjs +18 -6
- package/src/svn-working-identity.mjs +22 -8
- package/src/svn-working-scope.mjs +16 -5
- package/src/tool-view.mjs +5 -0
- package/src/verification-loop.mjs +2 -1
package/src/primary-input.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {sourceSegments} from './source-segments.mjs';
|
|
2
|
-
import {changeRegion} from './change-region.mjs';
|
|
1
|
+
import { sourceSegments } from './source-segments.mjs';
|
|
2
|
+
import { changeRegion } from './change-region.mjs';
|
|
3
3
|
import { CONTEXT_SIDE_NOTICE } from './context-identity.mjs';
|
|
4
4
|
import { approvedCallerBindings } from './caller-binding-input.mjs';
|
|
5
5
|
import { REQUIREMENT_NOTICE } from './business-requirement.mjs';
|
|
@@ -13,45 +13,88 @@ import { planWindowBatches } from './window-batches.mjs';
|
|
|
13
13
|
import { changeWindows } from './change-windows.mjs';
|
|
14
14
|
import { changeMap } from './change-map.mjs';
|
|
15
15
|
import { initialInputBudget } from './input-budget.mjs';
|
|
16
|
+
import { changeScopeNotice } from './git-change-scope.mjs';
|
|
17
|
+
|
|
18
|
+
const CPP_FILE = /\.(?:c|cc|cpp|cxx|h|hh|hpp|hxx)$/;
|
|
19
|
+
const RELATION_NOTICE = '命名和相对导入关系仅为源码线索,可能被同名局部变量遮蔽,不证明绑定、调用可达或因果;只报告主file的问题,不为relatedFiles重复生成发现。';
|
|
20
|
+
const optional = h => h.confidence === 'namespace-member-syntax-only' || h.confidence === 'instance-method-syntax-only';
|
|
16
21
|
|
|
17
22
|
// Pure preparation over already captured data. No model calls or new reads.
|
|
18
23
|
export function preparePrimaryInput(input, file, { instructions, scope, grouping, maxInputBytes }) {
|
|
19
24
|
let changes = changeMap(file.left.text, file.right.text);
|
|
20
|
-
if(changes.status==='limited')
|
|
21
|
-
|
|
25
|
+
if (changes.status === 'limited') {
|
|
26
|
+
const region = changeRegion(file.left.text, file.right.text);
|
|
27
|
+
if (region.status === 'changed') {
|
|
28
|
+
changes = {
|
|
29
|
+
status: 'changed', precision: 'envelope',
|
|
30
|
+
reason: 'Exact diff unavailable; broad region includes potentially unchanged lines, not exact edits or causal proof.',
|
|
31
|
+
edits: [{ id: 'envelope1', old: region.old, new: region.new }],
|
|
32
|
+
hunks: [{ id: 'envelope-h1', old: region.old, new: region.new, editIds: ['envelope1'] }],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const base = {
|
|
38
|
+
...(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.' } : {}),
|
|
39
|
+
...(input.changeScope ? { changeScope: input.changeScope, changeScopeNotice: changeScopeNotice(input.changeScope) } : {}),
|
|
40
|
+
...(input.history ? { contextSideNotice: CONTEXT_SIDE_NOTICE } : {}),
|
|
41
|
+
...(input.businessRequirement !== undefined ? { businessRequirement: input.businessRequirement, requirementNotice: REQUIREMENT_NOTICE } : {}),
|
|
42
|
+
defectGuidance: defectGuidance(file.path, file.rightExists === false ? file.left.text : file.right.text),
|
|
43
|
+
snapshotId: input.id,
|
|
44
|
+
file,
|
|
45
|
+
changes,
|
|
46
|
+
context: input.context ?? [],
|
|
47
|
+
rules: input.rules ?? [],
|
|
48
|
+
};
|
|
49
|
+
|
|
22
50
|
const contextRelations = inferContextRelations(input, file);
|
|
23
51
|
if (contextRelations.length) {
|
|
24
52
|
base.contextRelations = contextRelations;
|
|
25
53
|
base.contextRelationNotice = '导入/include/命名与调用位置仅为语法线索;可能被宏、构建搜索路径或同名局部变量影响,不证明绑定、可达性或因果。context.text为批准当前/目标侧文本;仅明确oldText/oldRevision字段是历史旧侧,禁止互换。';
|
|
26
54
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
55
|
+
|
|
56
|
+
if (CPP_FILE.test(file.path)) {
|
|
57
|
+
const calls = ['old', 'new'].flatMap(side => cppCallSites((side === 'old' ? file.left : file.right).text).map(site => ({ ...site, side })));
|
|
58
|
+
if (calls.length) {
|
|
59
|
+
base.cppCallSites = calls;
|
|
60
|
+
base.cppCallNotice = '最多20条零参语法位置/侧,仅导航子集;不表示全部调用覆盖,不证明宏展开、重载选择、动态分派或执行顺序。其他源码须在批准范围内读取。';
|
|
61
|
+
}
|
|
30
62
|
}
|
|
63
|
+
|
|
31
64
|
const csharpHints = inferCsharpContext(input, file);
|
|
32
|
-
if (csharpHints.length) {
|
|
65
|
+
if (csharpHints.length) {
|
|
66
|
+
base.csharpContextHints = csharpHints;
|
|
67
|
+
base.csharpContextNotice = '仅批准context的C#静态类型/方法语法位置;历史输入按contextSide/contextVersion区分正文,无旧正文不回退目标侧。不证明语义绑定或执行可达。歧义不推定,线索不增加审查覆盖。';
|
|
68
|
+
}
|
|
69
|
+
|
|
33
70
|
if (input.callerDiscovery) {
|
|
34
71
|
const callerBindings = approvedCallerBindings(input, file);
|
|
35
|
-
if (callerBindings.length) {
|
|
72
|
+
if (callerBindings.length) {
|
|
73
|
+
base.callerBindings = callerBindings;
|
|
74
|
+
base.callerBindingNotice = '调用方文件的导入行、调用行与目标声明行仅为语法位置线索,来源限于已批准上下文;历史side/revision要求调用方与目标声明同版本,缺侧不回退。仅导航,窗口中的原文须按相应版本catalog读取;不证明执行可达、动态分派、重载选择或完整调用覆盖,也不代表全部调用方。';
|
|
75
|
+
}
|
|
36
76
|
}
|
|
77
|
+
|
|
37
78
|
let payload = JSON.stringify(base);
|
|
38
79
|
const measure = value => initialInputBudget({ instructions, input: value }, scope, maxInputBytes);
|
|
39
80
|
const metadata = {};
|
|
40
|
-
|
|
81
|
+
|
|
41
82
|
// Optional member navigation must not block source review or force extra windows.
|
|
42
|
-
if (base.contextRelations?.some(e=>e.bindingHints?.some(optional)) && !measure(payload).fits) {
|
|
83
|
+
if (base.contextRelations?.some(e => e.bindingHints?.some(optional)) && !measure(payload).fits) {
|
|
43
84
|
metadata.optionalBindingFallback = 'input-budget';
|
|
44
|
-
metadata.omittedOptionalBindingCount = base.contextRelations.reduce((n,e)=>n+(e.bindingHints??[]).filter(optional).length,0);
|
|
45
|
-
base.contextRelations = base.contextRelations.map(e=>({...e,bindingHints:(e.bindingHints??[]).filter(h
|
|
85
|
+
metadata.omittedOptionalBindingCount = base.contextRelations.reduce((n, e) => n + (e.bindingHints ?? []).filter(optional).length, 0);
|
|
86
|
+
base.contextRelations = base.contextRelations.map(e => ({ ...e, bindingHints: (e.bindingHints ?? []).filter(h => !optional(h)) }));
|
|
46
87
|
payload = JSON.stringify(base);
|
|
47
88
|
}
|
|
48
89
|
// Optional navigation must not block source review or force extra windows.
|
|
49
90
|
if (base.callerBindings?.length && !measure(payload).fits) {
|
|
50
91
|
metadata.callerBindingFallback = 'input-budget';
|
|
51
|
-
metadata.omittedCallerBindingCount = base.callerBindings.reduce((count,c)=>count+c.bindings.length,0);
|
|
52
|
-
delete base.callerBindings;
|
|
92
|
+
metadata.omittedCallerBindingCount = base.callerBindings.reduce((count, c) => count + c.bindings.length, 0);
|
|
93
|
+
delete base.callerBindings;
|
|
94
|
+
delete base.callerBindingNotice;
|
|
53
95
|
payload = JSON.stringify(base);
|
|
54
96
|
}
|
|
97
|
+
|
|
55
98
|
let batches, synthesis;
|
|
56
99
|
const group = grouping?.groups.find(g => g.fileIds.includes(file.id));
|
|
57
100
|
if (group) {
|
|
@@ -59,32 +102,48 @@ export function preparePrimaryInput(input, file, { instructions, scope, grouping
|
|
|
59
102
|
const relatedFiles = input.files.filter(f => f.id !== file.id && f.eligibility === 'reviewable' && group.fileIds.includes(f.id));
|
|
60
103
|
if (relatedFiles.length) {
|
|
61
104
|
const relations = grouping.links.filter(link => !link.split && group.fileIds.includes(link.from) && group.fileIds.includes(link.to));
|
|
62
|
-
const bindingRelations = inferImportRelations(input.files)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
105
|
+
const bindingRelations = inferImportRelations(input.files)
|
|
106
|
+
.filter(edge => (edge.bindingHints?.length || edge.definitionHints?.length) && (edge.from === file.id || edge.to === file.id) && group.fileIds.includes(edge.from) && group.fileIds.includes(edge.to))
|
|
107
|
+
.map(({ from, to, side, bindingHints, definitionHints }) => ({ from, to, side, bindingHints, ...(definitionHints?.length ? { definitionHints } : {}) }));
|
|
108
|
+
const grouped = JSON.stringify({ ...base, relatedFiles, relations, bindingRelations, relationNotice: RELATION_NOTICE });
|
|
109
|
+
if (measure(grouped).fits) {
|
|
110
|
+
payload = grouped;
|
|
111
|
+
metadata.relatedFileIds = relatedFiles.map(f => f.id);
|
|
112
|
+
} else {
|
|
66
113
|
// Optional member hints must not cost the grouped related sources either.
|
|
67
114
|
const groupedOmitted = bindingRelations.reduce((n, e) => n + (e.bindingHints ?? []).filter(optional).length, 0);
|
|
68
115
|
const trimmedRelations = bindingRelations.map(e => ({ ...e, bindingHints: (e.bindingHints ?? []).filter(h => !optional(h)) })).filter(e => e.bindingHints.length || e.definitionHints?.length);
|
|
69
|
-
const trimmed = JSON.stringify({ ...base, relatedFiles, relations, bindingRelations: trimmedRelations, relationNotice:
|
|
70
|
-
if (groupedOmitted && measure(trimmed).fits) {
|
|
71
|
-
|
|
116
|
+
const trimmed = JSON.stringify({ ...base, relatedFiles, relations, bindingRelations: trimmedRelations, relationNotice: RELATION_NOTICE });
|
|
117
|
+
if (groupedOmitted && measure(trimmed).fits) {
|
|
118
|
+
payload = trimmed;
|
|
119
|
+
metadata.relatedFileIds = relatedFiles.map(f => f.id);
|
|
120
|
+
metadata.optionalBindingFallback = 'input-budget';
|
|
121
|
+
metadata.omittedOptionalBindingCount = (metadata.omittedOptionalBindingCount ?? 0) + groupedOmitted;
|
|
122
|
+
} else {
|
|
123
|
+
metadata.groupFallback = 'input-budget';
|
|
124
|
+
}
|
|
72
125
|
}
|
|
73
126
|
}
|
|
74
127
|
}
|
|
128
|
+
|
|
75
129
|
if (!measure(payload).fits && scope) {
|
|
76
|
-
const exactWindows=changeWindows(file,changes);
|
|
77
|
-
const windows=exactWindows?sourceSegments(exactWindows):null;
|
|
130
|
+
const exactWindows = changeWindows(file, changes);
|
|
131
|
+
const windows = exactWindows ? sourceSegments(exactWindows) : null;
|
|
78
132
|
if (windows) {
|
|
79
133
|
const compactFile = { ...file, left: { ...file.left, text: undefined }, right: { ...file.right, text: undefined } };
|
|
80
134
|
const contextCatalog = scope.catalog().filter(source => ['context', 'context-old'].includes(source.side));
|
|
81
135
|
if (contextCatalog.length) metadata.contextMode = 'approved-retrieval';
|
|
82
136
|
const windowBase = { ...base };
|
|
83
137
|
if (base.cppCallSites?.length) {
|
|
84
|
-
windowBase.cppCallSites = base.cppCallSites.map(({line,expression,side})=>({line,expression,side}));
|
|
138
|
+
windowBase.cppCallSites = base.cppCallSites.map(({ line, expression, side }) => ({ line, expression, side }));
|
|
85
139
|
windowBase.cppCallNotice = '最多40条旧/新侧零参语法导航;统一confidence=syntax-only-unresolved,可能为宏/重载/动态分派,无绑定、可达性或顺序保证,不是证据或完整覆盖。原文仍须按批准catalog读取。';
|
|
86
140
|
}
|
|
87
|
-
const compact = JSON.stringify({
|
|
141
|
+
const compact = JSON.stringify({
|
|
142
|
+
...windowBase, context: [], contextCatalog, contextMode: 'approved-retrieval',
|
|
143
|
+
contextNotice: '上下文全文仍在同一批准快照,仅目录进入窗口初始输入;必须按sourceId和版本侧read原文,不把目录视为证据或完整覆盖。',
|
|
144
|
+
file: compactFile, windows, sourceMode: 'change-windows',
|
|
145
|
+
windowNotice: '窗口可能为精确hunk的分段或粗粒度包络,精度以changes.precision为准;片段两侧不保证行对齐,不以片段证明精确编辑或回归因果。start/count为原始源码行号,不得重编号。完整源码未随初始输入发送,可在catalog批准范围内检索;缺少上下文时说明限制。',
|
|
146
|
+
});
|
|
88
147
|
if (!measure(compact).fits) {
|
|
89
148
|
const envelope = JSON.parse(compact);
|
|
90
149
|
const plan = planWindowBatches(windows, subset => JSON.stringify({ ...envelope, windows: subset, windowNotice: '本批仅审查windows包含的编辑,其他hunk另批;行号为原始行号,跨片段上下文不足说明限制。' }), measure);
|
|
@@ -105,11 +164,13 @@ export function preparePrimaryInput(input, file, { instructions, scope, grouping
|
|
|
105
164
|
}
|
|
106
165
|
}
|
|
107
166
|
}
|
|
167
|
+
|
|
108
168
|
if (batches?.length > 1) {
|
|
109
169
|
synthesis = prepareWindowSynthesis(batches.map(batch => batch.payload), measure);
|
|
110
170
|
metadata.synthesisStatus = synthesis.budget.fits ? 'ready' : 'input-blocked';
|
|
111
171
|
metadata.synthesisInputBytes = synthesis.budget.bytes;
|
|
112
172
|
}
|
|
173
|
+
|
|
113
174
|
const budget = measure(payload);
|
|
114
175
|
return { changes, payload, batches, synthesis, budget, metadata: { ...metadata, initialInputBytes: budget.bytes } };
|
|
115
176
|
}
|
package/src/project-report.mjs
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
|
-
import {markdownReport} from './review.mjs';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { markdownReport } from './review.mjs';
|
|
2
|
+
|
|
3
|
+
function dedupeFindings(findings) {
|
|
4
|
+
const seen = new Set();
|
|
5
|
+
const result = [];
|
|
6
|
+
for (const f of findings ?? []) {
|
|
7
|
+
const key = JSON.stringify([f.path, f.anchor, f.title, f.evidence]);
|
|
8
|
+
if (!seen.has(key)) { seen.add(key); result.push(f); }
|
|
9
|
+
}
|
|
10
|
+
return result;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function renderProjectReport(report) {
|
|
14
|
+
const merged = {
|
|
15
|
+
...report,
|
|
16
|
+
findings: dedupeFindings(report.findings),
|
|
17
|
+
interactions: (report.batches ?? []).flatMap(b => b.interactions ?? []),
|
|
18
|
+
limitations: (report.batches ?? []).flatMap(b => b.limitations ?? []),
|
|
19
|
+
};
|
|
20
|
+
const scope = '总体范围:' + report.coverage.completed + '/' + report.coverage.total + ' 个文件完成主审;排除 ' + report.coverage.excluded + ',阻断 ' + report.coverage.blocked + ',未完成 ' + report.coverage.pending + '。综合未完成项单独列出;主审完成不等于审核通过。';
|
|
21
|
+
const outdated = report.outdated ? '\n注意:来源已变化或无法复核,仅对应原批准快照。' : '';
|
|
22
|
+
return markdownReport(merged).replace('# Code Review 报告', '# 全项目代码审查报告') + '\n\n' + scope + outdated;
|
|
6
23
|
}
|
|
@@ -1,38 +1,178 @@
|
|
|
1
|
-
import {renderProjectReport} from './project-report.mjs';
|
|
2
|
-
import {realpath} from 'node:fs/promises';
|
|
3
|
-
import {randomUUID} from 'node:crypto';
|
|
4
|
-
import {hash} from './content.mjs';
|
|
5
|
-
import {resolveWorkspaceRepository} from './workspace-repository.mjs';
|
|
6
|
-
import {detectVcs} from './snapshot.mjs';
|
|
7
|
-
import {projectInventory,captureProjectBatch} from './project-snapshot.mjs';
|
|
8
|
-
import {reviewSnapshot
|
|
9
|
-
import {createDshModel} from './dsh-model.mjs';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
1
|
+
import { renderProjectReport } from './project-report.mjs';
|
|
2
|
+
import { realpath } from 'node:fs/promises';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { hash } from './content.mjs';
|
|
5
|
+
import { resolveWorkspaceRepository } from './workspace-repository.mjs';
|
|
6
|
+
import { detectVcs } from './snapshot.mjs';
|
|
7
|
+
import { projectInventory, captureProjectBatch } from './project-snapshot.mjs';
|
|
8
|
+
import { reviewSnapshot } from './review.mjs';
|
|
9
|
+
import { createDshModel } from './dsh-model.mjs';
|
|
10
|
+
|
|
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. Show scope and model before confirmed execute.';
|
|
12
|
+
const REPORT_NOTICE_SUFFIX = ' Coverage counts processing of selected tracked files only, not proof of correctness. Excluded and blocked are never completed. No cross-batch whole-program semantic coverage is claimed.';
|
|
13
|
+
|
|
14
|
+
function owner(exec) {
|
|
15
|
+
const a = exec?.agent;
|
|
16
|
+
if (!a?.session?.header?.cwd || !a.options?.provider || !a.options?.model) throw new Error('Active Agent with cwd and model required');
|
|
17
|
+
return { agent: a, cwd: a.session.header.cwd, route: { provider: a.options.provider, model: a.options.model, ...(a.options.reasoningEffort ? { reasoningEffort: a.options.reasoningEffort } : {}) } };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeCaptureBudget(signal) {
|
|
21
|
+
let commands = 0, metadataBytes = 0;
|
|
22
|
+
return {
|
|
23
|
+
signal,
|
|
24
|
+
reserve() { signal.throwIfAborted(); commands++; },
|
|
25
|
+
charge(size) {
|
|
26
|
+
metadataBytes += size;
|
|
27
|
+
if (metadataBytes > 8 * 1024 * 1024) throw Object.assign(new Error('Project shared metadata budget exceeded'), { code: 'PROJECT_CAPTURE_BUDGET' });
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function captureProject(cwd, args, signal) {
|
|
33
|
+
const captureBudget = makeCaptureBudget(signal);
|
|
34
|
+
const captureOptions = { signal, svnCaptureBudget: captureBudget };
|
|
35
|
+
const root = await realpath(await resolveWorkspaceRepository(cwd, args.repositoryPath));
|
|
36
|
+
const vcs = await detectVcs(root);
|
|
37
|
+
const inventory = await projectInventory(root, vcs, captureOptions);
|
|
38
|
+
|
|
39
|
+
let paths = inventory.records.map(record => record.path);
|
|
40
|
+
if (args.selectedPaths !== undefined) {
|
|
41
|
+
if (!Array.isArray(args.selectedPaths) || args.selectedPaths.length > 1000 || new Set(args.selectedPaths).size !== args.selectedPaths.length || args.selectedPaths.some(p => !paths.includes(p))) throw new Error('Invalid tracked project selection');
|
|
42
|
+
paths = paths.filter(p => args.selectedPaths.includes(p));
|
|
43
|
+
}
|
|
44
|
+
if (paths.length > 1000) throw new Error('Project exceeds 1000 tracked nodes; explicitly select a bounded file set instead of claiming full review');
|
|
45
|
+
|
|
46
|
+
const batches = [];
|
|
47
|
+
let bytes = 0;
|
|
48
|
+
for (let i = 0; i < paths.length; i += 12) {
|
|
49
|
+
const batch = await captureProjectBatch(inventory, paths.slice(i, i + 12), captureOptions);
|
|
50
|
+
bytes += Buffer.byteLength(JSON.stringify(batch));
|
|
51
|
+
if (bytes > 16 * 1024 * 1024) throw new Error('Project capture exceeds shared 16MiB limit; select fewer files');
|
|
52
|
+
batches.push(batch);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const verified = await projectInventory(root, vcs, captureOptions);
|
|
56
|
+
if (verified.id !== inventory.id) throw new Error('Project inventory changed during capture');
|
|
57
|
+
for (const b of batches) {
|
|
58
|
+
if ((await captureProjectBatch(verified, b.files.map(f => f.path), captureOptions)).id !== b.id) throw new Error('Project source changed during capture');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const result = {
|
|
62
|
+
root, vcs, inventoryId: inventory.id, inventoryComplete: inventory.vcs === 'git',
|
|
63
|
+
totalTrackedNodes: inventory.records.length, selectedFiles: paths.length,
|
|
64
|
+
selectionComplete: paths.length === inventory.records.length, notice: inventory.notice, batches,
|
|
65
|
+
};
|
|
66
|
+
return { ...result, id: hash(JSON.stringify(result)) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createProjectReviewService(llm, { allowModelSending = false, authorize = async () => false, now = Date.now, ttlMs = 300000, maxPreviews = 8, projectMaxCalls = Number.MAX_SAFE_INTEGER } = {}) {
|
|
70
|
+
for (const n of [ttlMs, maxPreviews]) if (!Number.isSafeInteger(n) || n < 1) throw new Error('Invalid project preview limit');
|
|
71
|
+
if (!Number.isSafeInteger(projectMaxCalls) || projectMaxCalls < 1) throw new Error('Invalid project model call limit');
|
|
72
|
+
|
|
73
|
+
const previews = new Map();
|
|
74
|
+
const lifetime = new AbortController();
|
|
75
|
+
let busy = false;
|
|
76
|
+
|
|
77
|
+
const signalFor = exec => AbortSignal.any([lifetime.signal, ...(exec.signal ? [exec.signal] : [])]);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
owns: id => previews.has(id),
|
|
81
|
+
|
|
82
|
+
async preview(args, exec) {
|
|
83
|
+
if (busy) throw new Error('Review service busy');
|
|
84
|
+
if (!args || typeof args !== 'object') throw new Error('Project preview parameters required');
|
|
85
|
+
if (Object.keys(args).some(k => !['reviewMode', 'repositoryPath', 'selectedPaths'].includes(k)) || args.reviewMode !== 'project') throw new Error('Project mode supports only reviewMode/repositoryPath/selectedPaths; change/history context parameters are not project parameters');
|
|
86
|
+
const current = owner(exec);
|
|
87
|
+
busy = true;
|
|
88
|
+
try {
|
|
89
|
+
const s = await captureProject(current.cwd, args, signalFor(exec));
|
|
90
|
+
for (const [id, p] of previews) if (p.expires <= now()) previews.delete(id);
|
|
91
|
+
while (previews.size >= maxPreviews) previews.delete(previews.keys().next().value);
|
|
92
|
+
const previewId = randomUUID();
|
|
93
|
+
previews.set(previewId, { ...current, args: structuredClone(args), snapshotId: s.id, expires: now() + ttlMs });
|
|
94
|
+
return {
|
|
95
|
+
previewId, snapshotId: s.id, reviewMode: 'project', repositoryRoot: s.root, vcs: s.vcs,
|
|
96
|
+
model: current.route, modelSendingEnabled: allowModelSending,
|
|
97
|
+
totalTrackedNodes: s.totalTrackedNodes, selectedFiles: s.selectedFiles, selectionComplete: s.selectionComplete,
|
|
98
|
+
inventoryComplete: s.inventoryComplete, batches: s.batches.length,
|
|
99
|
+
files: s.batches.flatMap(b => b.files.map(f => ({ path: f.path, eligibility: f.eligibility, reason: f.reason }))),
|
|
100
|
+
notice: s.notice + PREVIEW_NOTICE_SUFFIX,
|
|
101
|
+
};
|
|
102
|
+
} finally { busy = false; }
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async execute(args, exec) {
|
|
106
|
+
if (busy) throw new Error('Review service busy');
|
|
107
|
+
if (!allowModelSending) throw new Error('Model sending disabled');
|
|
108
|
+
if (args.confirmed !== true || typeof args.previewId !== 'string' || Object.keys(args).some(k => !['previewId', 'confirmed'].includes(k))) throw new Error('Confirmed preview required');
|
|
109
|
+
const p = previews.get(args.previewId);
|
|
110
|
+
const current = owner(exec);
|
|
111
|
+
if (!p || p.expires <= now() || p.agent !== current.agent || p.cwd !== current.cwd || JSON.stringify(p.route) !== JSON.stringify(current.route)) throw new Error('Preview missing expired or owner/model/directory changed');
|
|
112
|
+
previews.delete(args.previewId);
|
|
113
|
+
busy = true;
|
|
114
|
+
const signal = signalFor(exec);
|
|
115
|
+
try {
|
|
116
|
+
const s = await captureProject(current.cwd, p.args, signal);
|
|
117
|
+
if (s.id !== p.snapshotId) throw new Error('Project changed since preview');
|
|
118
|
+
if (await authorize({ snapshot: s, route: p.route, exec }) !== true) throw new Error('Model sending denied');
|
|
119
|
+
if (owner(exec).cwd !== p.cwd || JSON.stringify(owner(exec).route) !== JSON.stringify(p.route)) throw new Error('Agent changed during approval');
|
|
120
|
+
signal.throwIfAborted();
|
|
121
|
+
|
|
122
|
+
await exec.reviewCheckpoint?.({
|
|
123
|
+
stage: 'approved-manifest', snapshotId: s.id,
|
|
124
|
+
files: s.batches.flatMap(b => b.files.map(f => ({ fileId: f.id, path: f.path, status: f.eligibility === 'reviewable' ? 'pending' : f.eligibility }))),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
let calls = 0;
|
|
128
|
+
const adapter = createDshModel(llm, p.route, { correctJson: true });
|
|
129
|
+
const model = request => {
|
|
130
|
+
if (calls >= projectMaxCalls) throw new Error('Shared project model call limit exceeded');
|
|
131
|
+
calls++;
|
|
132
|
+
return adapter({ ...request, onProtocolCorrection: async info => {
|
|
133
|
+
if (calls >= projectMaxCalls) throw new Error('Shared project model call limit exceeded');
|
|
134
|
+
calls++;
|
|
135
|
+
await request.onProtocolCorrection?.(info);
|
|
136
|
+
}});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const results = [];
|
|
140
|
+
for (const b of s.batches) {
|
|
141
|
+
if (signal.aborted || calls >= projectMaxCalls) {
|
|
142
|
+
results.push({ snapshotId: b.id, status: 'partial', files: b.files.map(f => ({ fileId: f.id, path: f.path, status: f.eligibility === 'reviewable' ? 'pending' : f.eligibility, reason: 'Session project budget exhausted or cancelled' })), findings: [], limitations: [] });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
results.push(await reviewSnapshot(b, model, { signal, maxCalls: projectMaxCalls - calls, enableRetrieval: true, enableVerification: true, correctProtocol: true, onProgress: exec.reviewCheckpoint }));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const files = results.flatMap(r => r.files.map(f => ({ ...f, batchSnapshotId: r.snapshotId })));
|
|
149
|
+
const coverage = {
|
|
150
|
+
total: s.selectedFiles,
|
|
151
|
+
completed: files.filter(f => f.status === 'completed').length,
|
|
152
|
+
excluded: files.filter(f => f.status === 'excluded').length,
|
|
153
|
+
blocked: files.filter(f => f.status === 'blocked').length,
|
|
154
|
+
pending: files.filter(f => !['completed', 'excluded', 'blocked'].includes(f.status)).length,
|
|
155
|
+
};
|
|
156
|
+
const findings = results.flatMap(r => r.findings.map(f => ({ ...f, batchSnapshotId: r.snapshotId })));
|
|
157
|
+
|
|
158
|
+
const report = {
|
|
159
|
+
schemaVersion: 1, mode: 'project', snapshotId: s.id,
|
|
160
|
+
status: signal.aborted ? 'cancelled'
|
|
161
|
+
: coverage.total === 0 || coverage.completed === 0 ? 'partial'
|
|
162
|
+
: results.every(r => r.status === 'completed') && files.every(f => ['completed', 'excluded'].includes(f.status)) ? 'completed'
|
|
163
|
+
: 'partial',
|
|
164
|
+
repositoryRoot: s.root, vcs: s.vcs, model: p.route, modelCalls: calls, maxModelCalls: projectMaxCalls,
|
|
165
|
+
totalTrackedNodes: s.totalTrackedNodes, selectionComplete: s.selectionComplete, inventoryComplete: s.inventoryComplete,
|
|
166
|
+
coverage, files, findings, batches: results,
|
|
167
|
+
notice: s.notice + REPORT_NOTICE_SUFFIX,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
try { report.outdated = (await captureProject(current.cwd, p.args, signal)).id !== s.id; }
|
|
171
|
+
catch { report.outdated = true; }
|
|
172
|
+
return { report, markdown: renderProjectReport(report) };
|
|
173
|
+
} finally { busy = false; }
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
dispose() { lifetime.abort(); previews.clear(); },
|
|
177
|
+
};
|
|
38
178
|
}
|
package/src/project-snapshot.mjs
CHANGED
|
@@ -1,45 +1,106 @@
|
|
|
1
|
-
import {realpath,lstat} from 'node:fs/promises';
|
|
1
|
+
import { realpath, lstat } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import {checked} from './process.mjs';
|
|
4
|
-
import {hash,decode,readLocal,relativePath} from './content.mjs';
|
|
5
|
-
import {isSecretPath} from './secret-path.mjs';
|
|
6
|
-
import {xml} from './svn.mjs';
|
|
7
|
-
import {svnCallerNode} from './svn-caller-node.mjs';
|
|
3
|
+
import { checked } from './process.mjs';
|
|
4
|
+
import { hash, decode, readLocal, relativePath } from './content.mjs';
|
|
5
|
+
import { isSecretPath } from './secret-path.mjs';
|
|
6
|
+
import { xml } from './svn.mjs';
|
|
7
|
+
import { svnCallerNode } from './svn-caller-node.mjs';
|
|
8
8
|
|
|
9
|
-
const canonical=v=>Array.isArray(v)?v.map(canonical):v&&typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,canonical(v[k])])):v;
|
|
10
|
-
const text=b=>new TextDecoder('utf-8',{fatal:true}).decode(b);
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
9
|
+
const canonical = v => Array.isArray(v) ? v.map(canonical) : v && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])])) : v;
|
|
10
|
+
const text = b => new TextDecoder('utf-8', { fatal: true }).decode(b);
|
|
11
|
+
|
|
12
|
+
const INVENTORY_NOTICE = 'Tracked current working files only, including unchanged files. Not a historical diff, not untracked or ignored files, not external/submodule contents. Sparse SVN inventory is observed local scope only.';
|
|
13
|
+
|
|
14
|
+
async function inventoryGit(root, options) {
|
|
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 })).trim();
|
|
17
|
+
if (await realpath(actual) !== root) throw new Error('Full project requires explicit repository root');
|
|
18
|
+
const index = await checked('git', [...args, 'ls-files', '--stage', '-z'], { ...options, cwd: root, maxBytes: 2 * 1024 * 1024 });
|
|
19
|
+
const records = [];
|
|
20
|
+
for (const row of text(index).split(String.fromCharCode(0)).filter(Boolean)) {
|
|
21
|
+
const m = /^(\d{6}) ([a-f0-9]+) ([0-3])\t([\s\S]+)$/.exec(row);
|
|
22
|
+
if (!m) throw new Error('Invalid full-project Git index');
|
|
23
|
+
records.push({ path: relativePath(m[4]), kind: m[1], stage: m[3], indexOid: m[2] });
|
|
24
|
+
}
|
|
25
|
+
return { records, identity: { indexHash: hash(index) } };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function inventorySvn(root, options) {
|
|
29
|
+
const info = xml(await checked('svn', ['--non-interactive', 'info', '--xml', '--', '.@'], { ...options, cwd: root })).info?.entry?.[0];
|
|
30
|
+
if (!info || await realpath(info['wc-info']?.['wcroot-abspath']) !== root) throw new Error('Full project requires SVN working-copy root');
|
|
31
|
+
const status = await checked('svn', ['--non-interactive', 'status', '--xml', '--verbose', '--ignore-externals', '--', '.@'], { ...options, cwd: root, maxBytes: 2 * 1024 * 1024 });
|
|
32
|
+
const records = [];
|
|
33
|
+
for (const e of xml(status).status?.target?.flatMap(t => t.entry ?? []) ?? []) {
|
|
34
|
+
const p = e['@_path'].replaceAll('\\', '/');
|
|
35
|
+
if (p === '.') continue;
|
|
36
|
+
const state = e['wc-status'];
|
|
37
|
+
if (['unversioned', 'ignored'].includes(state?.['@_item'])) continue;
|
|
38
|
+
records.push({ path: relativePath(p), state });
|
|
39
|
+
}
|
|
40
|
+
const identity = {
|
|
41
|
+
repositoryUuid: info.repository?.uuid,
|
|
42
|
+
url: info.url,
|
|
43
|
+
statusHash: hash(JSON.stringify(records.map(r => ({ path: r.path, state: Object.fromEntries(Object.entries(r.state ?? {}).sort()) })).sort((a, b) => a.path.localeCompare(b.path)))),
|
|
44
|
+
depth: info['wc-info']?.depth ?? null,
|
|
45
|
+
};
|
|
46
|
+
return { records, identity };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function projectInventory(cwd, vcs, options = {}) {
|
|
50
|
+
const root = await realpath(cwd);
|
|
51
|
+
const source = vcs === 'git' ? await inventoryGit(root, options)
|
|
52
|
+
: vcs === 'svn' ? await inventorySvn(root, options)
|
|
53
|
+
: null;
|
|
54
|
+
if (!source) throw new Error('Unsupported project VCS');
|
|
55
|
+
let records = source.records;
|
|
56
|
+
if (records.length > 10000) throw new Error('Full-project inventory exceeds 10000 tracked nodes; select a smaller explicit scope');
|
|
57
|
+
records.sort((a, b) => a.path.localeCompare(b.path));
|
|
58
|
+
const grouped = new Map();
|
|
59
|
+
for (const r of records) { if (grouped.has(r.path)) { grouped.get(r.path).duplicate = true; } else grouped.set(r.path, r); }
|
|
60
|
+
records = [...grouped.values()];
|
|
61
|
+
const inventory = { root, vcs, records, identity: source.identity, notice: INVENTORY_NOTICE };
|
|
62
|
+
return { ...inventory, id: hash(JSON.stringify(canonical(inventory))) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function bindProjectSource(inventory, record, name, file, options) {
|
|
66
|
+
if (inventory.vcs === 'git') {
|
|
67
|
+
if (record.stage !== '0' || !['100644', '100755'].includes(record.kind) || record.duplicate) throw new Error('Conflicted, linked or submodule Git source excluded');
|
|
68
|
+
file.projectSource = { ...record };
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const state = record.state;
|
|
72
|
+
if (!['normal', 'modified', 'added'].includes(state?.['@_item']) || !['none', 'normal', 'modified'].includes(state['@_props']) || ['@_switched', '@_file-external', '@_tree-conflicted', '@_copied'].some(k => state[k] === 'true')) throw new Error('Unsafe full SVN project source state');
|
|
73
|
+
const node = await svnCallerNode(inventory.root, name, { ...options, allowProjectCurrent: true });
|
|
74
|
+
file.projectSource = { revision: node.revision, nodeFingerprint: node.fingerprint, repositoryUuid: inventory.identity.repositoryUuid };
|
|
30
75
|
}
|
|
31
76
|
|
|
32
|
-
export async function captureProjectBatch(inventory,paths,options={}){
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
77
|
+
export async function captureProjectBatch(inventory, paths, options = {}) {
|
|
78
|
+
if (!Array.isArray(paths) || paths.length > 200 || new Set(paths).size !== paths.length) throw new Error('Invalid project batch');
|
|
79
|
+
const files = [];
|
|
80
|
+
let bytes = 0;
|
|
81
|
+
for (const name of paths) {
|
|
82
|
+
const record = inventory.records.find(r => r.path === name);
|
|
83
|
+
if (!record) throw new Error('Project file outside inventory');
|
|
84
|
+
const file = { id: hash(name), path: name, kind: 'file', rawStatus: 'project-current', rightExists: true, eligibility: 'reviewable', properties: [], reviewMode: 'project', contentNotice: 'Full current-file review. Empty left side is a transport sentinel, NOT an actual empty baseline or a newly added file; review existing defects in current source, do not infer introduced regression.' };
|
|
85
|
+
files.push(file);
|
|
86
|
+
if (isSecretPath(name)) { file.eligibility = 'excluded'; file.reason = 'Credential path excluded'; continue; }
|
|
87
|
+
try {
|
|
88
|
+
const stat = await lstat(path.join(inventory.root, name));
|
|
89
|
+
if (stat.isDirectory()) { file.eligibility = 'excluded'; file.reason = 'Directory metadata is inventory only'; continue; }
|
|
90
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('Linked or nonregular project source excluded');
|
|
91
|
+
await bindProjectSource(inventory, record, name, file, options);
|
|
92
|
+
file.left = decode(Buffer.alloc(0));
|
|
93
|
+
file.right = await readLocal(inventory.root, name, options);
|
|
94
|
+
bytes += file.right.bytes;
|
|
95
|
+
if (bytes > 4 * 1024 * 1024) throw new Error('Project batch exceeds shared 4MiB source limit');
|
|
96
|
+
} catch (error) {
|
|
97
|
+
options.signal?.throwIfAborted();
|
|
98
|
+
if (error.code === 'PROJECT_CAPTURE_BUDGET' || /exceeds shared/.test(error.message)) throw error;
|
|
99
|
+
file.eligibility = 'blocked'; file.reason = error.message;
|
|
100
|
+
delete file.left; delete file.right;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const scope = { mode: 'project', inventoryId: inventory.id, totalTrackedNodes: inventory.records.length, batchPaths: paths, untrackedIncluded: false, inventoryComplete: inventory.vcs === 'git', scopeCompleteness: inventory.vcs === 'svn' ? 'observed-working-copy-only' : 'tracked-index-only', notice: inventory.notice };
|
|
104
|
+
const snapshot = { schemaVersion: 1, vcs: inventory.vcs, root: inventory.root, files, context: [], projectScope: scope };
|
|
105
|
+
return { ...snapshot, id: hash(JSON.stringify(snapshot)) };
|
|
45
106
|
}
|