@olegkoval/agent-skills 1.54.4 → 1.55.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 +135 -8
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/conventions.md +21 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/fix-mode.md +3 -1
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/bot-precision.mjs +285 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/lint-findings.mjs +239 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/mine-bot-comments.sh +280 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +186 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +62 -3
- 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 +135 -8
- package/plugins/olko-github-pr/skills/lekker-review/references/agents/conventions.md +21 -0
- package/plugins/olko-github-pr/skills/lekker-review/references/fix-mode.md +3 -1
- package/plugins/olko-github-pr/skills/lekker-review/scripts/bot-precision.mjs +285 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/lint-findings.mjs +239 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/mine-bot-comments.sh +280 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +186 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +62 -3
- package/plugins/olko-github-pr/skills/lekker-review/workflow.js +10 -0
- 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
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Close the loop on bot-comment prediction: record what the review predicted,
|
|
3
|
+
// then score it against what the bots actually said.
|
|
4
|
+
//
|
|
5
|
+
// Without this, "we pre-empted the bot comments" is a claim nobody checks. The
|
|
6
|
+
// only honest way to know whether pre-emption works is to write the prediction
|
|
7
|
+
// down BEFORE the bots run and compare afterwards - so that is what this does.
|
|
8
|
+
//
|
|
9
|
+
// Usage:
|
|
10
|
+
// node bot-precision.mjs record --repo <slug> --pr <n> --predicted <findings.json> [--at <iso>]
|
|
11
|
+
// node bot-precision.mjs score --repo <slug> --pr <n>
|
|
12
|
+
// node bot-precision.mjs summary
|
|
13
|
+
//
|
|
14
|
+
// Store: $LEKKER_DATA_DIR/bot-precision.jsonl, default ~/.cache/lekker-review
|
|
15
|
+
// (one JSON object per line, append-only). Override with --jsonl <path>.
|
|
16
|
+
//
|
|
17
|
+
// `record` takes either a lint-findings.mjs output ({findings:[...]}) or a bare
|
|
18
|
+
// array of findings; each needs at least `file`, and `line`/`title` when known.
|
|
19
|
+
|
|
20
|
+
import { execFileSync } from 'node:child_process';
|
|
21
|
+
import { readFileSync, appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
22
|
+
import { homedir } from 'node:os';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
const argv = process.argv.slice(2);
|
|
26
|
+
const cmd = argv[0];
|
|
27
|
+
|
|
28
|
+
function flag(name, fallback = null) {
|
|
29
|
+
const i = argv.indexOf('--' + name);
|
|
30
|
+
return i === -1 ? fallback : argv[i + 1];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const JSONL = flag(
|
|
34
|
+
'jsonl',
|
|
35
|
+
join(process.env.LEKKER_DATA_DIR || join(homedir(), '.cache', 'lekker-review'), 'bot-precision.jsonl'),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
function die(msg, code = 2) {
|
|
39
|
+
console.error(msg);
|
|
40
|
+
process.exit(code);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Private: the store quotes review comments, which can come from private repos.
|
|
44
|
+
function ensureStore() {
|
|
45
|
+
mkdirSync(dirname(JSONL), { recursive: true, mode: 0o700 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readRecords() {
|
|
49
|
+
if (!existsSync(JSONL)) return [];
|
|
50
|
+
return readFileSync(JSONL, 'utf8')
|
|
51
|
+
.split('\n')
|
|
52
|
+
.filter(l => l.trim())
|
|
53
|
+
.map(l => {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(l);
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
.filter(Boolean);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function append(record) {
|
|
64
|
+
ensureStore();
|
|
65
|
+
appendFileSync(JSONL, JSON.stringify(record) + '\n', { mode: 0o600 });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --- matching -------------------------------------------------------------
|
|
69
|
+
//
|
|
70
|
+
// A prediction counts as matched when a bot commented on the SAME FILE within
|
|
71
|
+
// LINE_WINDOW lines of it. Deliberately not semantic: an LLM judging its own
|
|
72
|
+
// prediction against the comment it was trying to predict would grade itself,
|
|
73
|
+
// and file+line is checkable by hand from the jsonl.
|
|
74
|
+
const LINE_WINDOW = 20;
|
|
75
|
+
|
|
76
|
+
// Both sides are repo-relative paths, so only a leading "./" or "/" differs.
|
|
77
|
+
function normFile(p) {
|
|
78
|
+
return String(p || '').replace(/^(\.\/|\/)+/, '');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// null/undefined is "no line", not line 0 (Number(null) is 0).
|
|
82
|
+
function toLine(v) {
|
|
83
|
+
return v == null ? NaN : Number(v);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function matches(prediction, comment) {
|
|
87
|
+
if (normFile(prediction.file) !== normFile(comment.path)) return false;
|
|
88
|
+
const a = toLine(prediction.line);
|
|
89
|
+
const b = toLine(comment.line ?? comment.original_line);
|
|
90
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) return true; // same file, no usable line
|
|
91
|
+
return Math.abs(a - b) <= LINE_WINDOW;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const BOT_RE = /gemini-code-assist|greptile|coderabbit/i;
|
|
95
|
+
|
|
96
|
+
const pct = v => (v === null ? 'n/a' : `${Math.round(v * 100)}%`);
|
|
97
|
+
|
|
98
|
+
function fetchBotComments(repo, pr) {
|
|
99
|
+
const raw = execFileSync(
|
|
100
|
+
'gh',
|
|
101
|
+
['api', `repos/${repo}/pulls/${pr}/comments?per_page=100`, '--paginate', '--slurp'],
|
|
102
|
+
{ encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 },
|
|
103
|
+
);
|
|
104
|
+
const pages = JSON.parse(raw);
|
|
105
|
+
const flat = [];
|
|
106
|
+
for (const page of pages) {
|
|
107
|
+
if (Array.isArray(page)) flat.push(...page);
|
|
108
|
+
else if (page) flat.push(page);
|
|
109
|
+
}
|
|
110
|
+
// Replies in a bot's own thread ("Thanks, updated") are not new findings.
|
|
111
|
+
return flat
|
|
112
|
+
.filter(c => c?.user?.type === 'Bot' && BOT_RE.test(c?.user?.login || '') && !c.in_reply_to_id)
|
|
113
|
+
.map(c => ({
|
|
114
|
+
path: c.path,
|
|
115
|
+
line: c.line ?? c.original_line,
|
|
116
|
+
createdAt: c.created_at || null,
|
|
117
|
+
author: c.user.login,
|
|
118
|
+
url: c.html_url,
|
|
119
|
+
claim: String(c.body || '')
|
|
120
|
+
.replace(/<[^>]+>/g, ' ')
|
|
121
|
+
.replace(/\s+/g, ' ')
|
|
122
|
+
.trim()
|
|
123
|
+
.slice(0, 200),
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- commands -------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
if (cmd === 'record') {
|
|
130
|
+
const repo = flag('repo');
|
|
131
|
+
const pr = flag('pr');
|
|
132
|
+
const predictedPath = flag('predicted');
|
|
133
|
+
if (!repo || !pr || !predictedPath) {
|
|
134
|
+
die('usage: bot-precision.mjs record --repo <slug> --pr <n> --predicted <findings.json>');
|
|
135
|
+
}
|
|
136
|
+
let parsed;
|
|
137
|
+
try {
|
|
138
|
+
parsed = JSON.parse(readFileSync(predictedPath, 'utf8'));
|
|
139
|
+
} catch (err) {
|
|
140
|
+
die(`could not read predictions: ${err.message}`);
|
|
141
|
+
}
|
|
142
|
+
const findings = Array.isArray(parsed) ? parsed : parsed.findings || [];
|
|
143
|
+
// A grouped lint finding stands for every site it lists; a bot comment on
|
|
144
|
+
// any of them was predicted.
|
|
145
|
+
const predicted = findings.flatMap(f =>
|
|
146
|
+
(Array.isArray(f.lintSites) && f.lintSites.length ? f.lintSites : [f]).map(s => ({
|
|
147
|
+
file: s.file,
|
|
148
|
+
line: s.line,
|
|
149
|
+
title: f.title,
|
|
150
|
+
rule: f.rule || null,
|
|
151
|
+
severity: f.severity || null,
|
|
152
|
+
source: f.botPredicted ? 'lint-preflight' : 'review-agent',
|
|
153
|
+
})),
|
|
154
|
+
);
|
|
155
|
+
// --at backdates the prediction to when it was made: branch mode records it
|
|
156
|
+
// after creating the PR, and a bot can comment in between.
|
|
157
|
+
const at = flag('at') || new Date().toISOString();
|
|
158
|
+
if (Number.isNaN(Date.parse(at))) die(`--at is not a timestamp: ${at}`);
|
|
159
|
+
append({
|
|
160
|
+
kind: 'prediction',
|
|
161
|
+
repo,
|
|
162
|
+
pr: Number(pr),
|
|
163
|
+
at,
|
|
164
|
+
predictedCount: predicted.length,
|
|
165
|
+
predicted,
|
|
166
|
+
});
|
|
167
|
+
console.log(`bot-precision: recorded ${predicted.length} prediction(s) for ${repo}#${pr}`);
|
|
168
|
+
process.exit(0);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (cmd === 'score') {
|
|
172
|
+
const repo = flag('repo');
|
|
173
|
+
const pr = flag('pr');
|
|
174
|
+
if (!repo || !pr) die('usage: bot-precision.mjs score --repo <slug> --pr <n>');
|
|
175
|
+
|
|
176
|
+
// The FIRST prediction is the one made before the bots ran; a re-review
|
|
177
|
+
// records another after seeing their comments.
|
|
178
|
+
const prediction = readRecords().find(
|
|
179
|
+
r => r.kind === 'prediction' && r.repo === repo && r.pr === Number(pr),
|
|
180
|
+
);
|
|
181
|
+
if (!prediction) {
|
|
182
|
+
// No prediction is "unknown", not "0% precision" - scoring an unrecorded
|
|
183
|
+
// review would manufacture a number out of a missing input.
|
|
184
|
+
console.log(`bot-precision: no recorded prediction for ${repo}#${pr} - nothing to score`);
|
|
185
|
+
process.exit(0);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let fetched;
|
|
189
|
+
try {
|
|
190
|
+
fetched = fetchBotComments(repo, pr);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
die(`gh api failed: ${err.message}`, 1);
|
|
193
|
+
}
|
|
194
|
+
// A comment posted before the prediction was visible when it was made, so
|
|
195
|
+
// it cannot count as predicted or missed.
|
|
196
|
+
// Compared as instants at whole-second precision: GitHub timestamps have no
|
|
197
|
+
// fraction, and a string compare against "...:00.500Z" orders them wrongly.
|
|
198
|
+
const cutoff = Math.floor(Date.parse(prediction.at) / 1000) * 1000;
|
|
199
|
+
const comments = fetched.filter(c => !c.createdAt || Date.parse(c.createdAt) >= cutoff);
|
|
200
|
+
const preexisting = fetched.length - comments.length;
|
|
201
|
+
|
|
202
|
+
if (comments.length === 0) {
|
|
203
|
+
// Distinguish "the bots left nothing" from "the bots have not run yet":
|
|
204
|
+
// the caller knows which, this script does not, so say both.
|
|
205
|
+
console.log(
|
|
206
|
+
`bot-precision: ${repo}#${pr} has 0 bot comment(s). ` +
|
|
207
|
+
`Either the bots found nothing (${prediction.predictedCount} prediction(s) unrefuted) ` +
|
|
208
|
+
`or they have not run yet - re-run score once they have.`,
|
|
209
|
+
);
|
|
210
|
+
process.exit(0);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const hitPredictions = prediction.predicted.filter(p => comments.some(c => matches(p, c)));
|
|
214
|
+
const predictedComments = comments.filter(c => prediction.predicted.some(p => matches(p, c)));
|
|
215
|
+
const missed = comments.filter(c => !prediction.predicted.some(p => matches(p, c)));
|
|
216
|
+
|
|
217
|
+
const precision = prediction.predicted.length
|
|
218
|
+
? hitPredictions.length / prediction.predicted.length
|
|
219
|
+
: null;
|
|
220
|
+
const recall = comments.length ? predictedComments.length / comments.length : null;
|
|
221
|
+
|
|
222
|
+
const record = {
|
|
223
|
+
kind: 'score',
|
|
224
|
+
repo,
|
|
225
|
+
pr: Number(pr),
|
|
226
|
+
at: new Date().toISOString(),
|
|
227
|
+
predictedCount: prediction.predicted.length,
|
|
228
|
+
botCommentCount: comments.length,
|
|
229
|
+
preexistingExcluded: preexisting,
|
|
230
|
+
predictedAndRaised: hitPredictions.length,
|
|
231
|
+
botCommentsPredicted: predictedComments.length,
|
|
232
|
+
precision,
|
|
233
|
+
recall,
|
|
234
|
+
matchRule: `same file within ${LINE_WINDOW} lines`,
|
|
235
|
+
missedBotComments: missed.slice(0, 20),
|
|
236
|
+
};
|
|
237
|
+
append(record);
|
|
238
|
+
|
|
239
|
+
console.log(
|
|
240
|
+
`bot-precision ${repo}#${pr}: ` +
|
|
241
|
+
`predicted ${record.predictedCount}, bots left ${record.botCommentCount}, ` +
|
|
242
|
+
`precision ${pct(precision)} (${hitPredictions.length}/${record.predictedCount}), ` +
|
|
243
|
+
`recall ${pct(recall)} (${predictedComments.length}/${record.botCommentCount}) ` +
|
|
244
|
+
`[match: ${record.matchRule}]`,
|
|
245
|
+
);
|
|
246
|
+
if (missed.length) {
|
|
247
|
+
console.log(`bot-precision: ${missed.length} bot comment(s) not predicted:`);
|
|
248
|
+
for (const m of missed.slice(0, 5)) {
|
|
249
|
+
console.log(` - ${m.path}:${m.line ?? '?'} ${m.claim.slice(0, 110)}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
process.exit(0);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (cmd === 'summary') {
|
|
256
|
+
// A PR is scored again once more bot comments land; only its latest score
|
|
257
|
+
// counts, or a re-scored PR would weigh double in the means.
|
|
258
|
+
const latest = new Map();
|
|
259
|
+
for (const r of readRecords()) {
|
|
260
|
+
if (r.kind === 'score') latest.set(`${r.repo}#${r.pr}`, r);
|
|
261
|
+
}
|
|
262
|
+
const scores = [...latest.values()];
|
|
263
|
+
if (!scores.length) {
|
|
264
|
+
console.log('bot-precision: no scored reviews yet');
|
|
265
|
+
process.exit(0);
|
|
266
|
+
}
|
|
267
|
+
const avg = key => {
|
|
268
|
+
const vals = scores.map(s => s[key]).filter(v => typeof v === 'number');
|
|
269
|
+
return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
|
|
270
|
+
};
|
|
271
|
+
console.log(
|
|
272
|
+
`bot-precision summary: ${scores.length} scored review(s), ` +
|
|
273
|
+
`mean precision ${pct(avg('precision'))}, mean recall ${pct(avg('recall'))}`,
|
|
274
|
+
);
|
|
275
|
+
for (const s of scores.slice(-10)) {
|
|
276
|
+
console.log(
|
|
277
|
+
` ${s.repo}#${s.pr} ${s.at.slice(0, 10)}: ` +
|
|
278
|
+
`precision ${pct(s.precision)}, recall ${pct(s.recall)} ` +
|
|
279
|
+
`(${s.predictedCount} predicted / ${s.botCommentCount} bot comments)`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
process.exit(0);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
die('usage: bot-precision.mjs <record|score|summary> [flags]');
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// eslint JSON report -> lekker-review findings, tagged botPredicted.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists: the bot reviewers (Greptile, Gemini) spend most of their
|
|
5
|
+
// budget restating what the repo's own eslint config already says, and they are
|
|
6
|
+
// wrong about it more often than eslint is. On one observed PR three of four
|
|
7
|
+
// predicted bot comments were eslint rules, and eslint OVERRULED the model's
|
|
8
|
+
// guess (it had suggested `.forEach`, which that config bans). So the linter is
|
|
9
|
+
// the authority for this class of finding, not an LLM.
|
|
10
|
+
//
|
|
11
|
+
// Usage:
|
|
12
|
+
// node lint-findings.mjs <eslint-json-file> <out-findings-json> [--max N] [--base <sha>]
|
|
13
|
+
//
|
|
14
|
+
// --base keeps only messages on lines the change added (git diff -U0 <sha> HEAD
|
|
15
|
+
// in LEKKER_WORKTREE_PATH). eslint lints whole files, but bots comment on diff
|
|
16
|
+
// lines, so standing debt elsewhere in a touched file is not a prediction and
|
|
17
|
+
// must not reach fix mode. The dropped count is reported as summary.outOfDiff.
|
|
18
|
+
//
|
|
19
|
+
// Reads an `eslint -f json` report. Writes {"findings":[...], "summary": {...}}
|
|
20
|
+
// where each finding matches the FINDINGS_SCHEMA shape used by workflow.js,
|
|
21
|
+
// plus `botPredicted: true` and `rule: "LINT:<ruleId>"`.
|
|
22
|
+
//
|
|
23
|
+
// Findings are grouped by ruleId and file so a rule that fires 40 times in a
|
|
24
|
+
// file produces one finding listing the other sites, not 40 findings that drown
|
|
25
|
+
// the review. Per file, because fix mode routes each finding to its one file.
|
|
26
|
+
|
|
27
|
+
import { execFileSync } from 'node:child_process';
|
|
28
|
+
import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
30
|
+
|
|
31
|
+
const [, , inPath, outPath, ...rest] = process.argv;
|
|
32
|
+
|
|
33
|
+
if (!inPath || !outPath) {
|
|
34
|
+
console.error('usage: lint-findings.mjs <eslint-json-file> <out-json> [--max N] [--base <sha>]');
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const maxIdx = rest.indexOf('--max');
|
|
39
|
+
const MAX_FINDINGS = maxIdx === -1 ? 25 : Number(rest[maxIdx + 1]) || 25;
|
|
40
|
+
const baseIdx = rest.indexOf('--base');
|
|
41
|
+
const BASE = baseIdx === -1 ? null : rest[baseIdx + 1];
|
|
42
|
+
|
|
43
|
+
// eslint severity 2 = error, 1 = warning.
|
|
44
|
+
//
|
|
45
|
+
// An error is something CI will reject and a bot will certainly comment on, so
|
|
46
|
+
// it is `important`. A warning is style the team has chosen not to enforce, so
|
|
47
|
+
// it lands in `idiomatic` where it cannot affect the verdict.
|
|
48
|
+
function severityFor(eslintSeverity) {
|
|
49
|
+
return eslintSeverity === 2 ? 'important' : 'idiomatic';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Write a zero-finding result whose status says why, then stop. A missing,
|
|
53
|
+
// empty or malformed report is "unknown", never "clean", so the review header
|
|
54
|
+
// cannot claim a green lint that never ran.
|
|
55
|
+
function bail(status, why) {
|
|
56
|
+
writeFileSync(
|
|
57
|
+
outPath,
|
|
58
|
+
JSON.stringify({ findings: [], summary: { status, ruleCount: 0, messageCount: 0 } }, null, 2),
|
|
59
|
+
);
|
|
60
|
+
console.log(`lint-findings: ${why}, 0 findings`);
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let report;
|
|
65
|
+
try {
|
|
66
|
+
const raw = readFileSync(inPath, 'utf8').trim();
|
|
67
|
+
if (!raw) bail('empty-report', 'empty eslint report');
|
|
68
|
+
report = JSON.parse(raw);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
bail('unreadable: ' + err.message, `could not read eslint report (${err.message})`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!Array.isArray(report)) bail('not-an-eslint-json-report', 'report is not an eslint JSON array');
|
|
74
|
+
// eslint emits one entry per linted file, so [] means nothing was linted.
|
|
75
|
+
if (report.length === 0) bail('empty-report', 'eslint report lists no files');
|
|
76
|
+
|
|
77
|
+
// Resolve symlinks on both sides: on macOS /tmp is /private/tmp, so eslint's
|
|
78
|
+
// filePath and the worktree path given here can spell the same dir differently.
|
|
79
|
+
function real(p) {
|
|
80
|
+
try {
|
|
81
|
+
return realpathSync(p);
|
|
82
|
+
} catch {
|
|
83
|
+
return p;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const repoRoot = process.env.LEKKER_WORKTREE_PATH ? real(process.env.LEKKER_WORKTREE_PATH) : '';
|
|
88
|
+
|
|
89
|
+
function relativize(p) {
|
|
90
|
+
if (!repoRoot || !isAbsolute(p)) return p;
|
|
91
|
+
const rel = relative(repoRoot, real(p));
|
|
92
|
+
return rel.startsWith('..') ? p : rel;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// repo-relative path -> Set of line numbers the change added, or null when no
|
|
96
|
+
// --base was given or git could not answer (then nothing is filtered).
|
|
97
|
+
function addedLines() {
|
|
98
|
+
if (!BASE || !repoRoot) return null;
|
|
99
|
+
let out;
|
|
100
|
+
try {
|
|
101
|
+
// Explicit prefixes: diff.noprefix / diff.mnemonicPrefix would otherwise
|
|
102
|
+
// change the "+++ b/" header the parser below slices.
|
|
103
|
+
const args = ['-C', repoRoot, 'diff', '-U0', '--no-color', '--no-ext-diff', '--src-prefix=a/', '--dst-prefix=b/', BASE, 'HEAD'];
|
|
104
|
+
out = execFileSync('git', args, {
|
|
105
|
+
encoding: 'utf8',
|
|
106
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
107
|
+
});
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const added = new Map();
|
|
112
|
+
let file = null;
|
|
113
|
+
for (const line of out.split('\n')) {
|
|
114
|
+
if (line.startsWith('+++ ')) {
|
|
115
|
+
file = line === '+++ /dev/null' ? null : line.slice(6);
|
|
116
|
+
if (file && !added.has(file)) added.set(file, new Set());
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const hunk = file && /^@@ -\S+ \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
120
|
+
if (!hunk) continue;
|
|
121
|
+
const start = Number(hunk[1]);
|
|
122
|
+
const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
|
|
123
|
+
for (let i = 0; i < count; i++) added.get(file).add(start + i);
|
|
124
|
+
}
|
|
125
|
+
return added;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const added = addedLines();
|
|
129
|
+
let outOfDiff = 0;
|
|
130
|
+
|
|
131
|
+
// Group every message by ruleId. Parse errors arrive with ruleId null; they are
|
|
132
|
+
// real and worth surfacing, so they get their own bucket.
|
|
133
|
+
const byRule = new Map();
|
|
134
|
+
let messageCount = 0;
|
|
135
|
+
|
|
136
|
+
for (const file of report) {
|
|
137
|
+
const filePath = String(file?.filePath ?? '');
|
|
138
|
+
for (const m of file?.messages ?? []) {
|
|
139
|
+
// A message with no ruleId is a parse error only when eslint marks it
|
|
140
|
+
// fatal. The rest are notices such as "File ignored because of a matching
|
|
141
|
+
// ignore pattern", which flag nothing wrong with the code.
|
|
142
|
+
if (!m.ruleId && !m.fatal) continue;
|
|
143
|
+
const ruleId = m.ruleId ?? 'parse-error';
|
|
144
|
+
// A parse error breaks the whole file, so it counts wherever it lands.
|
|
145
|
+
if (added && ruleId !== 'parse-error' && !added.get(relativize(filePath))?.has(m.line)) {
|
|
146
|
+
outOfDiff++;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
messageCount++;
|
|
150
|
+
const key = `${ruleId}\0${filePath}`;
|
|
151
|
+
if (!byRule.has(key)) {
|
|
152
|
+
byRule.set(key, { ruleId, severity: m.severity ?? 2, fixable: false, sites: [] });
|
|
153
|
+
}
|
|
154
|
+
const bucket = byRule.get(key);
|
|
155
|
+
// A rule reported at both levels keeps the louder one.
|
|
156
|
+
bucket.severity = Math.max(bucket.severity, m.severity ?? 2);
|
|
157
|
+
if (m.fix) {
|
|
158
|
+
bucket.fixable = true;
|
|
159
|
+
}
|
|
160
|
+
bucket.sites.push({
|
|
161
|
+
file: filePath,
|
|
162
|
+
line: m.line ?? 1,
|
|
163
|
+
column: m.column ?? 1,
|
|
164
|
+
message: String(m.message ?? '').trim(),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Errors before warnings, then the rules that fire most - a rule hit 12 times
|
|
170
|
+
// is a pattern the author is repeating, and worth more than a one-off.
|
|
171
|
+
const ruleCount = new Set([...byRule.values()].map(b => b.ruleId)).size;
|
|
172
|
+
const buckets = [...byRule.values()].sort((a, b) => {
|
|
173
|
+
if (b.severity !== a.severity) return b.severity - a.severity;
|
|
174
|
+
return b.sites.length - a.sites.length;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// The offending source line, so badCode is code and not a location string.
|
|
178
|
+
function sourceLine(file, line) {
|
|
179
|
+
try {
|
|
180
|
+
const abs = isAbsolute(file) ? file : join(repoRoot, file);
|
|
181
|
+
const text = readFileSync(abs, 'utf8').split('\n')[line - 1];
|
|
182
|
+
if (text?.trim()) return text.trim();
|
|
183
|
+
} catch {
|
|
184
|
+
// unreadable file: fall back to the location
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const findings = buckets.slice(0, MAX_FINDINGS).map(bucket => {
|
|
190
|
+
const first = bucket.sites[0];
|
|
191
|
+
const others = bucket.sites.slice(1);
|
|
192
|
+
const extra = others.length
|
|
193
|
+
? ' Also at: ' + others.slice(0, 8).map(s => `${relativize(s.file)}:${s.line}`).join(', ') +
|
|
194
|
+
(others.length > 8 ? ` (+${others.length - 8} more)` : '') + '.'
|
|
195
|
+
: '';
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
file: relativize(first.file),
|
|
199
|
+
line: first.line,
|
|
200
|
+
severity: severityFor(bucket.severity),
|
|
201
|
+
title: `${bucket.ruleId} (${bucket.sites.length} site${bucket.sites.length === 1 ? '' : 's'})`,
|
|
202
|
+
description:
|
|
203
|
+
`The repo's own eslint config reports \`${bucket.ruleId}\` on the changed files: ` +
|
|
204
|
+
`"${first.message}".${extra} ` +
|
|
205
|
+
`This is the repo's configured rule, not a preference - it is the single most likely ` +
|
|
206
|
+
`bot-reviewer comment on this diff, and CI enforces it.` +
|
|
207
|
+
(bucket.fixable ? ' eslint can fix this automatically (`eslint --fix`).' : ''),
|
|
208
|
+
badCode:
|
|
209
|
+
sourceLine(first.file, first.line) ?? `${relativize(first.file)}:${first.line}:${first.column}`,
|
|
210
|
+
fix: bucket.fixable
|
|
211
|
+
? `Apply eslint's autofix for \`${bucket.ruleId}\` to the cited lines only, then re-read the hunk. ` +
|
|
212
|
+
`A file-wide \`eslint --fix\` also rewrites lines this change did not touch.`
|
|
213
|
+
: `Resolve \`${bucket.ruleId}\` as the config intends. Check what the rule permits before ` +
|
|
214
|
+
`substituting another form: a config that bans one idiom often bans its obvious replacement too.`,
|
|
215
|
+
rule: `LINT:${bucket.ruleId}`,
|
|
216
|
+
botPredicted: true,
|
|
217
|
+
lintFixable: bucket.fixable,
|
|
218
|
+
lintSiteCount: bucket.sites.length,
|
|
219
|
+
// Every site, so bot-precision can match a bot comment on any of them.
|
|
220
|
+
lintSites: bucket.sites.map(s => ({ file: relativize(s.file), line: s.line })),
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const result = {
|
|
225
|
+
findings,
|
|
226
|
+
summary: {
|
|
227
|
+
status: messageCount === 0 ? 'clean' : 'issues',
|
|
228
|
+
ruleCount: ruleCount,
|
|
229
|
+
messageCount,
|
|
230
|
+
truncated: Math.max(0, buckets.length - findings.length),
|
|
231
|
+
outOfDiff,
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
writeFileSync(outPath, JSON.stringify(result, null, 2));
|
|
236
|
+
console.log(
|
|
237
|
+
`lint-findings: ${messageCount} message(s) across ${ruleCount} rule(s) -> ` +
|
|
238
|
+
`${findings.length} finding(s)${result.summary.truncated ? ` (${result.summary.truncated} rule(s) truncated)` : ''}`,
|
|
239
|
+
);
|