@stardustlc/dsh-review 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -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 +43 -0
- package/docs/review-policy.example.json +10 -0
- package/docs/validation-0.3.1.md +19 -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 +149 -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 +69 -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 +37 -0
- package/lib/run.js +282 -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 +136 -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/exec.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 受信任的结构化 Git 读取。证据命令由宿主 Bash 工具执行。
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-review/exec
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
8
|
+
const DEFAULT_OUTPUT_LIMIT = 256 * 1024;
|
|
9
|
+
export function run(command, args, options) {
|
|
10
|
+
const started = Date.now();
|
|
11
|
+
const limit = options.maxOutputBytes ?? DEFAULT_OUTPUT_LIMIT;
|
|
12
|
+
return new Promise(resolve => {
|
|
13
|
+
let settled = false;
|
|
14
|
+
const child = spawn(command, args, {
|
|
15
|
+
cwd: options.cwd,
|
|
16
|
+
env: options.env ?? process.env,
|
|
17
|
+
windowsHide: true,
|
|
18
|
+
shell: false,
|
|
19
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
20
|
+
});
|
|
21
|
+
let stdout = '';
|
|
22
|
+
let stderr = '';
|
|
23
|
+
let timedOut = false;
|
|
24
|
+
let truncated = false;
|
|
25
|
+
let bytes = 0;
|
|
26
|
+
const decoders = { stdout: new StringDecoder('utf8'), stderr: new StringDecoder('utf8') };
|
|
27
|
+
const abort = () => child.kill('SIGKILL');
|
|
28
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
29
|
+
if (options.signal?.aborted)
|
|
30
|
+
abort();
|
|
31
|
+
const timer = setTimeout(() => {
|
|
32
|
+
timedOut = true;
|
|
33
|
+
child.kill('SIGKILL');
|
|
34
|
+
}, options.timeoutMs);
|
|
35
|
+
const finish = (code) => {
|
|
36
|
+
if (settled)
|
|
37
|
+
return;
|
|
38
|
+
settled = true;
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
options.signal?.removeEventListener('abort', abort);
|
|
41
|
+
stdout += decoders.stdout.end();
|
|
42
|
+
stderr += decoders.stderr.end();
|
|
43
|
+
resolve({ ok: code === 0 && !timedOut && !options.signal?.aborted && !truncated, code, stdout, stderr, timedOut, truncated, durationMs: Date.now() - started });
|
|
44
|
+
};
|
|
45
|
+
const collect = (stream, chunk) => {
|
|
46
|
+
const available = Math.max(0, limit - bytes);
|
|
47
|
+
const accepted = chunk.subarray(0, available);
|
|
48
|
+
bytes += accepted.length;
|
|
49
|
+
const value = decoders[stream].write(accepted);
|
|
50
|
+
if (stream === 'stdout')
|
|
51
|
+
stdout += value;
|
|
52
|
+
else
|
|
53
|
+
stderr += value;
|
|
54
|
+
if (accepted.length < chunk.length)
|
|
55
|
+
truncated = true;
|
|
56
|
+
};
|
|
57
|
+
child.stdout?.on('data', (chunk) => collect('stdout', chunk));
|
|
58
|
+
child.stderr?.on('data', (chunk) => collect('stderr', chunk));
|
|
59
|
+
child.on('error', (error) => { stderr += error.message; finish(null); });
|
|
60
|
+
child.on('close', (code) => finish(code));
|
|
61
|
+
if (options.input !== undefined) {
|
|
62
|
+
child.stdin?.end(options.input);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
child.stdin?.end();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export async function git(cwd, args, timeoutMs = 30000, maxOutputBytes = DEFAULT_OUTPUT_LIMIT, signal) {
|
|
70
|
+
return run('git', ['-c', 'core.quotepath=false', '-c', 'color.ui=false', '-c', 'core.fsmonitor=false', ...args], { cwd, timeoutMs, maxOutputBytes, signal });
|
|
71
|
+
}
|
|
72
|
+
/** 输出裁剪:保留头部与尾部,中间省略。 */
|
|
73
|
+
export function excerpt(text, maxLines = 40, maxChars = 4000) {
|
|
74
|
+
const trimmed = text.trimEnd();
|
|
75
|
+
if (trimmed === '')
|
|
76
|
+
return '';
|
|
77
|
+
const lines = trimmed.split(/\r?\n/);
|
|
78
|
+
let out = lines.length > maxLines ? [...lines.slice(0, maxLines), '… (' + (lines.length - maxLines) + ' 行省略)'].join('\n') : trimmed;
|
|
79
|
+
if (out.length > maxChars)
|
|
80
|
+
out = out.slice(0, maxChars) + '…';
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* finding 指纹:用"种类 + 文件 + 归一化代码行"生成稳定 id,
|
|
3
|
+
* 这样重排、rebase、加空行都不会让同一条 finding 变新,天然支持"不复报"。
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-review/fingerprint
|
|
6
|
+
*/
|
|
7
|
+
export declare function normalizeLine(text: string): string;
|
|
8
|
+
export declare function fingerprintOf(input: {
|
|
9
|
+
kind: string;
|
|
10
|
+
file: string;
|
|
11
|
+
anchor: string;
|
|
12
|
+
}): string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* finding 指纹:用"种类 + 文件 + 归一化代码行"生成稳定 id,
|
|
3
|
+
* 这样重排、rebase、加空行都不会让同一条 finding 变新,天然支持"不复报"。
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-review/fingerprint
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
export function normalizeLine(text) {
|
|
9
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
10
|
+
}
|
|
11
|
+
export function fingerprintOf(input) {
|
|
12
|
+
const payload = [input.kind.trim().toLowerCase(), input.file.replace(/\\/g, '/').trim(), normalizeLine(input.anchor)].join('\u0000');
|
|
13
|
+
return createHash('sha256').update(payload).digest('hex').slice(0, 16);
|
|
14
|
+
}
|
package/lib/gate.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type ReviewConfig } from './config.js';
|
|
2
|
+
import type { HeadRecord, Ledger } from './ledger.js';
|
|
3
|
+
import { type ReviewTarget } from './target.js';
|
|
4
|
+
export declare const DROP_KINDS: string[];
|
|
5
|
+
export interface Witness {
|
|
6
|
+
file: string;
|
|
7
|
+
line: number;
|
|
8
|
+
anchor: string;
|
|
9
|
+
side?: 'before' | 'after';
|
|
10
|
+
}
|
|
11
|
+
export interface CandidateFinding {
|
|
12
|
+
id?: string;
|
|
13
|
+
file: string;
|
|
14
|
+
line: number;
|
|
15
|
+
kind: string;
|
|
16
|
+
severity: 'blocker' | 'important' | 'nit';
|
|
17
|
+
message: string;
|
|
18
|
+
anchor: string;
|
|
19
|
+
evidence: string;
|
|
20
|
+
lens?: string;
|
|
21
|
+
classOfBug?: string;
|
|
22
|
+
side?: 'before' | 'after';
|
|
23
|
+
introducedBy?: Witness;
|
|
24
|
+
carried?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface Vote {
|
|
27
|
+
candidateId: string;
|
|
28
|
+
decision: 'confirm' | 'reject' | 'pre-existing' | 'unverifiable';
|
|
29
|
+
reason: string;
|
|
30
|
+
witnesses: Witness[];
|
|
31
|
+
taskId: string;
|
|
32
|
+
}
|
|
33
|
+
export interface GatedFinding {
|
|
34
|
+
fingerprint: string;
|
|
35
|
+
file: string;
|
|
36
|
+
line: number;
|
|
37
|
+
kind: string;
|
|
38
|
+
lens: string;
|
|
39
|
+
severity: 'blocker' | 'important' | 'nit' | 'pre-existing';
|
|
40
|
+
message: string;
|
|
41
|
+
evidence: string;
|
|
42
|
+
preexisting: boolean;
|
|
43
|
+
anchor: string;
|
|
44
|
+
targetId: string;
|
|
45
|
+
verification: Vote[];
|
|
46
|
+
classOfBug?: string;
|
|
47
|
+
}
|
|
48
|
+
export interface GateResult {
|
|
49
|
+
postable: GatedFinding[];
|
|
50
|
+
suppressed: number;
|
|
51
|
+
suppressedFingerprints: string[];
|
|
52
|
+
dropped: number;
|
|
53
|
+
droppedDetail: Array<{
|
|
54
|
+
file: string;
|
|
55
|
+
reason: string;
|
|
56
|
+
}>;
|
|
57
|
+
unverified: number;
|
|
58
|
+
declined: unknown[];
|
|
59
|
+
rejected: Array<{
|
|
60
|
+
id: string;
|
|
61
|
+
reason: string;
|
|
62
|
+
}>;
|
|
63
|
+
}
|
|
64
|
+
export declare function candidateId(f: CandidateFinding, target: ReviewTarget): string;
|
|
65
|
+
export declare function parseCandidate(raw: unknown, lens: string, target: ReviewTarget): CandidateFinding | null;
|
|
66
|
+
export declare function parseVote(raw: unknown, taskId: string, expected: string, target: ReviewTarget, candidate?: CandidateFinding): Vote;
|
|
67
|
+
export declare function voteDecision(votes: Vote[], required: number): Vote['decision'];
|
|
68
|
+
export declare function gateFindings(options: {
|
|
69
|
+
root: string;
|
|
70
|
+
head: string;
|
|
71
|
+
base: string;
|
|
72
|
+
mode: string;
|
|
73
|
+
findings: CandidateFinding[];
|
|
74
|
+
config: ReviewConfig;
|
|
75
|
+
rules: ReviewTarget['rules'];
|
|
76
|
+
record: HeadRecord;
|
|
77
|
+
ledger: Ledger;
|
|
78
|
+
target: ReviewTarget;
|
|
79
|
+
votes: Record<string, Vote[]>;
|
|
80
|
+
requiredVotes: number;
|
|
81
|
+
}): GateResult;
|
package/lib/gate.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { severityRank } from './config.js';
|
|
2
|
+
import { fingerprintOf } from './fingerprint.js';
|
|
3
|
+
import { sourceMatches } from './target.js';
|
|
4
|
+
export const DROP_KINDS = ['style', 'formatting', 'format', 'diagnostics', 'diagnostic', 'cleanup', 'nitpick', 'naming', 'whitespace'];
|
|
5
|
+
export function candidateId(f, target) {
|
|
6
|
+
const file = target.files.find(item => (f.side === 'before' ? item.oldPath : item.path) === f.file);
|
|
7
|
+
const lines = ((f.side === 'before' ? file?.before : file?.after) ?? '').split(/\r?\n/);
|
|
8
|
+
const occurrence = lines.slice(0, f.line).filter(line => line === f.anchor).length;
|
|
9
|
+
return fingerprintOf({ kind: f.kind + ':' + (f.classOfBug ?? '') + ':' + occurrence, file: f.file, anchor: f.anchor });
|
|
10
|
+
}
|
|
11
|
+
export function parseCandidate(raw, lens, target) {
|
|
12
|
+
const item = raw;
|
|
13
|
+
if (!item || typeof item !== 'object' || typeof item.kind !== 'string')
|
|
14
|
+
throw new Error('Invalid finding shape');
|
|
15
|
+
if (DROP_KINDS.includes(item.kind.toLowerCase()))
|
|
16
|
+
return null;
|
|
17
|
+
if (!['correctness', 'state', 'breaking', 'security'].includes(item.kind))
|
|
18
|
+
throw new Error('Unknown finding kind');
|
|
19
|
+
for (const key of ['file', 'anchor', 'message'])
|
|
20
|
+
if (typeof item[key] !== 'string' || !item[key].trim())
|
|
21
|
+
throw new Error('Finding requires ' + key);
|
|
22
|
+
if (!['blocker', 'important', 'nit'].includes(item.severity))
|
|
23
|
+
throw new Error('Invalid finding severity');
|
|
24
|
+
if (item.side !== undefined && !['before', 'after'].includes(item.side))
|
|
25
|
+
throw new Error('Invalid finding side');
|
|
26
|
+
const side = item.side === 'before' ? 'before' : 'after';
|
|
27
|
+
if (!sourceMatches(target, item.file, item.line, item.anchor, side))
|
|
28
|
+
throw new Error('Finding does not quote exact snapshot source');
|
|
29
|
+
const introducedBy = item.introducedBy ?? { file: item.file, line: item.line, anchor: item.anchor, side };
|
|
30
|
+
if (introducedBy.side !== undefined && !['before', 'after'].includes(introducedBy.side))
|
|
31
|
+
throw new Error('Invalid introducing side');
|
|
32
|
+
if (!sourceMatches(target, introducedBy.file, introducedBy.line, introducedBy.anchor, introducedBy.side ?? 'after'))
|
|
33
|
+
throw new Error('Invalid introducing evidence');
|
|
34
|
+
const changed = target.files.find(file => (introducedBy.side === 'before' ? file.oldPath : file.path) === introducedBy.file);
|
|
35
|
+
if (!(introducedBy.side === 'before' ? changed?.removedLines : changed?.addedLines)?.includes(introducedBy.line))
|
|
36
|
+
throw new Error('Finding is not linked to a changed introducing line');
|
|
37
|
+
const finding = { file: item.file, line: item.line, kind: item.kind, severity: item.severity, message: item.message, anchor: item.anchor, evidence: typeof item.evidence === 'string' ? item.evidence : '', lens, side, introducedBy,
|
|
38
|
+
...(typeof item.classOfBug === 'string' && item.classOfBug.trim() ? { classOfBug: item.classOfBug.trim() } : {}) };
|
|
39
|
+
finding.id = candidateId(finding, target);
|
|
40
|
+
return finding;
|
|
41
|
+
}
|
|
42
|
+
export function parseVote(raw, taskId, expected, target, candidate) {
|
|
43
|
+
const item = raw;
|
|
44
|
+
if (!item || item.candidateId !== expected || !['confirm', 'reject', 'pre-existing', 'unverifiable'].includes(item.decision) || typeof item.reason !== 'string' || item.reason.trim().length < 12 || !Array.isArray(item.witnesses))
|
|
45
|
+
throw new Error('Invalid independent verification');
|
|
46
|
+
for (const witness of item.witnesses)
|
|
47
|
+
if (!witness || typeof witness.file !== 'string' || typeof witness.anchor !== 'string' || !witness.anchor.trim() || (witness.side !== undefined && !['before', 'after'].includes(witness.side)) || !sourceMatches(target, witness.file, witness.line, witness.anchor, witness.side ?? 'after'))
|
|
48
|
+
throw new Error('Verifier witness is blank or outside the immutable snapshot');
|
|
49
|
+
if (item.decision !== 'unverifiable' && item.witnesses.length === 0)
|
|
50
|
+
throw new Error('Verifier supplied no checkable source witness');
|
|
51
|
+
if (item.decision !== 'unverifiable') {
|
|
52
|
+
if (!candidate || (candidate.id ?? candidateId(candidate, target)) !== expected)
|
|
53
|
+
throw new Error('Verifier requires the exact candidate being checked');
|
|
54
|
+
const locations = [candidate, ...(candidate.introducedBy ? [candidate.introducedBy] : [])];
|
|
55
|
+
if (!item.witnesses.some(witness => locations.some(location => witness.file === location.file && Math.abs(witness.line - location.line) <= 3)))
|
|
56
|
+
throw new Error('Verifier witness is unrelated to the candidate or introducing change');
|
|
57
|
+
}
|
|
58
|
+
if (item.decision === 'pre-existing' && !item.witnesses.some(witness => witness.side === 'before'))
|
|
59
|
+
throw new Error('Pre-existing verdict requires base evidence');
|
|
60
|
+
return { candidateId: expected, decision: item.decision, reason: item.reason.trim(), witnesses: item.witnesses, taskId };
|
|
61
|
+
}
|
|
62
|
+
export function voteDecision(votes, required) {
|
|
63
|
+
if (votes.length !== required)
|
|
64
|
+
return 'unverifiable';
|
|
65
|
+
const majority = Math.floor(required / 2) + 1;
|
|
66
|
+
for (const decision of ['confirm', 'reject', 'pre-existing'])
|
|
67
|
+
if (votes.filter(vote => vote.decision === decision).length >= majority)
|
|
68
|
+
return decision;
|
|
69
|
+
return 'unverifiable';
|
|
70
|
+
}
|
|
71
|
+
export function gateFindings(options) {
|
|
72
|
+
const { record, target, config, rules } = options;
|
|
73
|
+
const result = { postable: [], suppressed: 0, suppressedFingerprints: [], dropped: 0, droppedDetail: [], unverified: 0, declined: [], rejected: [] };
|
|
74
|
+
let nits = 0;
|
|
75
|
+
const unique = new Map();
|
|
76
|
+
for (const finding of options.findings) {
|
|
77
|
+
if (DROP_KINDS.includes(finding.kind.toLowerCase())) {
|
|
78
|
+
result.dropped++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const id = finding.id ?? candidateId(finding, target), prior = unique.get(id);
|
|
82
|
+
if (!prior || severityRank(finding.severity) > severityRank(prior.severity))
|
|
83
|
+
unique.set(id, { ...finding, id });
|
|
84
|
+
}
|
|
85
|
+
for (const finding of [...unique.values()].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))) {
|
|
86
|
+
const id = finding.id, votes = options.votes[id] ?? [], decision = voteDecision(votes, options.requiredVotes);
|
|
87
|
+
if (decision === 'unverifiable') {
|
|
88
|
+
result.unverified++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (decision === 'reject') {
|
|
92
|
+
const prior = record.posted[id];
|
|
93
|
+
if (prior && !prior.disposition) {
|
|
94
|
+
const source = target.files.find(file => file.path === prior.file || file.oldPath === prior.file);
|
|
95
|
+
const removed = source !== undefined && typeof prior.anchor === 'string' && prior.anchor.trim() !== '' && (source.after === null || !source.after.split(/\r?\n/).includes(prior.anchor));
|
|
96
|
+
if (!removed) {
|
|
97
|
+
result.unverified++;
|
|
98
|
+
result.rejected.push({ id, reason: 'The original source anchor remains, or its removal is not covered by this snapshot; human review is required.' });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
prior.disposition = 'fixed';
|
|
102
|
+
prior.note = 'Independent re-verification and snapshot source confirm removal on target ' + target.id;
|
|
103
|
+
prior.verification = votes;
|
|
104
|
+
prior.targetId = target.id;
|
|
105
|
+
}
|
|
106
|
+
result.rejected.push({ id, reason: votes.map(v => v.reason).join('\n') });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (decision !== 'pre-existing' && severityRank(finding.severity) < severityRank(rules.severityFloor ?? config.severityFloor)) {
|
|
110
|
+
result.dropped++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (finding.severity === 'nit' && nits++ >= (rules.maxNits ?? config.maxNits)) {
|
|
114
|
+
result.dropped++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (result.postable.length >= config.maxFindings) {
|
|
118
|
+
result.unverified++;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const entry = { fingerprint: id, file: finding.file, line: finding.line, kind: finding.kind, lens: finding.lens ?? 'unknown', severity: decision === 'pre-existing' ? 'pre-existing' : finding.severity,
|
|
122
|
+
message: finding.message, evidence: votes.map(v => v.reason).join('\n'), preexisting: decision === 'pre-existing', anchor: finding.anchor, targetId: target.id, verification: votes,
|
|
123
|
+
...(finding.classOfBug ? { classOfBug: finding.classOfBug } : {}) };
|
|
124
|
+
const prior = record.posted[id];
|
|
125
|
+
if (prior && prior.targetId === target.id) {
|
|
126
|
+
prior.repeats++;
|
|
127
|
+
result.suppressed++;
|
|
128
|
+
result.suppressedFingerprints.push(id);
|
|
129
|
+
prior.verification = votes;
|
|
130
|
+
prior.evidence = entry.evidence;
|
|
131
|
+
if (severityRank(entry.severity) > severityRank(prior.severity)) {
|
|
132
|
+
prior.severity = entry.severity;
|
|
133
|
+
prior.message = entry.message;
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (prior && !prior.disposition) {
|
|
138
|
+
result.suppressed++;
|
|
139
|
+
result.suppressedFingerprints.push(id);
|
|
140
|
+
}
|
|
141
|
+
else
|
|
142
|
+
result.postable.push(entry);
|
|
143
|
+
options.ledger.remember(record, { ...entry, at: new Date().toISOString(), repeats: prior?.repeats ?? 0 });
|
|
144
|
+
}
|
|
145
|
+
record.unverified = result.unverified;
|
|
146
|
+
record.dropped += result.dropped;
|
|
147
|
+
record.reviewedFiles = target.files.map(file => file.path);
|
|
148
|
+
return result;
|
|
149
|
+
}
|
package/lib/github.d.ts
ADDED
package/lib/github.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export async function githubEvidence(repository, number, token, expectedHead, expectedBase, fetcher = fetch) {
|
|
2
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(repository) || !Number.isSafeInteger(number) || number < 1 || [expectedHead, expectedBase].some(sha => !/^[a-f0-9]{40,64}$/.test(sha)))
|
|
3
|
+
throw new Error('Invalid GitHub repository, pull request or commit');
|
|
4
|
+
const request = async (route, body) => {
|
|
5
|
+
const response = await fetcher('https://api.github.com' + route, { method: body ? 'POST' : 'GET', redirect: 'error', signal: AbortSignal.timeout(15000),
|
|
6
|
+
headers: { accept: 'application/vnd.github+json', 'content-type': 'application/json', authorization: 'Bearer ' + token, 'X-GitHub-Api-Version': '2026-03-10', 'user-agent': 'dsh-review-gate' }, ...(body ? { body: JSON.stringify(body) } : {}) });
|
|
7
|
+
if (!response.ok)
|
|
8
|
+
throw new Error('GitHub evidence unavailable (HTTP ' + response.status + '); gate remains blocked');
|
|
9
|
+
return response.json();
|
|
10
|
+
};
|
|
11
|
+
const prefix = '/repos/' + repository, pr = await request(prefix + '/pulls/' + number);
|
|
12
|
+
if (pr.head?.sha !== expectedHead || pr.base?.sha !== expectedBase || pr.base?.repo?.full_name?.toLowerCase() !== repository.toLowerCase())
|
|
13
|
+
throw new Error('GitHub PR head/base changed or repository mismatch');
|
|
14
|
+
const paged = async (route, field) => {
|
|
15
|
+
const values = [];
|
|
16
|
+
for (let page = 1; page <= 30; page++) {
|
|
17
|
+
const data = await request(route + (route.includes('?') ? '&' : '?') + 'per_page=100&page=' + page), rows = field ? data[field] : data;
|
|
18
|
+
if (!Array.isArray(rows))
|
|
19
|
+
throw new Error('Malformed GitHub evidence');
|
|
20
|
+
values.push(...rows);
|
|
21
|
+
if (rows.length < 100)
|
|
22
|
+
return values;
|
|
23
|
+
}
|
|
24
|
+
throw new Error('GitHub evidence pagination exceeds coverage limit');
|
|
25
|
+
};
|
|
26
|
+
const [reviews, checks, files] = await Promise.all([
|
|
27
|
+
paged(prefix + '/pulls/' + number + '/reviews'),
|
|
28
|
+
paged(prefix + '/commits/' + expectedHead + '/check-runs?filter=all', 'check_runs'),
|
|
29
|
+
paged(prefix + '/pulls/' + number + '/files'),
|
|
30
|
+
]);
|
|
31
|
+
if (files.length !== pr.changed_files || files.some(f => typeof f.filename !== 'string'))
|
|
32
|
+
throw new Error('Incomplete GitHub changed-file inventory');
|
|
33
|
+
const [owner, repo] = repository.split('/');
|
|
34
|
+
let cursor = null, unresolvedThreads = 0;
|
|
35
|
+
for (let page = 0;; page++) {
|
|
36
|
+
if (page >= 30)
|
|
37
|
+
throw new Error('Review-thread coverage limit exceeded');
|
|
38
|
+
const result = await request('/graphql', { query: 'query($owner:String!,$repo:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$repo){pullRequest(number:$number){headRefOid reviewThreads(first:100,after:$cursor){nodes{isResolved} pageInfo{hasNextPage endCursor}}}}}', variables: { owner, repo, number, cursor } });
|
|
39
|
+
const pull = result.data?.repository?.pullRequest, threads = pull?.reviewThreads;
|
|
40
|
+
if (result.errors || pull?.headRefOid !== expectedHead || !Array.isArray(threads?.nodes) || !threads.pageInfo || threads.nodes.some((t) => typeof t?.isResolved !== 'boolean'))
|
|
41
|
+
throw new Error('Review-thread evidence unavailable or changed');
|
|
42
|
+
unresolvedThreads += threads.nodes.filter((t) => !t.isResolved).length;
|
|
43
|
+
if (threads.pageInfo.hasNextPage === false)
|
|
44
|
+
break;
|
|
45
|
+
if (threads.pageInfo.hasNextPage !== true || typeof threads.pageInfo.endCursor !== 'string' || threads.pageInfo.endCursor === cursor)
|
|
46
|
+
throw new Error('Invalid thread pagination');
|
|
47
|
+
cursor = threads.pageInfo.endCursor;
|
|
48
|
+
}
|
|
49
|
+
const current = await request(prefix + '/pulls/' + number);
|
|
50
|
+
if (current.head?.sha !== expectedHead || current.base?.sha !== expectedBase)
|
|
51
|
+
throw new Error('PR changed while reading approval evidence');
|
|
52
|
+
return { head: expectedHead, author: pr.user, paths: files.flatMap(f => f.previous_filename ? [f.filename, f.previous_filename] : [f.filename]), draft: pr.draft, reviews, checks, unresolvedThreads };
|
|
53
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type ReviewConfig } from './config.js';
|
|
2
|
+
import { type ReviewToolDefinition } from './tools.js';
|
|
3
|
+
import { type SubagentService } from './dispatch.js';
|
|
4
|
+
export declare const name = "review";
|
|
5
|
+
export declare const inject: string[];
|
|
6
|
+
interface HostResult {
|
|
7
|
+
isError?: boolean;
|
|
8
|
+
value?: any;
|
|
9
|
+
error?: {
|
|
10
|
+
message?: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export interface ReviewPluginContext {
|
|
14
|
+
effect?(effect: () => () => void): unknown;
|
|
15
|
+
tools: {
|
|
16
|
+
register(definition: ReviewToolDefinition): () => void;
|
|
17
|
+
execute(call: Record<string, unknown>): Promise<HostResult>;
|
|
18
|
+
get?(name: string, agent: unknown): unknown;
|
|
19
|
+
};
|
|
20
|
+
subagents: SubagentService;
|
|
21
|
+
permissionPresets: {
|
|
22
|
+
current(session: unknown): string;
|
|
23
|
+
resolve(name: string): {
|
|
24
|
+
sandbox: string;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
jobs: {
|
|
28
|
+
kill(id: string, caller?: string, reason?: string): unknown;
|
|
29
|
+
wait(id: string, timeoutMs: number, caller?: string): Promise<{
|
|
30
|
+
status: string;
|
|
31
|
+
}>;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export declare function apply(ctx: ReviewPluginContext, config?: Partial<ReviewConfig> | null): void;
|
|
35
|
+
export { resolveConfig, DEFAULT_CONFIG } from './config.js';
|
|
36
|
+
export type { ReviewConfig } from './config.js';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { resolveConfig } from './config.js';
|
|
2
|
+
import { buildReviewTools } from './tools.js';
|
|
3
|
+
import { createDispatcher } from './dispatch.js';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
export const name = 'review';
|
|
8
|
+
export const inject = ['tools', 'subagents', 'permissionPresets', 'jobs'];
|
|
9
|
+
export function apply(ctx, config) {
|
|
10
|
+
const resolved = resolveConfig(config);
|
|
11
|
+
const runner = async (input) => {
|
|
12
|
+
const agent = input.exec.agent;
|
|
13
|
+
if (!agent)
|
|
14
|
+
throw new Error('Evidence execution requires the live calling agent');
|
|
15
|
+
const preset = ctx.permissionPresets.current(agent.session);
|
|
16
|
+
if (ctx.permissionPresets.resolve(preset).sandbox === 'read-only')
|
|
17
|
+
throw new Error('Evidence command denied in a read-only session; use static source evidence or request the normal workspace permission');
|
|
18
|
+
const tools = agent.ctx?.get?.('tools') ?? ctx.tools;
|
|
19
|
+
let toolName = 'bash', command = input.command;
|
|
20
|
+
if (process.platform === 'win32' && !tools.get?.('bash', agent) && tools.get?.('pwsh', agent)) {
|
|
21
|
+
const bash = [path.join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe'), path.join(process.env.LOCALAPPDATA ?? '', 'Programs', 'Git', 'bin', 'bash.exe')].find(file => existsSync(file));
|
|
22
|
+
if (!bash)
|
|
23
|
+
throw new Error('This Windows preset exposes pwsh only. Install Git Bash or enable the controlled Bash tool; no direct process fallback is allowed.');
|
|
24
|
+
// The official Windows preset exposes pwsh. It remains the policy/cancellation
|
|
25
|
+
// boundary; use Git Bash underneath, preserving the original visible command.
|
|
26
|
+
const quote = (s) => "'" + s.replaceAll("'", "''") + "'";
|
|
27
|
+
command = "if ($PSVersionTable.PSVersion -lt [version]'7.3') { throw 'Git Bash bridging requires PowerShell 7.3+' }; $PSNativeCommandArgumentPassing = 'Standard'; & " + quote(bash) + ' -lc ' + quote(input.command) + '; exit $LASTEXITCODE';
|
|
28
|
+
toolName = 'pwsh';
|
|
29
|
+
}
|
|
30
|
+
const result = await tools.execute({ callId: 'review-check-' + randomUUID(), name: toolName, arguments: { command, description: 'Run review evidence in Git Bash', workdir: input.cwd, timeoutMs: 600000 }, agent, signal: input.signal });
|
|
31
|
+
if (result.isError || !result.value)
|
|
32
|
+
throw new Error(result.error?.message ?? 'Controlled Bash execution failed');
|
|
33
|
+
const value = result.value;
|
|
34
|
+
if (value.kind === 'promoted' || value.kind === 'background') {
|
|
35
|
+
ctx.jobs.kill(value.jobId, agent.id, 'Review checks cannot continue in the background');
|
|
36
|
+
for (;;) {
|
|
37
|
+
const view = await ctx.jobs.wait(value.jobId, 5000, agent.id);
|
|
38
|
+
if (!['running', 'stopping'].includes(view.status))
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
throw new Error('Evidence command exceeded the foreground budget and was stopped');
|
|
42
|
+
}
|
|
43
|
+
const truncated = value.stdout?.truncated === true || value.stderr?.truncated === true;
|
|
44
|
+
return { ok: value.exitCode === 0 && !value.timedOut && !value.aborted && !value.sandbox?.denied && !truncated,
|
|
45
|
+
exitCode: value.exitCode ?? null, timedOut: !!value.timedOut, aborted: !!value.aborted, truncated,
|
|
46
|
+
output: String(value.stdout?.text ?? '') + (value.stderr?.text ? '\n' + value.stderr.text : '') };
|
|
47
|
+
};
|
|
48
|
+
const dispatcher = (exec, root, signal) => createDispatcher({ subagents: ctx.subagents, parent: exec.agent, provider: resolved.subagentProvider, signal, cwd: root });
|
|
49
|
+
for (const definition of buildReviewTools(resolved, { runner, dispatcher })) {
|
|
50
|
+
if (ctx.effect)
|
|
51
|
+
ctx.effect(() => ctx.tools.register(definition));
|
|
52
|
+
else
|
|
53
|
+
ctx.tools.register(definition);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export { resolveConfig, DEFAULT_CONFIG } from './config.js';
|
package/lib/ledger.d.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { Mode } from './modes.js';
|
|
2
|
+
import type { ReviewTarget } from './target.js';
|
|
3
|
+
import type { LensTask } from './lenses.js';
|
|
4
|
+
import type { CandidateFinding } from './gate.js';
|
|
5
|
+
import type { ReviewScope } from './scope.js';
|
|
6
|
+
export type Disposition = 'fixed' | 'wrong' | 'intentional' | 'wontfix';
|
|
7
|
+
export type RunStatus = 'pending' | 'running' | 'complete' | 'incomplete' | 'failed' | 'cancelled' | 'stale';
|
|
8
|
+
export interface LedgerFinding {
|
|
9
|
+
fingerprint: string;
|
|
10
|
+
file: string;
|
|
11
|
+
line: number;
|
|
12
|
+
kind: string;
|
|
13
|
+
lens?: string;
|
|
14
|
+
severity: 'blocker' | 'important' | 'nit' | 'pre-existing';
|
|
15
|
+
message: string;
|
|
16
|
+
evidence: string;
|
|
17
|
+
at: string;
|
|
18
|
+
disposition?: Disposition;
|
|
19
|
+
note?: string;
|
|
20
|
+
repeats: number;
|
|
21
|
+
targetId?: string;
|
|
22
|
+
anchor?: string;
|
|
23
|
+
classOfBug?: string;
|
|
24
|
+
verification?: unknown;
|
|
25
|
+
}
|
|
26
|
+
export interface HeadRecord {
|
|
27
|
+
head: string;
|
|
28
|
+
base?: string;
|
|
29
|
+
targetId?: string;
|
|
30
|
+
series?: string;
|
|
31
|
+
latestRunId?: string;
|
|
32
|
+
round: number;
|
|
33
|
+
mode: Mode;
|
|
34
|
+
posted: Record<string, LedgerFinding>;
|
|
35
|
+
reviewedFiles: string[];
|
|
36
|
+
dropped: number;
|
|
37
|
+
suppressed: number;
|
|
38
|
+
unverified: number;
|
|
39
|
+
updatedAt: string;
|
|
40
|
+
}
|
|
41
|
+
export interface StoredRun {
|
|
42
|
+
id: string;
|
|
43
|
+
targetId: string;
|
|
44
|
+
series: string;
|
|
45
|
+
head: string;
|
|
46
|
+
target: ReviewTarget;
|
|
47
|
+
mode: Mode;
|
|
48
|
+
status: RunStatus;
|
|
49
|
+
stage: 'find' | 'verify' | 'arbiter' | 'done';
|
|
50
|
+
tasks: LensTask[];
|
|
51
|
+
results: Record<string, unknown>;
|
|
52
|
+
candidates: CandidateFinding[];
|
|
53
|
+
round: number;
|
|
54
|
+
calls: number;
|
|
55
|
+
errors: string[];
|
|
56
|
+
createdAt: string;
|
|
57
|
+
completedAt?: string;
|
|
58
|
+
postable: unknown[];
|
|
59
|
+
rejected: unknown[];
|
|
60
|
+
unverified: number;
|
|
61
|
+
accepted?: LedgerFinding[];
|
|
62
|
+
independent: boolean;
|
|
63
|
+
scope?: ReviewScope;
|
|
64
|
+
lease?: string;
|
|
65
|
+
leasePid?: number;
|
|
66
|
+
summary?: string;
|
|
67
|
+
risk?: {
|
|
68
|
+
tier: 'low' | 'medium' | 'high';
|
|
69
|
+
extraHuman: boolean;
|
|
70
|
+
reasons: string[];
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export interface Receipt {
|
|
74
|
+
id: string;
|
|
75
|
+
runId: string;
|
|
76
|
+
targetId: string;
|
|
77
|
+
command: string;
|
|
78
|
+
cwd: string;
|
|
79
|
+
exitCode: number | null;
|
|
80
|
+
timedOut: boolean;
|
|
81
|
+
aborted: boolean;
|
|
82
|
+
truncated: boolean;
|
|
83
|
+
output: string;
|
|
84
|
+
at: string;
|
|
85
|
+
}
|
|
86
|
+
export interface LedgerState {
|
|
87
|
+
version: 2;
|
|
88
|
+
revision: number;
|
|
89
|
+
heads: Record<string, HeadRecord>;
|
|
90
|
+
runs: Record<string, StoredRun>;
|
|
91
|
+
receipts: Record<string, Receipt>;
|
|
92
|
+
legacy?: unknown;
|
|
93
|
+
evaluations?: {
|
|
94
|
+
cases: Record<string, unknown>;
|
|
95
|
+
results: Record<string, unknown>;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export declare function stateDirectory(root: string, stateDir: string): string;
|
|
99
|
+
export declare class Ledger {
|
|
100
|
+
private readonly root;
|
|
101
|
+
readonly directory: string;
|
|
102
|
+
private readonly file;
|
|
103
|
+
private state;
|
|
104
|
+
constructor(root: string, stateDir: string);
|
|
105
|
+
private read;
|
|
106
|
+
private locked;
|
|
107
|
+
private write;
|
|
108
|
+
transaction<T>(work: (state: LedgerState) => T): T;
|
|
109
|
+
save(): void;
|
|
110
|
+
event(type: string, payload: Record<string, unknown>): void;
|
|
111
|
+
snapshot(): LedgerState;
|
|
112
|
+
head(head: string): HeadRecord | undefined;
|
|
113
|
+
ensureHead(head: string, mode: Mode): HeadRecord;
|
|
114
|
+
beginRound(head: string, mode: Mode): HeadRecord;
|
|
115
|
+
remember(record: HeadRecord, finding: LedgerFinding): void;
|
|
116
|
+
respond(head: string, fingerprint: string, disposition: Disposition, note?: string): LedgerFinding | undefined;
|
|
117
|
+
status(head?: string): {
|
|
118
|
+
heads: number;
|
|
119
|
+
round: number;
|
|
120
|
+
posted: number;
|
|
121
|
+
byDisposition: Record<string, number>;
|
|
122
|
+
noiseRate: number | null;
|
|
123
|
+
repeatRate: number | null;
|
|
124
|
+
unverified: number;
|
|
125
|
+
dropped: number;
|
|
126
|
+
topKinds: {
|
|
127
|
+
kind: string;
|
|
128
|
+
count: number;
|
|
129
|
+
}[];
|
|
130
|
+
runs: {
|
|
131
|
+
id: string;
|
|
132
|
+
targetId: string;
|
|
133
|
+
status: RunStatus;
|
|
134
|
+
stage: "find" | "verify" | "arbiter" | "done";
|
|
135
|
+
calls: number;
|
|
136
|
+
errors: string[];
|
|
137
|
+
}[];
|
|
138
|
+
legacyPresent: boolean;
|
|
139
|
+
};
|
|
140
|
+
}
|