@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/LICENSE +9 -0
  3. package/README.en.md +43 -0
  4. package/README.md +120 -2
  5. package/cordis.patch.yml +15 -0
  6. package/docs/DESIGN.md +76 -0
  7. package/docs/INTEGRATION.md +68 -0
  8. package/docs/VALIDATION.md +39 -0
  9. package/docs/review-policy.example.json +10 -0
  10. package/lib/check.d.ts +23 -0
  11. package/lib/check.js +17 -0
  12. package/lib/cli.d.ts +82 -0
  13. package/lib/cli.js +59 -0
  14. package/lib/config.d.ts +33 -0
  15. package/lib/config.js +69 -0
  16. package/lib/diff.d.ts +38 -0
  17. package/lib/diff.js +136 -0
  18. package/lib/dispatch.d.ts +58 -0
  19. package/lib/dispatch.js +50 -0
  20. package/lib/eval.d.ts +71 -0
  21. package/lib/eval.js +83 -0
  22. package/lib/exec.d.ts +26 -0
  23. package/lib/exec.js +82 -0
  24. package/lib/fingerprint.d.ts +12 -0
  25. package/lib/fingerprint.js +14 -0
  26. package/lib/gate.d.ts +81 -0
  27. package/lib/gate.js +135 -0
  28. package/lib/github.d.ts +2 -0
  29. package/lib/github.js +53 -0
  30. package/lib/index.d.ts +36 -0
  31. package/lib/index.js +56 -0
  32. package/lib/ledger.d.ts +140 -0
  33. package/lib/ledger.js +125 -0
  34. package/lib/lenses.d.ts +43 -0
  35. package/lib/lenses.js +68 -0
  36. package/lib/modes.d.ts +30 -0
  37. package/lib/modes.js +62 -0
  38. package/lib/policy.d.ts +54 -0
  39. package/lib/policy.js +42 -0
  40. package/lib/preexisting.d.ts +8 -0
  41. package/lib/preexisting.js +39 -0
  42. package/lib/rules.d.ts +32 -0
  43. package/lib/rules.js +101 -0
  44. package/lib/run.d.ts +36 -0
  45. package/lib/run.js +277 -0
  46. package/lib/scope.d.ts +23 -0
  47. package/lib/scope.js +50 -0
  48. package/lib/target.d.ts +40 -0
  49. package/lib/target.js +134 -0
  50. package/lib/tools.d.ts +28 -0
  51. package/lib/tools.js +187 -0
  52. package/lib/verdict.d.ts +22 -0
  53. package/lib/verdict.js +25 -0
  54. package/package.json +67 -4
