@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.
- 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,280 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Mine real Greptile / Gemini review comments from a repo's newest PR review comments
|
|
3
|
+
# and cluster them into the patterns worth pre-empting.
|
|
4
|
+
#
|
|
5
|
+
# Why this exists: predicting bot-reviewer comments from a model's intuition is
|
|
6
|
+
# guesswork. The bots have already told us what they comment on - dozens of
|
|
7
|
+
# times, in this exact repo, on this exact codebase. Mining that is free and it
|
|
8
|
+
# is evidence, not a guess.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# mine-bot-comments.sh <repo-slug> [out-md] [--limit N]
|
|
12
|
+
#
|
|
13
|
+
# repo-slug e.g. acme/web-app
|
|
14
|
+
# out-md default: $LEKKER_DATA_DIR/bot-patterns.md, where LEKKER_DATA_DIR
|
|
15
|
+
# defaults to ~/.cache/lekker-review (outside the skill dir, so a
|
|
16
|
+
# plugin update does not wipe the mined history)
|
|
17
|
+
# --limit N max review comments to fetch, newest first (default 400);
|
|
18
|
+
# paging stops there, so a long history is never pulled whole
|
|
19
|
+
#
|
|
20
|
+
# Writes/replaces a "## <repo-slug>" section in out-md, leaving other repos'
|
|
21
|
+
# sections alone, and prints a one-line summary. The section carries a
|
|
22
|
+
# "<!-- mined: YYYY-MM-DD -->" marker that SKILL.md reads to decide staleness;
|
|
23
|
+
# a repo with no bot comments still gets a section, so it is not re-mined on
|
|
24
|
+
# every run.
|
|
25
|
+
|
|
26
|
+
set -uo pipefail
|
|
27
|
+
|
|
28
|
+
REPO_SLUG="${1:-}"
|
|
29
|
+
if [[ -z "$REPO_SLUG" ]]; then
|
|
30
|
+
echo "usage: mine-bot-comments.sh <repo-slug> [out-md] [--limit N]" >&2
|
|
31
|
+
exit 2
|
|
32
|
+
fi
|
|
33
|
+
shift
|
|
34
|
+
|
|
35
|
+
DATA_DIR="${LEKKER_DATA_DIR:-$HOME/.cache/lekker-review}"
|
|
36
|
+
OUT_MD="$DATA_DIR/bot-patterns.md"
|
|
37
|
+
LIMIT=400
|
|
38
|
+
|
|
39
|
+
while [[ $# -gt 0 ]]; do
|
|
40
|
+
case "$1" in
|
|
41
|
+
--limit) LIMIT="${2:-}"; shift; [[ $# -gt 0 ]] && shift ;;
|
|
42
|
+
*) OUT_MD="$1"; shift ;;
|
|
43
|
+
esac
|
|
44
|
+
done
|
|
45
|
+
|
|
46
|
+
if ! [[ "$LIMIT" =~ ^[1-9][0-9]*$ ]]; then
|
|
47
|
+
echo "mine-bot-comments: --limit needs a positive integer, got '$LIMIT'" >&2
|
|
48
|
+
exit 2
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
mkdir -p "$(dirname "$OUT_MD")"
|
|
52
|
+
|
|
53
|
+
python3 - "$REPO_SLUG" "$OUT_MD" "$LIMIT" <<'PYEOF'
|
|
54
|
+
import datetime, json, os, re, subprocess, sys
|
|
55
|
+
from collections import defaultdict
|
|
56
|
+
|
|
57
|
+
repo_slug, out_md, limit = sys.argv[1:4]
|
|
58
|
+
limit = int(limit)
|
|
59
|
+
|
|
60
|
+
# Review comments are the inline ones - the class the bots actually bill for.
|
|
61
|
+
# Page by hand rather than `gh api --paginate`, which fetches the whole history
|
|
62
|
+
# before LIMIT could apply.
|
|
63
|
+
comments = []
|
|
64
|
+
page = 1
|
|
65
|
+
while len(comments) < limit:
|
|
66
|
+
res = subprocess.run(
|
|
67
|
+
["gh", "api", "repos/%s/pulls/comments?per_page=100&sort=created&direction=desc&page=%d"
|
|
68
|
+
% (repo_slug, page)],
|
|
69
|
+
capture_output=True, text=True)
|
|
70
|
+
if res.returncode != 0:
|
|
71
|
+
print("mine-bot-comments: gh api failed for %s (auth? repo name?)" % repo_slug, file=sys.stderr)
|
|
72
|
+
sys.exit(1)
|
|
73
|
+
batch = json.loads(res.stdout or "[]")
|
|
74
|
+
if not isinstance(batch, list):
|
|
75
|
+
break
|
|
76
|
+
comments.extend(batch)
|
|
77
|
+
if len(batch) < 100:
|
|
78
|
+
break
|
|
79
|
+
page += 1
|
|
80
|
+
comments = comments[:limit]
|
|
81
|
+
|
|
82
|
+
BOT_RE = re.compile(r'gemini-code-assist|greptile|coderabbit', re.I)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_bot(c):
|
|
86
|
+
# A reply in a bot's own thread ("Thanks, updated") is not a new finding.
|
|
87
|
+
# The account type too: a human login can contain a bot's name.
|
|
88
|
+
user = c.get("user") or {}
|
|
89
|
+
return (user.get("type") == "Bot" and bool(BOT_RE.search(user.get("login", "")))
|
|
90
|
+
and not c.get("in_reply_to_id"))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
bot_comments = [c for c in comments if is_bot(c)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def first_claim(body):
|
|
97
|
+
"""The sentence that carries the finding, with the bot's chrome removed."""
|
|
98
|
+
text = body or ""
|
|
99
|
+
# Both bots lead with chrome: Gemini an image badge, Greptile an <a><img>
|
|
100
|
+
# priority pill. Neither is the claim.
|
|
101
|
+
text = re.sub(r'!\[[^\]]*\]\([^)]*\)', '', text)
|
|
102
|
+
text = re.sub(r'<!--.*?-->', '', text, flags=re.S)
|
|
103
|
+
text = re.sub(r'<[^>]+>', ' ', text)
|
|
104
|
+
# Fenced code is the suggestion, not the claim.
|
|
105
|
+
text = re.sub(r'```.*?```', ' ', text, flags=re.S)
|
|
106
|
+
text = re.sub(r'`[^`]*`', ' CODE ', text)
|
|
107
|
+
text = re.sub(r'^\s*[#>*\-]+\s*', '', text, flags=re.M)
|
|
108
|
+
text = re.sub(r'\s+', ' ', text).strip()
|
|
109
|
+
parts = re.split(r'(?<=[.!?])\s', text)
|
|
110
|
+
for p in parts:
|
|
111
|
+
if len(p.strip()) > 25:
|
|
112
|
+
return p.strip()[:400]
|
|
113
|
+
return text[:400]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
STOP = set("""a an and are as at be been but by can could does for from has have if in into is it
|
|
117
|
+
its may not of on or should that the their them there these this to was were will with would you your
|
|
118
|
+
we i be his her they code line file change changes when which what while also""".split())
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def signature(claim):
|
|
122
|
+
words = re.findall(r'[a-z]+', claim.lower())
|
|
123
|
+
words = [w for w in words if len(w) > 3 and w not in STOP]
|
|
124
|
+
# The 6 longest words are a good enough fingerprint at this scale: short
|
|
125
|
+
# common words carry little. Ties break alphabetically so wording order
|
|
126
|
+
# does not split one pattern into two clusters.
|
|
127
|
+
return tuple(sorted(set(words), key=lambda w: (-len(w), w))[:6])
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# A curated taxonomy on top of the free-text clustering. Fingerprint clustering
|
|
131
|
+
# splits one concern across several wordings ("casting violates policy", "the use
|
|
132
|
+
# of CODE is a type cast", "TypeScript casts bypass checks" are one comment the
|
|
133
|
+
# bots make three ways), and the CATEGORY is what a reviewer can act on before
|
|
134
|
+
# the bot ever runs.
|
|
135
|
+
CATEGORIES = [
|
|
136
|
+
("type cast / any", r'\btype (cast|assertion)|\bcasting\b|\bas unknown\b|\bany\b type|\bnon-null assertion'),
|
|
137
|
+
("null / undefined guard", r'\bnull\b|\bundefined\b|optional chain|\bmissing (a )?check'),
|
|
138
|
+
("error handling", r'\berror (handling|is swallowed)|silently (fail|ignor)|catch block|\bthrow\b|unhandled'),
|
|
139
|
+
("pagination", r'\bpaginat|\bpageInfo\b|\bhasNextPage\b|\bcursor\b|page size'),
|
|
140
|
+
("await / concurrency", r'\bawait\b|floating promise|not awaited|\brace\b|concurren|fire.and.forget'),
|
|
141
|
+
("unused / dead code", r'\bunused\b|not used|dead code|\bunreachable\b|never referenced'),
|
|
142
|
+
("debug / logging", r'\bconsole\.|debug(ging)? (statement|log)|log(ging)? (noise|level)|should be removed before'),
|
|
143
|
+
("hardcoded / magic value",r'\bhardcoded\b|magic (number|string)|should be (a )?constant|configurable'),
|
|
144
|
+
("naming", r'\bnaming\b|\brename\b|\bmisleading name|the name CODE'),
|
|
145
|
+
("comment / docs", r'\bcomment (is|does|adds)|\bredundant comment|\bjsdoc\b|\btypo\b'),
|
|
146
|
+
("test coverage", r'\btest (coverage|case)|\bnot (covered|tested)|missing (a )?test'),
|
|
147
|
+
("performance / N+1", r'\bN\+1\b|\bin a loop\b|inefficien|\bperformance\b|every (run|iteration)|batch'),
|
|
148
|
+
("schedule / config", r'\bcron\b|\bschedul|\benv (var|variable)|\byaml\b|\bvalues\.'),
|
|
149
|
+
("SQL / query", r'\bSQL\b|\bquery uses\b|\bwhere clause\b|\bindex\b|\btransaction\b'),
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def categorize(body):
|
|
154
|
+
text = body or ""
|
|
155
|
+
hits = [name for name, rx in CATEGORIES if re.search(rx, text, re.I)]
|
|
156
|
+
return hits or ["other"]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
PRIORITY_RE = re.compile(r'badges/(p[0-9])\.svg|\b(P[0-9])\b')
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def priority(body):
|
|
163
|
+
m = PRIORITY_RE.search(body or "")
|
|
164
|
+
if not m:
|
|
165
|
+
return None
|
|
166
|
+
return (m.group(1) or m.group(2) or "").upper()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
category_counts = defaultdict(int)
|
|
170
|
+
category_priority = defaultdict(list)
|
|
171
|
+
for c in bot_comments:
|
|
172
|
+
prio = priority(c.get("body", ""))
|
|
173
|
+
for name in categorize(c.get("body", "")):
|
|
174
|
+
category_counts[name] += 1
|
|
175
|
+
if prio:
|
|
176
|
+
category_priority[name].append(prio)
|
|
177
|
+
|
|
178
|
+
clusters = defaultdict(list)
|
|
179
|
+
for c in bot_comments:
|
|
180
|
+
claim = first_claim(c.get("body", ""))
|
|
181
|
+
if not claim:
|
|
182
|
+
continue
|
|
183
|
+
clusters[signature(claim)].append((claim, c))
|
|
184
|
+
|
|
185
|
+
# Merge clusters that share most of their fingerprint - one pattern phrased two
|
|
186
|
+
# ways should not read as two patterns with a count of 1 each.
|
|
187
|
+
merged = []
|
|
188
|
+
for sig, items in sorted(clusters.items(), key=lambda kv: -len(kv[1])):
|
|
189
|
+
for m in merged:
|
|
190
|
+
shared = len(set(sig) & set(m["sig"]))
|
|
191
|
+
if sig and m["sig"] and shared / max(len(set(sig) | set(m["sig"])), 1) >= 0.5:
|
|
192
|
+
m["items"].extend(items)
|
|
193
|
+
break
|
|
194
|
+
else:
|
|
195
|
+
merged.append({"sig": sig, "items": list(items)})
|
|
196
|
+
|
|
197
|
+
merged.sort(key=lambda m: -len(m["items"]))
|
|
198
|
+
|
|
199
|
+
lines = []
|
|
200
|
+
lines.append("## %s" % repo_slug)
|
|
201
|
+
lines.append("<!-- mined: %s -->" % datetime.date.today().isoformat())
|
|
202
|
+
lines.append("")
|
|
203
|
+
if not bot_comments:
|
|
204
|
+
lines.append("No bot review comments in the newest %d review comment(s)." % len(comments))
|
|
205
|
+
lines.append("")
|
|
206
|
+
else:
|
|
207
|
+
lines.append("Mined from %d bot review comment(s) (%s). Counts are how many times the "
|
|
208
|
+
"bots have raised that pattern in THIS repo - the higher the count, the more "
|
|
209
|
+
"certain it is to come back on the next PR."
|
|
210
|
+
% (len(bot_comments), ", ".join(sorted({(c.get("user") or {}).get("login", "?")
|
|
211
|
+
for c in bot_comments}))))
|
|
212
|
+
lines.append("")
|
|
213
|
+
lines.append("### Categories, most-raised first")
|
|
214
|
+
lines.append("")
|
|
215
|
+
lines.append("| times raised | category | typical priority |")
|
|
216
|
+
lines.append("|---|---|---|")
|
|
217
|
+
for name, count in sorted(category_counts.items(), key=lambda kv: -kv[1]):
|
|
218
|
+
prios = category_priority.get(name, [])
|
|
219
|
+
typical = min(prios) if prios else "-"
|
|
220
|
+
lines.append("| %d | %s | %s |" % (count, name, typical))
|
|
221
|
+
lines.append("")
|
|
222
|
+
lines.append("A comment can land in more than one category, so the column sums above the")
|
|
223
|
+
lines.append("comment total. Read the categories as priors: on a diff that touches one of")
|
|
224
|
+
lines.append("them, assume the bots will raise it and settle it before opening the PR.")
|
|
225
|
+
lines.append("")
|
|
226
|
+
lines.append("### Concrete comments, most-repeated first")
|
|
227
|
+
lines.append("")
|
|
228
|
+
|
|
229
|
+
for m in merged:
|
|
230
|
+
if len(m["items"]) < 2 and len(merged) > 12:
|
|
231
|
+
continue
|
|
232
|
+
claim, c = m["items"][0]
|
|
233
|
+
count = len(m["items"])
|
|
234
|
+
path = c.get("path") or "?"
|
|
235
|
+
lines.append("- **x%d** - %s" % (count, claim))
|
|
236
|
+
lines.append(" - e.g. `%s` ([comment](%s))" % (path, c.get("html_url", "")))
|
|
237
|
+
|
|
238
|
+
lines.append("")
|
|
239
|
+
|
|
240
|
+
body = "\n".join(lines)
|
|
241
|
+
|
|
242
|
+
# The file is shared by every repo, so two reviews mining at once must not
|
|
243
|
+
# lose each other's sections: hold a lock across read-modify-write, and replace
|
|
244
|
+
# the file atomically so a reader never sees half of it.
|
|
245
|
+
import fcntl, tempfile
|
|
246
|
+
lock = open(out_md + ".lock", "w")
|
|
247
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
248
|
+
|
|
249
|
+
existing = ""
|
|
250
|
+
if os.path.exists(out_md):
|
|
251
|
+
with open(out_md) as f:
|
|
252
|
+
existing = f.read()
|
|
253
|
+
|
|
254
|
+
header = ("# Bot reviewer patterns\n\n"
|
|
255
|
+
"What Greptile / Gemini / CodeRabbit have actually commented on, per repo, mined\n"
|
|
256
|
+
"from review-comment history by `scripts/mine-bot-comments.sh`. This is evidence,\n"
|
|
257
|
+
"not prediction: every line below is a comment a bot really left.\n\n"
|
|
258
|
+
"Delivered to the review agents as `botPatternsFile`. Regenerate per repo with\n"
|
|
259
|
+
"`scripts/mine-bot-comments.sh <repo-slug>`.\n\n")
|
|
260
|
+
|
|
261
|
+
if not existing.startswith("# Bot reviewer patterns"):
|
|
262
|
+
existing = header + existing
|
|
263
|
+
|
|
264
|
+
# Replace this repo's section in place; leave every other repo alone.
|
|
265
|
+
pattern = re.compile(r'^## %s$.*?(?=^## |\Z)' % re.escape(repo_slug), re.M | re.S)
|
|
266
|
+
if pattern.search(existing):
|
|
267
|
+
# A function replacement, so backslashes in mined bot text are literal.
|
|
268
|
+
existing = pattern.sub(lambda _: body, existing)
|
|
269
|
+
else:
|
|
270
|
+
existing = existing.rstrip() + "\n\n" + body
|
|
271
|
+
|
|
272
|
+
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(out_md)), prefix=".bot-patterns.")
|
|
273
|
+
with os.fdopen(fd, "w") as f:
|
|
274
|
+
f.write(existing)
|
|
275
|
+
os.replace(tmp, out_md)
|
|
276
|
+
fcntl.flock(lock, fcntl.LOCK_UN)
|
|
277
|
+
|
|
278
|
+
print("mine-bot-comments: %d bot comment(s) -> %d pattern(s) -> %s"
|
|
279
|
+
% (len(bot_comments), len(merged), out_md))
|
|
280
|
+
PYEOF
|
|
@@ -383,5 +383,191 @@ console.log('\ndeps depth: routing, classifier, line validation')
|
|
|
383
383
|
check('summary counts', [r.summary.remapped, r.summary.nulled, r.summary.unchecked], [1, 1, 1])
|
|
384
384
|
}
|
|
385
385
|
|
|
386
|
+
console.log('\nbot pre-emption: lint pre-flight, pattern mining, precision store')
|
|
387
|
+
{
|
|
388
|
+
const { spawnSync } = await import('node:child_process')
|
|
389
|
+
const { mkdtempSync, writeFileSync: wf, readFileSync: rf, mkdirSync, chmodSync, symlinkSync, existsSync, rmSync } = await import('node:fs')
|
|
390
|
+
const { tmpdir } = await import('node:os')
|
|
391
|
+
const tmp = mkdtempSync(join(tmpdir(), 'lekker-bots-'))
|
|
392
|
+
const script = name => join(SKILL, 'scripts', name)
|
|
393
|
+
|
|
394
|
+
const lint = (name, content, worktree = '/wt') => {
|
|
395
|
+
const inPath = join(tmp, `${name}.json`)
|
|
396
|
+
const outPath = join(tmp, `${name}.out.json`)
|
|
397
|
+
if (content !== undefined) wf(inPath, content)
|
|
398
|
+
const res = spawnSync('node', [script('lint-findings.mjs'), inPath, outPath], {
|
|
399
|
+
encoding: 'utf8', env: { ...process.env, LEKKER_WORKTREE_PATH: worktree },
|
|
400
|
+
})
|
|
401
|
+
return { status: res.status, out: JSON.parse(rf(outPath, 'utf8')) }
|
|
402
|
+
}
|
|
403
|
+
const msg = (ruleId, line, severity = 2, fix) => ({ ruleId, line, column: 1, severity, message: `${ruleId} hit`, ...(fix ? { fix } : {}) })
|
|
404
|
+
const report = JSON.stringify([
|
|
405
|
+
{ filePath: '/wt/web/a.ts', messages: [msg('no-unused-vars', 3), msg('no-console', 9, 1), msg('no-unused-vars', 7, 2, { range: [0, 1], text: '' })] },
|
|
406
|
+
{ filePath: '/wt/web/b.ts', messages: [msg('no-unused-vars', 1)] },
|
|
407
|
+
{ filePath: '/wt/web/c.ts', messages: [] },
|
|
408
|
+
])
|
|
409
|
+
const issues = lint('issues', report)
|
|
410
|
+
check('lint: one finding per rule and file, errors first',
|
|
411
|
+
issues.out.findings.map(f => `${f.rule} ${f.file}`), ['LINT:no-unused-vars web/a.ts', 'LINT:no-unused-vars web/b.ts', 'LINT:no-console web/a.ts'])
|
|
412
|
+
check('lint: error -> important, warning -> idiomatic', issues.out.findings.map(f => f.severity), ['important', 'important', 'idiomatic'])
|
|
413
|
+
check('lint: paths relative to the worktree', issues.out.findings[0].file, 'web/a.ts')
|
|
414
|
+
check('lint: fixable and site count carried', [issues.out.findings[0].lintFixable, issues.out.findings[0].lintSiteCount], [true, 2])
|
|
415
|
+
check('lint: every finding is botPredicted', issues.out.findings.every(f => f.botPredicted === true), true)
|
|
416
|
+
check('lint: summary', [issues.out.summary.status, issues.out.summary.messageCount, issues.out.summary.ruleCount], ['issues', 4, 2])
|
|
417
|
+
check('lint: a report with no messages is clean', lint('clean', '[{"filePath":"/wt/a.ts","messages":[]}]').out.summary.status, 'clean')
|
|
418
|
+
check('lint: an empty report is not clean', lint('empty', '').out.summary.status, 'empty-report')
|
|
419
|
+
check('lint: a report listing no files is not clean', lint('nofiles', '[]').out.summary.status, 'empty-report')
|
|
420
|
+
// eslint reports the resolved path while the worktree arrives via a symlink
|
|
421
|
+
// (macOS: /tmp -> /private/tmp); the finding must still be repo-relative.
|
|
422
|
+
const realWt = join(tmp, 'real-wt')
|
|
423
|
+
mkdirSync(join(realWt, 'web'), { recursive: true })
|
|
424
|
+
wf(join(realWt, 'web', 'd.ts'), 'const a = 1\nconst unused = 2\n')
|
|
425
|
+
symlinkSync(realWt, join(tmp, 'link-wt'))
|
|
426
|
+
const viaLink = lint('symlink', JSON.stringify([{ filePath: join(realWt, 'web', 'd.ts'), messages: [msg('no-unused-vars', 2)] }]), join(tmp, 'link-wt'))
|
|
427
|
+
check('lint: symlinked worktree still gives a relative path', viaLink.out.findings[0].file, 'web/d.ts')
|
|
428
|
+
check('lint: badCode is the source line, not a location', viaLink.out.findings[0].badCode, 'const unused = 2')
|
|
429
|
+
check('lint: a non-array report is not clean', lint('obj', '{}').out.summary.status, 'not-an-eslint-json-report')
|
|
430
|
+
const missing = lint('missing')
|
|
431
|
+
check('lint: a missing report is unknown, exit 0', [missing.status, missing.out.summary.status.startsWith('unreadable:')], [0, true])
|
|
432
|
+
check('lint: every site is carried for scoring', issues.out.findings[0].lintSites.map(x => `${x.file}:${x.line}`), ['web/a.ts:3', 'web/a.ts:7'])
|
|
433
|
+
const notices = lint('notices', JSON.stringify([{ filePath: '/wt/web/e.ts', messages: [
|
|
434
|
+
{ ruleId: null, severity: 1, line: 0, message: 'File ignored because of a matching ignore pattern.' },
|
|
435
|
+
{ ruleId: null, fatal: true, severity: 2, line: 4, message: 'Parsing error: Unexpected token' },
|
|
436
|
+
] }]))
|
|
437
|
+
check('lint: an ignored-file notice is not a finding, a fatal parse error is',
|
|
438
|
+
[notices.out.summary.messageCount, notices.out.findings.map(f => f.rule)], [1, ['LINT:parse-error']])
|
|
439
|
+
|
|
440
|
+
// --base: only lines the change added count. A real repo, since the filter
|
|
441
|
+
// reads git diff -U0 <base> HEAD in the worktree.
|
|
442
|
+
const repo = join(tmp, 'diffrepo')
|
|
443
|
+
mkdirSync(join(repo, 'web'), { recursive: true })
|
|
444
|
+
const git = (...a) => spawnSync('git', ['-C', repo, '-c', 'user.name=t', '-c', 'user.email=t@t', ...a], { encoding: 'utf8' })
|
|
445
|
+
git('init', '-q')
|
|
446
|
+
wf(join(repo, 'web', 'f.ts'), 'one\ntwo\nthree\n')
|
|
447
|
+
git('config', 'diff.noprefix', 'true')
|
|
448
|
+
git('add', '.'); git('commit', '-qm', 'base')
|
|
449
|
+
const base = git('rev-parse', 'HEAD').stdout.trim()
|
|
450
|
+
wf(join(repo, 'web', 'f.ts'), 'one\nTWO\nthree\nfour\n')
|
|
451
|
+
git('commit', '-qam', 'change')
|
|
452
|
+
const inDiff = (() => {
|
|
453
|
+
const inPath = join(tmp, 'diff.json'), outPath = join(tmp, 'diff.out.json')
|
|
454
|
+
wf(inPath, JSON.stringify([{ filePath: join(repo, 'web', 'f.ts'), messages: [
|
|
455
|
+
msg('no-console', 1), msg('no-console', 2), msg('no-unused-vars', 4),
|
|
456
|
+
{ ruleId: null, fatal: true, severity: 2, line: 1, message: 'Parsing error' },
|
|
457
|
+
] }]))
|
|
458
|
+
spawnSync('node', [script('lint-findings.mjs'), inPath, outPath, '--base', base], { encoding: 'utf8', env: { ...process.env, LEKKER_WORKTREE_PATH: repo } })
|
|
459
|
+
return JSON.parse(rf(outPath, 'utf8'))
|
|
460
|
+
})()
|
|
461
|
+
check('lint --base: untouched lines dropped, added lines and parse errors kept',
|
|
462
|
+
[inDiff.summary.messageCount, inDiff.summary.outOfDiff, inDiff.findings.flatMap(f => f.lintSites.map(x => x.line)).sort()], [3, 1, [1, 2, 4]])
|
|
463
|
+
|
|
464
|
+
const store = join(tmp, 'precision.jsonl')
|
|
465
|
+
const prec = args => spawnSync('node', [script('bot-precision.mjs'), ...args, '--jsonl', store], { encoding: 'utf8' })
|
|
466
|
+
const predicted = join(tmp, 'issues.out.json')
|
|
467
|
+
const rec = prec(['record', '--repo', 'acme/app', '--pr', '7', '--predicted', predicted])
|
|
468
|
+
check('precision: record appends one prediction per lint site', [rec.status, JSON.parse(rf(store, 'utf8').trim()).predictedCount], [0, 4])
|
|
469
|
+
check('precision: lint findings are tagged lint-preflight',
|
|
470
|
+
JSON.parse(rf(store, 'utf8').trim()).predicted.every(p => p.source === 'lint-preflight'), true)
|
|
471
|
+
check('precision: no prediction scores as nothing, not 0%', /no recorded prediction/.test(prec(['score', '--repo', 'acme/app', '--pr', '8']).stdout), true)
|
|
472
|
+
check('precision: summary with no scores', /no scored reviews yet/.test(prec(['summary']).stdout), true)
|
|
473
|
+
|
|
474
|
+
// Data dir: LEKKER_DATA_DIR wins, else ~/.cache/lekker-review - never the
|
|
475
|
+
// skill dir, which a plugin update replaces.
|
|
476
|
+
const noJsonl = (env, args) => spawnSync('node', [script('bot-precision.mjs'), ...args], { encoding: 'utf8', env: { ...process.env, ...env } })
|
|
477
|
+
const dataDir = join(tmp, 'data')
|
|
478
|
+
noJsonl({ LEKKER_DATA_DIR: dataDir }, ['record', '--repo', 'acme/app', '--pr', '1', '--predicted', predicted])
|
|
479
|
+
check('precision: LEKKER_DATA_DIR sets the store', existsSync(join(dataDir, 'bot-precision.jsonl')), true)
|
|
480
|
+
const home = join(tmp, 'home')
|
|
481
|
+
const envNoData = { ...process.env, HOME: home }
|
|
482
|
+
delete envNoData.LEKKER_DATA_DIR
|
|
483
|
+
spawnSync('node', [script('bot-precision.mjs'), 'record', '--repo', 'acme/app', '--pr', '1', '--predicted', predicted], { encoding: 'utf8', env: envNoData })
|
|
484
|
+
check('precision: default store is ~/.cache/lekker-review', existsSync(join(home, '.cache', 'lekker-review', 'bot-precision.jsonl')), true)
|
|
485
|
+
|
|
486
|
+
// Stub gh on PATH so the miner runs offline against a fixed comment set.
|
|
487
|
+
const bin = join(tmp, 'bin')
|
|
488
|
+
mkdirSync(bin)
|
|
489
|
+
const comments = [
|
|
490
|
+
{ user: { login: 'greptile-apps[bot]', type: 'Bot' }, path: 'src/a.ts', in_reply_to_id: 1, html_url: 'https://x/r', body: 'Thanks for the update, this looks resolved now to me.' },
|
|
491
|
+
{ user: { login: 'coderabbit-fan', type: 'User' }, path: 'src/a.ts', html_url: 'https://x/h', body: 'A human whose login names a bot is still a human here.' },
|
|
492
|
+
...[1, 2, 3].map(i => ({ user: { login: 'greptile-apps[bot]', type: 'Bot' }, path: 'src/a.ts', html_url: `https://x/${i}`, body: 'This type cast bypasses the compiler and hides a real mismatch here.' })),
|
|
493
|
+
{ user: { login: 'gemini-code-assist[bot]', type: 'Bot' }, path: 'src/b.ts', html_url: 'https://x/4', body: 'The promise is not awaited, so errors are silently ignored by the caller.' },
|
|
494
|
+
{ user: { login: 'a-human' }, path: 'src/c.ts', html_url: 'https://x/5', body: 'Humans are not mined, only the bots are counted here.' },
|
|
495
|
+
]
|
|
496
|
+
wf(join(tmp, 'comments.json'), JSON.stringify(comments))
|
|
497
|
+
// Precision fixture, served --slurp style (a list of pages) for pulls/9.
|
|
498
|
+
const bot = (login, path, line) => ({ user: { login, type: login.endsWith('[bot]') ? 'Bot' : 'User' }, path, line, html_url: `https://x/${path}`, body: `${path} remark` })
|
|
499
|
+
wf(join(tmp, 'pr9.json'), JSON.stringify([[
|
|
500
|
+
bot('greptile-apps[bot]', 'web/a.ts', 10),
|
|
501
|
+
bot('gemini-code-assist[bot]', 'web/c.ts', 5),
|
|
502
|
+
bot('greptile-apps[bot]', 'web/b.ts', 50),
|
|
503
|
+
bot('a-human', 'web/a.ts', 100),
|
|
504
|
+
bot('coderabbit-fan', 'web/z.ts', 1),
|
|
505
|
+
{ ...bot('greptile-apps[bot]', 'web/x.ts', 1), in_reply_to_id: 1 },
|
|
506
|
+
{ ...bot('greptile-apps[bot]', 'web/y.ts', 1), created_at: '2000-01-01T00:00:00Z' },
|
|
507
|
+
]]))
|
|
508
|
+
wf(join(bin, 'gh'), [
|
|
509
|
+
'#!/bin/sh',
|
|
510
|
+
'case "$*" in',
|
|
511
|
+
` *pulls/9/comments*) cat "${join(tmp, 'pr9.json')}" ;;`,
|
|
512
|
+
" *acme/empty*) echo '[]' ;;",
|
|
513
|
+
` *'&page=1') cat "${join(tmp, 'comments.json')}" ;;`,
|
|
514
|
+
" *) echo '[]' ;;",
|
|
515
|
+
'esac',
|
|
516
|
+
'',
|
|
517
|
+
].join('\n'))
|
|
518
|
+
chmodSync(join(bin, 'gh'), 0o755)
|
|
519
|
+
const ghEnv = { ...process.env, PATH: `${bin}:${process.env.PATH}` }
|
|
520
|
+
|
|
521
|
+
// Predictions: a.ts:3 is 7 lines from a bot comment (hit), a.ts:100 has no
|
|
522
|
+
// bot comment within 20 lines (the human one does not count), b.ts with no
|
|
523
|
+
// line matches any comment on b.ts. c.ts was not predicted (missed).
|
|
524
|
+
const pr9 = join(tmp, 'pr9-predicted.json')
|
|
525
|
+
wf(pr9, JSON.stringify([{ file: 'web/a.ts', line: 3 }, { file: 'web/a.ts', line: 100 }, { file: 'web/b.ts', line: null }]))
|
|
526
|
+
const score9 = args => spawnSync('node', [script('bot-precision.mjs'), ...args, '--jsonl', store], { encoding: 'utf8', env: ghEnv })
|
|
527
|
+
score9(['record', '--repo', 'acme/app', '--pr', '9', '--predicted', pr9, '--at', new Date(Date.now() - 60000).toISOString()])
|
|
528
|
+
check('precision: --at must be a timestamp', score9(['record', '--repo', 'acme/app', '--pr', '9', '--predicted', pr9, '--at', 'soon']).status, 2)
|
|
529
|
+
// A later prediction (a re-review after the bots ran) must not be scored.
|
|
530
|
+
const late = join(tmp, 'pr9-late.json')
|
|
531
|
+
wf(late, JSON.stringify([{ file: 'web/c.ts', line: 5 }]))
|
|
532
|
+
score9(['record', '--repo', 'acme/app', '--pr', '9', '--predicted', late])
|
|
533
|
+
const s9 = score9(['score', '--repo', 'acme/app', '--pr', '9'])
|
|
534
|
+
const last = () => rf(store, 'utf8').trim().split('\n').map(l => JSON.parse(l)).filter(r => r.kind === 'score').pop()
|
|
535
|
+
const sc = last()
|
|
536
|
+
check('precision: score counts only bot comments, no replies', [s9.status, sc.botCommentCount], [0, 3])
|
|
537
|
+
check('precision: comments older than the prediction excluded', sc.preexistingExcluded, 1)
|
|
538
|
+
check('precision: the first prediction is scored, not a later one', sc.predictedCount, 3)
|
|
539
|
+
check('precision: precision and recall', [sc.predictedAndRaised, sc.botCommentsPredicted, sc.precision.toFixed(3), sc.recall.toFixed(3)], [2, 2, '0.667', '0.667'])
|
|
540
|
+
check('precision: a null line is "no line", not line 0', sc.predictedAndRaised, 2)
|
|
541
|
+
check('precision: missed comments listed', sc.missedBotComments.map(c => c.path), ['web/c.ts'])
|
|
542
|
+
score9(['score', '--repo', 'acme/app', '--pr', '9'])
|
|
543
|
+
check('precision: a re-scored PR counts once in the summary', /summary: 1 scored review/.test(score9(['summary']).stdout), true)
|
|
544
|
+
const out = join(tmp, 'patterns.md')
|
|
545
|
+
const mine = slug => spawnSync('bash', [script('mine-bot-comments.sh'), slug, out], { encoding: 'utf8', env: ghEnv })
|
|
546
|
+
const m1 = mine('acme/app')
|
|
547
|
+
const md1 = rf(out, 'utf8')
|
|
548
|
+
check('mine: exit 0 and a section for the repo', [m1.status, md1.includes('## acme/app\n')], [0, true])
|
|
549
|
+
check('mine: counts only bot comments', /Mined from 4 bot review comment/.test(md1), true)
|
|
550
|
+
check('mine: a repeated claim is one pattern with its count', /\*\*x3\*\* - This type cast/.test(md1), true)
|
|
551
|
+
check('mine: categories table', /\| 3 \| type cast \/ any \|/.test(md1), true)
|
|
552
|
+
check('mine: section carries its own mined date', md1.includes(`## acme/app\n<!-- mined: ${new Date().toISOString().slice(0, 10)} -->`), true)
|
|
553
|
+
const noLimit = spawnSync('bash', [script('mine-bot-comments.sh'), 'acme/app', out, '--limit'], { encoding: 'utf8', env: ghEnv, timeout: 10000 })
|
|
554
|
+
check('mine: --limit with no value fails fast', noLimit.status, 2)
|
|
555
|
+
const empty = mine('acme/empty')
|
|
556
|
+
check('mine: a repo with no bot comments still gets a section', [empty.status, rf(out, 'utf8').includes('## acme/empty\n<!-- mined: ')], [0, true])
|
|
557
|
+
mine('acme/other')
|
|
558
|
+
mine('acme/app')
|
|
559
|
+
const md2 = rf(out, 'utf8')
|
|
560
|
+
check('mine: re-mining replaces the section, other repos kept',
|
|
561
|
+
[md2.split('## acme/app\n').length - 1, md2.includes('## acme/other\n')], [1, true])
|
|
562
|
+
|
|
563
|
+
check('workflow.js tells agents not to re-report the pre-flight', src.includes('"preflightFile"'), true)
|
|
564
|
+
const skillMd = readFileSync(join(SKILL, 'SKILL.md'), 'utf8')
|
|
565
|
+
check('SKILL.md passes preflightFile and botPatternsFile in CONTEXT_FILE',
|
|
566
|
+
skillMd.includes('"preflightFile":') && skillMd.includes('"botPatternsFile":'), true)
|
|
567
|
+
check('SKILL.md ages bot patterns by the section marker, not file mtime',
|
|
568
|
+
skillMd.includes('<!-- mined: /') && !skillMd.includes('-mtime +30'), true)
|
|
569
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
570
|
+
}
|
|
571
|
+
|
|
386
572
|
console.log(failed === 0 ? '\nall checks passed\n' : `\n${failed} check(s) FAILED\n`)
|
|
387
573
|
process.exit(failed === 0 ? 0 : 1)
|
|
@@ -14,6 +14,8 @@ set -euo pipefail
|
|
|
14
14
|
REPO_SLUG="${1:?REPO_SLUG required}"
|
|
15
15
|
PR_BRANCH="${2:?PR_BRANCH required}"
|
|
16
16
|
OUT_JSON="${3:?OUT_JSON required}"
|
|
17
|
+
# Absolute, because the static checks run after cd into the worktree.
|
|
18
|
+
case "$OUT_JSON" in /*) ;; *) OUT_JSON="$PWD/$OUT_JSON" ;; esac
|
|
17
19
|
PREV_SHA="${4:-}"
|
|
18
20
|
|
|
19
21
|
LOCAL_REPO="${LEKKER_LOCAL_REPO:-}"
|
|
@@ -311,11 +313,50 @@ TSC_CHANGED_FILE="/tmp/lekker-tsc-changed-$$.txt"
|
|
|
311
313
|
TSC_COUNT_FILE="/tmp/lekker-tsc-count-$$.txt"
|
|
312
314
|
ESLINT_TAIL_FILE="/tmp/lekker-eslint-$$.txt"
|
|
313
315
|
ESLINT_SCOPE_FILE="/tmp/lekker-eslint-scope-$$.txt"
|
|
316
|
+
# Structured lint report, kept beside the output JSON (NOT deleted with the
|
|
317
|
+
# temp files): lint-findings.mjs turns it into botPredicted findings, so the
|
|
318
|
+
# review can fix what the bot reviewers would have charged for. Exported by
|
|
319
|
+
# path as eslintJson.
|
|
320
|
+
ESLINT_JSON_FILE="$(dirname "$OUT_JSON")/eslint-report.json"
|
|
314
321
|
printf '' > "$TSC_TAIL_FILE"
|
|
315
322
|
printf '' > "$TSC_CHANGED_FILE"
|
|
316
323
|
printf '0' > "$TSC_COUNT_FILE"
|
|
317
324
|
printf '' > "$ESLINT_TAIL_FILE"
|
|
318
325
|
printf 'skipped' > "$ESLINT_SCOPE_FILE"
|
|
326
|
+
printf '' > "$ESLINT_JSON_FILE"
|
|
327
|
+
|
|
328
|
+
# Render a stylish-style tail from the JSON report. An empty or unparseable
|
|
329
|
+
# report (eslint crashed, bad config) keeps whatever eslint wrote on stderr
|
|
330
|
+
# instead of rendering "0 errors" from nothing.
|
|
331
|
+
render_eslint_tail() {
|
|
332
|
+
local rendered="$ESLINT_JSON_FILE.txt"
|
|
333
|
+
node -e '
|
|
334
|
+
const fs = require("fs");
|
|
335
|
+
const raw = fs.readFileSync(process.argv[1], "utf8");
|
|
336
|
+
if (!raw.trim()) process.exit(3);
|
|
337
|
+
let report;
|
|
338
|
+
try { report = JSON.parse(raw); } catch { process.exit(3); }
|
|
339
|
+
if (!Array.isArray(report) || !report.length) process.exit(3);
|
|
340
|
+
const lines = [];
|
|
341
|
+
let errors = 0, warnings = 0;
|
|
342
|
+
for (const f of report) {
|
|
343
|
+
const msgs = f.messages || [];
|
|
344
|
+
if (!msgs.length) continue;
|
|
345
|
+
lines.push(f.filePath);
|
|
346
|
+
for (const m of msgs) {
|
|
347
|
+
if (m.severity === 2) errors++; else warnings++;
|
|
348
|
+
lines.push(` ${m.line}:${m.column} ${m.severity === 2 ? "error" : "warning"} ${m.message} ${m.ruleId || ""}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (!errors && !warnings) process.exit(0);
|
|
352
|
+
lines.push(`total: ${errors} error(s), ${warnings} warning(s)`);
|
|
353
|
+
process.stdout.write(lines.slice(-40).join("\n") + "\n");
|
|
354
|
+
' "$ESLINT_JSON_FILE" > "$rendered" 2>/dev/null
|
|
355
|
+
if [[ -s "$rendered" ]]; then
|
|
356
|
+
cat "$rendered" > "$ESLINT_TAIL_FILE"
|
|
357
|
+
fi
|
|
358
|
+
rm -f "$rendered"
|
|
359
|
+
}
|
|
319
360
|
|
|
320
361
|
run_tsc() {
|
|
321
362
|
# tsc needs the whole program to resolve types, so it always runs
|
|
@@ -345,8 +386,11 @@ run_eslint() {
|
|
|
345
386
|
if [[ "$has_config" == "true" ]]; then
|
|
346
387
|
if [[ -s "$CHANGED_FILES_FILE" ]]; then
|
|
347
388
|
if [[ ${#ESLINT_TARGETS[@]} -gt 0 ]]; then
|
|
348
|
-
#
|
|
349
|
-
|
|
389
|
+
# One run, JSON formatter: the machine-readable report feeds
|
|
390
|
+
# lint-findings.mjs and the human tail is rendered from the
|
|
391
|
+
# same report, so the two can never disagree.
|
|
392
|
+
(cd "$WORKTREE_PATH" && npx eslint --max-warnings=0 -f json -o "$ESLINT_JSON_FILE" "${ESLINT_TARGETS[@]}" 2> "$ESLINT_TAIL_FILE") || true
|
|
393
|
+
render_eslint_tail
|
|
350
394
|
printf 'changed-files' > "$ESLINT_SCOPE_FILE"
|
|
351
395
|
else
|
|
352
396
|
printf 'no-changed-lintable-files\n' > "$ESLINT_TAIL_FILE"
|
|
@@ -354,6 +398,8 @@ run_eslint() {
|
|
|
354
398
|
fi
|
|
355
399
|
else
|
|
356
400
|
# Base ref could not be resolved -- fall back to the old repo-wide run.
|
|
401
|
+
# No JSON here on purpose: a repo-wide report is standing debt,
|
|
402
|
+
# not this change's, and must not become botPredicted findings.
|
|
357
403
|
(cd "$WORKTREE_PATH" && npx eslint . --max-warnings=0 2>&1 | tail -40 >> "$ESLINT_TAIL_FILE") || true
|
|
358
404
|
printf 'full-fallback' > "$ESLINT_SCOPE_FILE"
|
|
359
405
|
fi
|
|
@@ -428,6 +474,7 @@ python3 - \
|
|
|
428
474
|
"$TSC_COUNT_FILE" \
|
|
429
475
|
"$MERGE_BASE_VAL" \
|
|
430
476
|
"$DEPS_INSTALL_FILE" \
|
|
477
|
+
"$ESLINT_JSON_FILE" \
|
|
431
478
|
"$OUT_JSON" <<'PYEOF'
|
|
432
479
|
import json, sys, os
|
|
433
480
|
|
|
@@ -435,7 +482,7 @@ import json, sys, os
|
|
|
435
482
|
tsc_file, eslint_file, rules_file, delta_file, notes,
|
|
436
483
|
changed_files_file, base_ref_val, eslint_scope_file,
|
|
437
484
|
tsc_changed_file, tsc_count_file, merge_base_val,
|
|
438
|
-
deps_install_file, out_json) = sys.argv[1:
|
|
485
|
+
deps_install_file, eslint_json_file, out_json) = sys.argv[1:19]
|
|
439
486
|
|
|
440
487
|
def read_file(path):
|
|
441
488
|
if not os.path.exists(path):
|
|
@@ -462,6 +509,14 @@ for row in read_file(deps_install_file).splitlines():
|
|
|
462
509
|
|
|
463
510
|
changed_files = [l for l in read_file(changed_files_file).splitlines() if l.strip()]
|
|
464
511
|
|
|
512
|
+
|
|
513
|
+
def file_has_content(path):
|
|
514
|
+
try:
|
|
515
|
+
return os.path.getsize(path) > 0
|
|
516
|
+
except OSError:
|
|
517
|
+
return False
|
|
518
|
+
|
|
519
|
+
|
|
465
520
|
eslint_scope = read_file(eslint_scope_file).strip() or "skipped"
|
|
466
521
|
|
|
467
522
|
try:
|
|
@@ -486,6 +541,10 @@ data = {
|
|
|
486
541
|
"tscChangedTail": read_file(tsc_changed_file),
|
|
487
542
|
"tscErrorCount": tsc_error_count,
|
|
488
543
|
"depsInstall": deps_install,
|
|
544
|
+
# Path, not content: lint-findings.mjs reads it. None when eslint produced
|
|
545
|
+
# no structured report (skipped, no config, crashed, or the repo-wide
|
|
546
|
+
# fallback), so a consumer can tell "no lint report" from "no problems".
|
|
547
|
+
"eslintJson": eslint_json_file if file_has_content(eslint_json_file) else None,
|
|
489
548
|
}
|
|
490
549
|
|
|
491
550
|
for p in (tsc_file, eslint_file, rules_file, changed_files_file,
|
|
@@ -519,6 +519,16 @@ const budgetAtStart = budget.spent()
|
|
|
519
519
|
+ ` number of DIFF_FILE itself. Quote the code at that line verbatim in "badCode".`,
|
|
520
520
|
].join(' ')
|
|
521
521
|
|
|
522
|
+
// The deterministic lint pre-flight has already collected every rule the
|
|
523
|
+
// repo's own eslint config fires on this diff, and those are exactly the
|
|
524
|
+
// comments the bot reviewers bill for. An agent re-reporting them costs a
|
|
525
|
+
// verifier round and produces a worse description than the linter's.
|
|
526
|
+
prompt += ` PRE-FLIGHT: CONTEXT_FILE may carry a key "preflightFile" pointing`
|
|
527
|
+
+ ` at lint findings already collected deterministically from the repo's own`
|
|
528
|
+
+ ` eslint config. If it is present and non-null, Read it and do NOT re-report`
|
|
529
|
+
+ ` anything it already lists - those are already queued for fixing. Report`
|
|
530
|
+
+ ` only what it missed.`
|
|
531
|
+
|
|
522
532
|
if (prevSha) {
|
|
523
533
|
prompt += ` RE-REVIEW MODE: a prior review covered up to commit ${prevSha}.`
|
|
524
534
|
+ ` Focus findings on the delta (see key deltaFile in CONTEXT_FILE);`
|