@olegkoval/agent-skills 1.54.3 → 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.
Files changed (29) hide show
  1. package/adapters/claude/olko-github-pr/skills/lekker-review/SKILL.md +135 -8
  2. package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/conventions.md +21 -0
  3. package/adapters/claude/olko-github-pr/skills/lekker-review/references/fix-mode.md +3 -1
  4. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/bot-precision.mjs +285 -0
  5. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/lint-findings.mjs +239 -0
  6. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/mine-bot-comments.sh +280 -0
  7. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +186 -0
  8. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +62 -3
  9. package/package.json +1 -1
  10. package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
  11. package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
  12. package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
  13. package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
  14. package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
  15. package/plugins/olko-github-pr/skills/lekker-review/SKILL.md +135 -8
  16. package/plugins/olko-github-pr/skills/lekker-review/references/agents/conventions.md +21 -0
  17. package/plugins/olko-github-pr/skills/lekker-review/references/fix-mode.md +3 -1
  18. package/plugins/olko-github-pr/skills/lekker-review/scripts/bot-precision.mjs +285 -0
  19. package/plugins/olko-github-pr/skills/lekker-review/scripts/lint-findings.mjs +239 -0
  20. package/plugins/olko-github-pr/skills/lekker-review/scripts/mine-bot-comments.sh +280 -0
  21. package/plugins/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +186 -0
  22. package/plugins/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +62 -3
  23. package/plugins/olko-github-pr/skills/lekker-review/workflow.js +10 -0
  24. package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
  25. package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
  26. package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
  27. package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
  28. package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
  29. 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
+ );