@olegkoval/agent-skills 1.63.0 → 1.65.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 +9 -1
- 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/github-post.md +6 -0
- 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/human-noise.mjs +194 -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 +9 -1
- 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/github-post.md +6 -0
- package/plugins/olko-github-pr/skills/lekker-review/references/output-format.md +2 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/human-noise.mjs +194 -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 +60 -5
- 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
|
@@ -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,14 @@ 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
|
+
Bots are not the only signal. To measure noise from what humans did, run
|
|
1329
|
+
`node ${SKILL_ROOT}/scripts/human-noise.mjs --repo <REPO_SLUG> --pr <N> [--pr <N>...]`
|
|
1330
|
+
on merged or settled PRs: it classifies each posted lekker inline comment as
|
|
1331
|
+
fixed (thread outdated), dismissed (affirmative author reply), resolved (code
|
|
1332
|
+
outcome unknown) or ignored, and prints the dismissal rate per agent. Resolved
|
|
1333
|
+
threads remain in the total but do not count as dismissed. Only comments ending in the
|
|
1334
|
+
`<!-- lekker:<dimension> -->` marker (see `references/github-post.md`) are
|
|
1335
|
+
counted, so PRs posted before the marker existed score zero comments.
|
|
1328
1336
|
**Eval harness.** `evals/manifest.json` lists PRs with a known outcome (`expect:
|
|
1329
1337
|
"buggy"` with `knownBug: "file:line description"`, or `"clean"`). Run lekker on
|
|
1330
1338
|
each, save the `findings` arrays as `{"<repo>#<pr>": [...]}`, then
|
|
@@ -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
|
|
|
@@ -33,6 +33,12 @@ Rules:
|
|
|
33
33
|
determine commentability). A finding with `line: null` (set by
|
|
34
34
|
`scripts/validate-lines.mjs` when its quoted code is not in the file) is
|
|
35
35
|
never an inline comment: list it in the review body instead.
|
|
36
|
+
- **End every inline comment body with the marker** `<!-- lekker:<dimension> -->`
|
|
37
|
+
on its own last line, using the finding's `dimension` (`quality`,
|
|
38
|
+
`implementation`, `simplification`, `conventions`, `test-quality`, `critic`;
|
|
39
|
+
`unknown` when absent). It is invisible on GitHub, and is the only way
|
|
40
|
+
`scripts/human-noise.mjs` can tell lekker's comments from the author's own,
|
|
41
|
+
because the pending review is posted under the requesting user's account.
|
|
36
42
|
- **Keep every comment body to 2-4 sentences and under ~700 characters**
|
|
37
43
|
(excluding any suggestion block). Inline comments are read one at a time in a
|
|
38
44
|
narrow column; a long one does not get read, so length actively costs you the
|
|
@@ -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,194 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Measure lekker's noise from what humans did with its comments.
|
|
3
|
+
//
|
|
4
|
+
// bot-precision.mjs only compares predictions with other bots. The honest signal
|
|
5
|
+
// is whether the author acted on a comment, so this reads lekker's own posted
|
|
6
|
+
// inline comments and classifies each one:
|
|
7
|
+
// fixed the thread is outdated: the code at that location changed after the comment
|
|
8
|
+
// dismissed the PR author replied with a dismissal
|
|
9
|
+
// resolved resolved, but no code outcome is known
|
|
10
|
+
// ignored none of the above (no code change, no dismissal, still open)
|
|
11
|
+
// and reports the dismissal rate per agent.
|
|
12
|
+
//
|
|
13
|
+
// Identification: the pending review is posted under the requesting user's own
|
|
14
|
+
// account, so the author cannot tell lekker from a human. github-post.md makes
|
|
15
|
+
// every inline comment end with `<!-- lekker:<dimension> -->`; only threads whose
|
|
16
|
+
// first comment carries that marker are counted.
|
|
17
|
+
//
|
|
18
|
+
// Usage:
|
|
19
|
+
// node human-noise.mjs --repo <owner/repo> --pr <n> [--pr <n>...] [--json]
|
|
20
|
+
// node human-noise.mjs --from <threads.json> [--json]
|
|
21
|
+
// threads.json: [{ prAuthor, threads: [{ path, line, isResolved, isOutdated,
|
|
22
|
+
// comments: [{ author, body }] }] }] (offline fixture / saved fetch)
|
|
23
|
+
|
|
24
|
+
import { execFileSync } from 'node:child_process';
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const MARKER = /<!--\s*lekker:([a-z0-9-]+)\s*-->/i;
|
|
29
|
+
export const AGENTS = ['quality', 'implementation', 'simplification', 'conventions', 'test-quality'];
|
|
30
|
+
|
|
31
|
+
const INTENT = /\b(intentional(ly)?|by design|on purpose|deliberate(ly)?|as designed|expected behaviou?r)\b/i;
|
|
32
|
+
const DISMISS = /\b(won'?t fix|wontfix|not a (bug|problem|concern)|false positive|disagree|n\/a|not applicable|out of scope|ignor(e|ing)|nit)\b/i;
|
|
33
|
+
const NEGATOR = /\b(not|no|never|nor|isn'?t|aren'?t|wasn'?t|don'?t|doesn'?t|won'?t|cannot)\b/i;
|
|
34
|
+
|
|
35
|
+
// Check only the three words before each keyword in its clause for negation.
|
|
36
|
+
function mentions(pattern, text) {
|
|
37
|
+
return [...text.matchAll(new RegExp(pattern.source, 'gi'))]
|
|
38
|
+
.some((match) => {
|
|
39
|
+
const before = text.slice(0, match.index).split(/[.!?;:,\n-]/).pop();
|
|
40
|
+
return !NEGATOR.test(before.trim().split(/\s+/).slice(-3).join(' '));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Self-contained on purpose: this script must not depend on other lekker scripts.
|
|
45
|
+
export function classifyThread(thread, prAuthor) {
|
|
46
|
+
const text = (thread.replies || [])
|
|
47
|
+
.filter((r) => r.author && r.author === prAuthor)
|
|
48
|
+
.map((r) => r.body)
|
|
49
|
+
.join('\n');
|
|
50
|
+
if (thread.isOutdated) return 'fixed';
|
|
51
|
+
if (mentions(INTENT, text) || mentions(DISMISS, text)) return 'dismissed';
|
|
52
|
+
if (thread.isResolved) return 'resolved';
|
|
53
|
+
return 'ignored';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function agentOf(body) {
|
|
57
|
+
const m = MARKER.exec(String(body || ''));
|
|
58
|
+
return m ? m[1].toLowerCase() : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function classifyLekkerThread(thread, prAuthor) {
|
|
62
|
+
const [first, ...rest] = thread.comments || [];
|
|
63
|
+
const agent = first ? agentOf(first.body) : null;
|
|
64
|
+
if (!agent) return null;
|
|
65
|
+
const outcome = classifyThread({
|
|
66
|
+
isOutdated: thread.isOutdated,
|
|
67
|
+
isResolved: thread.isResolved,
|
|
68
|
+
replies: rest,
|
|
69
|
+
}, prAuthor);
|
|
70
|
+
return { agent, path: thread.path, line: thread.line, outcome };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function summarize(prs) {
|
|
74
|
+
const rows = prs.flatMap((pr) => (pr.threads || [])
|
|
75
|
+
.map((t) => classifyLekkerThread(t, pr.prAuthor))
|
|
76
|
+
.filter(Boolean));
|
|
77
|
+
const byAgent = rows.reduce((acc, r) => {
|
|
78
|
+
const cur = acc[r.agent] || { fixed: 0, dismissed: 0, ignored: 0, resolved: 0 };
|
|
79
|
+
acc[r.agent] = { ...cur, [r.outcome]: cur[r.outcome] + 1 };
|
|
80
|
+
return acc;
|
|
81
|
+
}, Object.create(null));
|
|
82
|
+
const agents = Object.entries(byAgent).map(([agent, c]) => {
|
|
83
|
+
const total = c.fixed + c.dismissed + c.ignored;
|
|
84
|
+
const allComments = total + c.resolved;
|
|
85
|
+
return {
|
|
86
|
+
agent,
|
|
87
|
+
total: allComments,
|
|
88
|
+
...c,
|
|
89
|
+
dismissalRate: total === 0 ? null : c.dismissed / total,
|
|
90
|
+
};
|
|
91
|
+
}).sort((a, b) => AGENTS.indexOf(a.agent) - AGENTS.indexOf(b.agent) || a.agent.localeCompare(b.agent));
|
|
92
|
+
return { comments: rows.length, agents };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
96
|
+
const THREADS_QUERY = `query($owner:String!,$name:String!,$pr:Int!,$threadCursor:String){repository(owner:$owner,name:$name){
|
|
97
|
+
pullRequest(number:$pr){author{login} reviewThreads(first:100,after:$threadCursor){nodes{id isResolved isOutdated path line
|
|
98
|
+
comments(first:50){nodes{author{login} body} pageInfo{hasNextPage endCursor}}} pageInfo{hasNextPage endCursor}}}}}`;
|
|
99
|
+
const COMMENTS_QUERY = `query($threadId:ID!,$commentCursor:String){node(id:$threadId){... on PullRequestReviewThread{
|
|
100
|
+
comments(first:50,after:$commentCursor){nodes{author{login} body} pageInfo{hasNextPage endCursor}}}}}`;
|
|
101
|
+
|
|
102
|
+
function graphql(repo, pr, query, cursors, run) {
|
|
103
|
+
const [owner, name] = repo.split('/');
|
|
104
|
+
const args = ['api', 'graphql', '-f', `query=${query}`, '-F', `owner=${owner}`,
|
|
105
|
+
'-F', `name=${name}`, '-F', `pr=${pr}`];
|
|
106
|
+
for (const [key, value] of Object.entries(cursors)) {
|
|
107
|
+
if (value !== undefined) args.push('-f', `${key}=${value}`);
|
|
108
|
+
}
|
|
109
|
+
let output;
|
|
110
|
+
try {
|
|
111
|
+
output = run('gh', args, { encoding: 'utf8', timeout: FETCH_TIMEOUT_MS });
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error && typeof error === 'object' && error.code === 'ETIMEDOUT') {
|
|
114
|
+
throw new Error(`GitHub GraphQL fetch timed out after ${FETCH_TIMEOUT_MS / 1000}s for ${repo}#${pr}`, { cause: error });
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
return JSON.parse(output).data;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function nextCursor(pageInfo, connection) {
|
|
122
|
+
if (!pageInfo.hasNextPage) return null;
|
|
123
|
+
if (!pageInfo.endCursor) throw new Error(`GitHub GraphQL ${connection} page has no end cursor`);
|
|
124
|
+
return pageInfo.endCursor;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function allComments(repo, pr, thread, run) {
|
|
128
|
+
const comments = thread.comments.nodes.map((c) => ({
|
|
129
|
+
author: c.author ? c.author.login : null,
|
|
130
|
+
body: c.body,
|
|
131
|
+
}));
|
|
132
|
+
let cursor = nextCursor(thread.comments.pageInfo, 'comments');
|
|
133
|
+
while (cursor) {
|
|
134
|
+
const data = graphql(repo, pr, COMMENTS_QUERY, { threadId: thread.id, commentCursor: cursor }, run);
|
|
135
|
+
const page = data.node.comments;
|
|
136
|
+
comments.push(...page.nodes.map((c) => ({
|
|
137
|
+
author: c.author ? c.author.login : null,
|
|
138
|
+
body: c.body,
|
|
139
|
+
})));
|
|
140
|
+
cursor = nextCursor(page.pageInfo, 'comments');
|
|
141
|
+
}
|
|
142
|
+
return comments;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function fetchPr(repo, pr, run = execFileSync) {
|
|
146
|
+
const threads = [];
|
|
147
|
+
let cursor;
|
|
148
|
+
let prAuthor = null;
|
|
149
|
+
do {
|
|
150
|
+
const data = graphql(repo, pr, THREADS_QUERY, { threadCursor: cursor }, run);
|
|
151
|
+
const pullRequest = data.repository.pullRequest;
|
|
152
|
+
if (pullRequest.author) prAuthor = pullRequest.author.login;
|
|
153
|
+
const page = pullRequest.reviewThreads;
|
|
154
|
+
threads.push(...page.nodes.map((thread) => ({
|
|
155
|
+
path: thread.path,
|
|
156
|
+
line: thread.line,
|
|
157
|
+
isResolved: thread.isResolved,
|
|
158
|
+
isOutdated: thread.isOutdated,
|
|
159
|
+
comments: allComments(repo, pr, thread, run),
|
|
160
|
+
})));
|
|
161
|
+
cursor = nextCursor(page.pageInfo, 'reviewThreads');
|
|
162
|
+
} while (cursor);
|
|
163
|
+
return {
|
|
164
|
+
prAuthor,
|
|
165
|
+
threads,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function fetchPrs(repo, prNumbers, fetch = fetchPr) {
|
|
170
|
+
return Array.from(new Set(prNumbers), (pr) => fetch(repo, pr));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function render(s) {
|
|
174
|
+
const pct = (r) => (r === null ? 'n/a' : `${Math.round(r * 100)}%`);
|
|
175
|
+
const lines = s.agents.map((a) =>
|
|
176
|
+
`${a.agent.padEnd(15)} ${String(a.total).padStart(3)} comments fixed ${a.fixed} dismissed ${a.dismissed} ignored ${a.ignored} resolved ${a.resolved} dismissal rate ${pct(a.dismissalRate)}`);
|
|
177
|
+
return [`lekker inline comments scored: ${s.comments}`, ...lines].join('\n');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function main(argv) {
|
|
181
|
+
const values = (name) => argv.flatMap((a, i) => (a === '--' + name && argv[i + 1] ? [argv[i + 1]] : []));
|
|
182
|
+
const from = values('from')[0];
|
|
183
|
+
const repo = values('repo')[0];
|
|
184
|
+
const prNumbers = values('pr');
|
|
185
|
+
if (!from && !(repo && prNumbers.length)) {
|
|
186
|
+
console.error('usage: human-noise.mjs --repo <owner/repo> --pr <n> [--pr <n>...] | --from <threads.json> [--json]');
|
|
187
|
+
process.exit(2);
|
|
188
|
+
}
|
|
189
|
+
const prs = from ? JSON.parse(readFileSync(from, 'utf8')) : fetchPrs(repo, prNumbers);
|
|
190
|
+
const summary = summarize(prs);
|
|
191
|
+
console.log(argv.includes('--json') ? JSON.stringify(summary) : render(summary));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
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,14 @@ 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
|
+
Bots are not the only signal. To measure noise from what humans did, run
|
|
1328
|
+
`node ${SKILL_ROOT}/scripts/human-noise.mjs --repo <REPO_SLUG> --pr <N> [--pr <N>...]`
|
|
1329
|
+
on merged or settled PRs: it classifies each posted lekker inline comment as
|
|
1330
|
+
fixed (thread outdated), dismissed (affirmative author reply), resolved (code
|
|
1331
|
+
outcome unknown) or ignored, and prints the dismissal rate per agent. Resolved
|
|
1332
|
+
threads remain in the total but do not count as dismissed. Only comments ending in the
|
|
1333
|
+
`<!-- lekker:<dimension> -->` marker (see `references/github-post.md`) are
|
|
1334
|
+
counted, so PRs posted before the marker existed score zero comments.
|
|
1327
1335
|
**Eval harness.** `evals/manifest.json` lists PRs with a known outcome (`expect:
|
|
1328
1336
|
"buggy"` with `knownBug: "file:line description"`, or `"clean"`). Run lekker on
|
|
1329
1337
|
each, save the `findings` arrays as `{"<repo>#<pr>": [...]}`, then
|
|
@@ -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
|
|
|
@@ -33,6 +33,12 @@ Rules:
|
|
|
33
33
|
determine commentability). A finding with `line: null` (set by
|
|
34
34
|
`scripts/validate-lines.mjs` when its quoted code is not in the file) is
|
|
35
35
|
never an inline comment: list it in the review body instead.
|
|
36
|
+
- **End every inline comment body with the marker** `<!-- lekker:<dimension> -->`
|
|
37
|
+
on its own last line, using the finding's `dimension` (`quality`,
|
|
38
|
+
`implementation`, `simplification`, `conventions`, `test-quality`, `critic`;
|
|
39
|
+
`unknown` when absent). It is invisible on GitHub, and is the only way
|
|
40
|
+
`scripts/human-noise.mjs` can tell lekker's comments from the author's own,
|
|
41
|
+
because the pending review is posted under the requesting user's account.
|
|
36
42
|
- **Keep every comment body to 2-4 sentences and under ~700 characters**
|
|
37
43
|
(excluding any suggestion block). Inline comments are read one at a time in a
|
|
38
44
|
narrow column; a long one does not get read, so length actively costs you the
|
|
@@ -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,194 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Measure lekker's noise from what humans did with its comments.
|
|
3
|
+
//
|
|
4
|
+
// bot-precision.mjs only compares predictions with other bots. The honest signal
|
|
5
|
+
// is whether the author acted on a comment, so this reads lekker's own posted
|
|
6
|
+
// inline comments and classifies each one:
|
|
7
|
+
// fixed the thread is outdated: the code at that location changed after the comment
|
|
8
|
+
// dismissed the PR author replied with a dismissal
|
|
9
|
+
// resolved resolved, but no code outcome is known
|
|
10
|
+
// ignored none of the above (no code change, no dismissal, still open)
|
|
11
|
+
// and reports the dismissal rate per agent.
|
|
12
|
+
//
|
|
13
|
+
// Identification: the pending review is posted under the requesting user's own
|
|
14
|
+
// account, so the author cannot tell lekker from a human. github-post.md makes
|
|
15
|
+
// every inline comment end with `<!-- lekker:<dimension> -->`; only threads whose
|
|
16
|
+
// first comment carries that marker are counted.
|
|
17
|
+
//
|
|
18
|
+
// Usage:
|
|
19
|
+
// node human-noise.mjs --repo <owner/repo> --pr <n> [--pr <n>...] [--json]
|
|
20
|
+
// node human-noise.mjs --from <threads.json> [--json]
|
|
21
|
+
// threads.json: [{ prAuthor, threads: [{ path, line, isResolved, isOutdated,
|
|
22
|
+
// comments: [{ author, body }] }] }] (offline fixture / saved fetch)
|
|
23
|
+
|
|
24
|
+
import { execFileSync } from 'node:child_process';
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const MARKER = /<!--\s*lekker:([a-z0-9-]+)\s*-->/i;
|
|
29
|
+
export const AGENTS = ['quality', 'implementation', 'simplification', 'conventions', 'test-quality'];
|
|
30
|
+
|
|
31
|
+
const INTENT = /\b(intentional(ly)?|by design|on purpose|deliberate(ly)?|as designed|expected behaviou?r)\b/i;
|
|
32
|
+
const DISMISS = /\b(won'?t fix|wontfix|not a (bug|problem|concern)|false positive|disagree|n\/a|not applicable|out of scope|ignor(e|ing)|nit)\b/i;
|
|
33
|
+
const NEGATOR = /\b(not|no|never|nor|isn'?t|aren'?t|wasn'?t|don'?t|doesn'?t|won'?t|cannot)\b/i;
|
|
34
|
+
|
|
35
|
+
// Check only the three words before each keyword in its clause for negation.
|
|
36
|
+
function mentions(pattern, text) {
|
|
37
|
+
return [...text.matchAll(new RegExp(pattern.source, 'gi'))]
|
|
38
|
+
.some((match) => {
|
|
39
|
+
const before = text.slice(0, match.index).split(/[.!?;:,\n-]/).pop();
|
|
40
|
+
return !NEGATOR.test(before.trim().split(/\s+/).slice(-3).join(' '));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Self-contained on purpose: this script must not depend on other lekker scripts.
|
|
45
|
+
export function classifyThread(thread, prAuthor) {
|
|
46
|
+
const text = (thread.replies || [])
|
|
47
|
+
.filter((r) => r.author && r.author === prAuthor)
|
|
48
|
+
.map((r) => r.body)
|
|
49
|
+
.join('\n');
|
|
50
|
+
if (thread.isOutdated) return 'fixed';
|
|
51
|
+
if (mentions(INTENT, text) || mentions(DISMISS, text)) return 'dismissed';
|
|
52
|
+
if (thread.isResolved) return 'resolved';
|
|
53
|
+
return 'ignored';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function agentOf(body) {
|
|
57
|
+
const m = MARKER.exec(String(body || ''));
|
|
58
|
+
return m ? m[1].toLowerCase() : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function classifyLekkerThread(thread, prAuthor) {
|
|
62
|
+
const [first, ...rest] = thread.comments || [];
|
|
63
|
+
const agent = first ? agentOf(first.body) : null;
|
|
64
|
+
if (!agent) return null;
|
|
65
|
+
const outcome = classifyThread({
|
|
66
|
+
isOutdated: thread.isOutdated,
|
|
67
|
+
isResolved: thread.isResolved,
|
|
68
|
+
replies: rest,
|
|
69
|
+
}, prAuthor);
|
|
70
|
+
return { agent, path: thread.path, line: thread.line, outcome };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function summarize(prs) {
|
|
74
|
+
const rows = prs.flatMap((pr) => (pr.threads || [])
|
|
75
|
+
.map((t) => classifyLekkerThread(t, pr.prAuthor))
|
|
76
|
+
.filter(Boolean));
|
|
77
|
+
const byAgent = rows.reduce((acc, r) => {
|
|
78
|
+
const cur = acc[r.agent] || { fixed: 0, dismissed: 0, ignored: 0, resolved: 0 };
|
|
79
|
+
acc[r.agent] = { ...cur, [r.outcome]: cur[r.outcome] + 1 };
|
|
80
|
+
return acc;
|
|
81
|
+
}, Object.create(null));
|
|
82
|
+
const agents = Object.entries(byAgent).map(([agent, c]) => {
|
|
83
|
+
const total = c.fixed + c.dismissed + c.ignored;
|
|
84
|
+
const allComments = total + c.resolved;
|
|
85
|
+
return {
|
|
86
|
+
agent,
|
|
87
|
+
total: allComments,
|
|
88
|
+
...c,
|
|
89
|
+
dismissalRate: total === 0 ? null : c.dismissed / total,
|
|
90
|
+
};
|
|
91
|
+
}).sort((a, b) => AGENTS.indexOf(a.agent) - AGENTS.indexOf(b.agent) || a.agent.localeCompare(b.agent));
|
|
92
|
+
return { comments: rows.length, agents };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
96
|
+
const THREADS_QUERY = `query($owner:String!,$name:String!,$pr:Int!,$threadCursor:String){repository(owner:$owner,name:$name){
|
|
97
|
+
pullRequest(number:$pr){author{login} reviewThreads(first:100,after:$threadCursor){nodes{id isResolved isOutdated path line
|
|
98
|
+
comments(first:50){nodes{author{login} body} pageInfo{hasNextPage endCursor}}} pageInfo{hasNextPage endCursor}}}}}`;
|
|
99
|
+
const COMMENTS_QUERY = `query($threadId:ID!,$commentCursor:String){node(id:$threadId){... on PullRequestReviewThread{
|
|
100
|
+
comments(first:50,after:$commentCursor){nodes{author{login} body} pageInfo{hasNextPage endCursor}}}}}`;
|
|
101
|
+
|
|
102
|
+
function graphql(repo, pr, query, cursors, run) {
|
|
103
|
+
const [owner, name] = repo.split('/');
|
|
104
|
+
const args = ['api', 'graphql', '-f', `query=${query}`, '-F', `owner=${owner}`,
|
|
105
|
+
'-F', `name=${name}`, '-F', `pr=${pr}`];
|
|
106
|
+
for (const [key, value] of Object.entries(cursors)) {
|
|
107
|
+
if (value !== undefined) args.push('-f', `${key}=${value}`);
|
|
108
|
+
}
|
|
109
|
+
let output;
|
|
110
|
+
try {
|
|
111
|
+
output = run('gh', args, { encoding: 'utf8', timeout: FETCH_TIMEOUT_MS });
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error && typeof error === 'object' && error.code === 'ETIMEDOUT') {
|
|
114
|
+
throw new Error(`GitHub GraphQL fetch timed out after ${FETCH_TIMEOUT_MS / 1000}s for ${repo}#${pr}`, { cause: error });
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
return JSON.parse(output).data;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function nextCursor(pageInfo, connection) {
|
|
122
|
+
if (!pageInfo.hasNextPage) return null;
|
|
123
|
+
if (!pageInfo.endCursor) throw new Error(`GitHub GraphQL ${connection} page has no end cursor`);
|
|
124
|
+
return pageInfo.endCursor;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function allComments(repo, pr, thread, run) {
|
|
128
|
+
const comments = thread.comments.nodes.map((c) => ({
|
|
129
|
+
author: c.author ? c.author.login : null,
|
|
130
|
+
body: c.body,
|
|
131
|
+
}));
|
|
132
|
+
let cursor = nextCursor(thread.comments.pageInfo, 'comments');
|
|
133
|
+
while (cursor) {
|
|
134
|
+
const data = graphql(repo, pr, COMMENTS_QUERY, { threadId: thread.id, commentCursor: cursor }, run);
|
|
135
|
+
const page = data.node.comments;
|
|
136
|
+
comments.push(...page.nodes.map((c) => ({
|
|
137
|
+
author: c.author ? c.author.login : null,
|
|
138
|
+
body: c.body,
|
|
139
|
+
})));
|
|
140
|
+
cursor = nextCursor(page.pageInfo, 'comments');
|
|
141
|
+
}
|
|
142
|
+
return comments;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function fetchPr(repo, pr, run = execFileSync) {
|
|
146
|
+
const threads = [];
|
|
147
|
+
let cursor;
|
|
148
|
+
let prAuthor = null;
|
|
149
|
+
do {
|
|
150
|
+
const data = graphql(repo, pr, THREADS_QUERY, { threadCursor: cursor }, run);
|
|
151
|
+
const pullRequest = data.repository.pullRequest;
|
|
152
|
+
if (pullRequest.author) prAuthor = pullRequest.author.login;
|
|
153
|
+
const page = pullRequest.reviewThreads;
|
|
154
|
+
threads.push(...page.nodes.map((thread) => ({
|
|
155
|
+
path: thread.path,
|
|
156
|
+
line: thread.line,
|
|
157
|
+
isResolved: thread.isResolved,
|
|
158
|
+
isOutdated: thread.isOutdated,
|
|
159
|
+
comments: allComments(repo, pr, thread, run),
|
|
160
|
+
})));
|
|
161
|
+
cursor = nextCursor(page.pageInfo, 'reviewThreads');
|
|
162
|
+
} while (cursor);
|
|
163
|
+
return {
|
|
164
|
+
prAuthor,
|
|
165
|
+
threads,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function fetchPrs(repo, prNumbers, fetch = fetchPr) {
|
|
170
|
+
return Array.from(new Set(prNumbers), (pr) => fetch(repo, pr));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function render(s) {
|
|
174
|
+
const pct = (r) => (r === null ? 'n/a' : `${Math.round(r * 100)}%`);
|
|
175
|
+
const lines = s.agents.map((a) =>
|
|
176
|
+
`${a.agent.padEnd(15)} ${String(a.total).padStart(3)} comments fixed ${a.fixed} dismissed ${a.dismissed} ignored ${a.ignored} resolved ${a.resolved} dismissal rate ${pct(a.dismissalRate)}`);
|
|
177
|
+
return [`lekker inline comments scored: ${s.comments}`, ...lines].join('\n');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function main(argv) {
|
|
181
|
+
const values = (name) => argv.flatMap((a, i) => (a === '--' + name && argv[i + 1] ? [argv[i + 1]] : []));
|
|
182
|
+
const from = values('from')[0];
|
|
183
|
+
const repo = values('repo')[0];
|
|
184
|
+
const prNumbers = values('pr');
|
|
185
|
+
if (!from && !(repo && prNumbers.length)) {
|
|
186
|
+
console.error('usage: human-noise.mjs --repo <owner/repo> --pr <n> [--pr <n>...] | --from <threads.json> [--json]');
|
|
187
|
+
process.exit(2);
|
|
188
|
+
}
|
|
189
|
+
const prs = from ? JSON.parse(readFileSync(from, 'utf8')) : fetchPrs(repo, prNumbers);
|
|
190
|
+
const summary = summarize(prs);
|
|
191
|
+
console.log(argv.includes('--json') ? JSON.stringify(summary) : render(summary));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
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}`,
|
|
@@ -1000,9 +1037,27 @@ const budgetAtStart = budget.spent()
|
|
|
1000
1037
|
}
|
|
1001
1038
|
}
|
|
1002
1039
|
|
|
1003
|
-
//
|
|
1040
|
+
// Expose the originating agent as `dimension` (critic angles collapse to
|
|
1041
|
+
// "critic"). The posted inline comment carries it as a marker so
|
|
1042
|
+
// scripts/human-noise.mjs can score each agent by what humans did with it.
|
|
1043
|
+
const revmuxDimensions = {
|
|
1044
|
+
'lekker-quality': 'quality',
|
|
1045
|
+
'lekker-implementation': 'implementation',
|
|
1046
|
+
'lekker-simplification': 'simplification',
|
|
1047
|
+
'lekker-conventions': 'conventions',
|
|
1048
|
+
'lekker-test-quality': 'test-quality',
|
|
1049
|
+
tests: 'test-quality',
|
|
1050
|
+
}
|
|
1004
1051
|
const output = finalFindings.map(function(f) {
|
|
1005
1052
|
const clean = Object.assign({}, f)
|
|
1053
|
+
const dimension = clean._dimension || clean.dimension
|
|
1054
|
+
if (Array.isArray(dimension)) {
|
|
1055
|
+
clean.dimension = dimension.map(function(lens) {
|
|
1056
|
+
return revmuxDimensions[lens]
|
|
1057
|
+
}).find(Boolean) || 'unknown'
|
|
1058
|
+
} else if (clean._dimension) {
|
|
1059
|
+
clean.dimension = String(clean._dimension).split(':')[0]
|
|
1060
|
+
}
|
|
1006
1061
|
delete clean._dimension
|
|
1007
1062
|
return clean
|
|
1008
1063
|
})
|