@@ -0,0 +1,33 @@
1
+ /**
2
+ * dsh-review 配置:审阅轮次、证据门槛、噪音上限。
3
+ *
4
+ * @module dsh-review/config
5
+ */
6
+ export type Severity = 'blocker' | 'important' | 'nit' | 'pre-existing';
7
+ export interface ReviewConfig {
8
+ /** @profile 按仓库隔离,或显式指定仓库内子目录。 */
9
+ stateDir: string;
10
+ /** 每个审阅系列的完成轮次上限,超过后停止。 */
11
+ roundCap: number;
12
+ /** 超过这轮后,新问题限定到相对上轮的增量行。 */
13
+ diffOnlyAfterRound: number;
14
+ /** 单轮最多发多少条 finding。 */
15
+ maxFindings: number;
16
+ /** 单轮最多发多少条 nit(0 表示不发 nit)。 */
17
+ maxNits: number;
18
+ /** 低于该严重度的 finding 不发。 */
19
+ severityFloor: Exclude<Severity, 'pre-existing'>;
20
+ /** 旧配置兼容项;不能关闭独立验证或以文字断言替代证据。 */
21
+ evidenceRequired: boolean;
22
+ /** 证据命令超时。 */
23
+ commandTimeoutMs: number;
24
+ /** 补丁与源码快照上限;超出则保持未完成。 */
25
+ maxDiffBytes: number;
26
+ maxCalls: number;
27
+ runTimeoutMs: number;
28
+ subagentProvider: string;
29
+ maxParallel: number;
30
+ }
31
+ export declare const DEFAULT_CONFIG: ReviewConfig;
32
+ export declare function severityRank(value: Severity): number;
33
+ export declare function resolveConfig(raw?: Partial<ReviewConfig> | null): ReviewConfig;
package/lib/config.js ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * dsh-review 配置:审阅轮次、证据门槛、噪音上限。
3
+ *
4
+ * @module dsh-review/config
5
+ */
6
+ export const DEFAULT_CONFIG = {
7
+ stateDir: '@profile',
8
+ roundCap: 3,
9
+ diffOnlyAfterRound: 2,
10
+ maxFindings: 20,
11
+ maxNits: 2,
12
+ severityFloor: 'important',
13
+ evidenceRequired: true,
14
+ commandTimeoutMs: 120000,
15
+ maxDiffBytes: 4 * 1024 * 1024,
16
+ maxCalls: 80,
17
+ runTimeoutMs: 600000,
18
+ subagentProvider: 'spawn',
19
+ maxParallel: 3,
20
+ };
21
+ const SEVERITY_RANK = { blocker: 3, important: 2, nit: 1, 'pre-existing': 0 };
22
+ export function severityRank(value) {
23
+ return SEVERITY_RANK[value] ?? 0;
24
+ }
25
+ function intIn(value, fallback, min, max, label) {
26
+ if (value === undefined || value === null)
27
+ return fallback;
28
+ if (typeof value !== 'number' || !Number.isFinite(value))
29
+ throw new Error(label + ' 必须是数字');
30
+ const n = Math.trunc(value);
31
+ if (n < min || n > max)
32
+ throw new Error(label + ' 必须在 ' + min + ' 到 ' + max + ' 之间');
33
+ return n;
34
+ }
35
+ export function resolveConfig(raw) {
36
+ const input = (raw ?? {});
37
+ const config = { ...DEFAULT_CONFIG };
38
+ if (input.stateDir !== undefined) {
39
+ if (typeof input.stateDir !== 'string' || input.stateDir.trim() === '')
40
+ throw new Error('stateDir 必须是非空字符串');
41
+ config.stateDir = input.stateDir.trim();
42
+ }
43
+ config.roundCap = intIn(input.roundCap, config.roundCap, 1, 20, 'roundCap');
44
+ config.diffOnlyAfterRound = intIn(input.diffOnlyAfterRound, config.diffOnlyAfterRound, 1, 20, 'diffOnlyAfterRound');
45
+ config.maxFindings = intIn(input.maxFindings, config.maxFindings, 1, 200, 'maxFindings');
46
+ config.maxNits = intIn(input.maxNits, config.maxNits, 0, 100, 'maxNits');
47
+ config.commandTimeoutMs = intIn(input.commandTimeoutMs, config.commandTimeoutMs, 1000, 600000, 'commandTimeoutMs');
48
+ config.maxDiffBytes = intIn(input.maxDiffBytes, config.maxDiffBytes, 1024, 64 * 1024 * 1024, 'maxDiffBytes');
49
+ config.maxCalls = intIn(input.maxCalls, config.maxCalls, 1, 1000, 'maxCalls');
50
+ config.maxParallel = intIn(input.maxParallel, config.maxParallel, 1, 8, 'maxParallel');
51
+ config.runTimeoutMs = intIn(input.runTimeoutMs, config.runTimeoutMs, 1000, 3600000, 'runTimeoutMs');
52
+ if (input.subagentProvider !== undefined) {
53
+ if (typeof input.subagentProvider !== 'string' || !/^[\w-]+$/.test(input.subagentProvider))
54
+ throw new Error('Invalid subagentProvider');
55
+ config.subagentProvider = input.subagentProvider;
56
+ }
57
+ if (input.severityFloor !== undefined) {
58
+ const floor = String(input.severityFloor);
59
+ if (!['blocker', 'important', 'nit'].includes(floor))
60
+ throw new Error('severityFloor 只能是 blocker | important | nit');
61
+ config.severityFloor = floor;
62
+ }
63
+ if (input.evidenceRequired !== undefined) {
64
+ if (typeof input.evidenceRequired !== 'boolean')
65
+ throw new Error('evidenceRequired 必须是布尔值');
66
+ config.evidenceRequired = input.evidenceRequired;
67
+ }
68
+ return config;
69
+ }
package/lib/diff.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * 统一 diff 解析:把 git diff 拆成文件 / hunk / 行,并保留新文件行号。
3
+ *
4
+ * @module dsh-review/diff
5
+ */
6
+ export type LineType = 'add' | 'del' | 'ctx';
7
+ export interface DiffLine {
8
+ type: LineType;
9
+ oldNo: number | null;
10
+ newNo: number | null;
11
+ text: string;
12
+ }
13
+ export interface Hunk {
14
+ header: string;
15
+ oldStart: number;
16
+ newStart: number;
17
+ lines: DiffLine[];
18
+ }
19
+ export type FileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'copied';
20
+ export interface FileDiff {
21
+ path: string;
22
+ oldPath: string | null;
23
+ status: FileStatus;
24
+ binary: boolean;
25
+ hunks: Hunk[];
26
+ added: number;
27
+ removed: number;
28
+ }
29
+ export interface ParsedDiff {
30
+ files: FileDiff[];
31
+ added: number;
32
+ removed: number;
33
+ complete: boolean;
34
+ }
35
+ export declare function gitPath(value: string): string;
36
+ export declare function parseDiff(raw: string): ParsedDiff;
37
+ /** 取某个新行号附近的上下文(用于给 finding 定位)。 */
38
+ export declare function contextAround(file: FileDiff, line: number, radius?: number): DiffLine[];
package/lib/diff.js ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * 统一 diff 解析:把 git diff 拆成文件 / hunk / 行,并保留新文件行号。
3
+ *
4
+ * @module dsh-review/diff
5
+ */
6
+ const HEADER = /^diff --git a\/(.+?) b\/(.+)$/;
7
+ export function gitPath(value) {
8
+ if (!value.startsWith('"'))
9
+ return value;
10
+ if (!value.endsWith('"'))
11
+ throw new Error('Incomplete quoted Git path');
12
+ const bytes = [];
13
+ for (let i = 1; i < value.length - 1; i++) {
14
+ if (value[i] !== '\\') {
15
+ bytes.push(...Buffer.from(value[i]));
16
+ continue;
17
+ }
18
+ const octal = /^[0-7]{1,3}/.exec(value.slice(i + 1));
19
+ if (octal) {
20
+ bytes.push(parseInt(octal[0], 8));
21
+ i += octal[0].length;
22
+ continue;
23
+ }
24
+ const ch = value[++i];
25
+ const escaped = { t: '\t', n: '\n', r: '\r', b: '\b', f: '\f', v: '\v', a: '\x07', '\\': '\\', '"': '"' };
26
+ if (!(ch in escaped))
27
+ throw new Error('Invalid Git path escape');
28
+ bytes.push(...Buffer.from(escaped[ch]));
29
+ }
30
+ return Buffer.from(bytes).toString('utf8');
31
+ }
32
+ export function parseDiff(raw) {
33
+ const files = [];
34
+ let current = null;
35
+ let hunk = null;
36
+ let oldNo = 0;
37
+ let newNo = 0;
38
+ let complete = true;
39
+ const push = () => {
40
+ if (current !== null)
41
+ files.push(current);
42
+ current = null;
43
+ hunk = null;
44
+ };
45
+ for (const line of raw.split(/\r?\n/)) {
46
+ const quoted = /^diff --git ("(?:[^"\\]|\\.)*") ("(?:[^"\\]|\\.)*")$/.exec(line);
47
+ const header = HEADER.exec(line)
48
+ ?? (quoted ? [line, gitPath(quoted[1]).replace(/^a\//, ''), gitPath(quoted[2]).replace(/^b\//, '')] : null);
49
+ if (line.startsWith('diff --git ') && header === null)
50
+ complete = false;
51
+ if (header !== null) {
52
+ push();
53
+ current = { path: header[2], oldPath: header[1], status: 'modified', binary: false, hunks: [], added: 0, removed: 0 };
54
+ continue;
55
+ }
56
+ if (current === null)
57
+ continue;
58
+ if (line.startsWith('new file mode')) {
59
+ current.status = 'added';
60
+ continue;
61
+ }
62
+ if (line.startsWith('deleted file mode')) {
63
+ current.status = 'deleted';
64
+ continue;
65
+ }
66
+ if (line.startsWith('rename from')) {
67
+ current.status = 'renamed';
68
+ current.oldPath = line.slice(12).trim();
69
+ continue;
70
+ }
71
+ if (line.startsWith('copy from')) {
72
+ current.status = 'copied';
73
+ current.oldPath = line.slice(10).trim();
74
+ continue;
75
+ }
76
+ if (line.startsWith('Binary files ') || line === 'GIT binary patch') {
77
+ current.binary = true;
78
+ continue;
79
+ }
80
+ if (hunk === null && line.startsWith('--- ')) {
81
+ const target = line.slice(4).trim();
82
+ if (current.status !== 'added' && target !== '/dev/null')
83
+ current.oldPath = gitPath(target).replace(/^a\//, '');
84
+ continue;
85
+ }
86
+ if (hunk === null && line.startsWith('+++ ')) {
87
+ const target = line.slice(4).trim();
88
+ if (target !== '/dev/null')
89
+ current.path = gitPath(target).replace(/^b\//, '');
90
+ continue;
91
+ }
92
+ const hunkHeader = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
93
+ if (hunkHeader !== null) {
94
+ oldNo = Number(hunkHeader[1]);
95
+ newNo = Number(hunkHeader[3]);
96
+ hunk = { header: line, oldStart: oldNo, newStart: newNo, lines: [] };
97
+ current.hunks.push(hunk);
98
+ continue;
99
+ }
100
+ if (hunk === null)
101
+ continue;
102
+ if (line.startsWith('\\'))
103
+ continue;
104
+ const marker = line.slice(0, 1);
105
+ const text = line.slice(1);
106
+ if (marker === '+') {
107
+ hunk.lines.push({ type: 'add', oldNo: null, newNo, text });
108
+ current.added++;
109
+ newNo++;
110
+ }
111
+ else if (marker === '-') {
112
+ hunk.lines.push({ type: 'del', oldNo, newNo: null, text });
113
+ current.removed++;
114
+ oldNo++;
115
+ }
116
+ else if (marker === ' ') {
117
+ hunk.lines.push({ type: 'ctx', oldNo, newNo, text });
118
+ oldNo++;
119
+ newNo++;
120
+ }
121
+ }
122
+ push();
123
+ const added = files.reduce((n, f) => n + f.added, 0);
124
+ const removed = files.reduce((n, f) => n + f.removed, 0);
125
+ return { files, added, removed, complete };
126
+ }
127
+ /** 取某个新行号附近的上下文(用于给 finding 定位)。 */
128
+ export function contextAround(file, line, radius = 3) {
129
+ for (const hunk of file.hunks) {
130
+ const index = hunk.lines.findIndex(l => l.newNo === line);
131
+ if (index < 0)
132
+ continue;
133
+ return hunk.lines.slice(Math.max(0, index - radius), index + radius + 1);
134
+ }
135
+ return [];
136
+ }
@@ -0,0 +1,58 @@
1
+ import type { LensTask } from './lenses.js';
2
+ export interface TaskResult {
3
+ ok: boolean;
4
+ reason?: string;
5
+ output?: string;
6
+ childId?: string;
7
+ }
8
+ export interface Dispatcher {
9
+ kind: 'subagents' | 'external';
10
+ reason?: string;
11
+ dispatch(task: LensTask): Promise<TaskResult>;
12
+ settle(): Promise<void>;
13
+ }
14
+ export interface Activation {
15
+ childId: string;
16
+ result: Promise<{
17
+ output?: Array<{
18
+ type: string;
19
+ text?: string;
20
+ }>;
21
+ stopReason?: unknown;
22
+ }>;
23
+ dispose(): Promise<void>;
24
+ }
25
+ export interface SubagentService {
26
+ startActivation(input: {
27
+ provider: string;
28
+ label: string;
29
+ delivery: 'caller';
30
+ request: {
31
+ prompt: Array<{
32
+ type: 'text';
33
+ text: string;
34
+ }>;
35
+ parent: unknown;
36
+ cwd: string;
37
+ maxDepth: number;
38
+ };
39
+ signal: AbortSignal;
40
+ }): Promise<Activation>;
41
+ getProvider?(name: string): {
42
+ inheritsParentContext?: boolean;
43
+ capabilities?: {
44
+ depthLimit?: boolean;
45
+ };
46
+ } | undefined;
47
+ resolveMaxDepth?(configured?: number): number | undefined;
48
+ }
49
+ export interface DispatcherOptions {
50
+ subagents?: SubagentService;
51
+ parent?: unknown;
52
+ provider?: string;
53
+ signal?: AbortSignal;
54
+ cwd?: string;
55
+ maxDepth?: number;
56
+ }
57
+ export declare function externalDispatcher(reason?: string): Dispatcher;
58
+ export declare function createDispatcher(options: DispatcherOptions): Dispatcher;
@@ -0,0 +1,50 @@
1
+ export function externalDispatcher(reason = 'Subagent service or live parent unavailable. Execute the returned tasks and submit their identity-bound JSON with review_report.') {
2
+ return { kind: 'external', reason, async dispatch() { return { ok: false, reason: 'external' }; }, async settle() { } };
3
+ }
4
+ export function createDispatcher(options) {
5
+ const svc = options.subagents;
6
+ if (!svc || !options.parent || !options.signal || !options.cwd)
7
+ return externalDispatcher();
8
+ const provider = options.provider ?? 'spawn', signal = options.signal;
9
+ return {
10
+ kind: 'subagents',
11
+ async dispatch(task) {
12
+ let activation;
13
+ try {
14
+ signal.throwIfAborted();
15
+ if (svc.getProvider?.(provider)?.inheritsParentContext !== false)
16
+ throw new Error('Review workers require a provider that explicitly guarantees fresh context, not a fork or unknown backend');
17
+ const maxDepth = svc.resolveMaxDepth?.(options.maxDepth) ?? options.maxDepth ?? 1;
18
+ activation = await svc.startActivation({ provider, label: task.id, delivery: 'caller', signal,
19
+ request: { prompt: [{ type: 'text', text: task.prompt }], parent: options.parent, cwd: options.cwd, maxDepth } });
20
+ const onAbort = () => { void activation?.dispose().catch(() => { }); };
21
+ signal.addEventListener('abort', onAbort, { once: true });
22
+ try {
23
+ if (signal.aborted) {
24
+ await activation.dispose();
25
+ signal.throwIfAborted();
26
+ }
27
+ const result = await activation.result;
28
+ signal.throwIfAborted();
29
+ if (result.stopReason !== 'completed')
30
+ return { ok: false, reason: 'Child did not complete: ' + String(result.stopReason), childId: activation.childId };
31
+ const output = result.output?.filter(block => block.type === 'text').map(block => block.text ?? '').join('\n') ?? '';
32
+ if (!output.trim())
33
+ return { ok: false, reason: 'Child finished without JSON output', childId: activation.childId };
34
+ return { ok: true, output, childId: activation.childId };
35
+ }
36
+ finally {
37
+ signal.removeEventListener('abort', onAbort);
38
+ }
39
+ }
40
+ catch (error) {
41
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
42
+ }
43
+ finally {
44
+ if (activation)
45
+ await activation.dispose();
46
+ }
47
+ },
48
+ async settle() { },
49
+ };
50
+ }
package/lib/eval.d.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { Ledger } from './ledger.js';
2
+ export declare function evaluation(ledger: Ledger, root: string, args: Record<string, any>): Promise<{
3
+ action: any;
4
+ count: number;
5
+ entries?: undefined;
6
+ results?: undefined;
7
+ tasks?: undefined;
8
+ recorded?: undefined;
9
+ registeredCases?: undefined;
10
+ cohorts?: undefined;
11
+ metric?: undefined;
12
+ } | {
13
+ action: any;
14
+ entries: unknown[];
15
+ results: unknown[];
16
+ count?: undefined;
17
+ tasks?: undefined;
18
+ recorded?: undefined;
19
+ registeredCases?: undefined;
20
+ cohorts?: undefined;
21
+ metric?: undefined;
22
+ } | {
23
+ action: any;
24
+ tasks: {
25
+ caseId: string;
26
+ head: string;
27
+ base: string;
28
+ instructions: string;
29
+ }[];
30
+ count?: undefined;
31
+ entries?: undefined;
32
+ results?: undefined;
33
+ recorded?: undefined;
34
+ registeredCases?: undefined;
35
+ cohorts?: undefined;
36
+ metric?: undefined;
37
+ } | {
38
+ action: any;
39
+ recorded: boolean;
40
+ count?: undefined;
41
+ entries?: undefined;
42
+ results?: undefined;
43
+ tasks?: undefined;
44
+ registeredCases?: undefined;
45
+ cohorts?: undefined;
46
+ metric?: undefined;
47
+ } | {
48
+ action: any;
49
+ registeredCases: number;
50
+ cohorts: {
51
+ cohort: string;
52
+ tested: number;
53
+ unrun: string[];
54
+ coverage: number;
55
+ truePositives: number;
56
+ falseNegatives: number;
57
+ falsePositives: number;
58
+ strictRecall: number | null;
59
+ precision: number | null;
60
+ cleanCases: number;
61
+ cleanCaseFalsePositiveRate: number | null;
62
+ completeEvaluation: boolean;
63
+ matchedRunIds: string[];
64
+ }[];
65
+ metric: string;
66
+ count?: undefined;
67
+ entries?: undefined;
68
+ results?: undefined;
69
+ tasks?: undefined;
70
+ recorded?: undefined;
71
+ }>;
package/lib/eval.js ADDED
@@ -0,0 +1,83 @@
1
+ import { git } from './exec.js';
2
+ export async function evaluation(ledger, root, args) {
3
+ const action = args.action;
4
+ const store = () => ledger.snapshot().evaluations ?? { cases: {}, results: {} };
5
+ if (action === 'register') {
6
+ if (!Array.isArray(args.entries))
7
+ throw new Error('register requires entries');
8
+ const prepared = [];
9
+ for (const entry of args.entries) {
10
+ if (!entry || typeof entry.id !== 'string' || !/^[\w.-]{1,64}$/.test(entry.id) || !Array.isArray(entry.expected))
11
+ throw new Error('Each case needs id and human-curated expected bugs; [] is a known-clean case');
12
+ const commits = [];
13
+ for (const ref of [entry.sha, entry.base]) {
14
+ if (typeof ref !== 'string' || !/^[a-f0-9]{7,40}$/.test(ref))
15
+ throw new Error('Evaluation sha/base must be immutable commit ids');
16
+ const result = await git(root, ['rev-parse', '--verify', '--end-of-options', ref + '^{commit}']);
17
+ if (!result.ok)
18
+ throw new Error('Evaluation commit does not exist');
19
+ commits.push(result.stdout.trim());
20
+ }
21
+ for (const bug of entry.expected)
22
+ if (['bugId', 'file', 'kind', 'classOfBug', 'anchor'].some(key => typeof bug[key] !== 'string' || !bug[key].trim()))
23
+ throw new Error('Ground truth requires bugId, file, kind, classOfBug and exact anchor');
24
+ if (new Set(entry.expected.map((bug) => bug.bugId)).size !== entry.expected.length)
25
+ throw new Error('Duplicate ground-truth bug ids');
26
+ prepared.push({ id: entry.id, sha: commits[0], base: commits[1], expected: entry.expected.map((bug) => ({ bugId: bug.bugId, file: bug.file, kind: bug.kind, classOfBug: bug.classOfBug, anchor: bug.anchor })), note: typeof entry.note === 'string' ? entry.note : '' });
27
+ }
28
+ ledger.transaction(state => {
29
+ const db = state.evaluations ??= { cases: {}, results: {} };
30
+ for (const item of prepared) {
31
+ if (db.cases[item.id])
32
+ throw new Error('Case id already registered; ground truth is immutable');
33
+ db.cases[item.id] = item;
34
+ }
35
+ });
36
+ return { action, count: Object.keys(store().cases).length };
37
+ }
38
+ if (action === 'list')
39
+ return { action, entries: Object.values(store().cases), results: Object.values(store().results) };
40
+ if (action === 'prompts') {
41
+ // Labels and incident notes deliberately stay out of the reviewer prompt.
42
+ return { action, tasks: Object.values(store().cases).map(value => {
43
+ const entry = value;
44
+ return { caseId: entry.id, head: entry.sha, base: entry.base, instructions: 'Use an isolated checkout of this exact head and base, without future commits or incident labels. Run the normal review pipeline, then record its native runId. Do not inspect the evaluation ground truth.' };
45
+ }) };
46
+ }
47
+ if (action === 'record') {
48
+ if (typeof args.caseId !== 'string' || typeof args.runId !== 'string' || typeof args.cohort !== 'string' || !args.cohort.trim())
49
+ throw new Error('record requires caseId, runId and cohort');
50
+ ledger.transaction(state => {
51
+ const db = state.evaluations ??= { cases: {}, results: {} }, entry = db.cases[args.caseId], run = state.runs[args.runId];
52
+ if (!entry || !run || run.status !== 'complete' || !run.independent || run.target.kind !== 'commit' || run.head !== entry.sha || run.target.base !== entry.base)
53
+ throw new Error('Evaluation requires a completed native commit review of the exact case head/base');
54
+ const findings = (run.accepted ?? []).filter(f => f.severity !== 'pre-existing');
55
+ const unmatched = new Set(entry.expected.map(bug => bug.bugId)), matched = [];
56
+ let fp = 0;
57
+ for (const f of findings) {
58
+ const bug = entry.expected.find(bug => unmatched.has(bug.bugId) && f.file === bug.file && f.kind === bug.kind && f.classOfBug === bug.classOfBug && f.anchor === bug.anchor);
59
+ if (bug) {
60
+ matched.push(bug);
61
+ unmatched.delete(bug.bugId);
62
+ }
63
+ else
64
+ fp++;
65
+ }
66
+ const key = args.caseId + ':' + args.cohort;
67
+ if (db.results[key])
68
+ throw new Error('A case/cohort result already exists; use a new cohort for a new reviewer');
69
+ db.results[key] = { caseId: entry.id, runId: run.id, cohort: args.cohort, truePositives: matched.map(b => b.bugId), falseNegatives: entry.expected.filter(b => !matched.includes(b)).map(b => b.bugId), falsePositives: fp, predictions: findings.length, at: new Date().toISOString() };
70
+ });
71
+ return { action, recorded: true };
72
+ }
73
+ if (action !== 'score')
74
+ throw new Error('Unknown evaluation action');
75
+ const db = store(), cases = Object.keys(db.cases), results = Object.values(db.results).map(r => r).filter(r => !args.cohort || r.cohort === args.cohort);
76
+ const cohorts = [...new Set(results.map(r => r.cohort))];
77
+ return { action, registeredCases: cases.length, cohorts: cohorts.map(cohort => {
78
+ const rows = results.filter(r => r.cohort === cohort), tp = rows.reduce((n, r) => n + r.truePositives.length, 0), fn = rows.reduce((n, r) => n + r.falseNegatives.length, 0), fp = rows.reduce((n, r) => n + r.falsePositives, 0);
79
+ const tested = new Set(rows.map(r => r.caseId)), clean = rows.filter(r => db.cases[r.caseId].expected.length === 0);
80
+ return { cohort, tested: tested.size, unrun: cases.filter(id => !tested.has(id)), coverage: cases.length ? tested.size / cases.length : 0, truePositives: tp, falseNegatives: fn, falsePositives: fp, strictRecall: tp + fn ? tp / (tp + fn) : null, precision: tp + fp ? tp / (tp + fp) : null, cleanCases: clean.length, cleanCaseFalsePositiveRate: clean.length ? clean.filter(r => r.falsePositives > 0).length / clean.length : null,
81
+ completeEvaluation: tested.size === cases.length && cases.length > 0, matchedRunIds: rows.map(r => r.runId) };
82
+ }), metric: 'Strict human-curated location/category/anchor matching; not a semantic-quality claim. Unrun cases are reported separately.' };
83
+ }
package/lib/exec.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 受信任的结构化 Git 读取。证据命令由宿主 Bash 工具执行。
3
+ *
4
+ * @module dsh-review/exec
5
+ */
6
+ export interface RunResult {
7
+ ok: boolean;
8
+ code: number | null;
9
+ stdout: string;
10
+ stderr: string;
11
+ timedOut: boolean;
12
+ durationMs: number;
13
+ truncated: boolean;
14
+ }
15
+ export interface RunOptions {
16
+ cwd: string;
17
+ timeoutMs: number;
18
+ maxOutputBytes?: number;
19
+ input?: string;
20
+ env?: NodeJS.ProcessEnv;
21
+ signal?: AbortSignal;
22
+ }
23
+ export declare function run(command: string, args: string[], options: RunOptions): Promise<RunResult>;
24
+ export declare function git(cwd: string, args: string[], timeoutMs?: number, maxOutputBytes?: number, signal?: AbortSignal): Promise<RunResult>;
25
+ /** 输出裁剪:保留头部与尾部,中间省略。 */
26
+ export declare function excerpt(text: string, maxLines?: number, maxChars?: number): string;
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;