@olegkoval/agent-skills 1.61.0 → 1.62.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/adapters/claude/olko-github-pr/skills/lekker-review/SKILL.md +30 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/context-gathering.md +3 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/finding-state.mjs +207 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +25 -4
- package/package.json +1 -1
- package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-github-pr/skills/lekker-review/SKILL.md +30 -0
- package/plugins/olko-github-pr/skills/lekker-review/references/context-gathering.md +3 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/finding-state.mjs +207 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +25 -4
- package/plugins/olko-github-pr/skills/lekker-review/workflow.js +60 -4
- package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-web-ops/.claude-plugin/plugin.json +1 -1
|
@@ -353,6 +353,33 @@ treat as a full review and leave PREV_SHA unset. Also grep the same file for
|
|
|
353
353
|
- Step 3.5 reuses that exact URL or local path so the artifact stays a living
|
|
354
354
|
page for this PR.
|
|
355
355
|
|
|
356
|
+
**Convergence state (every PR, first review included):** the review file
|
|
357
|
+
records only the last head sha, so finding status lives in
|
|
358
|
+
`scripts/finding-state.mjs` (one JSON file per PR under
|
|
359
|
+
`~/.local/state/lekker-review/findings/`). In `pr` mode, on a re-review, first
|
|
360
|
+
fetch the PR's review threads into `<scratchpad>/threads.json` as
|
|
361
|
+
`{ "prAuthor": <login>, "threads": [{ path, line, body, isResolved, isOutdated,
|
|
362
|
+
replies: [{ author, body }] }] }` (`gh api graphql` over `reviewThreads`; `body`
|
|
363
|
+
is the first comment, `replies` the later ones). Then run
|
|
364
|
+
`node "${CLAUDE_PLUGIN_ROOT}/skills/lekker-review/scripts/finding-state.mjs" begin --repo <REPO_SLUG> --target <TARGET_SLUG> --run <RUN_ID> [--threads <scratchpad>/threads.json]`.
|
|
365
|
+
`RUN_ID` is one id per review attempt, saved to `<scratchpad>/finding-run-id`
|
|
366
|
+
and reused when that attempt is retried, so a retry does not count as a new
|
|
367
|
+
cycle. In Codex, replace the quoted script path with the absolute path to
|
|
368
|
+
`scripts/finding-state.mjs` beside the selected `SKILL.md`, as for run-log. Pass
|
|
369
|
+
its `cycle`, `suppressed` and `recurringClasses` to the workflow. A
|
|
370
|
+
finding is `fixed` when its thread is outdated, `intentional` when the author
|
|
371
|
+
replied "by design" and nothing changed, `rejected` when the author dismissed it
|
|
372
|
+
or the thread was resolved with no code change. The workflow never re-raises a
|
|
373
|
+
`suppressed` (rejected or intentional) finding. From `cycle` 4 the workflow
|
|
374
|
+
returns `verdict: "rethink"` with the recurring finding classes and runs no
|
|
375
|
+
agents: skip Steps 2 to 4, render the verdict as `RETHINK THE APPROACH: this PR
|
|
376
|
+
has been reviewed N times; the same classes keep coming back: <classes>`, and
|
|
377
|
+
suggest the author and reviewer agree the approach before another round.
|
|
378
|
+
Run `finding-state.mjs record --run <RUN_ID> --repo ... --target ... --findings <findings.json>`
|
|
379
|
+
before the review is published or the artifact is updated, so a stop after
|
|
380
|
+
publishing cannot lose what was raised. `--run` files the findings under this
|
|
381
|
+
attempt's cycle even when another review of the PR started in the meantime.
|
|
382
|
+
|
|
356
383
|
**Open the run log** once mode, depth and engine are settled (see
|
|
357
384
|
`references/traces.md` for where every trace of a run lives):
|
|
358
385
|
|
|
@@ -688,6 +715,9 @@ args: {
|
|
|
688
715
|
worktreePath: <null for scan, else from worktree.json>,
|
|
689
716
|
promptDir: "${SKILL_ROOT}/references/agents",
|
|
690
717
|
prevSha: <null unless re-review>,
|
|
718
|
+
cycle, // finding-state.mjs begin output; omit when unknown
|
|
719
|
+
suppressed, // rejected/intentional findings never to re-raise
|
|
720
|
+
recurringClasses, // shown in the rethink verdict
|
|
691
721
|
testRunner: <worktree.json testRunner, null for scan>
|
|
692
722
|
}
|
|
693
723
|
```
|
|
@@ -142,6 +142,9 @@ Collect into `EXISTING_REVIEWS`. Purpose:
|
|
|
142
142
|
- **Deduplication**: if an existing review already raised a finding at a
|
|
143
143
|
specific `file:line`, skip that finding in your output and note
|
|
144
144
|
`(already raised by <reviewer>)` internally in your deduplication pass.
|
|
145
|
+
- **Answered findings**: file:line dedup is not enough across re-reviews. A finding the
|
|
146
|
+
author rejected or called intentional is carried in `suppressed` from
|
|
147
|
+
`scripts/finding-state.mjs` (SKILL.md, Convergence state) and is never re-raised.
|
|
145
148
|
- **Design decisions**: if the author replied to a review comment explaining
|
|
146
149
|
an intentional choice, that context informs whether a pattern is a bug or
|
|
147
150
|
a deliberate trade-off.
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Per-PR finding state for lekker-review, so re-reviews converge.
|
|
3
|
+
//
|
|
4
|
+
// A re-review used to know only the last head sha and dedupe by file:line, so a
|
|
5
|
+
// finding the author had already rejected came back every round. This keeps one
|
|
6
|
+
// small JSON file per PR next to the run ledger (${LEKKER_STATE_DIR:-
|
|
7
|
+
// ~/.local/state/lekker-review}/findings/) holding each finding's status and the
|
|
8
|
+
// number of review cycles.
|
|
9
|
+
//
|
|
10
|
+
// Statuses: open | fixed | rejected | intentional. A finding is
|
|
11
|
+
// fixed when GitHub marks its thread outdated (the line changed)
|
|
12
|
+
// intentional when the author replied "by design" and the code did not change
|
|
13
|
+
// rejected when the author replied with another dismissal, or the thread
|
|
14
|
+
// was resolved without a code change and the PR author is known
|
|
15
|
+
//
|
|
16
|
+
// Usage:
|
|
17
|
+
// node finding-state.mjs begin --repo <slug> --target <pr-N> [--threads <threads.json>]
|
|
18
|
+
// Folds thread outcomes into the stored statuses, counts the cycle, and
|
|
19
|
+
// prints {cycle, rethink, suppressed, recurringClasses} for workflow.js.
|
|
20
|
+
// node finding-state.mjs record --repo <slug> --target <pr-N> --findings <findings.json>
|
|
21
|
+
// Stores this run's findings as open (an already-known finding keeps its status).
|
|
22
|
+
// node finding-state.mjs show --repo <slug> --target <pr-N>
|
|
23
|
+
//
|
|
24
|
+
// threads.json: { prAuthor, threads: [{ path, line, title?, body, isResolved, isOutdated,
|
|
25
|
+
// replies: [{ author, body }] }] }, where `body` is lekker's comment and only
|
|
26
|
+
// replies by `prAuthor` count as the author's answer. A bare array is accepted
|
|
27
|
+
// when the author is unknown; it can then only yield fixed (outdated) threads.
|
|
28
|
+
// When title is absent, the first nonempty comment line supplies the title.
|
|
29
|
+
|
|
30
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
31
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
32
|
+
import { homedir } from 'node:os';
|
|
33
|
+
import { dirname, join } from 'node:path';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
|
|
36
|
+
export const RETHINK_CYCLE = 4;
|
|
37
|
+
const INTENT = /\b(intentional(ly)?|by design|on purpose|deliberate(ly)?|as designed|expected behaviou?r)\b/i;
|
|
38
|
+
const DISMISS = /\b(won'?t fix|wontfix|not a (bug|problem|concern)|false positive|disagree|n\/a|not applicable|out of scope|ignor(e|ing)|nit)\b/i;
|
|
39
|
+
|
|
40
|
+
export function titleTokens(title) {
|
|
41
|
+
return (String(title || '').toLowerCase().match(/[a-z0-9]+/g) || []).filter((w) => w.length > 2);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function sameIssue(a, b) {
|
|
45
|
+
const normalize = (title) => String(title || '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
46
|
+
const title = normalize(a.title);
|
|
47
|
+
return title !== '' && title === normalize(b.title);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function sameFinding(a, b) {
|
|
51
|
+
// Status reuse requires the same anchor and title; word overlap is only a
|
|
52
|
+
// class signal, never proof that two findings identify the same issue.
|
|
53
|
+
return Boolean(a.file) && a.file === b.file && a.line === b.line && sameIssue(a, b);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Group key for "the same kind of finding came back again".
|
|
57
|
+
export function findingClass(f) {
|
|
58
|
+
return f.bugClass || titleTokens(f.title).slice(0, 4).sort().join('-') || 'unclassified';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const NEGATOR = /\b(not|no|never|nor|isn'?t|aren'?t|wasn'?t|don'?t|doesn'?t|won'?t|cannot)\b/i;
|
|
62
|
+
|
|
63
|
+
// Check only the three words before each keyword in its clause for negation.
|
|
64
|
+
function mentions(pattern, text) {
|
|
65
|
+
return [...text.matchAll(new RegExp(pattern.source, 'gi'))]
|
|
66
|
+
.some((match) => {
|
|
67
|
+
const before = text.slice(0, match.index).split(/[.!?;:,\n-]/).pop();
|
|
68
|
+
return !NEGATOR.test(before.trim().split(/\s+/).slice(-3).join(' '));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function classifyThread(thread, prAuthor) {
|
|
73
|
+
const authorReplies = (thread.replies || []).filter((r) => r.author && r.author === prAuthor);
|
|
74
|
+
const text = authorReplies.map((r) => r.body).join('\n');
|
|
75
|
+
if (thread.isOutdated) return 'fixed';
|
|
76
|
+
if (mentions(INTENT, text)) return 'intentional';
|
|
77
|
+
if (mentions(DISMISS, text)) return 'rejected';
|
|
78
|
+
if (thread.isResolved && prAuthor) return 'rejected';
|
|
79
|
+
return 'open';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function applyThreads(findings, threads, prAuthor) {
|
|
83
|
+
return findings.map((f) => {
|
|
84
|
+
const thread = threads.find((t) => {
|
|
85
|
+
const title = t.title?.trim() || String(t.body || '').split(/\r?\n/).find((line) => line.trim())
|
|
86
|
+
?.trim().replace(/^#{1,6}\s+/, '').replace(/^(\*\*|__)(.*?)\1[.:]?$/, '$2');
|
|
87
|
+
return sameFinding(f, { file: t.path, line: t.line, title });
|
|
88
|
+
});
|
|
89
|
+
if (!thread) return f;
|
|
90
|
+
const status = classifyThread(thread, prAuthor);
|
|
91
|
+
return f.status === status ? f : { ...f, status };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function mergeFindings(prior, found, cycle) {
|
|
96
|
+
const added = found
|
|
97
|
+
.filter((f) => !prior.some((p) => sameFinding(p, f)))
|
|
98
|
+
.map((f) => ({ file: f.file, line: f.line, title: f.title, class: findingClass(f), status: 'open', cycles: [cycle] }));
|
|
99
|
+
const seen = prior.map((p) => (found.some((f) => sameFinding(p, f)) && !p.cycles.includes(cycle)
|
|
100
|
+
? { ...p, cycles: [...p.cycles, cycle] }
|
|
101
|
+
: p));
|
|
102
|
+
return seen.concat(added);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function recurringClasses(findings) {
|
|
106
|
+
const byClass = findings.reduce((acc, f) => {
|
|
107
|
+
const cycles = new Set([...(acc[f.class] || []), ...f.cycles]);
|
|
108
|
+
return { ...acc, [f.class]: [...cycles] };
|
|
109
|
+
}, {});
|
|
110
|
+
return Object.entries(byClass)
|
|
111
|
+
.filter(([, cycles]) => cycles.length >= 2)
|
|
112
|
+
.map(([cls, cycles]) => ({ class: cls, cycles: cycles.length }))
|
|
113
|
+
.sort((a, b) => b.cycles - a.cycles);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function suppressedFindings(findings) {
|
|
117
|
+
return findings
|
|
118
|
+
.filter((f) => f.status === 'rejected' || f.status === 'intentional')
|
|
119
|
+
.map(({ file, line, title, status }) => ({ file, line, title, status }));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// A run id makes begin idempotent: a retry of the same run reuses its cycle instead of inflating the count.
|
|
123
|
+
export function begin(state, threads, prAuthor, run = null) {
|
|
124
|
+
const findings = applyThreads(state.findings, threads, prAuthor);
|
|
125
|
+
const known = run === null ? undefined : state.runs?.[run];
|
|
126
|
+
const cycle = known ?? state.cycle + 1;
|
|
127
|
+
const runs = run === null || known !== undefined ? state.runs : { ...state.runs, [run]: cycle };
|
|
128
|
+
return {
|
|
129
|
+
state: { ...state, cycle: Math.max(state.cycle, cycle), findings, ...(runs && { runs }) },
|
|
130
|
+
summary: {
|
|
131
|
+
cycle,
|
|
132
|
+
rethink: cycle >= RETHINK_CYCLE,
|
|
133
|
+
suppressed: suppressedFindings(findings),
|
|
134
|
+
recurringClasses: recurringClasses(findings),
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const STATE_DIR = process.env.LEKKER_STATE_DIR || join(homedir(), '.local', 'state', 'lekker-review');
|
|
140
|
+
// Hash the tuple so punctuation, case and repo/target boundaries stay distinct.
|
|
141
|
+
const statePath = (repo, target) => join(STATE_DIR, 'findings',
|
|
142
|
+
`${createHash('sha256').update(JSON.stringify([repo, target])).digest('hex')}.json`);
|
|
143
|
+
|
|
144
|
+
function load(path) {
|
|
145
|
+
let raw;
|
|
146
|
+
try {
|
|
147
|
+
raw = readFileSync(path, 'utf8');
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error.code === 'ENOENT') return { cycle: 0, findings: [] };
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
const state = JSON.parse(raw);
|
|
153
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)
|
|
154
|
+
|| !Number.isSafeInteger(state.cycle) || state.cycle < 0 || !Array.isArray(state.findings)) {
|
|
155
|
+
throw new Error(`Invalid finding state: ${path}`);
|
|
156
|
+
}
|
|
157
|
+
if (state.runs !== undefined && (!state.runs || typeof state.runs !== 'object' || Array.isArray(state.runs)
|
|
158
|
+
|| !Object.values(state.runs).every((c) => Number.isSafeInteger(c) && c >= 1))) {
|
|
159
|
+
throw new Error(`Invalid finding state runs: ${path}`);
|
|
160
|
+
}
|
|
161
|
+
return state;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function save(path, state) {
|
|
165
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
166
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
167
|
+
try {
|
|
168
|
+
writeFileSync(temporary, JSON.stringify(state, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
169
|
+
renameSync(temporary, path);
|
|
170
|
+
} finally {
|
|
171
|
+
rmSync(temporary, { force: true });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function main(argv) {
|
|
176
|
+
const flag = (name) => { const i = argv.indexOf('--' + name); return i === -1 ? null : argv[i + 1]; };
|
|
177
|
+
const repo = flag('repo');
|
|
178
|
+
const target = flag('target');
|
|
179
|
+
if (!repo || !target) { console.log(JSON.stringify({ error: '--repo and --target are required' })); process.exit(2); }
|
|
180
|
+
const path = statePath(repo, target);
|
|
181
|
+
const state = load(path);
|
|
182
|
+
const cmd = argv[0];
|
|
183
|
+
if (cmd === 'begin') {
|
|
184
|
+
const raw = flag('threads') ? JSON.parse(readFileSync(flag('threads'), 'utf8')) : [];
|
|
185
|
+
const threads = Array.isArray(raw) ? raw : raw.threads || [];
|
|
186
|
+
const prAuthor = Array.isArray(raw) ? null : raw.prAuthor || null;
|
|
187
|
+
const next = begin(state, threads, prAuthor, flag('run') ?? null);
|
|
188
|
+
save(path, next.state);
|
|
189
|
+
console.log(JSON.stringify(next.summary));
|
|
190
|
+
} else if (cmd === 'record') {
|
|
191
|
+
const raw = JSON.parse(readFileSync(flag('findings'), 'utf8'));
|
|
192
|
+
const found = Array.isArray(raw) ? raw : raw.findings || [];
|
|
193
|
+
const run = flag('run') ?? null;
|
|
194
|
+
// A late record for an earlier run keeps that run's cycle; the current cycle stays as it was.
|
|
195
|
+
const cycle = run === null ? Math.max(state.cycle, 1) : state.runs?.[run];
|
|
196
|
+
if (cycle === undefined) { console.log(JSON.stringify({ error: `unknown --run ${run}; call begin --run first` })); process.exit(2); }
|
|
197
|
+
save(path, { ...state, cycle: Math.max(state.cycle, 1), findings: mergeFindings(state.findings, found, cycle) });
|
|
198
|
+
console.log(JSON.stringify({ recorded: found.length, cycle }));
|
|
199
|
+
} else if (cmd === 'show') {
|
|
200
|
+
console.log(JSON.stringify(state, null, 2));
|
|
201
|
+
} else {
|
|
202
|
+
console.log(JSON.stringify({ error: 'usage: begin|record|show' }));
|
|
203
|
+
process.exit(2);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) main(process.argv.slice(2));
|
|
@@ -53,13 +53,19 @@ const preamble = [
|
|
|
53
53
|
// hardRuleCorroborated is likewise gone from the current file and kept in this
|
|
54
54
|
// list for the same reason: an older workflow.js calls it from isHardRule.
|
|
55
55
|
...['titleTokens', 'sameIssue', 'nearbyLines', 'spanWithinWindow', 'hardRuleCorroborated',
|
|
56
|
-
'isHardRule', 'longest', 'mergeFindings', 'dedup', 'shouldVerify'].map(n => {
|
|
57
|
-
|
|
56
|
+
'isHardRule', 'longest', 'mergeFindings', 'dedup', 'shouldVerify', 'isProveAttempt', 'countProveAttempt'].map(n => {
|
|
57
|
+
// countProveAttempt is called directly by the checks, so its stub returns NaN and the
|
|
58
|
+
// checks FAIL with a diff instead of the run crashing on an older workflow.js.
|
|
59
|
+
try { return lift(n) } catch {
|
|
60
|
+
return n === 'countProveAttempt'
|
|
61
|
+
? 'function countProveAttempt() { return NaN }'
|
|
62
|
+
: `function ${n}() { throw new Error('${n} absent from this workflow.js') }`
|
|
63
|
+
}
|
|
58
64
|
}),
|
|
59
65
|
].join('\n')
|
|
60
66
|
|
|
61
|
-
const { dedup, isHardRule, shouldVerify, sameIssue } =
|
|
62
|
-
new Function(preamble + '\nreturn { dedup, isHardRule, shouldVerify, sameIssue }')()
|
|
67
|
+
const { dedup, isHardRule, shouldVerify, sameIssue, isProveAttempt, countProveAttempt } =
|
|
68
|
+
new Function(preamble + '\nreturn { dedup, isHardRule, shouldVerify, sameIssue, isProveAttempt, countProveAttempt }')()
|
|
63
69
|
|
|
64
70
|
let failed = 0
|
|
65
71
|
function check(name, actual, expected) {
|
|
@@ -676,5 +682,20 @@ console.log('\nprove: skipped without an installed test runner')
|
|
|
676
682
|
rmSync(tmp, { recursive: true, force: true })
|
|
677
683
|
}
|
|
678
684
|
|
|
685
|
+
console.log('\nprove ledger: a declined prover is a skip, not an attempt')
|
|
686
|
+
// Observed: 6 critical findings, every prover answered attempted:false (no node_modules),
|
|
687
|
+
// and the ledger recorded proven 0/6 as if six tests had run and not failed.
|
|
688
|
+
check('not_attempted is not an attempt', isProveAttempt({ attempted: false, proven: false, outcome: 'not_attempted' }), false)
|
|
689
|
+
check('inconclusive run is an attempt', isProveAttempt({ attempted: true, proven: false, outcome: 'inconclusive' }), true)
|
|
690
|
+
check('proven run is an attempt', isProveAttempt({ attempted: true, proven: true, outcome: 'proven' }), true)
|
|
691
|
+
// The counter path itself, not just the predicate: a declined prover must leave the count unchanged.
|
|
692
|
+
check('ledger: null proof (prover failed or rejected) adds nothing', countProveAttempt(0, null), 0)
|
|
693
|
+
check('ledger: declined prover adds nothing', countProveAttempt(2, { attempted: false, proven: false, outcome: 'not_attempted' }), 2)
|
|
694
|
+
check('ledger: inconclusive prover adds one', countProveAttempt(2, { attempted: true, proven: false, outcome: 'inconclusive' }), 3)
|
|
695
|
+
check('ledger: proven prover adds one', countProveAttempt(2, { attempted: true, proven: true, outcome: 'proven' }), 3)
|
|
696
|
+
// Source guard: a bare increment would bypass the predicate, and no unit check above would notice.
|
|
697
|
+
const bareWrites = src.split('\n').filter(l => /proveAttemptCount\s*(\+\+|\+=|=[^=])/.test(l) && !/countProveAttempt\(|let proveAttemptCount/.test(l))
|
|
698
|
+
check('the attempt count is written only through countProveAttempt', bareWrites, [])
|
|
699
|
+
|
|
679
700
|
console.log(failed === 0 ? '\nall checks passed\n' : `\n${failed} check(s) FAILED\n`)
|
|
680
701
|
process.exit(failed === 0 ? 0 : 1)
|
package/package.json
CHANGED
|
@@ -352,6 +352,33 @@ treat as a full review and leave PREV_SHA unset. Also grep the same file for
|
|
|
352
352
|
- Step 3.5 reuses that exact URL or local path so the artifact stays a living
|
|
353
353
|
page for this PR.
|
|
354
354
|
|
|
355
|
+
**Convergence state (every PR, first review included):** the review file
|
|
356
|
+
records only the last head sha, so finding status lives in
|
|
357
|
+
`scripts/finding-state.mjs` (one JSON file per PR under
|
|
358
|
+
`~/.local/state/lekker-review/findings/`). In `pr` mode, on a re-review, first
|
|
359
|
+
fetch the PR's review threads into `<scratchpad>/threads.json` as
|
|
360
|
+
`{ "prAuthor": <login>, "threads": [{ path, line, body, isResolved, isOutdated,
|
|
361
|
+
replies: [{ author, body }] }] }` (`gh api graphql` over `reviewThreads`; `body`
|
|
362
|
+
is the first comment, `replies` the later ones). Then run
|
|
363
|
+
`node "${CLAUDE_PLUGIN_ROOT}/skills/lekker-review/scripts/finding-state.mjs" begin --repo <REPO_SLUG> --target <TARGET_SLUG> --run <RUN_ID> [--threads <scratchpad>/threads.json]`.
|
|
364
|
+
`RUN_ID` is one id per review attempt, saved to `<scratchpad>/finding-run-id`
|
|
365
|
+
and reused when that attempt is retried, so a retry does not count as a new
|
|
366
|
+
cycle. In Codex, replace the quoted script path with the absolute path to
|
|
367
|
+
`scripts/finding-state.mjs` beside the selected `SKILL.md`, as for run-log. Pass
|
|
368
|
+
its `cycle`, `suppressed` and `recurringClasses` to the workflow. A
|
|
369
|
+
finding is `fixed` when its thread is outdated, `intentional` when the author
|
|
370
|
+
replied "by design" and nothing changed, `rejected` when the author dismissed it
|
|
371
|
+
or the thread was resolved with no code change. The workflow never re-raises a
|
|
372
|
+
`suppressed` (rejected or intentional) finding. From `cycle` 4 the workflow
|
|
373
|
+
returns `verdict: "rethink"` with the recurring finding classes and runs no
|
|
374
|
+
agents: skip Steps 2 to 4, render the verdict as `RETHINK THE APPROACH: this PR
|
|
375
|
+
has been reviewed N times; the same classes keep coming back: <classes>`, and
|
|
376
|
+
suggest the author and reviewer agree the approach before another round.
|
|
377
|
+
Run `finding-state.mjs record --run <RUN_ID> --repo ... --target ... --findings <findings.json>`
|
|
378
|
+
before the review is published or the artifact is updated, so a stop after
|
|
379
|
+
publishing cannot lose what was raised. `--run` files the findings under this
|
|
380
|
+
attempt's cycle even when another review of the PR started in the meantime.
|
|
381
|
+
|
|
355
382
|
**Open the run log** once mode, depth and engine are settled (see
|
|
356
383
|
`references/traces.md` for where every trace of a run lives):
|
|
357
384
|
|
|
@@ -687,6 +714,9 @@ args: {
|
|
|
687
714
|
worktreePath: <null for scan, else from worktree.json>,
|
|
688
715
|
promptDir: "${SKILL_ROOT}/references/agents",
|
|
689
716
|
prevSha: <null unless re-review>,
|
|
717
|
+
cycle, // finding-state.mjs begin output; omit when unknown
|
|
718
|
+
suppressed, // rejected/intentional findings never to re-raise
|
|
719
|
+
recurringClasses, // shown in the rethink verdict
|
|
690
720
|
testRunner: <worktree.json testRunner, null for scan>
|
|
691
721
|
}
|
|
692
722
|
```
|
|
@@ -142,6 +142,9 @@ Collect into `EXISTING_REVIEWS`. Purpose:
|
|
|
142
142
|
- **Deduplication**: if an existing review already raised a finding at a
|
|
143
143
|
specific `file:line`, skip that finding in your output and note
|
|
144
144
|
`(already raised by <reviewer>)` internally in your deduplication pass.
|
|
145
|
+
- **Answered findings**: file:line dedup is not enough across re-reviews. A finding the
|
|
146
|
+
author rejected or called intentional is carried in `suppressed` from
|
|
147
|
+
`scripts/finding-state.mjs` (SKILL.md, Convergence state) and is never re-raised.
|
|
145
148
|
- **Design decisions**: if the author replied to a review comment explaining
|
|
146
149
|
an intentional choice, that context informs whether a pattern is a bug or
|
|
147
150
|
a deliberate trade-off.
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Per-PR finding state for lekker-review, so re-reviews converge.
|
|
3
|
+
//
|
|
4
|
+
// A re-review used to know only the last head sha and dedupe by file:line, so a
|
|
5
|
+
// finding the author had already rejected came back every round. This keeps one
|
|
6
|
+
// small JSON file per PR next to the run ledger (${LEKKER_STATE_DIR:-
|
|
7
|
+
// ~/.local/state/lekker-review}/findings/) holding each finding's status and the
|
|
8
|
+
// number of review cycles.
|
|
9
|
+
//
|
|
10
|
+
// Statuses: open | fixed | rejected | intentional. A finding is
|
|
11
|
+
// fixed when GitHub marks its thread outdated (the line changed)
|
|
12
|
+
// intentional when the author replied "by design" and the code did not change
|
|
13
|
+
// rejected when the author replied with another dismissal, or the thread
|
|
14
|
+
// was resolved without a code change and the PR author is known
|
|
15
|
+
//
|
|
16
|
+
// Usage:
|
|
17
|
+
// node finding-state.mjs begin --repo <slug> --target <pr-N> [--threads <threads.json>]
|
|
18
|
+
// Folds thread outcomes into the stored statuses, counts the cycle, and
|
|
19
|
+
// prints {cycle, rethink, suppressed, recurringClasses} for workflow.js.
|
|
20
|
+
// node finding-state.mjs record --repo <slug> --target <pr-N> --findings <findings.json>
|
|
21
|
+
// Stores this run's findings as open (an already-known finding keeps its status).
|
|
22
|
+
// node finding-state.mjs show --repo <slug> --target <pr-N>
|
|
23
|
+
//
|
|
24
|
+
// threads.json: { prAuthor, threads: [{ path, line, title?, body, isResolved, isOutdated,
|
|
25
|
+
// replies: [{ author, body }] }] }, where `body` is lekker's comment and only
|
|
26
|
+
// replies by `prAuthor` count as the author's answer. A bare array is accepted
|
|
27
|
+
// when the author is unknown; it can then only yield fixed (outdated) threads.
|
|
28
|
+
// When title is absent, the first nonempty comment line supplies the title.
|
|
29
|
+
|
|
30
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
31
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
32
|
+
import { homedir } from 'node:os';
|
|
33
|
+
import { dirname, join } from 'node:path';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
|
|
36
|
+
export const RETHINK_CYCLE = 4;
|
|
37
|
+
const INTENT = /\b(intentional(ly)?|by design|on purpose|deliberate(ly)?|as designed|expected behaviou?r)\b/i;
|
|
38
|
+
const DISMISS = /\b(won'?t fix|wontfix|not a (bug|problem|concern)|false positive|disagree|n\/a|not applicable|out of scope|ignor(e|ing)|nit)\b/i;
|
|
39
|
+
|
|
40
|
+
export function titleTokens(title) {
|
|
41
|
+
return (String(title || '').toLowerCase().match(/[a-z0-9]+/g) || []).filter((w) => w.length > 2);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function sameIssue(a, b) {
|
|
45
|
+
const normalize = (title) => String(title || '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
46
|
+
const title = normalize(a.title);
|
|
47
|
+
return title !== '' && title === normalize(b.title);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function sameFinding(a, b) {
|
|
51
|
+
// Status reuse requires the same anchor and title; word overlap is only a
|
|
52
|
+
// class signal, never proof that two findings identify the same issue.
|
|
53
|
+
return Boolean(a.file) && a.file === b.file && a.line === b.line && sameIssue(a, b);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Group key for "the same kind of finding came back again".
|
|
57
|
+
export function findingClass(f) {
|
|
58
|
+
return f.bugClass || titleTokens(f.title).slice(0, 4).sort().join('-') || 'unclassified';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const NEGATOR = /\b(not|no|never|nor|isn'?t|aren'?t|wasn'?t|don'?t|doesn'?t|won'?t|cannot)\b/i;
|
|
62
|
+
|
|
63
|
+
// Check only the three words before each keyword in its clause for negation.
|
|
64
|
+
function mentions(pattern, text) {
|
|
65
|
+
return [...text.matchAll(new RegExp(pattern.source, 'gi'))]
|
|
66
|
+
.some((match) => {
|
|
67
|
+
const before = text.slice(0, match.index).split(/[.!?;:,\n-]/).pop();
|
|
68
|
+
return !NEGATOR.test(before.trim().split(/\s+/).slice(-3).join(' '));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function classifyThread(thread, prAuthor) {
|
|
73
|
+
const authorReplies = (thread.replies || []).filter((r) => r.author && r.author === prAuthor);
|
|
74
|
+
const text = authorReplies.map((r) => r.body).join('\n');
|
|
75
|
+
if (thread.isOutdated) return 'fixed';
|
|
76
|
+
if (mentions(INTENT, text)) return 'intentional';
|
|
77
|
+
if (mentions(DISMISS, text)) return 'rejected';
|
|
78
|
+
if (thread.isResolved && prAuthor) return 'rejected';
|
|
79
|
+
return 'open';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function applyThreads(findings, threads, prAuthor) {
|
|
83
|
+
return findings.map((f) => {
|
|
84
|
+
const thread = threads.find((t) => {
|
|
85
|
+
const title = t.title?.trim() || String(t.body || '').split(/\r?\n/).find((line) => line.trim())
|
|
86
|
+
?.trim().replace(/^#{1,6}\s+/, '').replace(/^(\*\*|__)(.*?)\1[.:]?$/, '$2');
|
|
87
|
+
return sameFinding(f, { file: t.path, line: t.line, title });
|
|
88
|
+
});
|
|
89
|
+
if (!thread) return f;
|
|
90
|
+
const status = classifyThread(thread, prAuthor);
|
|
91
|
+
return f.status === status ? f : { ...f, status };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function mergeFindings(prior, found, cycle) {
|
|
96
|
+
const added = found
|
|
97
|
+
.filter((f) => !prior.some((p) => sameFinding(p, f)))
|
|
98
|
+
.map((f) => ({ file: f.file, line: f.line, title: f.title, class: findingClass(f), status: 'open', cycles: [cycle] }));
|
|
99
|
+
const seen = prior.map((p) => (found.some((f) => sameFinding(p, f)) && !p.cycles.includes(cycle)
|
|
100
|
+
? { ...p, cycles: [...p.cycles, cycle] }
|
|
101
|
+
: p));
|
|
102
|
+
return seen.concat(added);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function recurringClasses(findings) {
|
|
106
|
+
const byClass = findings.reduce((acc, f) => {
|
|
107
|
+
const cycles = new Set([...(acc[f.class] || []), ...f.cycles]);
|
|
108
|
+
return { ...acc, [f.class]: [...cycles] };
|
|
109
|
+
}, {});
|
|
110
|
+
return Object.entries(byClass)
|
|
111
|
+
.filter(([, cycles]) => cycles.length >= 2)
|
|
112
|
+
.map(([cls, cycles]) => ({ class: cls, cycles: cycles.length }))
|
|
113
|
+
.sort((a, b) => b.cycles - a.cycles);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function suppressedFindings(findings) {
|
|
117
|
+
return findings
|
|
118
|
+
.filter((f) => f.status === 'rejected' || f.status === 'intentional')
|
|
119
|
+
.map(({ file, line, title, status }) => ({ file, line, title, status }));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// A run id makes begin idempotent: a retry of the same run reuses its cycle instead of inflating the count.
|
|
123
|
+
export function begin(state, threads, prAuthor, run = null) {
|
|
124
|
+
const findings = applyThreads(state.findings, threads, prAuthor);
|
|
125
|
+
const known = run === null ? undefined : state.runs?.[run];
|
|
126
|
+
const cycle = known ?? state.cycle + 1;
|
|
127
|
+
const runs = run === null || known !== undefined ? state.runs : { ...state.runs, [run]: cycle };
|
|
128
|
+
return {
|
|
129
|
+
state: { ...state, cycle: Math.max(state.cycle, cycle), findings, ...(runs && { runs }) },
|
|
130
|
+
summary: {
|
|
131
|
+
cycle,
|
|
132
|
+
rethink: cycle >= RETHINK_CYCLE,
|
|
133
|
+
suppressed: suppressedFindings(findings),
|
|
134
|
+
recurringClasses: recurringClasses(findings),
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const STATE_DIR = process.env.LEKKER_STATE_DIR || join(homedir(), '.local', 'state', 'lekker-review');
|
|
140
|
+
// Hash the tuple so punctuation, case and repo/target boundaries stay distinct.
|
|
141
|
+
const statePath = (repo, target) => join(STATE_DIR, 'findings',
|
|
142
|
+
`${createHash('sha256').update(JSON.stringify([repo, target])).digest('hex')}.json`);
|
|
143
|
+
|
|
144
|
+
function load(path) {
|
|
145
|
+
let raw;
|
|
146
|
+
try {
|
|
147
|
+
raw = readFileSync(path, 'utf8');
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error.code === 'ENOENT') return { cycle: 0, findings: [] };
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
const state = JSON.parse(raw);
|
|
153
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)
|
|
154
|
+
|| !Number.isSafeInteger(state.cycle) || state.cycle < 0 || !Array.isArray(state.findings)) {
|
|
155
|
+
throw new Error(`Invalid finding state: ${path}`);
|
|
156
|
+
}
|
|
157
|
+
if (state.runs !== undefined && (!state.runs || typeof state.runs !== 'object' || Array.isArray(state.runs)
|
|
158
|
+
|| !Object.values(state.runs).every((c) => Number.isSafeInteger(c) && c >= 1))) {
|
|
159
|
+
throw new Error(`Invalid finding state runs: ${path}`);
|
|
160
|
+
}
|
|
161
|
+
return state;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function save(path, state) {
|
|
165
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
166
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
167
|
+
try {
|
|
168
|
+
writeFileSync(temporary, JSON.stringify(state, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
169
|
+
renameSync(temporary, path);
|
|
170
|
+
} finally {
|
|
171
|
+
rmSync(temporary, { force: true });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function main(argv) {
|
|
176
|
+
const flag = (name) => { const i = argv.indexOf('--' + name); return i === -1 ? null : argv[i + 1]; };
|
|
177
|
+
const repo = flag('repo');
|
|
178
|
+
const target = flag('target');
|
|
179
|
+
if (!repo || !target) { console.log(JSON.stringify({ error: '--repo and --target are required' })); process.exit(2); }
|
|
180
|
+
const path = statePath(repo, target);
|
|
181
|
+
const state = load(path);
|
|
182
|
+
const cmd = argv[0];
|
|
183
|
+
if (cmd === 'begin') {
|
|
184
|
+
const raw = flag('threads') ? JSON.parse(readFileSync(flag('threads'), 'utf8')) : [];
|
|
185
|
+
const threads = Array.isArray(raw) ? raw : raw.threads || [];
|
|
186
|
+
const prAuthor = Array.isArray(raw) ? null : raw.prAuthor || null;
|
|
187
|
+
const next = begin(state, threads, prAuthor, flag('run') ?? null);
|
|
188
|
+
save(path, next.state);
|
|
189
|
+
console.log(JSON.stringify(next.summary));
|
|
190
|
+
} else if (cmd === 'record') {
|
|
191
|
+
const raw = JSON.parse(readFileSync(flag('findings'), 'utf8'));
|
|
192
|
+
const found = Array.isArray(raw) ? raw : raw.findings || [];
|
|
193
|
+
const run = flag('run') ?? null;
|
|
194
|
+
// A late record for an earlier run keeps that run's cycle; the current cycle stays as it was.
|
|
195
|
+
const cycle = run === null ? Math.max(state.cycle, 1) : state.runs?.[run];
|
|
196
|
+
if (cycle === undefined) { console.log(JSON.stringify({ error: `unknown --run ${run}; call begin --run first` })); process.exit(2); }
|
|
197
|
+
save(path, { ...state, cycle: Math.max(state.cycle, 1), findings: mergeFindings(state.findings, found, cycle) });
|
|
198
|
+
console.log(JSON.stringify({ recorded: found.length, cycle }));
|
|
199
|
+
} else if (cmd === 'show') {
|
|
200
|
+
console.log(JSON.stringify(state, null, 2));
|
|
201
|
+
} else {
|
|
202
|
+
console.log(JSON.stringify({ error: 'usage: begin|record|show' }));
|
|
203
|
+
process.exit(2);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) main(process.argv.slice(2));
|
|
@@ -53,13 +53,19 @@ const preamble = [
|
|
|
53
53
|
// hardRuleCorroborated is likewise gone from the current file and kept in this
|
|
54
54
|
// list for the same reason: an older workflow.js calls it from isHardRule.
|
|
55
55
|
...['titleTokens', 'sameIssue', 'nearbyLines', 'spanWithinWindow', 'hardRuleCorroborated',
|
|
56
|
-
'isHardRule', 'longest', 'mergeFindings', 'dedup', 'shouldVerify'].map(n => {
|
|
57
|
-
|
|
56
|
+
'isHardRule', 'longest', 'mergeFindings', 'dedup', 'shouldVerify', 'isProveAttempt', 'countProveAttempt'].map(n => {
|
|
57
|
+
// countProveAttempt is called directly by the checks, so its stub returns NaN and the
|
|
58
|
+
// checks FAIL with a diff instead of the run crashing on an older workflow.js.
|
|
59
|
+
try { return lift(n) } catch {
|
|
60
|
+
return n === 'countProveAttempt'
|
|
61
|
+
? 'function countProveAttempt() { return NaN }'
|
|
62
|
+
: `function ${n}() { throw new Error('${n} absent from this workflow.js') }`
|
|
63
|
+
}
|
|
58
64
|
}),
|
|
59
65
|
].join('\n')
|
|
60
66
|
|
|
61
|
-
const { dedup, isHardRule, shouldVerify, sameIssue } =
|
|
62
|
-
new Function(preamble + '\nreturn { dedup, isHardRule, shouldVerify, sameIssue }')()
|
|
67
|
+
const { dedup, isHardRule, shouldVerify, sameIssue, isProveAttempt, countProveAttempt } =
|
|
68
|
+
new Function(preamble + '\nreturn { dedup, isHardRule, shouldVerify, sameIssue, isProveAttempt, countProveAttempt }')()
|
|
63
69
|
|
|
64
70
|
let failed = 0
|
|
65
71
|
function check(name, actual, expected) {
|
|
@@ -676,5 +682,20 @@ console.log('\nprove: skipped without an installed test runner')
|
|
|
676
682
|
rmSync(tmp, { recursive: true, force: true })
|
|
677
683
|
}
|
|
678
684
|
|
|
685
|
+
console.log('\nprove ledger: a declined prover is a skip, not an attempt')
|
|
686
|
+
// Observed: 6 critical findings, every prover answered attempted:false (no node_modules),
|
|
687
|
+
// and the ledger recorded proven 0/6 as if six tests had run and not failed.
|
|
688
|
+
check('not_attempted is not an attempt', isProveAttempt({ attempted: false, proven: false, outcome: 'not_attempted' }), false)
|
|
689
|
+
check('inconclusive run is an attempt', isProveAttempt({ attempted: true, proven: false, outcome: 'inconclusive' }), true)
|
|
690
|
+
check('proven run is an attempt', isProveAttempt({ attempted: true, proven: true, outcome: 'proven' }), true)
|
|
691
|
+
// The counter path itself, not just the predicate: a declined prover must leave the count unchanged.
|
|
692
|
+
check('ledger: null proof (prover failed or rejected) adds nothing', countProveAttempt(0, null), 0)
|
|
693
|
+
check('ledger: declined prover adds nothing', countProveAttempt(2, { attempted: false, proven: false, outcome: 'not_attempted' }), 2)
|
|
694
|
+
check('ledger: inconclusive prover adds one', countProveAttempt(2, { attempted: true, proven: false, outcome: 'inconclusive' }), 3)
|
|
695
|
+
check('ledger: proven prover adds one', countProveAttempt(2, { attempted: true, proven: true, outcome: 'proven' }), 3)
|
|
696
|
+
// Source guard: a bare increment would bypass the predicate, and no unit check above would notice.
|
|
697
|
+
const bareWrites = src.split('\n').filter(l => /proveAttemptCount\s*(\+\+|\+=|=[^=])/.test(l) && !/countProveAttempt\(|let proveAttemptCount/.test(l))
|
|
698
|
+
check('the attempt count is written only through countProveAttempt', bareWrites, [])
|
|
699
|
+
|
|
679
700
|
console.log(failed === 0 ? '\nall checks passed\n' : `\n${failed} check(s) FAILED\n`)
|
|
680
701
|
process.exit(failed === 0 ? 0 : 1)
|
|
@@ -145,6 +145,8 @@ function isHardRule(finding) {
|
|
|
145
145
|
return typeof finding.rule === 'string' && finding.rule.trim().length > 0
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
const RETHINK_CYCLE = 4
|
|
149
|
+
|
|
148
150
|
const SEVERITY_RANK = { critical: 3, important: 2, observation: 1, idiomatic: 0 }
|
|
149
151
|
|
|
150
152
|
function titleTokens(title) {
|
|
@@ -391,6 +393,19 @@ function normalizeProof(result) {
|
|
|
391
393
|
}
|
|
392
394
|
}
|
|
393
395
|
|
|
396
|
+
// A prover that declines at its testability gate (no runner, not importable)
|
|
397
|
+
// returns outcome 'not_attempted'. That is a skip, not a failed attempt, so it
|
|
398
|
+
// must not inflate the proven/attempts ratio in the run ledger.
|
|
399
|
+
function isProveAttempt(proof) {
|
|
400
|
+
return proof.attempted === true
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// The only writer of the run ledger's attempt count. A null proof means the
|
|
404
|
+
// prover agent failed or was rejected, so there is no evidence a test ran.
|
|
405
|
+
function countProveAttempt(count, proof) {
|
|
406
|
+
return Boolean(proof) && isProveAttempt(proof) ? count + 1 : count
|
|
407
|
+
}
|
|
408
|
+
|
|
394
409
|
// Run thunks in sequential batches, giving the per-finding stages a ceiling.
|
|
395
410
|
//
|
|
396
411
|
// The review stage is bounded by its dimension count, but verify, critic and
|
|
@@ -445,6 +460,9 @@ const {
|
|
|
445
460
|
worktreePath,
|
|
446
461
|
promptDir,
|
|
447
462
|
prevSha,
|
|
463
|
+
cycle,
|
|
464
|
+
suppressed,
|
|
465
|
+
recurringClasses,
|
|
448
466
|
reviewBatchPlan,
|
|
449
467
|
maxConcurrent,
|
|
450
468
|
targetLabel: targetLabelArg,
|
|
@@ -480,6 +498,25 @@ const REVIEW_PLAN = (Array.isArray(reviewBatchPlan) && reviewBatchPlan.length >
|
|
|
480
498
|
const FANOUT_PLAN = [Math.max(1, maxConcurrent || 5)]
|
|
481
499
|
const targetMetadata = JSON.stringify({ targetLabel })
|
|
482
500
|
|
|
501
|
+
// Convergence. `cycle` and `suppressed` come from scripts/finding-state.mjs.
|
|
502
|
+
// From the fourth review of one PR the findings are not the problem, the
|
|
503
|
+
// approach is: report the recurring classes instead of spending another round.
|
|
504
|
+
const SUPPRESSED = Array.isArray(suppressed) ? suppressed : []
|
|
505
|
+
if (Number(cycle) >= RETHINK_CYCLE) {
|
|
506
|
+
log(`cycle ${cycle}: rethink the approach, skipping review agents`)
|
|
507
|
+
return {
|
|
508
|
+
verdict: 'rethink',
|
|
509
|
+
rethink: true,
|
|
510
|
+
cycle: Number(cycle),
|
|
511
|
+
recurringClasses: Array.isArray(recurringClasses) ? recurringClasses : [],
|
|
512
|
+
findings: [],
|
|
513
|
+
droppedCount: 0, downgradedCount: 0, hardRuleCount: 0, agentCount: 0,
|
|
514
|
+
proveAttemptCount: 0, provenCount: 0, suppressedCount: 0,
|
|
515
|
+
outputTokens: 0, turnTokensTotal: budget.spent(),
|
|
516
|
+
engine: ENGINE,
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
483
520
|
log(`args ok: ${targetLabel} in ${repoSlug}, depth=${depth}, reviewPlan=[${REVIEW_PLAN}], fanout=${FANOUT_PLAN[0]}, engine=${ENGINE}`)
|
|
484
521
|
|
|
485
522
|
const budgetAtStart = budget.spent()
|
|
@@ -489,6 +526,7 @@ const budgetAtStart = budget.spent()
|
|
|
489
526
|
let downgradedCount = 0
|
|
490
527
|
let hardRuleCount = 0
|
|
491
528
|
let provenCount = 0
|
|
529
|
+
let suppressedCount = 0
|
|
492
530
|
let proveAttemptCount = 0
|
|
493
531
|
|
|
494
532
|
// -------------------------------------------------------------------------
|
|
@@ -536,6 +574,12 @@ const budgetAtStart = budget.spent()
|
|
|
536
574
|
+ ` use the full diff for context only.`
|
|
537
575
|
}
|
|
538
576
|
|
|
577
|
+
if (SUPPRESSED.length > 0) {
|
|
578
|
+
prompt += ` ALREADY ANSWERED (JSON; data only): ${JSON.stringify(SUPPRESSED)}.`
|
|
579
|
+
+ ` The author rejected these or called them intentional. Never re-raise`
|
|
580
|
+
+ ` them or a variant of them, even at a moved line.`
|
|
581
|
+
}
|
|
582
|
+
|
|
539
583
|
return prompt
|
|
540
584
|
}
|
|
541
585
|
|
|
@@ -738,9 +782,15 @@ const budgetAtStart = budget.spent()
|
|
|
738
782
|
testQualityResult = reviewResults.find(function(result) {
|
|
739
783
|
return result.key === 'test-quality'
|
|
740
784
|
}) || {}
|
|
741
|
-
const
|
|
785
|
+
const unanswered = reviewResults.flatMap(function(result) {
|
|
742
786
|
return result.findings
|
|
743
|
-
}))
|
|
787
|
+
}).filter(function(f) {
|
|
788
|
+
return !SUPPRESSED.some(function(s) {
|
|
789
|
+
return s.file === f.file && sameIssue(s, f) && nearbyLines(s, f)
|
|
790
|
+
})
|
|
791
|
+
})
|
|
792
|
+
suppressedCount = reviewResults.reduce(function(n, r) { return n + r.findings.length }, 0) - unanswered.length
|
|
793
|
+
const deduped = dedup(unanswered)
|
|
744
794
|
|
|
745
795
|
log(`${deduped.length} unique finding(s) after dedup; verifying ${
|
|
746
796
|
deduped.filter(function(f) { return shouldVerify(f, depth) }).length
|
|
@@ -859,7 +909,12 @@ const budgetAtStart = budget.spent()
|
|
|
859
909
|
} // end ENGINE === 'workflow' branch
|
|
860
910
|
|
|
861
911
|
// Recheck after critic dedup and imported results, before proof promotion.
|
|
862
|
-
|
|
912
|
+
// Suppression runs here too, so critic and revmux findings cannot re-raise an answered one.
|
|
913
|
+
const answered = finalFindings.filter(function(f) {
|
|
914
|
+
return SUPPRESSED.some(function(s) { return s.file === f.file && sameIssue(s, f) && nearbyLines(s, f) })
|
|
915
|
+
})
|
|
916
|
+
finalFindings = finalFindings.filter(function(f) { return !answered.includes(f) }).map(enforceBoundary)
|
|
917
|
+
suppressedCount += answered.length
|
|
863
918
|
|
|
864
919
|
// -------------------------------------------------------------------------
|
|
865
920
|
// Prove: for each Critical finding and each Important finding with a
|
|
@@ -909,7 +964,6 @@ const budgetAtStart = budget.spent()
|
|
|
909
964
|
await batched(capped.map(function(finding) {
|
|
910
965
|
return async function() {
|
|
911
966
|
agentCount++
|
|
912
|
-
proveAttemptCount++
|
|
913
967
|
const result = await agent(proverPrompt(finding), {
|
|
914
968
|
label: 'prove:' + finding.file + ':' + finding.line,
|
|
915
969
|
phase: 'Prove',
|
|
@@ -926,6 +980,7 @@ const budgetAtStart = budget.spent()
|
|
|
926
980
|
|
|
927
981
|
const proof = normalizeProof(result)
|
|
928
982
|
finding.proof = proof
|
|
983
|
+
proveAttemptCount = countProveAttempt(proveAttemptCount, proof)
|
|
929
984
|
if (proof.proven === true) {
|
|
930
985
|
provenCount++
|
|
931
986
|
finding.verificationStatus = 'proven'
|
|
@@ -960,6 +1015,7 @@ const budgetAtStart = budget.spent()
|
|
|
960
1015
|
agentCount,
|
|
961
1016
|
proveAttemptCount,
|
|
962
1017
|
provenCount,
|
|
1018
|
+
suppressedCount,
|
|
963
1019
|
acCoverage: implementationResult.acCoverage || null,
|
|
964
1020
|
coverageVerdict: testQualityResult.coverageVerdict || null,
|
|
965
1021
|
mutationSlip: testQualityResult.mutationSlip || null,
|