@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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -2
  3. package/index.js +255 -0
  4. 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
- # Temporary Holding Version
1
+ # opencode-comment-checker
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }