@stardustlc/dsh-review 0.0.0-stage → 0.3.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 +44 -0
- package/LICENSE +9 -0
- package/README.en.md +43 -0
- package/README.md +120 -2
- package/cordis.patch.yml +15 -0
- package/docs/DESIGN.md +76 -0
- package/docs/INTEGRATION.md +68 -0
- package/docs/VALIDATION.md +39 -0
- package/docs/review-policy.example.json +10 -0
- package/lib/check.d.ts +23 -0
- package/lib/check.js +17 -0
- package/lib/cli.d.ts +82 -0
- package/lib/cli.js +59 -0
- package/lib/config.d.ts +33 -0
- package/lib/config.js +69 -0
- package/lib/diff.d.ts +38 -0
- package/lib/diff.js +136 -0
- package/lib/dispatch.d.ts +58 -0
- package/lib/dispatch.js +50 -0
- package/lib/eval.d.ts +71 -0
- package/lib/eval.js +83 -0
- package/lib/exec.d.ts +26 -0
- package/lib/exec.js +82 -0
- package/lib/fingerprint.d.ts +12 -0
- package/lib/fingerprint.js +14 -0
- package/lib/gate.d.ts +81 -0
- package/lib/gate.js +135 -0
- package/lib/github.d.ts +2 -0
- package/lib/github.js +53 -0
- package/lib/index.d.ts +36 -0
- package/lib/index.js +56 -0
- package/lib/ledger.d.ts +140 -0
- package/lib/ledger.js +125 -0
- package/lib/lenses.d.ts +43 -0
- package/lib/lenses.js +68 -0
- package/lib/modes.d.ts +30 -0
- package/lib/modes.js +62 -0
- package/lib/policy.d.ts +54 -0
- package/lib/policy.js +42 -0
- package/lib/preexisting.d.ts +8 -0
- package/lib/preexisting.js +39 -0
- package/lib/rules.d.ts +32 -0
- package/lib/rules.js +101 -0
- package/lib/run.d.ts +36 -0
- package/lib/run.js +277 -0
- package/lib/scope.d.ts +23 -0
- package/lib/scope.js +50 -0
- package/lib/target.d.ts +40 -0
- package/lib/target.js +134 -0
- package/lib/tools.d.ts +28 -0
- package/lib/tools.js +187 -0
- package/lib/verdict.d.ts +22 -0
- package/lib/verdict.js +25 -0
- package/package.json +67 -4
package/lib/run.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { finderTask, verifierTask, arbiterTask, riskTask } from './lenses.js';
|
|
5
|
+
import { planForMode, classifyRisk } from './modes.js';
|
|
6
|
+
import { parseCandidate, parseVote, voteDecision, gateFindings } from './gate.js';
|
|
7
|
+
import { captureTarget, targetArgs } from './target.js';
|
|
8
|
+
import { prepareScope, inScope } from './scope.js';
|
|
9
|
+
function inputFor(run, ledger, config) {
|
|
10
|
+
return { runId: run.id, target: run.target, mode: run.mode, outDir: path.join(ledger.directory, 'runs', run.id), snapshotFile: path.join(ledger.directory, 'runs', run.id, 'snapshot.json'), maxFindings: config.maxFindings };
|
|
11
|
+
}
|
|
12
|
+
export function startRun(target, mode, config, ledger, independent) {
|
|
13
|
+
const id = randomUUID(), now = new Date().toISOString();
|
|
14
|
+
const run = ledger.transaction(state => {
|
|
15
|
+
const prior = state.heads[target.series], pending = prior?.latestRunId ? state.runs[prior.latestRunId] : undefined;
|
|
16
|
+
if (pending?.lease)
|
|
17
|
+
throw new Error('A review is still executing; cancel it and wait for its workers to stop first');
|
|
18
|
+
if (pending && ['running', 'pending', 'incomplete'].includes(pending.status) && pending.targetId === target.id && pending.stage !== 'done')
|
|
19
|
+
throw new Error('Resume or explicitly abandon the existing review run ' + pending.id + ' instead of overlapping work');
|
|
20
|
+
if (pending && pending.targetId !== target.id && ['running', 'pending', 'incomplete'].includes(pending.status)) {
|
|
21
|
+
pending.status = 'stale';
|
|
22
|
+
pending.errors.push('Superseded by a changed target');
|
|
23
|
+
}
|
|
24
|
+
const round = prior?.round ?? 0;
|
|
25
|
+
if (round >= config.roundCap)
|
|
26
|
+
throw new Error('Review round cap reached. Stop and reconsider the approach.');
|
|
27
|
+
const record = prior ?? { head: target.head, round: 0, mode, posted: {}, reviewedFiles: [], dropped: 0, suppressed: 0, unverified: 0, updatedAt: now };
|
|
28
|
+
Object.assign(record, { round, head: target.head, base: target.base, targetId: target.id, series: target.series, latestRunId: id, mode });
|
|
29
|
+
state.heads[target.series] = record;
|
|
30
|
+
const created = { id, targetId: target.id, series: target.series, head: target.head, target, mode, status: 'pending', stage: 'find', tasks: [], results: {}, candidates: [], round: round + 1, calls: 0, errors: [], createdAt: now, postable: [], rejected: [], unverified: 0, independent };
|
|
31
|
+
const input = inputFor(created, ledger, config);
|
|
32
|
+
created.tasks = [...planForMode(mode).finders.map(lens => finderTask(input, lens)), riskTask(input, false), riskTask(input, true)];
|
|
33
|
+
state.runs[id] = created;
|
|
34
|
+
return created;
|
|
35
|
+
});
|
|
36
|
+
const input = inputFor(run, ledger, config);
|
|
37
|
+
mkdirSync(input.outDir, { recursive: true });
|
|
38
|
+
ledger.event('run-start', { runId: id, targetId: target.id, mode, independent });
|
|
39
|
+
return run;
|
|
40
|
+
}
|
|
41
|
+
export function parseTaskResult(task, text, target, maxFindings) {
|
|
42
|
+
if (Buffer.byteLength(text) > 1024 * 1024)
|
|
43
|
+
throw new Error('Worker output exceeds 1 MiB');
|
|
44
|
+
const value = JSON.parse(text.trim().replace(/^\x60{3}(?:json)?\s*\n([\s\S]*?)\n\x60{3}$/, '$1'));
|
|
45
|
+
if (!value || value.runId !== task.runId || value.targetId !== task.targetId || value.taskId !== task.id)
|
|
46
|
+
throw new Error('Wrong run, target or task identity');
|
|
47
|
+
if (task.role === 'find') {
|
|
48
|
+
if (!Array.isArray(value.findings) || !Array.isArray(value.declined) || value.findings.length > maxFindings)
|
|
49
|
+
throw new Error('Invalid or excessive finder output');
|
|
50
|
+
const parsed = value.findings.map((finding) => parseCandidate(finding, task.id, target));
|
|
51
|
+
return { runId: task.runId, targetId: task.targetId, taskId: task.id, findings: parsed.filter(Boolean), ignoredStyleCount: parsed.filter((finding) => finding === null).length, declined: value.declined };
|
|
52
|
+
}
|
|
53
|
+
if (task.role === 'verify')
|
|
54
|
+
return { runId: task.runId, targetId: task.targetId, ...parseVote(value, task.id, task.candidateId, target) };
|
|
55
|
+
if (task.role === 'risk') {
|
|
56
|
+
if (!['low', 'medium', 'high'].includes(value.tier) || typeof value.extraHuman !== 'boolean' || !Array.isArray(value.reasons) || !value.reasons.length || value.reasons.some((r) => typeof r !== 'string' || !r.trim()))
|
|
57
|
+
throw new Error('Invalid risk classification');
|
|
58
|
+
return { runId: task.runId, targetId: task.targetId, taskId: task.id, tier: value.tier, extraHuman: value.extraHuman, reasons: value.reasons };
|
|
59
|
+
}
|
|
60
|
+
if (!Array.isArray(value.orderedIds) || value.orderedIds.some((id) => typeof id !== 'string') || typeof value.summary !== 'string' || !value.summary.trim() || typeof value.needsHuman !== 'boolean')
|
|
61
|
+
throw new Error('Invalid arbiter output');
|
|
62
|
+
return { runId: task.runId, targetId: task.targetId, taskId: task.id, orderedIds: value.orderedIds, summary: value.summary, needsHuman: value.needsHuman };
|
|
63
|
+
}
|
|
64
|
+
export function submitResults(ledger, runId, targetId, values, config) {
|
|
65
|
+
ledger.transaction(state => {
|
|
66
|
+
const run = state.runs[runId];
|
|
67
|
+
if (!run || run.targetId !== targetId || run.stage === 'done' || ['failed', 'cancelled', 'stale'].includes(run.status))
|
|
68
|
+
throw new Error('Unknown, terminal or mismatched review run');
|
|
69
|
+
const pending = run.tasks.filter(task => !Object.hasOwn(run.results, task.id));
|
|
70
|
+
const parsed = values.map(value => {
|
|
71
|
+
const task = pending.find(task => task.id === value?.taskId);
|
|
72
|
+
if (!task)
|
|
73
|
+
throw new Error('Result duplicated or not a task in this stage');
|
|
74
|
+
return [task.id, parseTaskResult(task, JSON.stringify(value), run.target, config.maxFindings)];
|
|
75
|
+
});
|
|
76
|
+
if (new Set(parsed.map(([id]) => id)).size !== parsed.length)
|
|
77
|
+
throw new Error('Duplicate task result');
|
|
78
|
+
for (const [id, value] of parsed)
|
|
79
|
+
run.results[id] = value;
|
|
80
|
+
run.status = 'pending';
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function votesFor(run) {
|
|
84
|
+
const votes = {};
|
|
85
|
+
for (const task of run.tasks.filter(task => task.role === 'verify')) {
|
|
86
|
+
const value = run.results[task.id];
|
|
87
|
+
if (value)
|
|
88
|
+
(votes[task.candidateId] ??= []).push(value);
|
|
89
|
+
}
|
|
90
|
+
return votes;
|
|
91
|
+
}
|
|
92
|
+
export function advance(ledger, runId, config) {
|
|
93
|
+
ledger.transaction(state => {
|
|
94
|
+
const run = state.runs[runId];
|
|
95
|
+
if (!run || run.stage === 'done' || ['failed', 'cancelled', 'stale'].includes(run.status) || run.tasks.some(task => !Object.hasOwn(run.results, task.id)))
|
|
96
|
+
return;
|
|
97
|
+
const input = inputFor(run, ledger, config), plan = planForMode(run.mode), record = state.heads[run.series];
|
|
98
|
+
if (run.stage === 'find') {
|
|
99
|
+
const floor = classifyRisk(run.target.allPaths, run.target.rules.sensitivePaths);
|
|
100
|
+
const classifiers = ['risk-classifier', 'risk-second'].map(id => run.results[id]);
|
|
101
|
+
const levels = ['low', 'medium', 'high'];
|
|
102
|
+
const tier = levels[Math.max(levels.indexOf(floor.tier), ...classifiers.map(c => levels.indexOf(c.tier)))];
|
|
103
|
+
run.risk = { tier, reasons: [...floor.reasons, ...classifiers.flatMap(c => c.reasons)], extraHuman: classifiers.some(c => c.extraHuman) };
|
|
104
|
+
if (tier === 'high' && run.mode !== 'deep') {
|
|
105
|
+
run.mode = 'deep';
|
|
106
|
+
const missing = planForMode('deep').finders.filter(lens => !run.tasks.some(task => task.id === 'find-' + lens.id));
|
|
107
|
+
run.tasks.push(...missing.map(lens => finderTask({ ...input, mode: 'deep' }, lens)));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const candidates = new Map();
|
|
111
|
+
record.dropped += Object.values(run.results).reduce((n, value) => n + (value.ignoredStyleCount ?? 0), 0);
|
|
112
|
+
for (const value of Object.values(run.results))
|
|
113
|
+
for (const finding of value.findings ?? []) {
|
|
114
|
+
if (!inScope(finding, run.scope, run.target))
|
|
115
|
+
continue;
|
|
116
|
+
const existing = candidates.get(finding.id), rank = { blocker: 3, important: 2, nit: 1 };
|
|
117
|
+
if (!existing || rank[finding.severity] > rank[existing.severity])
|
|
118
|
+
candidates.set(finding.id, finding);
|
|
119
|
+
}
|
|
120
|
+
for (const [id, former] of Object.entries(record.posted))
|
|
121
|
+
if (!former.disposition && former.severity !== 'pre-existing' && former.anchor && !candidates.has(id))
|
|
122
|
+
candidates.set(id, { id, file: former.file, line: former.line, kind: former.kind, severity: former.severity, message: former.message, anchor: former.anchor, evidence: former.evidence, carried: true, ...(former.classOfBug ? { classOfBug: former.classOfBug } : {}), ...(former.lens ? { lens: former.lens } : {}) });
|
|
123
|
+
run.candidates = [...candidates.values()];
|
|
124
|
+
if (run.candidates.length > config.maxFindings) {
|
|
125
|
+
run.status = 'incomplete';
|
|
126
|
+
run.errors.push('Candidate budget exceeded; split the target');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
run.stage = 'verify';
|
|
130
|
+
run.tasks.push(...run.candidates.flatMap(finding => Array.from({ length: plan.verifiersPerFinding }, (_, i) => verifierTask(input, finding, i))));
|
|
131
|
+
if (run.tasks.some(task => !Object.hasOwn(run.results, task.id)))
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const votes = votesFor(run);
|
|
135
|
+
if (run.stage === 'verify' && plan.arbiter) {
|
|
136
|
+
const confirmed = run.candidates.filter(f => ['confirm', 'pre-existing'].includes(voteDecision(votes[f.id] ?? [], plan.verifiersPerFinding)));
|
|
137
|
+
run.stage = 'arbiter';
|
|
138
|
+
run.tasks.push(arbiterTask(input, confirmed, votes));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const confirmedIds = run.candidates.filter(f => ['confirm', 'pre-existing'].includes(voteDecision(votes[f.id] ?? [], plan.verifiersPerFinding))).map(f => f.id);
|
|
142
|
+
if (run.stage === 'arbiter') {
|
|
143
|
+
const result = run.results.arbiter;
|
|
144
|
+
if (result.needsHuman || new Set(result.orderedIds).size !== confirmedIds.length || result.orderedIds.length !== confirmedIds.length || confirmedIds.some(id => !result.orderedIds.includes(id))) {
|
|
145
|
+
run.status = 'incomplete';
|
|
146
|
+
run.errors.push('Arbiter requires human attention or changed the accepted finding set');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const gated = gateFindings({ root: run.target.root, head: run.head, base: run.target.base, mode: run.mode, findings: run.candidates, config, rules: run.target.rules, record, ledger, target: run.target, votes, requiredVotes: plan.verifiersPerFinding });
|
|
151
|
+
run.postable = gated.postable;
|
|
152
|
+
run.rejected = gated.rejected;
|
|
153
|
+
run.unverified = gated.unverified;
|
|
154
|
+
run.accepted = Object.values(record.posted).filter(f => f.targetId === run.targetId && !f.disposition).map(f => structuredClone(f));
|
|
155
|
+
run.summary = run.accepted.length + ' verified finding(s), ' + gated.unverified + ' unverified. ' + (run.accepted.length ? 'Resolve every finding with a fix or a specific reason.' : 'No verified defect to fix in the reviewed scope.');
|
|
156
|
+
run.stage = 'done';
|
|
157
|
+
run.status = gated.unverified ? 'incomplete' : 'complete';
|
|
158
|
+
run.completedAt = new Date().toISOString();
|
|
159
|
+
if (run.status === 'complete')
|
|
160
|
+
record.round = run.round;
|
|
161
|
+
record.updatedAt = new Date().toISOString();
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
export async function assertCurrent(run, ledger, config, signal) {
|
|
165
|
+
let current;
|
|
166
|
+
try {
|
|
167
|
+
current = await captureTarget(run.target.root, targetArgs(run.target), config.maxDiffBytes, config.stateDir, signal);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
ledger.transaction(state => { const r = state.runs[run.id]; r.status = signal?.aborted ? 'cancelled' : 'incomplete'; r.errors.push(error instanceof Error ? error.message : String(error)); });
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
if (current.id !== run.targetId) {
|
|
174
|
+
ledger.transaction(state => { state.runs[run.id].status = 'stale'; state.runs[run.id].errors.push('Target changed; prior findings cannot approve this version'); });
|
|
175
|
+
throw new Error('Review target changed; prior approval is invalid');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function executeStages(ledger, runId, config, dispatcher, signal) {
|
|
179
|
+
for (;;) {
|
|
180
|
+
const run = ledger.snapshot().runs[runId];
|
|
181
|
+
if (!run)
|
|
182
|
+
throw new Error('Unknown run');
|
|
183
|
+
if (['failed', 'cancelled', 'stale'].includes(run.status))
|
|
184
|
+
return run;
|
|
185
|
+
await assertCurrent(run, ledger, config, signal);
|
|
186
|
+
if (run.stage === 'done')
|
|
187
|
+
return run;
|
|
188
|
+
if (!run.scope) {
|
|
189
|
+
await prepareScope(run, ledger, config, signal);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const pending = run.tasks.filter(task => !Object.hasOwn(run.results, task.id));
|
|
193
|
+
if (!pending.length) {
|
|
194
|
+
advance(ledger, runId, config);
|
|
195
|
+
const updated = ledger.snapshot().runs[runId];
|
|
196
|
+
if (updated.status === 'incomplete' && updated.tasks.length === run.tasks.length)
|
|
197
|
+
return updated;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (dispatcher.kind === 'external')
|
|
201
|
+
return run;
|
|
202
|
+
if (run.calls + pending.length > config.maxCalls) {
|
|
203
|
+
ledger.transaction(state => { state.runs[runId].status = 'incomplete'; state.runs[runId].errors.push('Actual model call budget exhausted'); });
|
|
204
|
+
return ledger.snapshot().runs[runId];
|
|
205
|
+
}
|
|
206
|
+
ledger.transaction(state => { state.runs[runId].status = 'running'; });
|
|
207
|
+
let next = 0, failed = false;
|
|
208
|
+
await Promise.all(Array.from({ length: Math.min(config.maxParallel, pending.length) }, async () => {
|
|
209
|
+
for (;;) {
|
|
210
|
+
const task = pending[next++];
|
|
211
|
+
if (!task)
|
|
212
|
+
return;
|
|
213
|
+
try {
|
|
214
|
+
signal.throwIfAborted();
|
|
215
|
+
ledger.transaction(state => { state.runs[runId].calls++; });
|
|
216
|
+
const result = await dispatcher.dispatch(task);
|
|
217
|
+
if (!result.ok)
|
|
218
|
+
throw new Error(result.reason ?? 'Worker failed');
|
|
219
|
+
const text = result.output ?? '';
|
|
220
|
+
const parsed = parseTaskResult(task, text, run.target, config.maxFindings);
|
|
221
|
+
ledger.transaction(state => { state.runs[runId].results[task.id] = parsed; });
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
failed = true;
|
|
225
|
+
ledger.transaction(state => { const r = state.runs[runId]; r.errors.push(task.id + ': ' + (error instanceof Error ? error.message : String(error))); r.status = signal.aborted ? 'cancelled' : 'incomplete'; });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}));
|
|
229
|
+
await dispatcher.settle();
|
|
230
|
+
if (failed)
|
|
231
|
+
return ledger.snapshot().runs[runId];
|
|
232
|
+
signal.throwIfAborted();
|
|
233
|
+
ledger.transaction(state => { state.runs[runId].status = 'pending'; });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
export async function executeRun(ledger, runId, config, dispatcher, signal) {
|
|
237
|
+
const lease = randomUUID();
|
|
238
|
+
ledger.transaction(state => {
|
|
239
|
+
const run = state.runs[runId];
|
|
240
|
+
if (!run)
|
|
241
|
+
throw new Error('Unknown review run');
|
|
242
|
+
if (run.lease && run.leasePid) {
|
|
243
|
+
let alive = true;
|
|
244
|
+
try {
|
|
245
|
+
process.kill(run.leasePid, 0);
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
if (error.code === 'ESRCH')
|
|
249
|
+
alive = false;
|
|
250
|
+
}
|
|
251
|
+
if (!alive) {
|
|
252
|
+
delete run.lease;
|
|
253
|
+
delete run.leasePid;
|
|
254
|
+
run.status = 'incomplete';
|
|
255
|
+
run.errors.push('Recovered interrupted worker owner; no approval was preserved');
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (run.lease)
|
|
259
|
+
throw new Error('This review run is already executing');
|
|
260
|
+
run.lease = lease;
|
|
261
|
+
run.leasePid = process.pid;
|
|
262
|
+
});
|
|
263
|
+
try {
|
|
264
|
+
return await executeStages(ledger, runId, config, dispatcher, signal);
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
ledger.transaction(state => { const run = state.runs[runId]; if (run?.lease === lease) {
|
|
268
|
+
delete run.lease;
|
|
269
|
+
delete run.leasePid;
|
|
270
|
+
} });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
export function runView(run) {
|
|
274
|
+
return { runId: run.id, targetId: run.targetId, head: run.head, mode: run.mode, status: run.status, stage: run.stage, independent: run.independent,
|
|
275
|
+
round: run.round, calls: run.calls, errors: run.errors, risk: run.risk ?? null, postable: run.postable, rejected: run.rejected, unverified: run.unverified, incremental: run.scope?.incremental ?? false, summary: run.summary ?? 'Review has not completed; no clean conclusion is available.',
|
|
276
|
+
missingOutputs: run.tasks.filter(task => !Object.hasOwn(run.results, task.id)).map(task => task.id), tasks: run.tasks.filter(task => !Object.hasOwn(run.results, task.id)) };
|
|
277
|
+
}
|
package/lib/scope.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReviewTarget } from './target.js';
|
|
2
|
+
import type { Ledger, StoredRun } from './ledger.js';
|
|
3
|
+
import type { ReviewConfig } from './config.js';
|
|
4
|
+
export interface ReviewScope {
|
|
5
|
+
incremental: boolean;
|
|
6
|
+
fromRunId?: string;
|
|
7
|
+
files: Array<{
|
|
8
|
+
file: string;
|
|
9
|
+
addedLines: number[];
|
|
10
|
+
removedLines: number[];
|
|
11
|
+
}>;
|
|
12
|
+
}
|
|
13
|
+
export declare function prepareScope(run: StoredRun, ledger: Ledger, config: ReviewConfig, signal: AbortSignal): Promise<void>;
|
|
14
|
+
export declare function inScope(finding: {
|
|
15
|
+
introducedBy?: {
|
|
16
|
+
file: string;
|
|
17
|
+
line: number;
|
|
18
|
+
side?: string;
|
|
19
|
+
};
|
|
20
|
+
file: string;
|
|
21
|
+
line: number;
|
|
22
|
+
side?: string;
|
|
23
|
+
}, scope: ReviewScope | undefined, target: ReviewTarget): boolean;
|
package/lib/scope.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { git } from './exec.js';
|
|
4
|
+
import { parseDiff } from './diff.js';
|
|
5
|
+
export async function prepareScope(run, ledger, config, signal) {
|
|
6
|
+
if (run.scope)
|
|
7
|
+
return;
|
|
8
|
+
const directory = path.join(ledger.directory, 'runs', run.id), snapshot = path.join(directory, 'snapshot.json');
|
|
9
|
+
const earlier = Object.values(ledger.snapshot().runs).filter(r => r.id !== run.id && r.series === run.series && r.status === 'complete' && r.completedAt).sort((a, b) => b.completedAt.localeCompare(a.completedAt))[0];
|
|
10
|
+
const incremental = run.round > config.diffOnlyAfterRound && !!earlier;
|
|
11
|
+
const scope = { incremental, ...(incremental ? { fromRunId: earlier.id } : {}), files: [] };
|
|
12
|
+
mkdirSync(directory, { recursive: true });
|
|
13
|
+
for (let i = 0; i < run.target.files.length; i++) {
|
|
14
|
+
const file = run.target.files[i], previous = earlier?.target.files.find(f => f.path === file.path);
|
|
15
|
+
if (!incremental || !previous) {
|
|
16
|
+
scope.files.push({ file: file.path, addedLines: file.addedLines, removedLines: file.removedLines });
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (previous.after === file.after && previous.before === file.before)
|
|
20
|
+
continue;
|
|
21
|
+
const oldPath = path.join(directory, 'scope-' + i + '-old'), newPath = path.join(directory, 'scope-' + i + '-new');
|
|
22
|
+
writeFileSync(oldPath, previous.after ?? '');
|
|
23
|
+
writeFileSync(newPath, file.after ?? '');
|
|
24
|
+
const diff = await git(run.target.root, ['diff', '--no-index', '--no-ext-diff', '--no-textconv', '--unified=0', '--', oldPath, newPath], 30000, config.maxDiffBytes, signal);
|
|
25
|
+
if ((diff.code !== 0 && diff.code !== 1) || diff.truncated || diff.timedOut || signal.aborted)
|
|
26
|
+
throw new Error('Incremental comparison did not complete');
|
|
27
|
+
// Only hunk line numbers are used: temp-file paths are not project paths.
|
|
28
|
+
const parsed = parseDiff(diff.stdout);
|
|
29
|
+
if (!parsed.complete)
|
|
30
|
+
throw new Error('Incremental comparison has an unsupported diff header');
|
|
31
|
+
const addedLines = parsed.files.flatMap(f => f.hunks.flatMap(h => h.lines.filter(l => l.type === 'add').map(l => l.newNo)));
|
|
32
|
+
const removedLines = previous.before === file.before ? file.removedLines.filter(n => !previous.removedLines.includes(n)) : file.removedLines;
|
|
33
|
+
scope.files.push({ file: file.path, addedLines, removedLines });
|
|
34
|
+
}
|
|
35
|
+
const document = JSON.stringify({ ...run.target, reviewScope: scope }, null, 2);
|
|
36
|
+
if (existsSync(snapshot)) {
|
|
37
|
+
if (readFileSync(snapshot, 'utf8') !== document)
|
|
38
|
+
throw new Error('Immutable run snapshot already exists with different content');
|
|
39
|
+
}
|
|
40
|
+
else
|
|
41
|
+
writeFileSync(snapshot, document, { flag: 'wx' });
|
|
42
|
+
ledger.transaction(state => { state.runs[run.id].scope = scope; });
|
|
43
|
+
}
|
|
44
|
+
export function inScope(finding, scope, target) {
|
|
45
|
+
if (!scope?.incremental)
|
|
46
|
+
return true;
|
|
47
|
+
const anchor = finding.introducedBy ?? finding, file = target.files.find(f => (anchor.side === 'before' ? f.oldPath : f.path) === anchor.file);
|
|
48
|
+
const entry = scope.files.find(f => f.file === file?.path);
|
|
49
|
+
return !!entry && (anchor.side === 'before' ? entry.removedLines : entry.addedLines).includes(anchor.line);
|
|
50
|
+
}
|
package/lib/target.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type FileDiff } from './diff.js';
|
|
2
|
+
import { type ReviewRules } from './rules.js';
|
|
3
|
+
export interface TargetFile {
|
|
4
|
+
path: string;
|
|
5
|
+
oldPath: string | null;
|
|
6
|
+
status: string;
|
|
7
|
+
binary: boolean;
|
|
8
|
+
before: string | null;
|
|
9
|
+
after: string | null;
|
|
10
|
+
addedLines: number[];
|
|
11
|
+
removedLines: number[];
|
|
12
|
+
}
|
|
13
|
+
export interface ReviewTarget {
|
|
14
|
+
id: string;
|
|
15
|
+
series: string;
|
|
16
|
+
root: string;
|
|
17
|
+
head: string;
|
|
18
|
+
base: string;
|
|
19
|
+
baseRef: string;
|
|
20
|
+
kind: 'worktree' | 'staged' | 'commit';
|
|
21
|
+
includeUntracked: boolean;
|
|
22
|
+
patch: string;
|
|
23
|
+
files: TargetFile[];
|
|
24
|
+
allPaths: string[];
|
|
25
|
+
instructions: string;
|
|
26
|
+
skipped: string[];
|
|
27
|
+
rules: ReviewRules;
|
|
28
|
+
diff: FileDiff[];
|
|
29
|
+
}
|
|
30
|
+
export declare function captureTarget(root: string, args: {
|
|
31
|
+
base?: unknown;
|
|
32
|
+
staged?: unknown;
|
|
33
|
+
includeUntracked?: unknown;
|
|
34
|
+
}, maxBytes: number, stateDir: string, signal?: AbortSignal): Promise<ReviewTarget>;
|
|
35
|
+
export declare function targetArgs(target: ReviewTarget): {
|
|
36
|
+
base: string;
|
|
37
|
+
staged: boolean;
|
|
38
|
+
includeUntracked: boolean;
|
|
39
|
+
};
|
|
40
|
+
export declare function sourceMatches(target: ReviewTarget, file: string, line: number, anchor: string, side?: 'before' | 'after'): boolean;
|
package/lib/target.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, realpathSync, lstatSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { git } from './exec.js';
|
|
5
|
+
import { parseDiff } from './diff.js';
|
|
6
|
+
import { loadRules, parseRules, RULE_PATHS, isSkipped } from './rules.js';
|
|
7
|
+
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
8
|
+
export async function captureTarget(root, args, maxBytes, stateDir, signal) {
|
|
9
|
+
root = realpathSync(root);
|
|
10
|
+
const query = async (argv) => {
|
|
11
|
+
const r = await git(root, argv, 30000, maxBytes, signal);
|
|
12
|
+
if (!r.ok)
|
|
13
|
+
throw new Error(r.truncated ? 'Review incomplete: diff or source exceeds maxDiffBytes' : 'Git read failed: ' + r.stderr.slice(0, 300));
|
|
14
|
+
return r.stdout;
|
|
15
|
+
};
|
|
16
|
+
const head = (await query(['rev-parse', '--verify', 'HEAD'])).trim();
|
|
17
|
+
const baseRef = typeof args.base === 'string' && args.base.trim() ? args.base.trim() : 'HEAD';
|
|
18
|
+
if (baseRef.startsWith('-') || /[\s\x00-\x1f]/.test(baseRef))
|
|
19
|
+
throw new Error('Invalid base ref');
|
|
20
|
+
const kind = args.staged === true ? 'staged' : baseRef !== 'HEAD' ? 'commit' : 'worktree';
|
|
21
|
+
if (args.staged === true && baseRef !== 'HEAD')
|
|
22
|
+
throw new Error('staged and base are mutually exclusive');
|
|
23
|
+
const requestedBase = (await query(['rev-parse', '--verify', '--end-of-options', baseRef + '^{commit}'])).trim();
|
|
24
|
+
const base = kind === 'commit' ? (await query(['merge-base', requestedBase, head])).trim() : head;
|
|
25
|
+
const selector = kind === 'staged' ? ['--cached', head] : kind === 'commit' ? [base, head] : [head];
|
|
26
|
+
const common = ['diff', '--no-ext-diff', '--no-textconv', '--no-color'];
|
|
27
|
+
const names = await query([...common, '--name-status', '-z', ...selector, '--']);
|
|
28
|
+
const patch = await query([...common, '--unified=3', ...selector, '--']);
|
|
29
|
+
const parsed = parseDiff(patch);
|
|
30
|
+
if (!parsed.complete)
|
|
31
|
+
throw new Error('Review incomplete: unsupported Git diff header');
|
|
32
|
+
const fields = names.split('\0');
|
|
33
|
+
if (fields.at(-1) === '')
|
|
34
|
+
fields.pop();
|
|
35
|
+
const entries = [];
|
|
36
|
+
for (let i = 0; i < fields.length;) {
|
|
37
|
+
const code = fields[i++], first = fields[i++];
|
|
38
|
+
if (!first || !/^[AMDTRC][0-9]*$/.test(code))
|
|
39
|
+
throw new Error('Review incomplete: unsupported name-status entry');
|
|
40
|
+
const renamed = /^[RC]/.test(code), next = renamed ? fields[i++] : first;
|
|
41
|
+
if (!next)
|
|
42
|
+
throw new Error('Review incomplete: truncated file inventory');
|
|
43
|
+
entries.push({ path: next, oldPath: code[0] === 'A' ? null : first, code });
|
|
44
|
+
}
|
|
45
|
+
const includeUntracked = kind === 'worktree' && args.includeUntracked !== false;
|
|
46
|
+
if (includeUntracked) {
|
|
47
|
+
const extra = (await query(['ls-files', '--others', '--exclude-standard', '-z'])).split('\0').filter(Boolean);
|
|
48
|
+
for (const file of extra)
|
|
49
|
+
if (!entries.some(e => e.path === file))
|
|
50
|
+
entries.push({ path: file, oldPath: null, code: 'A' });
|
|
51
|
+
}
|
|
52
|
+
let rules;
|
|
53
|
+
if (kind === 'commit') {
|
|
54
|
+
const entries = (await query(['ls-tree', '-z', base, '--', ...RULE_PATHS])).split('\0').filter(Boolean);
|
|
55
|
+
const source = RULE_PATHS.find(file => entries.some(e => e.split('\t')[1] === file));
|
|
56
|
+
if (source) {
|
|
57
|
+
if (!entries.find(e => e.split('\t')[1] === source)?.startsWith('100'))
|
|
58
|
+
throw new Error('Committed review guidance must be a regular file');
|
|
59
|
+
rules = parseRules(await query(['show', '--no-ext-diff', '--no-textconv', base + ':' + source]), source);
|
|
60
|
+
}
|
|
61
|
+
else
|
|
62
|
+
rules = parseRules('', null);
|
|
63
|
+
}
|
|
64
|
+
else
|
|
65
|
+
rules = loadRules(root);
|
|
66
|
+
const skipped = [], files = [];
|
|
67
|
+
const internal = path.isAbsolute(stateDir) ? path.relative(root, stateDir).replaceAll('\\', '/') : stateDir;
|
|
68
|
+
const inState = (file) => file === internal || file.startsWith(internal + '/') || file === '.dsh-review' || file.startsWith('.dsh-review/');
|
|
69
|
+
let total = Buffer.byteLength(patch);
|
|
70
|
+
const blob = async (spec) => query(['show', '--no-ext-diff', '--no-textconv', spec]);
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (inState(entry.path) || isSkipped(entry.path, rules.skip)) {
|
|
73
|
+
skipped.push(entry.path);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const diff = parsed.files.find(f => f.path === entry.path);
|
|
77
|
+
if (!diff && entry.oldPath !== null)
|
|
78
|
+
throw new Error('Review incomplete: file missing from patch: ' + entry.path);
|
|
79
|
+
const before = entry.oldPath === null ? null : await blob(base + ':' + entry.oldPath);
|
|
80
|
+
let after = null;
|
|
81
|
+
if (entry.code[0] !== 'D') {
|
|
82
|
+
if (kind === 'worktree') {
|
|
83
|
+
const file = path.resolve(root, entry.path), relative = path.relative(root, realpathSync(file));
|
|
84
|
+
if (relative.startsWith('..') || path.isAbsolute(relative) || lstatSync(file).isSymbolicLink())
|
|
85
|
+
throw new Error('Review incomplete: symlink or out-of-root path');
|
|
86
|
+
after = readFileSync(file, 'utf8');
|
|
87
|
+
}
|
|
88
|
+
else
|
|
89
|
+
after = await blob((kind === 'staged' ? '' : head) + ':' + entry.path);
|
|
90
|
+
}
|
|
91
|
+
total += Buffer.byteLength(before ?? '') + Buffer.byteLength(after ?? '');
|
|
92
|
+
if (total > maxBytes)
|
|
93
|
+
throw new Error('Review incomplete: snapshot exceeds maxDiffBytes');
|
|
94
|
+
files.push({ path: entry.path, oldPath: entry.oldPath, status: diff?.status ?? 'added', binary: diff?.binary ?? (after?.includes('\0') ?? false), before, after,
|
|
95
|
+
addedLines: diff ? diff.hunks.flatMap(h => h.lines.filter(l => l.type === 'add').map(l => l.newNo)) : (after ?? '').split('\n').map((_, n) => n + 1),
|
|
96
|
+
removedLines: diff?.hunks.flatMap(h => h.lines.filter(l => l.type === 'del').map(l => l.oldNo)) ?? [] });
|
|
97
|
+
}
|
|
98
|
+
if ((await query(['rev-parse', 'HEAD'])).trim() !== head || await query([...common, '--name-status', '-z', ...selector, '--']) !== names || await query([...common, '--unified=3', ...selector, '--']) !== patch)
|
|
99
|
+
throw new Error('Review target changed while reading; retry');
|
|
100
|
+
const branchResult = await git(root, ['symbolic-ref', '--short', 'HEAD']);
|
|
101
|
+
const series = hash(JSON.stringify([root, branchResult.ok ? branchResult.stdout.trim() : head, kind, base, kind === 'commit' ? null : head]));
|
|
102
|
+
const instructions = ['AGENTS.md', 'CLAUDE.md'].map(file => {
|
|
103
|
+
try {
|
|
104
|
+
const absolute = path.join(root, file), relative = path.relative(root, realpathSync(absolute));
|
|
105
|
+
if (lstatSync(absolute).isSymbolicLink() || relative.startsWith('..') || path.isAbsolute(relative))
|
|
106
|
+
throw new Error('Project guidance must be a regular workspace file');
|
|
107
|
+
const content = readFileSync(absolute, 'utf8');
|
|
108
|
+
if (Buffer.byteLength(content) > 16384)
|
|
109
|
+
throw new Error('Project instruction file exceeds 16 KiB');
|
|
110
|
+
return file + '\n' + content;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error.code === 'ENOENT')
|
|
114
|
+
return '';
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}).filter(Boolean).join('\n');
|
|
118
|
+
const allPaths = entries.filter(entry => !inState(entry.path)).map(entry => entry.path);
|
|
119
|
+
if (includeUntracked) {
|
|
120
|
+
const now = (await query(['ls-files', '--others', '--exclude-standard', '-z'])).split('\0').filter(file => file && !inState(file) && !isSkipped(file, rules.skip));
|
|
121
|
+
const then = entries.filter(e => e.oldPath === null && !parsed.files.some(f => f.path === e.path) && !inState(e.path) && !isSkipped(e.path, rules.skip)).map(e => e.path);
|
|
122
|
+
if (JSON.stringify(now.sort()) !== JSON.stringify(then.sort()))
|
|
123
|
+
throw new Error('Untracked file inventory changed while reading; retry');
|
|
124
|
+
for (const file of files.filter(f => f.oldPath === null && !parsed.files.some(d => d.path === f.path)))
|
|
125
|
+
if (readFileSync(path.resolve(root, file.path), 'utf8') !== file.after)
|
|
126
|
+
throw new Error('Untracked source changed while reading; retry');
|
|
127
|
+
}
|
|
128
|
+
return { id: hash(JSON.stringify([root, kind, head, base, patch, files, rules, instructions, allPaths])), series, root, head, base, baseRef, kind, includeUntracked, patch, files, allPaths, instructions, skipped, rules, diff: parsed.files.filter(f => files.some(item => item.path === f.path)) };
|
|
129
|
+
}
|
|
130
|
+
export function targetArgs(target) { return { base: target.baseRef, staged: target.kind === 'staged', includeUntracked: target.includeUntracked }; }
|
|
131
|
+
export function sourceMatches(target, file, line, anchor, side = 'after') {
|
|
132
|
+
const item = target.files.find(f => (side === 'before' ? f.oldPath : f.path) === file);
|
|
133
|
+
return !!item && Number.isInteger(line) && line > 0 && item[side]?.split(/\r?\n/)[line - 1] === anchor;
|
|
134
|
+
}
|
package/lib/tools.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type CommandRunner, type ToolExecutionContext } from './check.js';
|
|
2
|
+
import { type Dispatcher } from './dispatch.js';
|
|
3
|
+
import type { ReviewConfig } from './config.js';
|
|
4
|
+
export interface ContentBlock {
|
|
5
|
+
type: 'text';
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ReviewToolDefinition {
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
parameters: {
|
|
12
|
+
type: 'object';
|
|
13
|
+
properties: Record<string, unknown>;
|
|
14
|
+
required?: string[];
|
|
15
|
+
additionalProperties?: boolean;
|
|
16
|
+
};
|
|
17
|
+
output: {
|
|
18
|
+
schema: Record<string, unknown>;
|
|
19
|
+
render(args: unknown, value: unknown): ContentBlock[];
|
|
20
|
+
};
|
|
21
|
+
execute(args: unknown, exec?: unknown): Promise<unknown>;
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface BuildOptions {
|
|
25
|
+
runner?: CommandRunner;
|
|
26
|
+
dispatcher?: (exec: ToolExecutionContext, root: string, signal: AbortSignal) => Dispatcher;
|
|
27
|
+
}
|
|
28
|
+
export declare function buildReviewTools(config: ReviewConfig, options?: BuildOptions): ReviewToolDefinition[];
|