@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,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): 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,135 @@
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) {
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 || (witness.side !== undefined && !['before', 'after'].includes(witness.side)) || !sourceMatches(target, witness.file, witness.line, witness.anchor, witness.side ?? 'after'))
48
+ throw new Error('Verifier cited source 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 === 'pre-existing' && !item.witnesses.some(witness => witness.side === 'before'))
52
+ throw new Error('Pre-existing verdict requires base evidence');
53
+ return { candidateId: expected, decision: item.decision, reason: item.reason.trim(), witnesses: item.witnesses, taskId };
54
+ }
55
+ export function voteDecision(votes, required) {
56
+ if (votes.length !== required)
57
+ return 'unverifiable';
58
+ const majority = Math.floor(required / 2) + 1;
59
+ for (const decision of ['confirm', 'reject', 'pre-existing'])
60
+ if (votes.filter(vote => vote.decision === decision).length >= majority)
61
+ return decision;
62
+ return 'unverifiable';
63
+ }
64
+ export function gateFindings(options) {
65
+ const { record, target, config, rules } = options;
66
+ const result = { postable: [], suppressed: 0, suppressedFingerprints: [], dropped: 0, droppedDetail: [], unverified: 0, declined: [], rejected: [] };
67
+ let nits = 0;
68
+ const unique = new Map();
69
+ for (const finding of options.findings) {
70
+ if (DROP_KINDS.includes(finding.kind.toLowerCase())) {
71
+ result.dropped++;
72
+ continue;
73
+ }
74
+ const id = finding.id ?? candidateId(finding, target), prior = unique.get(id);
75
+ if (!prior || severityRank(finding.severity) > severityRank(prior.severity))
76
+ unique.set(id, { ...finding, id });
77
+ }
78
+ for (const finding of [...unique.values()].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))) {
79
+ const id = finding.id, votes = options.votes[id] ?? [], decision = voteDecision(votes, options.requiredVotes);
80
+ if (decision === 'unverifiable') {
81
+ result.unverified++;
82
+ continue;
83
+ }
84
+ if (decision === 'reject') {
85
+ result.rejected.push({ id, reason: votes.map(v => v.reason).join('\n') });
86
+ const prior = record.posted[id];
87
+ if (prior && !prior.disposition) {
88
+ prior.disposition = prior.targetId === target.id ? 'wrong' : 'fixed';
89
+ prior.note = 'Independent re-verification rejects the former finding on target ' + target.id;
90
+ prior.verification = votes;
91
+ prior.targetId = target.id;
92
+ }
93
+ continue;
94
+ }
95
+ if (decision !== 'pre-existing' && severityRank(finding.severity) < severityRank(rules.severityFloor ?? config.severityFloor)) {
96
+ result.dropped++;
97
+ continue;
98
+ }
99
+ if (finding.severity === 'nit' && nits++ >= (rules.maxNits ?? config.maxNits)) {
100
+ result.dropped++;
101
+ continue;
102
+ }
103
+ if (result.postable.length >= config.maxFindings) {
104
+ result.unverified++;
105
+ continue;
106
+ }
107
+ 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,
108
+ message: finding.message, evidence: votes.map(v => v.reason).join('\n'), preexisting: decision === 'pre-existing', anchor: finding.anchor, targetId: target.id, verification: votes,
109
+ ...(finding.classOfBug ? { classOfBug: finding.classOfBug } : {}) };
110
+ const prior = record.posted[id];
111
+ if (prior && prior.targetId === target.id) {
112
+ prior.repeats++;
113
+ result.suppressed++;
114
+ result.suppressedFingerprints.push(id);
115
+ prior.verification = votes;
116
+ prior.evidence = entry.evidence;
117
+ if (severityRank(entry.severity) > severityRank(prior.severity)) {
118
+ prior.severity = entry.severity;
119
+ prior.message = entry.message;
120
+ }
121
+ continue;
122
+ }
123
+ if (prior && !prior.disposition) {
124
+ result.suppressed++;
125
+ result.suppressedFingerprints.push(id);
126
+ }
127
+ else
128
+ result.postable.push(entry);
129
+ options.ledger.remember(record, { ...entry, at: new Date().toISOString(), repeats: prior?.repeats ?? 0 });
130
+ }
131
+ record.unverified = result.unverified;
132
+ record.dropped += result.dropped;
133
+ record.reviewedFiles = target.files.map(file => file.path);
134
+ return result;
135
+ }
@@ -0,0 +1,2 @@
1
+ import type { PullRequestEvidence } from './policy.js';
2
+ export declare function githubEvidence(repository: string, number: number, token: string, expectedHead: string, expectedBase: string, fetcher?: typeof fetch): Promise<PullRequestEvidence>;
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';
@@ -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
+ }
package/lib/ledger.js ADDED
@@ -0,0 +1,125 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, rmdirSync, unlinkSync, copyFileSync, constants, realpathSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ export function stateDirectory(root, stateDir) {
6
+ const configuredHome = process.env.DSH_HOME || path.join(homedir(), '.dsh');
7
+ let profileBase = configuredHome;
8
+ if (stateDir === '@profile') {
9
+ let ancestor = path.resolve(configuredHome);
10
+ while (!existsSync(ancestor))
11
+ ancestor = path.dirname(ancestor);
12
+ profileBase = path.join(realpathSync(ancestor), path.relative(ancestor, path.resolve(configuredHome)));
13
+ }
14
+ const dir = stateDir === '@profile'
15
+ ? path.join(profileBase, 'plugins', 'dsh-review', createHash('sha256').update(realpathSync(root)).digest('hex').slice(0, 24))
16
+ : path.resolve(root, stateDir);
17
+ if (stateDir !== '@profile') {
18
+ const relative = path.relative(root, dir);
19
+ if (relative.startsWith('..') || path.isAbsolute(relative) || relative === '')
20
+ throw new Error('stateDir must be a child directory of the workspace, or @profile');
21
+ }
22
+ let current = dir;
23
+ while (!existsSync(current))
24
+ current = path.dirname(current);
25
+ if (path.resolve(realpathSync(current)).toLowerCase() !== path.resolve(current).toLowerCase())
26
+ throw new Error('Review state path may not traverse a symlink');
27
+ return dir;
28
+ }
29
+ export class Ledger {
30
+ root;
31
+ directory;
32
+ file;
33
+ state;
34
+ constructor(root, stateDir) {
35
+ this.root = root;
36
+ this.directory = stateDirectory(root, stateDir);
37
+ this.file = path.join(this.directory, 'state.json');
38
+ this.state = this.read();
39
+ }
40
+ read() {
41
+ if (!existsSync(this.file))
42
+ return { version: 2, revision: 0, heads: {}, runs: {}, receipts: {} };
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(readFileSync(this.file, 'utf8'));
46
+ }
47
+ catch {
48
+ throw new Error('Review ledger is corrupt; original file preserved. Gate remains blocked.');
49
+ }
50
+ if (parsed?.version === 1 && parsed.heads && typeof parsed.heads === 'object' && !Array.isArray(parsed.heads))
51
+ return { version: 2, revision: 0, heads: {}, runs: {}, receipts: {}, legacy: parsed };
52
+ if (parsed?.version !== 2 || !Number.isInteger(parsed.revision) || !parsed.heads || !parsed.runs || !parsed.receipts || [parsed.heads, parsed.runs, parsed.receipts].some(value => typeof value !== 'object' || Array.isArray(value)))
53
+ throw new Error('Unsupported or invalid review ledger; gate remains blocked');
54
+ return parsed;
55
+ }
56
+ locked(work) {
57
+ mkdirSync(this.directory, { recursive: true });
58
+ const lock = path.join(this.directory, 'write.lock');
59
+ try {
60
+ mkdirSync(lock);
61
+ }
62
+ catch {
63
+ throw new Error('Review ledger is busy; retry after the current update finishes');
64
+ }
65
+ try {
66
+ return work();
67
+ }
68
+ finally {
69
+ rmdirSync(lock);
70
+ }
71
+ }
72
+ write() {
73
+ if (this.state.legacy && !existsSync(this.file + '.v1.backup') && existsSync(this.file))
74
+ copyFileSync(this.file, this.file + '.v1.backup', constants.COPYFILE_EXCL);
75
+ const tmp = this.file + '.tmp-' + process.pid + '-' + randomUUID();
76
+ const text = JSON.stringify({ ...this.state, revision: this.state.revision + 1 }, null, 2) + '\n';
77
+ try {
78
+ writeFileSync(tmp, text, { flag: 'wx' });
79
+ renameSync(tmp, this.file);
80
+ this.state.revision++;
81
+ }
82
+ finally {
83
+ if (existsSync(tmp))
84
+ unlinkSync(tmp);
85
+ }
86
+ }
87
+ transaction(work) {
88
+ return this.locked(() => { this.state = this.read(); const value = work(this.state); this.write(); return value; });
89
+ }
90
+ save() { this.locked(() => { const current = this.read(); if (current.revision !== this.state.revision)
91
+ throw new Error('Review ledger changed; refusing a stale write'); this.write(); }); }
92
+ event(type, payload) {
93
+ mkdirSync(this.directory, { recursive: true });
94
+ appendFileSync(path.join(this.directory, 'ledger.jsonl'), JSON.stringify({ at: new Date().toISOString(), type, ...payload }) + '\n');
95
+ }
96
+ snapshot() { return this.read(); }
97
+ head(head) { return this.state.heads[head]; }
98
+ ensureHead(head, mode) { return this.state.heads[head] ??= { head, round: 0, mode, posted: {}, reviewedFiles: [], dropped: 0, suppressed: 0, unverified: 0, updatedAt: new Date().toISOString() }; }
99
+ beginRound(head, mode) { const record = this.ensureHead(head, mode); record.round++; return record; }
100
+ remember(record, finding) { record.posted[finding.fingerprint] = finding; record.updatedAt = new Date().toISOString(); }
101
+ respond(head, fingerprint, disposition, note) {
102
+ const finding = this.state.heads[head]?.posted[fingerprint];
103
+ if (!finding)
104
+ return undefined;
105
+ finding.disposition = disposition;
106
+ if (note)
107
+ finding.note = note;
108
+ return finding;
109
+ }
110
+ status(head) {
111
+ const state = this.read(), records = Object.entries(state.heads).filter(([key, value]) => !head || key === head || value.head === head).map(([, value]) => value);
112
+ const findings = records.flatMap(record => Object.values(record.posted)), byDisposition = { fixed: 0, wrong: 0, intentional: 0, wontfix: 0, unanswered: 0 };
113
+ for (const finding of findings)
114
+ byDisposition[finding.disposition ?? 'unanswered']++;
115
+ const answered = findings.filter(f => f.disposition).length, kinds = new Map();
116
+ for (const f of findings)
117
+ kinds.set(f.kind, (kinds.get(f.kind) ?? 0) + 1);
118
+ return { heads: records.length, round: Math.max(0, ...records.map(r => r.round)), posted: findings.length, byDisposition,
119
+ noiseRate: answered ? ((byDisposition.wrong + byDisposition.intentional) / answered) : null,
120
+ repeatRate: findings.length ? findings.reduce((n, f) => n + f.repeats, 0) / findings.length : null,
121
+ unverified: records.reduce((n, r) => n + r.unverified, 0), dropped: records.reduce((n, r) => n + r.dropped, 0),
122
+ topKinds: [...kinds].map(([kind, count]) => ({ kind, count })).sort((a, b) => b.count - a.count).slice(0, 8),
123
+ runs: Object.values(state.runs).filter(r => !head || r.head === head || r.targetId === head).map(r => ({ id: r.id, targetId: r.targetId, status: r.status, stage: r.stage, calls: r.calls, errors: r.errors })), legacyPresent: !!state.legacy };
124
+ }
125
+ }