@olegkoval/agent-skills 1.62.1 → 1.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapters/claude/olko-github-pr/skills/lekker-review/SKILL.md +11 -1
- package/adapters/claude/olko-github-pr/skills/lekker-review/evals/manifest.json +16 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/fixer.md +8 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/artifact-page.md +6 -2
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/codex-workflow.md +14 -2
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/fix-mode.md +13 -2
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/output-format.md +2 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/eval-runner.mjs +93 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/revmux-adapter.mjs +11 -0
- 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 +11 -1
- package/plugins/olko-github-pr/skills/lekker-review/evals/manifest.json +16 -0
- package/plugins/olko-github-pr/skills/lekker-review/fix-workflow.js +115 -10
- package/plugins/olko-github-pr/skills/lekker-review/references/agents/fixer.md +8 -0
- package/plugins/olko-github-pr/skills/lekker-review/references/artifact-page.md +6 -2
- package/plugins/olko-github-pr/skills/lekker-review/references/codex-workflow.md +14 -2
- package/plugins/olko-github-pr/skills/lekker-review/references/fix-mode.md +13 -2
- package/plugins/olko-github-pr/skills/lekker-review/references/output-format.md +2 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/eval-runner.mjs +93 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/revmux-adapter.mjs +11 -0
- package/plugins/olko-github-pr/skills/lekker-review/workflow.js +41 -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
- package/scripts/build-adapters.sh +1 -1
|
@@ -775,7 +775,7 @@ The workflow runs three phases:
|
|
|
775
775
|
carries `proof: {attempted: false, reason: "no runnable test runner: ..."}`.
|
|
776
776
|
|
|
777
777
|
Findings have schema:
|
|
778
|
-
`{file, line, severity, title, description, badCode, fix, rule?, precedent?, agreedBy?, verificationStatus?, verifierReasoning?, proof?}`
|
|
778
|
+
`{file, line, severity, title, description, badCode, fix, bugClass, otherInstances, rule?, precedent?, agreedBy?, verificationStatus?, verifierReasoning?, proof?}`
|
|
779
779
|
`badCode` and `fix` are schema-required: an empty string is allowed only on
|
|
780
780
|
`observation` / `idiomatic` findings.
|
|
781
781
|
|
|
@@ -1325,6 +1325,16 @@ is not failure, the review may find things the bots never would. `0 bot
|
|
|
1325
1325
|
comment(s)` is ambiguous (nothing found, or the bots have not run yet); never
|
|
1326
1326
|
turn it into a 100% claim. Store: `~/.cache/lekker-review/bot-precision.jsonl`.
|
|
1327
1327
|
|
|
1328
|
+
**Eval harness.** `evals/manifest.json` lists PRs with a known outcome (`expect:
|
|
1329
|
+
"buggy"` with `knownBug: "file:line description"`, or `"clean"`). Run lekker on
|
|
1330
|
+
each, save the `findings` arrays as `{"<repo>#<pr>": [...]}`, then
|
|
1331
|
+
`node "${SKILL_ROOT}/scripts/eval-runner.mjs" --manifest "${SKILL_ROOT}/evals/manifest.json" --results <results.json>`
|
|
1332
|
+
prints recall on buggy PRs and the false-positive count (critical or important
|
|
1333
|
+
findings) on clean ones. The shipped manifest holds two placeholders; choosing
|
|
1334
|
+
real PRs is a human job. A hit is location-only (same file, within 10 lines),
|
|
1335
|
+
so a nearby finding about a different problem still counts; read recall as an
|
|
1336
|
+
upper bound.
|
|
1337
|
+
|
|
1328
1338
|
If you maintain a persistent notes/memory system across reviews, launch ONE
|
|
1329
1339
|
background Agent with `model: 'haiku'`, passing the review file path +
|
|
1330
1340
|
repo-short-name + PR author login, instructed to follow
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"note": "PLACEHOLDERS ONLY. Replace with real PRs chosen by a human: known-buggy PRs with the bug located, and clean PRs that merged without follow-up fixes.",
|
|
3
|
+
"prs": [
|
|
4
|
+
{
|
|
5
|
+
"repo": "example-org/example-repo",
|
|
6
|
+
"pr": 1,
|
|
7
|
+
"expect": "buggy",
|
|
8
|
+
"knownBug": "src/orders/sync.ts:42 off-by-one drops the last line item"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"repo": "example-org/example-repo",
|
|
12
|
+
"pr": 2,
|
|
13
|
+
"expect": "clean"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -74,6 +74,14 @@ outcome; a half-applied fix that breaks callers is the worst outcome.
|
|
|
74
74
|
|
|
75
75
|
---
|
|
76
76
|
|
|
77
|
+
## Same-class instances
|
|
78
|
+
|
|
79
|
+
A finding with `instanceOf` is another occurrence of a bug class already
|
|
80
|
+
reported elsewhere. Treat it like any finding: confirm the pattern is really
|
|
81
|
+
there at that spot, apply the same fix adapted to local names and types, and
|
|
82
|
+
skip it (with the reason) when the code differs enough that the fix does not
|
|
83
|
+
carry over. Its `title` is its own join key.
|
|
84
|
+
|
|
77
85
|
## Multiple findings in one file
|
|
78
86
|
|
|
79
87
|
Apply them in file order, top to bottom, re-reading after each edit so later
|
|
@@ -18,7 +18,8 @@ without creating a new artifact identity.
|
|
|
18
18
|
All of the following are already in hand after Step 3 of SKILL.md:
|
|
19
19
|
|
|
20
20
|
- `findings.json` path (scratchpad) -- each finding carries: `file`, `line`,
|
|
21
|
-
`severity`, `title`, `description`, `badCode`, `fix`, `
|
|
21
|
+
`severity`, `title`, `description`, `badCode`, `fix`, `bugClass`,
|
|
22
|
+
`otherInstances`, `rule?`, `precedent?`,
|
|
22
23
|
`agreedBy?`, `verifierReasoning?`, `proof?` (proof = `{attempted, proven,
|
|
23
24
|
outcome, reason, testCode?, testCommand?, redOutput?}`).
|
|
24
25
|
- The saved review file path: `~/code-reviews/YYYY-MM-DD-pr-N-repo.md`.
|
|
@@ -154,7 +155,10 @@ Hard requirements for the HTML page:
|
|
|
154
155
|
- **Findings**, grouped Critical → Important → Observations → Idiomatic. Each
|
|
155
156
|
finding is an expandable `<details>` block:
|
|
156
157
|
- summary row: severity dot + title + `file:line`
|
|
157
|
-
- body: description, `badCode` in a `<pre>`, `fix` in a `<pre>`, a
|
|
158
|
+
- body: description, `badCode` in a `<pre>`, `fix` in a `<pre>`, a
|
|
159
|
+
"Bug class: `bugClass`" line followed by "Also at:" and each
|
|
160
|
+
`otherInstances` entry as a `file:line` chip (omit "Also at:" when the
|
|
161
|
+
list is empty), a small
|
|
158
162
|
hard-rule chip (e.g. "TS-1 · hard rule") when `rule` is set, `agreedBy`
|
|
159
163
|
chips when 2+ dimensions agreed, `verifierReasoning` as a muted footnote.
|
|
160
164
|
- **Proof block:** when `proof.proven === true`, render a visually distinct
|
|
@@ -43,12 +43,17 @@ path. The file is an object with a required `findings` array. Each finding is:
|
|
|
43
43
|
"description": "failure mode and reachability",
|
|
44
44
|
"badCode": "verbatim changed code",
|
|
45
45
|
"fix": "minimal correction",
|
|
46
|
+
"bugClass": "kebab-case-defect-class",
|
|
47
|
+
"otherInstances": ["other/file.ts:17"],
|
|
46
48
|
"rule": "optional house-rule tag",
|
|
47
49
|
"precedent": "required for idiomatic findings"
|
|
48
50
|
}
|
|
49
51
|
```
|
|
50
52
|
|
|
51
53
|
`badCode` and `fix` may be empty only for Observation or Idiomatic findings.
|
|
54
|
+
`bugClass` and `otherInstances` are required: after the first hit, search the
|
|
55
|
+
whole diff for the same pattern and list every other place as a
|
|
56
|
+
repository-relative `file:line` (empty array when there is none).
|
|
52
57
|
Implementation additionally returns `acCoverage`. Test-quality additionally
|
|
53
58
|
returns `coverageVerdict`, `mutationSlip`, and `mockSmells`.
|
|
54
59
|
|
|
@@ -95,6 +100,8 @@ findings before spending verifier agents:
|
|
|
95
100
|
- candidates must name the same file;
|
|
96
101
|
- their anchor lines must fit within a 30-line total span;
|
|
97
102
|
- normalized title-token Jaccard similarity must be at least 0.4;
|
|
103
|
+
- findings whose `bugClass` values differ never merge; merged findings union
|
|
104
|
+
their `otherInstances`;
|
|
98
105
|
- keep the highest severity and longest `description`, `badCode`, `fix`, and
|
|
99
106
|
`precedent` values;
|
|
100
107
|
- union contributing dimensions into `agreedBy`;
|
|
@@ -202,8 +209,13 @@ to measurements the host actually exposes.
|
|
|
202
209
|
Follow `references/fix-mode.md` for selection, preconditions, verification,
|
|
203
210
|
commit, landing/push confirmation, and cleanup. Replace only its Workflow call:
|
|
204
211
|
|
|
205
|
-
1.
|
|
206
|
-
|
|
212
|
+
1. Expand `otherInstances` first, as `fix-workflow.js` does: each entry that is
|
|
213
|
+
a validated repository-relative path (rules in item 5) naming a file changed
|
|
214
|
+
in the diff becomes a sibling finding with `instanceOf` set to the source
|
|
215
|
+
title, unless the same bug class already claims that `file:line`. Drop every
|
|
216
|
+
other entry and report it. Then group primary and sibling findings by
|
|
217
|
+
normalized file. Resolve cross-file dependencies before spawning;
|
|
218
|
+
overlapping groups must be combined.
|
|
207
219
|
2. Spawn one `worker` fixer per disjoint group. Assign exact file ownership and
|
|
208
220
|
state that other agents share the worktree; it must preserve their changes.
|
|
209
221
|
The fixer reads `<SKILL_ROOT>/references/agents/fixer.md`, edits only its
|
|
@@ -101,6 +101,13 @@ downgrades a `good` verdict that arrives without verified evidence. Pass the ACs
|
|
|
101
101
|
even when they look irrelevant to the finding: the finding's own rationale may
|
|
102
102
|
be the thing that contradicts them.
|
|
103
103
|
|
|
104
|
+
A finding may carry `bugClass` and `otherInstances` (`file:line` list). The
|
|
105
|
+
workflow expands each other instance into a sibling finding (`instanceOf` set)
|
|
106
|
+
in its own file's group, so one pass fixes the whole class. An instance outside
|
|
107
|
+
the diff's changed files, in a generated file (Step 2 rule), or with an absolute
|
|
108
|
+
or `..` path, is dropped. Report siblings in
|
|
109
|
+
the Step 8 table under their own title.
|
|
110
|
+
|
|
104
111
|
Pass `findings` as a real JSON array, not a stringified one. The workflow groups
|
|
105
112
|
by file (one agent per file, so no two agents ever edit the same file), applies
|
|
106
113
|
the fix, then runs a read-only fix-verifier over the actual `git diff`. A
|
|
@@ -115,8 +122,12 @@ the whole turn's pool. Report the former on the `Fix agents:` cost line.
|
|
|
115
122
|
|
|
116
123
|
If Claude's Workflow tool is unavailable, fall back to one Agent per file group
|
|
117
124
|
with `<SKILL_ROOT>/references/agents/fixer.md`, then one read-only Agent per
|
|
118
|
-
group with `<SKILL_ROOT>/references/agents/fix-verifier.md`.
|
|
119
|
-
|
|
125
|
+
group with `<SKILL_ROOT>/references/agents/fix-verifier.md`. Expand
|
|
126
|
+
`otherInstances` into sibling findings before grouping, with the same rules as
|
|
127
|
+
the workflow: only repository-relative paths (no leading `/`, drive letter,
|
|
128
|
+
backslash or `..`) that name a file changed in the diff, normalized so
|
|
129
|
+
`./src/a.ts` and `src/a.ts` form one group. Same rules, same verdict handling.
|
|
130
|
+
State the fallback in the report.
|
|
120
131
|
|
|
121
132
|
---
|
|
122
133
|
|
|
@@ -118,6 +118,8 @@ proof inline.>
|
|
|
118
118
|
<corrected version: exact drop-in replacement or minimal diff>
|
|
119
119
|
\```
|
|
120
120
|
|
|
121
|
+
**Bug class:** `<bugClass>` *(always shown)* · **Also at:** `<file:line>`, `<file:line>` *(from `otherInstances`; omit the clause when the list is empty)*
|
|
122
|
+
|
|
121
123
|
**Proof (ran in the worktree):** *(include only when the finding carries a `proof` with `proven: true`)*
|
|
122
124
|
\```
|
|
123
125
|
<redOutput, verbatim>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Score lekker findings against a manifest of PRs with known outcomes.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// node eval-runner.mjs --manifest evals/manifest.json --results <results.json> [--json]
|
|
6
|
+
//
|
|
7
|
+
// manifest.json: { prs: [{ repo, pr, expect: "buggy"|"clean", knownBug?: "file:line + description" }] }
|
|
8
|
+
// results.json: { "<repo>#<pr>": [ <finding>, ... ] } the findings of one lekker run per PR
|
|
9
|
+
// (the `findings` array the workflow returns). A PR missing from results is
|
|
10
|
+
// reported as unscored, never as zero findings.
|
|
11
|
+
//
|
|
12
|
+
// A buggy PR is HIT when some finding is on the knownBug file within LINE_WINDOW lines
|
|
13
|
+
// of the knownBug line. Known limitation: the match is by location only, so a
|
|
14
|
+
// same-file finding near the line counts even if it describes a different problem;
|
|
15
|
+
// judging "same bug" needs an LLM or a description match and is not attempted. Recall = hits / scored buggy PRs. On a clean PR every
|
|
16
|
+
// verdict-affecting finding (critical or important) is a false positive.
|
|
17
|
+
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
export const LINE_WINDOW = 10;
|
|
22
|
+
const BLOCKING = new Set(['critical', 'important']);
|
|
23
|
+
|
|
24
|
+
export function parseKnownBug(text) {
|
|
25
|
+
// The file is everything before the last ":<digits>" that is followed by whitespace
|
|
26
|
+
// or the end, so paths containing spaces parse.
|
|
27
|
+
const m = /^(.+):(\d+)(?=\s|$)/.exec(String(text || '').trim());
|
|
28
|
+
if (!m) return null;
|
|
29
|
+
const line = Number(m[2]);
|
|
30
|
+
return line >= 1 ? { file: m[1], line } : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isHit(findings, knownBug) {
|
|
34
|
+
const spot = parseKnownBug(knownBug);
|
|
35
|
+
if (!spot) return false;
|
|
36
|
+
return findings.some((f) => f.file === spot.file
|
|
37
|
+
&& Number.isInteger(f.line) && f.line > 0
|
|
38
|
+
&& Math.abs(f.line - spot.line) <= LINE_WINDOW);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function validateManifest(manifest) {
|
|
42
|
+
const prs = manifest && Array.isArray(manifest.prs) ? manifest.prs : null;
|
|
43
|
+
if (!prs) throw new Error('manifest needs a "prs" array');
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
prs.forEach((e, i) => {
|
|
46
|
+
if (!e.repo || !Number.isInteger(e.pr)) throw new Error(`prs[${i}]: repo and integer pr are required`);
|
|
47
|
+
if (e.expect !== 'buggy' && e.expect !== 'clean') throw new Error(`prs[${i}]: expect must be "buggy" or "clean"`);
|
|
48
|
+
const key = `${e.repo}#${e.pr}`;
|
|
49
|
+
if (seen.has(key)) throw new Error(`prs[${i}]: duplicate entry ${key}`);
|
|
50
|
+
seen.add(key);
|
|
51
|
+
if (e.expect === 'buggy' && !parseKnownBug(e.knownBug)) {
|
|
52
|
+
throw new Error(`prs[${i}]: a buggy entry needs knownBug starting with "file:line" (line >= 1)`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
return prs;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function score(manifest, results) {
|
|
59
|
+
const rows = validateManifest(manifest).map((e) => {
|
|
60
|
+
const key = `${e.repo}#${e.pr}`;
|
|
61
|
+
const findings = results[key];
|
|
62
|
+
if (!Array.isArray(findings)) return { key, expect: e.expect, scored: false };
|
|
63
|
+
if (e.expect === 'buggy') return { key, expect: 'buggy', scored: true, hit: isHit(findings, e.knownBug) };
|
|
64
|
+
return {
|
|
65
|
+
key, expect: 'clean', scored: true,
|
|
66
|
+
falsePositives: findings.filter((f) => BLOCKING.has(f.severity)).length,
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
const buggy = rows.filter((r) => r.expect === 'buggy' && r.scored);
|
|
70
|
+
const clean = rows.filter((r) => r.expect === 'clean' && r.scored);
|
|
71
|
+
return {
|
|
72
|
+
recall: buggy.length === 0 ? null : buggy.filter((r) => r.hit).length / buggy.length,
|
|
73
|
+
buggyScored: buggy.length,
|
|
74
|
+
falsePositives: clean.reduce((n, r) => n + r.falsePositives, 0),
|
|
75
|
+
cleanScored: clean.length,
|
|
76
|
+
unscored: rows.filter((r) => !r.scored).map((r) => r.key),
|
|
77
|
+
rows,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function main(argv) {
|
|
82
|
+
const flag = (n) => { const i = argv.indexOf('--' + n); return i === -1 ? null : argv[i + 1]; };
|
|
83
|
+
if (!flag('manifest') || !flag('results')) {
|
|
84
|
+
console.error('usage: eval-runner.mjs --manifest <manifest.json> --results <results.json> [--json]');
|
|
85
|
+
process.exit(2);
|
|
86
|
+
}
|
|
87
|
+
const out = score(JSON.parse(readFileSync(flag('manifest'), 'utf8')), JSON.parse(readFileSync(flag('results'), 'utf8')));
|
|
88
|
+
if (argv.includes('--json')) { console.log(JSON.stringify(out)); return; }
|
|
89
|
+
const pct = out.recall === null ? 'n/a' : `${Math.round(out.recall * 100)}%`;
|
|
90
|
+
console.log(`recall ${pct} (${out.buggyScored} buggy scored), false positives ${out.falsePositives} (${out.cleanScored} clean scored), unscored ${out.unscored.length}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) main(process.argv.slice(2));
|
|
@@ -97,6 +97,13 @@ function extractRule(title) {
|
|
|
97
97
|
return m && HARD_RULES.has(m[1]) ? m[1] : undefined
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// revmux reports one location per finding and no class, so the class is derived
|
|
101
|
+
// from the rule tag or title and no other instances are claimed.
|
|
102
|
+
function bugClassOf(rule, title) {
|
|
103
|
+
const words = String(rule || title || '').toLowerCase().match(/[a-z0-9]+/g) || []
|
|
104
|
+
return words.slice(0, 6).join('-') || 'unclassified'
|
|
105
|
+
}
|
|
106
|
+
|
|
100
107
|
function mapSeverity(f) {
|
|
101
108
|
if (f.severity === 'critical') return 'critical'
|
|
102
109
|
if (f.severity === 'major') return 'important'
|
|
@@ -131,6 +138,8 @@ for (const f of report.findings || []) {
|
|
|
131
138
|
description: f.body,
|
|
132
139
|
badCode,
|
|
133
140
|
fix: f.fix,
|
|
141
|
+
bugClass: bugClassOf(rule, f.title),
|
|
142
|
+
otherInstances: [],
|
|
134
143
|
confidence: f.confidence,
|
|
135
144
|
rule,
|
|
136
145
|
dimension: f.lenses || [],
|
|
@@ -171,6 +180,8 @@ for (const pe of report.pre_existing || []) {
|
|
|
171
180
|
description: `Pre-existing: ${pe.body}`,
|
|
172
181
|
badCode: extractBadCode(pe.body),
|
|
173
182
|
fix: '',
|
|
183
|
+
bugClass: bugClassOf(null, pe.title),
|
|
184
|
+
otherInstances: [],
|
|
174
185
|
dimension: pe.lenses || [],
|
|
175
186
|
})
|
|
176
187
|
}
|
package/package.json
CHANGED
|
@@ -774,7 +774,7 @@ The workflow runs three phases:
|
|
|
774
774
|
carries `proof: {attempted: false, reason: "no runnable test runner: ..."}`.
|
|
775
775
|
|
|
776
776
|
Findings have schema:
|
|
777
|
-
`{file, line, severity, title, description, badCode, fix, rule?, precedent?, agreedBy?, verificationStatus?, verifierReasoning?, proof?}`
|
|
777
|
+
`{file, line, severity, title, description, badCode, fix, bugClass, otherInstances, rule?, precedent?, agreedBy?, verificationStatus?, verifierReasoning?, proof?}`
|
|
778
778
|
`badCode` and `fix` are schema-required: an empty string is allowed only on
|
|
779
779
|
`observation` / `idiomatic` findings.
|
|
780
780
|
|
|
@@ -1324,6 +1324,16 @@ is not failure, the review may find things the bots never would. `0 bot
|
|
|
1324
1324
|
comment(s)` is ambiguous (nothing found, or the bots have not run yet); never
|
|
1325
1325
|
turn it into a 100% claim. Store: `~/.cache/lekker-review/bot-precision.jsonl`.
|
|
1326
1326
|
|
|
1327
|
+
**Eval harness.** `evals/manifest.json` lists PRs with a known outcome (`expect:
|
|
1328
|
+
"buggy"` with `knownBug: "file:line description"`, or `"clean"`). Run lekker on
|
|
1329
|
+
each, save the `findings` arrays as `{"<repo>#<pr>": [...]}`, then
|
|
1330
|
+
`node "${SKILL_ROOT}/scripts/eval-runner.mjs" --manifest "${SKILL_ROOT}/evals/manifest.json" --results <results.json>`
|
|
1331
|
+
prints recall on buggy PRs and the false-positive count (critical or important
|
|
1332
|
+
findings) on clean ones. The shipped manifest holds two placeholders; choosing
|
|
1333
|
+
real PRs is a human job. A hit is location-only (same file, within 10 lines),
|
|
1334
|
+
so a nearby finding about a different problem still counts; read recall as an
|
|
1335
|
+
upper bound.
|
|
1336
|
+
|
|
1327
1337
|
If you maintain a persistent notes/memory system across reviews, launch ONE
|
|
1328
1338
|
background Agent with `model: 'haiku'`, passing the review file path +
|
|
1329
1339
|
repo-short-name + PR author login, instructed to follow
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"note": "PLACEHOLDERS ONLY. Replace with real PRs chosen by a human: known-buggy PRs with the bug located, and clean PRs that merged without follow-up fixes.",
|
|
3
|
+
"prs": [
|
|
4
|
+
{
|
|
5
|
+
"repo": "example-org/example-repo",
|
|
6
|
+
"pr": 1,
|
|
7
|
+
"expect": "buggy",
|
|
8
|
+
"knownBug": "src/orders/sync.ts:42 off-by-one drops the last line item"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"repo": "example-org/example-repo",
|
|
12
|
+
"pr": 2,
|
|
13
|
+
"expect": "clean"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -97,19 +97,124 @@ let retryCount = 0
|
|
|
97
97
|
// same file concurrently.
|
|
98
98
|
// ---------------------------------------------------------------------------
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
100
|
+
// Paths come from model output, so they are untrusted. Accept only a lexically
|
|
101
|
+
// normalized repository-relative path; './src/a.ts' and 'src/a.ts' must land in
|
|
102
|
+
// one file group, or two fixers edit the same file concurrently.
|
|
103
|
+
function safeRelativePath(raw) {
|
|
104
|
+
const text = String(raw == null ? '' : raw)
|
|
105
|
+
if (!text || /[\\\u0000-\u001f]/.test(text) || text.startsWith('/') || /^[A-Za-z]:/.test(text)) {
|
|
106
|
+
return null
|
|
104
107
|
}
|
|
105
|
-
|
|
108
|
+
const parts = text.split('/').filter(function(part) { return part !== '' && part !== '.' })
|
|
109
|
+
if (parts.length === 0 || parts.includes('..')) {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
return parts.join('/')
|
|
106
113
|
}
|
|
107
114
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
115
|
+
function changedFilesFromDiff(diffText) {
|
|
116
|
+
const files = new Set()
|
|
117
|
+
for (const match of String(diffText || '').matchAll(/^\+\+\+ b\/(.+)$/gm)) {
|
|
118
|
+
const file = safeRelativePath(match[1])
|
|
119
|
+
if (file) {
|
|
120
|
+
files.add(file)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return files
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A finding names its bug class and the other places the same pattern occurs.
|
|
127
|
+
// Each other instance becomes a sibling finding in ITS file's group, so the whole
|
|
128
|
+
// class is fixed in one pass and the one-agent-per-file rule still holds.
|
|
129
|
+
function parseInstance(ref) {
|
|
130
|
+
const at = String(ref).lastIndexOf(':')
|
|
131
|
+
const line = Number(String(ref).slice(at + 1))
|
|
132
|
+
const file = at > 0 ? safeRelativePath(String(ref).slice(0, at)) : null
|
|
133
|
+
return file && Number.isInteger(line) && line > 0 ? { file, line } : null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Step 2 of fix-mode.md skips generated primaries before the workflow runs, but
|
|
137
|
+
// siblings are created here, after that filter, so the same rule applies again.
|
|
138
|
+
function isGeneratedPath(file) {
|
|
139
|
+
return /(^|\/)generated\//.test(file)
|
|
140
|
+
|| /\.snap$/.test(file)
|
|
141
|
+
|| /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?)$/.test(file)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function classKey(f, file, line) {
|
|
145
|
+
return (f.bugClass || f.title) + '|' + file + ':' + line
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A sibling must point at a file the diff changed: an instance outside the diff
|
|
149
|
+
// would let a fixer edit, and commit, unrelated code.
|
|
150
|
+
function expandInstances(list, changedFiles) {
|
|
151
|
+
const taken = new Set(list.map(function(f) { return classKey(f, f.file, f.line) }))
|
|
152
|
+
const siblings = []
|
|
153
|
+
for (const f of list) {
|
|
154
|
+
for (const ref of (Array.isArray(f.otherInstances) ? f.otherInstances : [])) {
|
|
155
|
+
const spot = parseInstance(ref)
|
|
156
|
+
if (!spot || !changedFiles.has(spot.file) || isGeneratedPath(spot.file) || taken.has(classKey(f, spot.file, spot.line))) {
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
taken.add(classKey(f, spot.file, spot.line))
|
|
160
|
+
siblings.push(Object.assign({}, f, {
|
|
161
|
+
file: spot.file,
|
|
162
|
+
line: spot.line,
|
|
163
|
+
title: f.title + ' (same bug class at ' + spot.file + ':' + spot.line + ')',
|
|
164
|
+
description: 'Same bug class' + (f.bugClass ? ' "' + f.bugClass + '"' : '') + ' as '
|
|
165
|
+
+ f.file + ':' + f.line + '. ' + f.description,
|
|
166
|
+
otherInstances: [],
|
|
167
|
+
instanceOf: f.title,
|
|
168
|
+
}))
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return list.concat(siblings)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function groupFindings(list, changedFiles) {
|
|
175
|
+
const primaries = list
|
|
176
|
+
.map(function(f) { return Object.assign({}, f, { file: safeRelativePath(f.file) }) })
|
|
177
|
+
.filter(function(f) { return f.file })
|
|
178
|
+
const all = expandInstances(primaries, changedFiles)
|
|
179
|
+
const byFile = new Map()
|
|
180
|
+
for (const f of all) {
|
|
181
|
+
if (!byFile.has(f.file)) {
|
|
182
|
+
byFile.set(f.file, [])
|
|
183
|
+
}
|
|
184
|
+
byFile.get(f.file).push(f)
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
all,
|
|
188
|
+
rejected: list.length - primaries.length,
|
|
189
|
+
groups: Array.from(byFile.entries()).map(function(entry) {
|
|
190
|
+
return { file: entry[0], findings: entry[1] }
|
|
191
|
+
}),
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function readChangedFiles() {
|
|
196
|
+
if (!diffFile) {
|
|
197
|
+
return new Set()
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
return changedFilesFromDiff(readFileSync(diffFile, 'utf8'))
|
|
201
|
+
} catch (_) {
|
|
202
|
+
return new Set()
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const changedFiles = readChangedFiles()
|
|
207
|
+
if (changedFiles.size === 0) {
|
|
208
|
+
log('no changed-file list from diffFile; same-class instances are not expanded')
|
|
209
|
+
}
|
|
210
|
+
const grouped = groupFindings(findings, changedFiles)
|
|
211
|
+
const allFindings = grouped.all
|
|
212
|
+
const groups = grouped.groups
|
|
213
|
+
if (grouped.rejected > 0) {
|
|
214
|
+
log(`rejected ${grouped.rejected} finding(s) with an absolute or traversing file path`)
|
|
215
|
+
}
|
|
111
216
|
|
|
112
|
-
log(`fixing ${
|
|
217
|
+
log(`fixing ${allFindings.length} finding(s) (${findings.length} reported) across ${groups.length} file(s)`)
|
|
113
218
|
|
|
114
219
|
// ---------------------------------------------------------------------------
|
|
115
220
|
// Prompts
|
|
@@ -330,7 +435,7 @@ const groupsOut = results.filter(Boolean).map(function(state) {
|
|
|
330
435
|
return {
|
|
331
436
|
file: state.file,
|
|
332
437
|
findings: state.findings.map(function(f) {
|
|
333
|
-
return { title: f.title, line: f.line, severity: f.severity }
|
|
438
|
+
return { title: f.title, line: f.line, severity: f.severity, instanceOf: f.instanceOf }
|
|
334
439
|
}),
|
|
335
440
|
results: (state.fixResult && state.fixResult.results) || [],
|
|
336
441
|
filesTouched: (state.fixResult && state.fixResult.filesTouched) || [],
|
|
@@ -74,6 +74,14 @@ outcome; a half-applied fix that breaks callers is the worst outcome.
|
|
|
74
74
|
|
|
75
75
|
---
|
|
76
76
|
|
|
77
|
+
## Same-class instances
|
|
78
|
+
|
|
79
|
+
A finding with `instanceOf` is another occurrence of a bug class already
|
|
80
|
+
reported elsewhere. Treat it like any finding: confirm the pattern is really
|
|
81
|
+
there at that spot, apply the same fix adapted to local names and types, and
|
|
82
|
+
skip it (with the reason) when the code differs enough that the fix does not
|
|
83
|
+
carry over. Its `title` is its own join key.
|
|
84
|
+
|
|
77
85
|
## Multiple findings in one file
|
|
78
86
|
|
|
79
87
|
Apply them in file order, top to bottom, re-reading after each edit so later
|
|
@@ -18,7 +18,8 @@ without creating a new artifact identity.
|
|
|
18
18
|
All of the following are already in hand after Step 3 of SKILL.md:
|
|
19
19
|
|
|
20
20
|
- `findings.json` path (scratchpad) -- each finding carries: `file`, `line`,
|
|
21
|
-
`severity`, `title`, `description`, `badCode`, `fix`, `
|
|
21
|
+
`severity`, `title`, `description`, `badCode`, `fix`, `bugClass`,
|
|
22
|
+
`otherInstances`, `rule?`, `precedent?`,
|
|
22
23
|
`agreedBy?`, `verifierReasoning?`, `proof?` (proof = `{attempted, proven,
|
|
23
24
|
outcome, reason, testCode?, testCommand?, redOutput?}`).
|
|
24
25
|
- The saved review file path: `~/code-reviews/YYYY-MM-DD-pr-N-repo.md`.
|
|
@@ -154,7 +155,10 @@ Hard requirements for the HTML page:
|
|
|
154
155
|
- **Findings**, grouped Critical → Important → Observations → Idiomatic. Each
|
|
155
156
|
finding is an expandable `<details>` block:
|
|
156
157
|
- summary row: severity dot + title + `file:line`
|
|
157
|
-
- body: description, `badCode` in a `<pre>`, `fix` in a `<pre>`, a
|
|
158
|
+
- body: description, `badCode` in a `<pre>`, `fix` in a `<pre>`, a
|
|
159
|
+
"Bug class: `bugClass`" line followed by "Also at:" and each
|
|
160
|
+
`otherInstances` entry as a `file:line` chip (omit "Also at:" when the
|
|
161
|
+
list is empty), a small
|
|
158
162
|
hard-rule chip (e.g. "TS-1 · hard rule") when `rule` is set, `agreedBy`
|
|
159
163
|
chips when 2+ dimensions agreed, `verifierReasoning` as a muted footnote.
|
|
160
164
|
- **Proof block:** when `proof.proven === true`, render a visually distinct
|
|
@@ -43,12 +43,17 @@ path. The file is an object with a required `findings` array. Each finding is:
|
|
|
43
43
|
"description": "failure mode and reachability",
|
|
44
44
|
"badCode": "verbatim changed code",
|
|
45
45
|
"fix": "minimal correction",
|
|
46
|
+
"bugClass": "kebab-case-defect-class",
|
|
47
|
+
"otherInstances": ["other/file.ts:17"],
|
|
46
48
|
"rule": "optional house-rule tag",
|
|
47
49
|
"precedent": "required for idiomatic findings"
|
|
48
50
|
}
|
|
49
51
|
```
|
|
50
52
|
|
|
51
53
|
`badCode` and `fix` may be empty only for Observation or Idiomatic findings.
|
|
54
|
+
`bugClass` and `otherInstances` are required: after the first hit, search the
|
|
55
|
+
whole diff for the same pattern and list every other place as a
|
|
56
|
+
repository-relative `file:line` (empty array when there is none).
|
|
52
57
|
Implementation additionally returns `acCoverage`. Test-quality additionally
|
|
53
58
|
returns `coverageVerdict`, `mutationSlip`, and `mockSmells`.
|
|
54
59
|
|
|
@@ -95,6 +100,8 @@ findings before spending verifier agents:
|
|
|
95
100
|
- candidates must name the same file;
|
|
96
101
|
- their anchor lines must fit within a 30-line total span;
|
|
97
102
|
- normalized title-token Jaccard similarity must be at least 0.4;
|
|
103
|
+
- findings whose `bugClass` values differ never merge; merged findings union
|
|
104
|
+
their `otherInstances`;
|
|
98
105
|
- keep the highest severity and longest `description`, `badCode`, `fix`, and
|
|
99
106
|
`precedent` values;
|
|
100
107
|
- union contributing dimensions into `agreedBy`;
|
|
@@ -202,8 +209,13 @@ to measurements the host actually exposes.
|
|
|
202
209
|
Follow `references/fix-mode.md` for selection, preconditions, verification,
|
|
203
210
|
commit, landing/push confirmation, and cleanup. Replace only its Workflow call:
|
|
204
211
|
|
|
205
|
-
1.
|
|
206
|
-
|
|
212
|
+
1. Expand `otherInstances` first, as `fix-workflow.js` does: each entry that is
|
|
213
|
+
a validated repository-relative path (rules in item 5) naming a file changed
|
|
214
|
+
in the diff becomes a sibling finding with `instanceOf` set to the source
|
|
215
|
+
title, unless the same bug class already claims that `file:line`. Drop every
|
|
216
|
+
other entry and report it. Then group primary and sibling findings by
|
|
217
|
+
normalized file. Resolve cross-file dependencies before spawning;
|
|
218
|
+
overlapping groups must be combined.
|
|
207
219
|
2. Spawn one `worker` fixer per disjoint group. Assign exact file ownership and
|
|
208
220
|
state that other agents share the worktree; it must preserve their changes.
|
|
209
221
|
The fixer reads `<SKILL_ROOT>/references/agents/fixer.md`, edits only its
|
|
@@ -101,6 +101,13 @@ downgrades a `good` verdict that arrives without verified evidence. Pass the ACs
|
|
|
101
101
|
even when they look irrelevant to the finding: the finding's own rationale may
|
|
102
102
|
be the thing that contradicts them.
|
|
103
103
|
|
|
104
|
+
A finding may carry `bugClass` and `otherInstances` (`file:line` list). The
|
|
105
|
+
workflow expands each other instance into a sibling finding (`instanceOf` set)
|
|
106
|
+
in its own file's group, so one pass fixes the whole class. An instance outside
|
|
107
|
+
the diff's changed files, in a generated file (Step 2 rule), or with an absolute
|
|
108
|
+
or `..` path, is dropped. Report siblings in
|
|
109
|
+
the Step 8 table under their own title.
|
|
110
|
+
|
|
104
111
|
Pass `findings` as a real JSON array, not a stringified one. The workflow groups
|
|
105
112
|
by file (one agent per file, so no two agents ever edit the same file), applies
|
|
106
113
|
the fix, then runs a read-only fix-verifier over the actual `git diff`. A
|
|
@@ -115,8 +122,12 @@ the whole turn's pool. Report the former on the `Fix agents:` cost line.
|
|
|
115
122
|
|
|
116
123
|
If Claude's Workflow tool is unavailable, fall back to one Agent per file group
|
|
117
124
|
with `<SKILL_ROOT>/references/agents/fixer.md`, then one read-only Agent per
|
|
118
|
-
group with `<SKILL_ROOT>/references/agents/fix-verifier.md`.
|
|
119
|
-
|
|
125
|
+
group with `<SKILL_ROOT>/references/agents/fix-verifier.md`. Expand
|
|
126
|
+
`otherInstances` into sibling findings before grouping, with the same rules as
|
|
127
|
+
the workflow: only repository-relative paths (no leading `/`, drive letter,
|
|
128
|
+
backslash or `..`) that name a file changed in the diff, normalized so
|
|
129
|
+
`./src/a.ts` and `src/a.ts` form one group. Same rules, same verdict handling.
|
|
130
|
+
State the fallback in the report.
|
|
120
131
|
|
|
121
132
|
---
|
|
122
133
|
|
|
@@ -118,6 +118,8 @@ proof inline.>
|
|
|
118
118
|
<corrected version: exact drop-in replacement or minimal diff>
|
|
119
119
|
\```
|
|
120
120
|
|
|
121
|
+
**Bug class:** `<bugClass>` *(always shown)* · **Also at:** `<file:line>`, `<file:line>` *(from `otherInstances`; omit the clause when the list is empty)*
|
|
122
|
+
|
|
121
123
|
**Proof (ran in the worktree):** *(include only when the finding carries a `proof` with `proven: true`)*
|
|
122
124
|
\```
|
|
123
125
|
<redOutput, verbatim>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Score lekker findings against a manifest of PRs with known outcomes.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// node eval-runner.mjs --manifest evals/manifest.json --results <results.json> [--json]
|
|
6
|
+
//
|
|
7
|
+
// manifest.json: { prs: [{ repo, pr, expect: "buggy"|"clean", knownBug?: "file:line + description" }] }
|
|
8
|
+
// results.json: { "<repo>#<pr>": [ <finding>, ... ] } the findings of one lekker run per PR
|
|
9
|
+
// (the `findings` array the workflow returns). A PR missing from results is
|
|
10
|
+
// reported as unscored, never as zero findings.
|
|
11
|
+
//
|
|
12
|
+
// A buggy PR is HIT when some finding is on the knownBug file within LINE_WINDOW lines
|
|
13
|
+
// of the knownBug line. Known limitation: the match is by location only, so a
|
|
14
|
+
// same-file finding near the line counts even if it describes a different problem;
|
|
15
|
+
// judging "same bug" needs an LLM or a description match and is not attempted. Recall = hits / scored buggy PRs. On a clean PR every
|
|
16
|
+
// verdict-affecting finding (critical or important) is a false positive.
|
|
17
|
+
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
export const LINE_WINDOW = 10;
|
|
22
|
+
const BLOCKING = new Set(['critical', 'important']);
|
|
23
|
+
|
|
24
|
+
export function parseKnownBug(text) {
|
|
25
|
+
// The file is everything before the last ":<digits>" that is followed by whitespace
|
|
26
|
+
// or the end, so paths containing spaces parse.
|
|
27
|
+
const m = /^(.+):(\d+)(?=\s|$)/.exec(String(text || '').trim());
|
|
28
|
+
if (!m) return null;
|
|
29
|
+
const line = Number(m[2]);
|
|
30
|
+
return line >= 1 ? { file: m[1], line } : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isHit(findings, knownBug) {
|
|
34
|
+
const spot = parseKnownBug(knownBug);
|
|
35
|
+
if (!spot) return false;
|
|
36
|
+
return findings.some((f) => f.file === spot.file
|
|
37
|
+
&& Number.isInteger(f.line) && f.line > 0
|
|
38
|
+
&& Math.abs(f.line - spot.line) <= LINE_WINDOW);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function validateManifest(manifest) {
|
|
42
|
+
const prs = manifest && Array.isArray(manifest.prs) ? manifest.prs : null;
|
|
43
|
+
if (!prs) throw new Error('manifest needs a "prs" array');
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
prs.forEach((e, i) => {
|
|
46
|
+
if (!e.repo || !Number.isInteger(e.pr)) throw new Error(`prs[${i}]: repo and integer pr are required`);
|
|
47
|
+
if (e.expect !== 'buggy' && e.expect !== 'clean') throw new Error(`prs[${i}]: expect must be "buggy" or "clean"`);
|
|
48
|
+
const key = `${e.repo}#${e.pr}`;
|
|
49
|
+
if (seen.has(key)) throw new Error(`prs[${i}]: duplicate entry ${key}`);
|
|
50
|
+
seen.add(key);
|
|
51
|
+
if (e.expect === 'buggy' && !parseKnownBug(e.knownBug)) {
|
|
52
|
+
throw new Error(`prs[${i}]: a buggy entry needs knownBug starting with "file:line" (line >= 1)`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
return prs;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function score(manifest, results) {
|
|
59
|
+
const rows = validateManifest(manifest).map((e) => {
|
|
60
|
+
const key = `${e.repo}#${e.pr}`;
|
|
61
|
+
const findings = results[key];
|
|
62
|
+
if (!Array.isArray(findings)) return { key, expect: e.expect, scored: false };
|
|
63
|
+
if (e.expect === 'buggy') return { key, expect: 'buggy', scored: true, hit: isHit(findings, e.knownBug) };
|
|
64
|
+
return {
|
|
65
|
+
key, expect: 'clean', scored: true,
|
|
66
|
+
falsePositives: findings.filter((f) => BLOCKING.has(f.severity)).length,
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
const buggy = rows.filter((r) => r.expect === 'buggy' && r.scored);
|
|
70
|
+
const clean = rows.filter((r) => r.expect === 'clean' && r.scored);
|
|
71
|
+
return {
|
|
72
|
+
recall: buggy.length === 0 ? null : buggy.filter((r) => r.hit).length / buggy.length,
|
|
73
|
+
buggyScored: buggy.length,
|
|
74
|
+
falsePositives: clean.reduce((n, r) => n + r.falsePositives, 0),
|
|
75
|
+
cleanScored: clean.length,
|
|
76
|
+
unscored: rows.filter((r) => !r.scored).map((r) => r.key),
|
|
77
|
+
rows,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function main(argv) {
|
|
82
|
+
const flag = (n) => { const i = argv.indexOf('--' + n); return i === -1 ? null : argv[i + 1]; };
|
|
83
|
+
if (!flag('manifest') || !flag('results')) {
|
|
84
|
+
console.error('usage: eval-runner.mjs --manifest <manifest.json> --results <results.json> [--json]');
|
|
85
|
+
process.exit(2);
|
|
86
|
+
}
|
|
87
|
+
const out = score(JSON.parse(readFileSync(flag('manifest'), 'utf8')), JSON.parse(readFileSync(flag('results'), 'utf8')));
|
|
88
|
+
if (argv.includes('--json')) { console.log(JSON.stringify(out)); return; }
|
|
89
|
+
const pct = out.recall === null ? 'n/a' : `${Math.round(out.recall * 100)}%`;
|
|
90
|
+
console.log(`recall ${pct} (${out.buggyScored} buggy scored), false positives ${out.falsePositives} (${out.cleanScored} clean scored), unscored ${out.unscored.length}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) main(process.argv.slice(2));
|
|
@@ -97,6 +97,13 @@ function extractRule(title) {
|
|
|
97
97
|
return m && HARD_RULES.has(m[1]) ? m[1] : undefined
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// revmux reports one location per finding and no class, so the class is derived
|
|
101
|
+
// from the rule tag or title and no other instances are claimed.
|
|
102
|
+
function bugClassOf(rule, title) {
|
|
103
|
+
const words = String(rule || title || '').toLowerCase().match(/[a-z0-9]+/g) || []
|
|
104
|
+
return words.slice(0, 6).join('-') || 'unclassified'
|
|
105
|
+
}
|
|
106
|
+
|
|
100
107
|
function mapSeverity(f) {
|
|
101
108
|
if (f.severity === 'critical') return 'critical'
|
|
102
109
|
if (f.severity === 'major') return 'important'
|
|
@@ -131,6 +138,8 @@ for (const f of report.findings || []) {
|
|
|
131
138
|
description: f.body,
|
|
132
139
|
badCode,
|
|
133
140
|
fix: f.fix,
|
|
141
|
+
bugClass: bugClassOf(rule, f.title),
|
|
142
|
+
otherInstances: [],
|
|
134
143
|
confidence: f.confidence,
|
|
135
144
|
rule,
|
|
136
145
|
dimension: f.lenses || [],
|
|
@@ -171,6 +180,8 @@ for (const pe of report.pre_existing || []) {
|
|
|
171
180
|
description: `Pre-existing: ${pe.body}`,
|
|
172
181
|
badCode: extractBadCode(pe.body),
|
|
173
182
|
fix: '',
|
|
183
|
+
bugClass: bugClassOf(null, pe.title),
|
|
184
|
+
otherInstances: [],
|
|
174
185
|
dimension: pe.lenses || [],
|
|
175
186
|
})
|
|
176
187
|
}
|
|
@@ -23,11 +23,13 @@ const BOUNDARY_SCHEMA = {
|
|
|
23
23
|
},
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
const INSTANCE_PATTERN_SOURCE = '^(?!/)(?!.*\\\\)(?![A-Za-z]:)(?!(?:.*/)?\\.\\.(?:/|:)).+:[1-9][0-9]*$'
|
|
27
|
+
|
|
26
28
|
const FINDINGS_ARRAY_SCHEMA = {
|
|
27
29
|
type: 'array',
|
|
28
30
|
items: {
|
|
29
31
|
type: 'object',
|
|
30
|
-
required: ['file', 'line', 'severity', 'title', 'description', 'badCode', 'fix'],
|
|
32
|
+
required: ['file', 'line', 'severity', 'title', 'description', 'badCode', 'fix', 'bugClass', 'otherInstances'],
|
|
31
33
|
anyOf: [
|
|
32
34
|
{ properties: { severity: { enum: ['observation', 'idiomatic'] } } },
|
|
33
35
|
{ required: ['boundary'] },
|
|
@@ -43,6 +45,11 @@ const FINDINGS_ARRAY_SCHEMA = {
|
|
|
43
45
|
precedent: { type: 'string' },
|
|
44
46
|
rule: { type: 'string', minLength: 1, pattern: '\\S' },
|
|
45
47
|
boundary: BOUNDARY_SCHEMA,
|
|
48
|
+
// Short kebab-case slug naming the class of bug ("missing-null-check").
|
|
49
|
+
bugClass: { type: 'string', minLength: 1, pattern: '^[a-z0-9]+(-[a-z0-9]+)*$' },
|
|
50
|
+
// Other places in the diff with the same pattern, as "file:line".
|
|
51
|
+
// Repository-relative only: no leading slash, drive letter or '..' segment.
|
|
52
|
+
otherInstances: { type: 'array', items: { type: 'string', pattern: INSTANCE_PATTERN_SOURCE } },
|
|
46
53
|
},
|
|
47
54
|
},
|
|
48
55
|
}
|
|
@@ -155,6 +162,10 @@ function titleTokens(title) {
|
|
|
155
162
|
}
|
|
156
163
|
|
|
157
164
|
function sameIssue(a, b) {
|
|
165
|
+
// Merging two classes would send one class's instances to the other's fix.
|
|
166
|
+
if (a.bugClass && b.bugClass && a.bugClass !== b.bugClass) {
|
|
167
|
+
return false
|
|
168
|
+
}
|
|
158
169
|
const ta = titleTokens(a.title)
|
|
159
170
|
const tb = titleTokens(b.title)
|
|
160
171
|
if (ta.length === 0 || tb.length === 0) {
|
|
@@ -166,6 +177,29 @@ function sameIssue(a, b) {
|
|
|
166
177
|
return (shared / union) >= 0.4
|
|
167
178
|
}
|
|
168
179
|
|
|
180
|
+
const BUG_CLASS_INSTRUCTION = `BUG CLASS: give every finding a "bugClass" (short kebab-case slug for the`
|
|
181
|
+
+ ` kind of defect, e.g. missing-null-check) and "otherInstances": after the first`
|
|
182
|
+
+ ` hit, search the whole diff for the same pattern and list every other place as`
|
|
183
|
+
+ ` "file:line" (empty array when there is none). Report the class, not just the first hit.`
|
|
184
|
+
|
|
185
|
+
const BUG_CLASS_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
|
186
|
+
const INSTANCE_PATTERN = new RegExp(INSTANCE_PATTERN_SOURCE)
|
|
187
|
+
|
|
188
|
+
function slugify(text) {
|
|
189
|
+
const words = String(text || '').toLowerCase().match(/[a-z0-9]+/g) || []
|
|
190
|
+
return words.slice(0, 6).join('-') || 'unclassified'
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Adapter findings skip the review schema, so the class fields are filled here:
|
|
194
|
+
// fix mode and every renderer rely on them being present and well-formed.
|
|
195
|
+
function withClassFields(f) {
|
|
196
|
+
return Object.assign({}, f, {
|
|
197
|
+
bugClass: BUG_CLASS_PATTERN.test(String(f.bugClass || '')) ? f.bugClass : slugify(f.rule || f.title),
|
|
198
|
+
otherInstances: (Array.isArray(f.otherInstances) ? f.otherInstances : [])
|
|
199
|
+
.filter(function(ref) { return typeof ref === 'string' && INSTANCE_PATTERN.test(ref) }),
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
169
203
|
function longest(a, b) {
|
|
170
204
|
const sa = a || ''
|
|
171
205
|
const sb = b || ''
|
|
@@ -195,6 +229,8 @@ function mergeFindings(a, b) {
|
|
|
195
229
|
merged.badCode = longest(a.badCode, b.badCode)
|
|
196
230
|
merged.fix = longest(a.fix, b.fix)
|
|
197
231
|
merged.precedent = longest(a.precedent, b.precedent)
|
|
232
|
+
merged.bugClass = a.bugClass || b.bugClass
|
|
233
|
+
merged.otherInstances = Array.from(new Set((a.otherInstances || []).concat(b.otherInstances || [])))
|
|
198
234
|
|
|
199
235
|
if (!merged.rule && b.rule) {
|
|
200
236
|
merged.rule = b.rule
|
|
@@ -558,6 +594,8 @@ const budgetAtStart = budget.spent()
|
|
|
558
594
|
+ ` number of DIFF_FILE itself. Quote the code at that line verbatim in "badCode".`,
|
|
559
595
|
].join(' ')
|
|
560
596
|
|
|
597
|
+
prompt += ' ' + BUG_CLASS_INSTRUCTION
|
|
598
|
+
|
|
561
599
|
// The deterministic lint pre-flight has already collected every rule the
|
|
562
600
|
// repo's own eslint config fires on this diff, and those are exactly the
|
|
563
601
|
// comments the bot reviewers bill for. An agent re-reporting them costs a
|
|
@@ -748,9 +786,7 @@ const budgetAtStart = budget.spent()
|
|
|
748
786
|
|
|
749
787
|
if (ENGINE === 'revmux') {
|
|
750
788
|
const adapterOutput = JSON.parse(readFileSync(findingsFile, 'utf8'))
|
|
751
|
-
finalFindings = (adapterOutput.findings || []).map(
|
|
752
|
-
return Object.assign({}, f)
|
|
753
|
-
})
|
|
789
|
+
finalFindings = (adapterOutput.findings || []).map(withClassFields)
|
|
754
790
|
droppedCount = adapterOutput.droppedCount || 0
|
|
755
791
|
hardRuleCount = adapterOutput.hardRuleCount || 0
|
|
756
792
|
agentCount = adapterOutput.agentCount || 0
|
|
@@ -847,6 +883,7 @@ const budgetAtStart = budget.spent()
|
|
|
847
883
|
`Reason for re-examination: ${angle.reason}.`,
|
|
848
884
|
`Apply the same diff-anchor and verification rules from ${promptDir}/verifier.md.`,
|
|
849
885
|
`Return zero or one finding using DIFF_FILE=${diffFile} and WORKTREE_PATH=${wtDisplay}.`,
|
|
886
|
+
BUG_CLASS_INSTRUCTION,
|
|
850
887
|
].join(' '),
|
|
851
888
|
{
|
|
852
889
|
label: `critic-reexamine:${angle.file}:${angle.line}`,
|
|
@@ -28,7 +28,7 @@ const writeJson = (p, value) => {
|
|
|
28
28
|
writeFileSync(p, JSON.stringify(value, null, 2) + '\n')
|
|
29
29
|
}
|
|
30
30
|
const copyResources = (skill, destDir) => {
|
|
31
|
-
for (const d of ['scripts', 'references', 'templates']) {
|
|
31
|
+
for (const d of ['scripts', 'references', 'templates', 'evals']) {
|
|
32
32
|
const src = join(root, skill.path, d)
|
|
33
33
|
if (!existsSync(src)) continue
|
|
34
34
|
const dest = join(destDir, d)
|