@vasilvestre/opencode-comment-checker 0.0.0-stage → 1.0.1
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/LICENSE +21 -0
- package/README.md +86 -2
- package/index.js +255 -0
- package/package.json +42 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vasilvestre
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,87 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-comment-checker
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Flags comments that add nothing, after an `edit`, `write`, or `patch` in OpenCode v2.
|
|
4
|
+
|
|
5
|
+
The model sees the warning on its next turn and can fix it. The plugin never blocks a write: a false positive must not cost you a working edit.
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
comment-checker: 1 comment(s) in src/cart.ts add nothing.
|
|
9
|
+
line 42: restates the line below it — "increment counter"
|
|
10
|
+
Delete them, or replace each with why the code exists. Keep one only if it earns its place: add `@allow` on that line.
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
opencode plugin add @vasilvestre/opencode-comment-checker
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or add it to `opencode.jsonc`:
|
|
20
|
+
|
|
21
|
+
```jsonc
|
|
22
|
+
{
|
|
23
|
+
"plugins": ["@vasilvestre/opencode-comment-checker"]
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Requires OpenCode 2. The plugin uses the v2 `Plugin.define` API and has no dependency.
|
|
28
|
+
|
|
29
|
+
## What it flags
|
|
30
|
+
|
|
31
|
+
Five patterns, each with a reason the model can act on:
|
|
32
|
+
|
|
33
|
+
| Rule | Example |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| `restates-next-line` | `// increment counter` above `increment(counter)` |
|
|
36
|
+
| `restates-name` | `// helper` above `function helper()` |
|
|
37
|
+
| `bare-todo` | `// TODO fix this later` |
|
|
38
|
+
| `commented-code` | `// const total = price * qty;` |
|
|
39
|
+
| `decorative-banner` | `// ==========` |
|
|
40
|
+
|
|
41
|
+
A comment that explains why the code exists passes. So does a well-formed `TODO(name): action, reason`.
|
|
42
|
+
|
|
43
|
+
## Escape hatches
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
// @allow
|
|
47
|
+
let counter = 0
|
|
48
|
+
// increment counter @allow
|
|
49
|
+
increment(counter)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Add `comment-checker-disable-file` anywhere in a file to skip it entirely.
|
|
53
|
+
|
|
54
|
+
## Scope
|
|
55
|
+
|
|
56
|
+
- Only lines the tool added are checked. Comments that were already there are not the agent's work.
|
|
57
|
+
- Deletes are ignored: removing a file cannot add a comment.
|
|
58
|
+
- Failed tool calls are ignored.
|
|
59
|
+
- Files with an unknown extension are skipped rather than guessed.
|
|
60
|
+
- The warning is capped at 2000 characters, so a large patch cannot push a long session into compaction.
|
|
61
|
+
|
|
62
|
+
## Difference from oh-my-openagent
|
|
63
|
+
|
|
64
|
+
oh-my-openagent ships a Rust binary that flags **every** added comment and asks the model to justify it. This plugin flags only the comments it can prove are useless.
|
|
65
|
+
|
|
66
|
+
The trade-off:
|
|
67
|
+
|
|
68
|
+
| | oh-my-openagent | this plugin |
|
|
69
|
+
| --- | --- | --- |
|
|
70
|
+
| Detection | any added comment | five patterns |
|
|
71
|
+
| Decision | the model judges | the rule judges |
|
|
72
|
+
| False positives | many, by design | few |
|
|
73
|
+
| False negatives | none | some |
|
|
74
|
+
|
|
75
|
+
A comment that restates the code without sharing words with it passes here. That is the price of not nagging the model on every legitimate comment.
|
|
76
|
+
|
|
77
|
+
## Test
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
npm test
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
17 tests cover each rule, both escape hatches, the `patch` path, and the truncation.
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT.
|
package/index.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// comment-checker — OpenCode v2 plugin.
|
|
2
|
+
//
|
|
3
|
+
// After an edit or write, scan the comments the tool added and report the ones
|
|
4
|
+
// that only restate the code. The model sees the report on the next turn and
|
|
5
|
+
// can fix them. This is a warning, never a block: a false positive must not
|
|
6
|
+
// cost the user a working edit.
|
|
7
|
+
//
|
|
8
|
+
// Escape hatches, same as oh-my-openagent:
|
|
9
|
+
// // @allow on the offending line
|
|
10
|
+
// // comment-checker-disable-file anywhere in the file
|
|
11
|
+
//
|
|
12
|
+
// Only added lines are checked. Pre-existing comments are not the agent's work.
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs'
|
|
15
|
+
|
|
16
|
+
const DISABLE_FILE = 'comment-checker-disable-file'
|
|
17
|
+
const ALLOW_LINE = '@allow'
|
|
18
|
+
|
|
19
|
+
// Comment syntax per extension. A file missing from this map is skipped.
|
|
20
|
+
const SYNTAX = {
|
|
21
|
+
'.js': ['//', '/*'], '.mjs': ['//', '/*'], '.cjs': ['//', '/*'],
|
|
22
|
+
'.ts': ['//', '/*'], '.tsx': ['//', '/*'], '.jsx': ['//', '/*'],
|
|
23
|
+
'.go': ['//', '/*'], '.rs': ['//', '/*'], '.java': ['//', '/*'],
|
|
24
|
+
'.c': ['//', '/*'], '.h': ['//', '/*'], '.cpp': ['//', '/*'],
|
|
25
|
+
'.php': ['//', '/*', '#'], '.swift': ['//', '/*'], '.kt': ['//', '/*'],
|
|
26
|
+
'.cs': ['//', '/*'], '.scala': ['//', '/*'],
|
|
27
|
+
'.py': ['#'], '.sh': ['#'], '.bash': ['#'], '.rb': ['#'],
|
|
28
|
+
'.yaml': ['#'], '.yml': ['#'], '.toml': ['#'], '.tf': ['#'],
|
|
29
|
+
'.sql': ['--'], '.lua': ['--'], '.vim': ['"'],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Each rule returns a reason, or null when the comment is fine.
|
|
33
|
+
const RULES = [
|
|
34
|
+
{
|
|
35
|
+
// "// increment i" above "i++"
|
|
36
|
+
name: 'restates-next-line',
|
|
37
|
+
test: (text, next) => {
|
|
38
|
+
if (!next) return null
|
|
39
|
+
const words = text.toLowerCase().split(/\W+/).filter(Boolean)
|
|
40
|
+
if (words.length < 2) return null
|
|
41
|
+
const code = next.toLowerCase()
|
|
42
|
+
const hits = words.filter((w) => w.length > 2 && code.includes(w)).length
|
|
43
|
+
if (hits >= Math.max(2, Math.ceil(words.length * 0.6))) {
|
|
44
|
+
return 'restates the line below it'
|
|
45
|
+
}
|
|
46
|
+
return null
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
// "// helper" above "function helper()"
|
|
51
|
+
name: 'restates-name',
|
|
52
|
+
test: (text, next) => {
|
|
53
|
+
if (!next) return null
|
|
54
|
+
const decl = next.match(/(?:function|def|fn|func|class|const|let|var)\s+([A-Za-z_$][\w$]*)/)
|
|
55
|
+
if (!decl) return null
|
|
56
|
+
const name = decl[1].replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]/g, ' ').toLowerCase()
|
|
57
|
+
const words = text.toLowerCase().replace(/\W+/g, ' ').trim()
|
|
58
|
+
if (words && name.includes(words)) return `restates the name \`${decl[1]}\``
|
|
59
|
+
return null
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'bare-todo',
|
|
64
|
+
test: (text) => {
|
|
65
|
+
const m = text.match(/^TODO\b(.*)$/i)
|
|
66
|
+
if (!m) return null
|
|
67
|
+
const rest = m[1].trim()
|
|
68
|
+
// An owner is either "(name)" or "name:" at the start. Anything else is prose.
|
|
69
|
+
if (!rest || !/^(\([\w.-]+\)|[\w.-]+:)/.test(rest)) {
|
|
70
|
+
return 'TODO needs an owner and a reason: `TODO(name): action, reason`'
|
|
71
|
+
}
|
|
72
|
+
return null
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'commented-code',
|
|
77
|
+
test: (text) => {
|
|
78
|
+
if (/[;{}]\s*$/.test(text) && /[=(]/.test(text)) return 'commented-out code'
|
|
79
|
+
if (/^\s*(if|for|while|return|const|let|var|def|function|class)\b.*[;{]?$/.test(text)) {
|
|
80
|
+
return 'commented-out code'
|
|
81
|
+
}
|
|
82
|
+
return null
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'decorative-banner',
|
|
87
|
+
test: (text) => {
|
|
88
|
+
if (/^[=\-*#_~]{4,}$/.test(text.replace(/\s/g, ''))) return 'decorative separator'
|
|
89
|
+
return null
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
function syntaxFor(filePath) {
|
|
95
|
+
const dot = filePath.lastIndexOf('.')
|
|
96
|
+
if (dot < 0) return null
|
|
97
|
+
return SYNTAX[filePath.slice(dot).toLowerCase()] ?? null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Strip the comment marker and surrounding decoration, keep the prose.
|
|
101
|
+
function commentText(line, markers) {
|
|
102
|
+
for (const marker of markers) {
|
|
103
|
+
const at = line.indexOf(marker)
|
|
104
|
+
if (at < 0) continue
|
|
105
|
+
return line
|
|
106
|
+
.slice(at + marker.length)
|
|
107
|
+
.replace(/\*\/\s*$/, '')
|
|
108
|
+
.replace(/^\s*\*\s?/, '')
|
|
109
|
+
.trim()
|
|
110
|
+
}
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isComment(line, markers) {
|
|
115
|
+
return markers.some((m) => line.trimStart().startsWith(m))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function checkFile(filePath, before, after) {
|
|
119
|
+
const markers = syntaxFor(filePath)
|
|
120
|
+
if (!markers) return []
|
|
121
|
+
if (after.includes(DISABLE_FILE)) return []
|
|
122
|
+
|
|
123
|
+
const beforeLines = new Set(before.split('\n'))
|
|
124
|
+
const lines = after.split('\n')
|
|
125
|
+
const findings = []
|
|
126
|
+
|
|
127
|
+
lines.forEach((line, i) => {
|
|
128
|
+
if (beforeLines.has(line)) return
|
|
129
|
+
if (!isComment(line, markers)) return
|
|
130
|
+
if (line.includes(ALLOW_LINE)) return
|
|
131
|
+
|
|
132
|
+
const text = commentText(line, markers)
|
|
133
|
+
if (!text) return
|
|
134
|
+
|
|
135
|
+
const next = lines.slice(i + 1).find((l) => l.trim() && !isComment(l, markers))
|
|
136
|
+
for (const rule of RULES) {
|
|
137
|
+
const reason = rule.test(text, next)
|
|
138
|
+
if (reason) {
|
|
139
|
+
findings.push({ line: i + 1, text: text.slice(0, 80), reason })
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
return findings
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function readOrEmpty(filePath) {
|
|
149
|
+
try {
|
|
150
|
+
return fs.readFileSync(filePath, 'utf8')
|
|
151
|
+
} catch {
|
|
152
|
+
return ''
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// `patch` carries every file it touches in one text blob, and reports the
|
|
157
|
+
// resolved absolute path per file in its result. Prefer the result: patch paths
|
|
158
|
+
// are relative to the workspace, which is not the server's working directory.
|
|
159
|
+
function patchPaths(patchText) {
|
|
160
|
+
if (typeof patchText !== 'string') return []
|
|
161
|
+
const paths = []
|
|
162
|
+
for (const line of patchText.split('\n')) {
|
|
163
|
+
const m = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/)
|
|
164
|
+
if (m) paths.push(m[1].trim())
|
|
165
|
+
}
|
|
166
|
+
return paths
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function appliedTargets(result) {
|
|
170
|
+
const applied = result?.output?.applied
|
|
171
|
+
if (!Array.isArray(applied)) return []
|
|
172
|
+
return applied.map((a) => a?.target).filter((t) => typeof t === 'string')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function report(findings, filePath) {
|
|
176
|
+
const head = `comment-checker: ${findings.length} comment(s) in ${filePath} add nothing.`
|
|
177
|
+
const body = findings
|
|
178
|
+
.map((f) => ` line ${f.line}: ${f.reason} — "${f.text}"`)
|
|
179
|
+
.join('\n')
|
|
180
|
+
return [
|
|
181
|
+
head,
|
|
182
|
+
body,
|
|
183
|
+
'Delete them, or replace each with why the code exists. Keep one only if it earns its place: add `@allow` on that line.',
|
|
184
|
+
].join('\n')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// A finding list grows with the file. Cap the message so a large patch cannot
|
|
188
|
+
// push a long session into compaction: the first findings are the useful ones.
|
|
189
|
+
const MAX_REPORT_CHARS = 2000
|
|
190
|
+
|
|
191
|
+
function truncate(text, max = MAX_REPORT_CHARS) {
|
|
192
|
+
if (text.length <= max) return text
|
|
193
|
+
const marker = `\n[Truncated to ${max} characters. Fix these first, then run the check again.]`
|
|
194
|
+
const head = text.slice(0, max - marker.length).replace(/[ \t\r\n]+$/, '')
|
|
195
|
+
return `${head}${marker}`
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export { patchPaths, truncate, MAX_REPORT_CHARS }
|
|
199
|
+
|
|
200
|
+
export default {
|
|
201
|
+
id: 'vasilvestre.opencode-comment-checker',
|
|
202
|
+
async setup(ctx) {
|
|
203
|
+
// write replaces the whole file, so the previous content is gone by the time
|
|
204
|
+
// the hook runs. Fingerprint every file this plugin saw before it changed;
|
|
205
|
+
// a write to an unknown file is a new file, and all of it is the agent's.
|
|
206
|
+
const seen = new Map()
|
|
207
|
+
|
|
208
|
+
await ctx.tool.hook('execute.before', (event) => {
|
|
209
|
+
const input = event.input ?? {}
|
|
210
|
+
const targets =
|
|
211
|
+
event.tool === 'patch' ? patchPaths(input.patchText) : [input.filePath ?? input.path]
|
|
212
|
+
for (const filePath of targets) {
|
|
213
|
+
if (typeof filePath !== 'string') continue
|
|
214
|
+
// Only an existing file has a previous state. Recording '' for a new file
|
|
215
|
+
// would make every line look pre-existing and hide all findings.
|
|
216
|
+
if (!fs.existsSync(filePath)) continue
|
|
217
|
+
seen.set(filePath, readOrEmpty(filePath))
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
await ctx.tool.hook('execute.after', async (event) => {
|
|
222
|
+
if (event.status !== 'completed') return
|
|
223
|
+
if (event.tool !== 'edit' && event.tool !== 'write' && event.tool !== 'patch') return
|
|
224
|
+
|
|
225
|
+
const input = event.input ?? {}
|
|
226
|
+
const targets =
|
|
227
|
+
event.tool === 'patch'
|
|
228
|
+
? appliedTargets(event.result)
|
|
229
|
+
: [input.filePath ?? input.path]
|
|
230
|
+
|
|
231
|
+
const notes = []
|
|
232
|
+
for (const filePath of targets) {
|
|
233
|
+
if (typeof filePath !== 'string') continue
|
|
234
|
+
|
|
235
|
+
// edit rewrites part of a file: the old text is in the args.
|
|
236
|
+
const before =
|
|
237
|
+
event.tool === 'edit' && typeof input.oldString === 'string'
|
|
238
|
+
? readOrEmpty(filePath).replace(input.newString ?? '', input.oldString)
|
|
239
|
+
: (seen.get(filePath) ?? '')
|
|
240
|
+
seen.delete(filePath)
|
|
241
|
+
|
|
242
|
+
const findings = checkFile(filePath, before, readOrEmpty(filePath))
|
|
243
|
+
if (findings.length > 0) notes.push(report(findings, filePath))
|
|
244
|
+
}
|
|
245
|
+
if (notes.length === 0) return
|
|
246
|
+
|
|
247
|
+
const note = truncate(notes.join('\n\n'))
|
|
248
|
+
const existing = typeof event.result.content === 'string' ? event.result.content : ''
|
|
249
|
+
event.result = {
|
|
250
|
+
...event.result,
|
|
251
|
+
content: existing ? `${existing}\n\n${note}` : note,
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
},
|
|
255
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vasilvestre/opencode-comment-checker",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Flags comments that add nothing after an edit, write, or patch in OpenCode v2.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"opencode",
|
|
7
|
+
"opencode-plugin",
|
|
8
|
+
"comment",
|
|
9
|
+
"code-quality",
|
|
10
|
+
"ai-slop"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": {
|
|
14
|
+
"name": "vasilvestre",
|
|
15
|
+
"url": "https://github.com/vasilvestre"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/vasilvestre/opencode-comment-checker",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/vasilvestre/opencode-comment-checker.git"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/vasilvestre/opencode-comment-checker/issues"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "./index.js",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./index.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"index.js",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "node --test index.test.mjs"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=20"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
44
|
+
}
|