@nt-ai-lab/opencode-skillz 0.2.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.
@@ -0,0 +1,277 @@
1
+ ---
2
+ description: "Principles for writing effective, maintainable tests. Covers naming conventions, assertion best practices, and comprehensive edge case checklists. Based on BugMagnet by Gojko Adzic. Triggers on: writing any test, 'add tests', test review, test naming, assertion choices, edge case coverage, 'what should I test', test structure decisions."
3
+ ---
4
+
5
+ Apply the following principles to all new and existing tests in the current session or specific files specified by the user.
6
+
7
+
8
+ # Writing Tests
9
+
10
+ How to write tests that catch bugs, document behavior, and remain maintainable.
11
+
12
+ > Based on [BugMagnet](https://github.com/gojko/bugmagnet-ai-assistant) by Gojko Adzic. Adapted with attribution.
13
+
14
+ ## Critical Rules
15
+
16
+ 🚨 **Test names describe outcomes, not actions.** "returns empty array when input is null" not "test null input". The name IS the specification.
17
+
18
+ 🚨 **Assertions must match test titles.** If the test claims to verify "different IDs", assert on the actual ID valuesβ€”not just count or existence.
19
+
20
+ 🚨 **Assert specific values, not types.** `expect(result).toEqual(['First.', ' Second.'])` not `expect(result).toBeDefined()`. Specific assertions catch specific bugs.
21
+
22
+ 🚨 **One concept per test.** Each test verifies one behavior. If you need "and" in your test name, split it.
23
+
24
+ 🚨 **Bugs cluster together.** When you find one bug, test related scenarios. The same misunderstanding often causes multiple failures.
25
+
26
+ ## When This Applies
27
+
28
+ - Writing new tests
29
+ - Reviewing test quality
30
+ - During TDD RED phase (writing the failing test)
31
+ - Expanding test coverage
32
+ - Investigating discovered bugs
33
+
34
+ ## Test Naming
35
+
36
+ **Pattern:** `[outcome] when [condition]`
37
+
38
+ ### Good Names (Describe Outcomes)
39
+
40
+ ```
41
+ returns empty array when input is null
42
+ throws ValidationError when email format invalid
43
+ calculates tax correctly for tax-exempt items
44
+ preserves original order when duplicates removed
45
+ ```
46
+
47
+ ### Bad Names (Describe Actions)
48
+
49
+ ```
50
+ test null input // What about null input?
51
+ should work // What does "work" mean?
52
+ handles edge cases // Which edge cases?
53
+ email validation test // What's being validated?
54
+ ```
55
+
56
+ ### The Specification Test
57
+
58
+ Your test name should read like a specification. If someone reads ONLY the test names, they should understand the complete behavior of the system.
59
+
60
+ ## Assertion Best Practices
61
+
62
+ ### Assert Specific Values
63
+
64
+ ```typescript
65
+ // ❌ WEAK - passes even if completely wrong data
66
+ expect(result).toBeDefined()
67
+ expect(result.items).toHaveLength(2)
68
+ expect(user).toBeTruthy()
69
+
70
+ // βœ… STRONG - catches actual bugs
71
+ expect(result).toEqual({ status: 'success', items: ['a', 'b'] })
72
+ expect(user.email).toBe('test@example.com')
73
+ ```
74
+
75
+ ### Match Assertions to Test Title
76
+
77
+ ```typescript
78
+ // ❌ TEST SAYS "different IDs" BUT ASSERTS COUNT
79
+ it('generates different IDs for each call', () => {
80
+ const id1 = generateId()
81
+ const id2 = generateId()
82
+ expect([id1, id2]).toHaveLength(2) // WRONG: doesn't check they're different!
83
+ })
84
+
85
+ // βœ… ACTUALLY VERIFIES DIFFERENT IDs
86
+ it('generates different IDs for each call', () => {
87
+ const id1 = generateId()
88
+ const id2 = generateId()
89
+ expect(id1).not.toBe(id2) // RIGHT: verifies the claim
90
+ })
91
+ ```
92
+
93
+ ### Avoid Implementation Coupling
94
+
95
+ ```typescript
96
+ // ❌ BRITTLE - tests implementation details
97
+ expect(mockDatabase.query).toHaveBeenCalledWith('SELECT * FROM users WHERE id = 1')
98
+
99
+ // βœ… FLEXIBLE - tests behavior
100
+ expect(result.user.name).toBe('Alice')
101
+ ```
102
+
103
+ ## Test Structure
104
+
105
+ ### Arrange-Act-Assert
106
+
107
+ ```typescript
108
+ it('calculates total with tax for non-exempt items', () => {
109
+ // Arrange: Set up test data
110
+ const item = { price: 100, taxExempt: false }
111
+ const taxRate = 0.1
112
+
113
+ // Act: Execute the behavior
114
+ const total = calculateTotal(item, taxRate)
115
+
116
+ // Assert: Verify the outcome
117
+ expect(total).toBe(110)
118
+ })
119
+ ```
120
+
121
+ ### One Concept Per Test
122
+
123
+ ```typescript
124
+ // ❌ MULTIPLE CONCEPTS - hard to diagnose failures
125
+ it('validates and processes order', () => {
126
+ expect(validate(order)).toBe(true)
127
+ expect(process(order).status).toBe('complete')
128
+ expect(sendEmail).toHaveBeenCalled()
129
+ })
130
+
131
+ // βœ… SINGLE CONCEPT - clear failures
132
+ it('accepts valid orders', () => {
133
+ expect(validate(validOrder)).toBe(true)
134
+ })
135
+
136
+ it('rejects orders with negative quantities', () => {
137
+ expect(validate(negativeQuantityOrder)).toBe(false)
138
+ })
139
+
140
+ it('sends confirmation email after processing', () => {
141
+ process(order)
142
+ expect(sendEmail).toHaveBeenCalledWith(order.customerEmail)
143
+ })
144
+ ```
145
+
146
+ ## Edge Case Checklists
147
+
148
+ When testing a function, systematically consider these edge cases based on input types.
149
+
150
+ ### Numbers
151
+
152
+ - [ ] Zero
153
+ - [ ] Negative numbers
154
+ - [ ] Very large numbers (near MAX_SAFE_INTEGER)
155
+ - [ ] Very small numbers (near MIN_SAFE_INTEGER)
156
+ - [ ] Decimal precision (0.1 + 0.2)
157
+ - [ ] NaN
158
+ - [ ] Infinity / -Infinity
159
+ - [ ] Boundary values (off-by-one at limits)
160
+
161
+ ### Strings
162
+
163
+ - [ ] Empty string `""`
164
+ - [ ] Whitespace only `" "`
165
+ - [ ] Very long strings (10K+ characters)
166
+ - [ ] Unicode: emojis πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦, RTL text, combining characters
167
+ - [ ] Special characters: quotes, backslashes, null bytes
168
+ - [ ] SQL/HTML/script injection patterns
169
+ - [ ] Leading/trailing whitespace
170
+ - [ ] Mixed case sensitivity
171
+
172
+ ### Collections (Arrays, Objects, Maps)
173
+
174
+ - [ ] Empty collection `[]`, `{}`
175
+ - [ ] Single element
176
+ - [ ] Duplicates
177
+ - [ ] Nested structures
178
+ - [ ] Circular references
179
+ - [ ] Very large collections (performance)
180
+ - [ ] Sparse arrays
181
+ - [ ] Mixed types in arrays
182
+
183
+ ### Dates and Times
184
+
185
+ - [ ] Leap years (Feb 29)
186
+ - [ ] Daylight saving transitions
187
+ - [ ] Timezone boundaries
188
+ - [ ] Midnight (00:00:00)
189
+ - [ ] End of day (23:59:59)
190
+ - [ ] Year boundaries (Dec 31 β†’ Jan 1)
191
+ - [ ] Invalid dates (Feb 30, Month 13)
192
+ - [ ] Unix epoch edge cases
193
+ - [ ] Far future/past dates
194
+
195
+ ### Null and Undefined
196
+
197
+ - [ ] `null` input
198
+ - [ ] `undefined` input
199
+ - [ ] Missing optional properties
200
+ - [ ] Explicit `undefined` vs missing key
201
+
202
+ ### Domain-Specific
203
+
204
+ - [ ] Email: valid formats, edge cases (plus signs, subdomains)
205
+ - [ ] URLs: protocols, ports, special characters, relative paths
206
+ - [ ] Phone numbers: international formats, extensions
207
+ - [ ] Addresses: Unicode, multi-line, missing components
208
+ - [ ] Currency: rounding, different currencies, zero amounts
209
+ - [ ] Percentages: 0%, 100%, over 100%
210
+
211
+ ### Violated Domain Constraints
212
+
213
+ These test implicit assumptions in your domain:
214
+
215
+ - [ ] Uniqueness violations (duplicate IDs, emails)
216
+ - [ ] Missing required relationships (orphaned records)
217
+ - [ ] Ordering violations (events out of sequence)
218
+ - [ ] Range breaches (age -1, quantity 1000000)
219
+ - [ ] State inconsistencies (shipped but not paid)
220
+ - [ ] Format mismatches (expected JSON, got XML)
221
+ - [ ] Temporal ordering (end before start)
222
+
223
+ ### Typed Property Validation
224
+
225
+ When testing code that validates properties against type constraints (e.g., validating `route: string` in an interface):
226
+
227
+ **Wrong-type literals:**
228
+ - [ ] Numeric literal when string expected (`route = 123`)
229
+ - [ ] Boolean literal when string expected (`route = true`)
230
+ - [ ] String literal when number expected (`count = 'five'`)
231
+ - [ ] String literal when boolean expected (`enabled = 'yes'`)
232
+
233
+ **Non-literal expressions:**
234
+ - [ ] Template literal (`` route = `/path/${id}` ``)
235
+ - [ ] Variable reference (`route = someVariable`)
236
+ - [ ] Function call (`route = getRoute()`)
237
+ - [ ] Computed property (`route = config.path`)
238
+
239
+ **Correct type:**
240
+ - [ ] Valid literal of correct type (`route = '/orders'`)
241
+ - [ ] Edge values (empty string `''`, zero `0`, `false`)
242
+
243
+ **Why this matters:**
244
+ A common bug pattern is validating "is this a literal?" without checking "is this the RIGHT TYPE of literal?"
245
+ - `hasLiteralValue()` returns true for `123`, `true`, and `'string'`
246
+ - `hasStringLiteralValue()` returns true only for `'string'`
247
+
248
+ When an interface specifies `property: string`, validation must reject numeric and boolean literals, not just non-literal expressions.
249
+
250
+ ## Bug Clustering
251
+
252
+ When you discover a bug, don't stopβ€”explore related scenarios:
253
+
254
+ 1. **Same function, similar inputs** - If null fails, test undefined, empty string
255
+ 2. **Same pattern, different locations** - If one endpoint mishandles auth, check others
256
+ 3. **Same developer assumption** - If off-by-one here, check other boundaries
257
+ 4. **Same data type** - If dates fail at DST, check other time edge cases
258
+
259
+ ## When Tempted to Cut Corners
260
+
261
+ - If your test name says "test" or "should work": STOP. What outcome are you actually verifying? Name it specifically.
262
+
263
+ - If you're asserting `toBeDefined()` or `toBeTruthy()`: STOP. What value do you actually expect? Assert that instead.
264
+
265
+ - If your assertion doesn't match your test title: STOP. Either fix the assertion or rename the test. They must agree.
266
+
267
+ - If you're testing multiple concepts in one test: STOP. Split it. Future you debugging a failure will thank you.
268
+
269
+ - If you found a bug and wrote one test: STOP. Bugs cluster. What related scenarios might have the same problem?
270
+
271
+ - If you're skipping edge cases because "that won't happen": STOP. It will happen. In production. At 3 AM.
272
+
273
+ ## Integration with Other Skills
274
+
275
+ **With TDD Process:** This skill guides the RED phaseβ€”how to write the failing test well.
276
+
277
+ **With Software Design Principles:** Testable code follows design principles. Hard-to-test code often has design problems.
package/index.js ADDED
@@ -0,0 +1,175 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
6
+ const pluginRoot = __dirname
7
+ const commandNamespace = "nt-skillz"
8
+
9
+ function buildCommandName(name) {
10
+ return `${commandNamespace}:${name}`
11
+ }
12
+
13
+ function extractFrontmatter(content) {
14
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
15
+ if (!match) return { meta: {}, body: content }
16
+
17
+ const meta = {}
18
+ for (const rawLine of match[1].split("\n")) {
19
+ const line = rawLine.trim()
20
+ if (!line || line.startsWith("#")) continue
21
+ const idx = line.indexOf(":")
22
+ if (idx <= 0) continue
23
+
24
+ const key = line.slice(0, idx).trim()
25
+ let value = line.slice(idx + 1).trim()
26
+ value = value.replace(/^['\"]|['\"]$/g, "")
27
+ if (value === "true") value = true
28
+ if (value === "false") value = false
29
+ meta[key] = value
30
+ }
31
+
32
+ return { meta, body: match[2] }
33
+ }
34
+
35
+ function readMarkdownFiles(dirPath) {
36
+ if (!fs.existsSync(dirPath)) return []
37
+ return fs
38
+ .readdirSync(dirPath)
39
+ .filter((file) => file.endsWith(".md"))
40
+ .sort((a, b) => a.localeCompare(b))
41
+ }
42
+
43
+ function loadCommands() {
44
+ const commandsDir = path.join(pluginRoot, "commands")
45
+ const files = readMarkdownFiles(commandsDir)
46
+ const commands = {}
47
+
48
+ for (const file of files) {
49
+ const name = file.replace(/\.md$/, "")
50
+ const fullPath = path.join(commandsDir, file)
51
+ const content = fs.readFileSync(fullPath, "utf8")
52
+ const { meta, body } = extractFrontmatter(content)
53
+
54
+ const command = {
55
+ description: meta.description || `Run /${name}`,
56
+ template: body.trim(),
57
+ }
58
+
59
+ if (meta.agent) command.agent = meta.agent
60
+ if (meta.model) command.model = meta.model
61
+ if (typeof meta.subtask === "boolean") command.subtask = meta.subtask
62
+
63
+ commands[name] = command
64
+ }
65
+
66
+ return commands
67
+ }
68
+
69
+ function parseCsvList(value) {
70
+ if (!value || typeof value !== "string") return []
71
+ return value
72
+ .split(",")
73
+ .map((item) => item.trim())
74
+ .filter(Boolean)
75
+ }
76
+
77
+ function materializePreloadedTemplate(template) {
78
+ return template.replace(/\$ARGUMENTS/g, "all relevant current work in this session")
79
+ }
80
+
81
+ function loadAgents(commands) {
82
+ const agentsDir = path.join(pluginRoot, "agents")
83
+ const files = readMarkdownFiles(agentsDir)
84
+ const rawAgents = {}
85
+
86
+ for (const file of files) {
87
+ const name = file.replace(/\.md$/, "")
88
+ const fullPath = path.join(agentsDir, file)
89
+ const content = fs.readFileSync(fullPath, "utf8")
90
+ const { meta, body } = extractFrontmatter(content)
91
+
92
+ rawAgents[name] = {
93
+ meta,
94
+ body: body.trim(),
95
+ }
96
+ }
97
+
98
+ const agents = {}
99
+
100
+ for (const [name, raw] of Object.entries(rawAgents)) {
101
+ const { meta, body } = raw
102
+ const promptParts = []
103
+
104
+ const parentAgentName = typeof meta.extends === "string" ? meta.extends.trim() : ""
105
+ if (parentAgentName && rawAgents[parentAgentName]) {
106
+ const parentPrompt = rawAgents[parentAgentName].body
107
+ if (parentPrompt) promptParts.push(parentPrompt)
108
+ }
109
+
110
+ if (body) {
111
+ promptParts.push(body)
112
+ }
113
+
114
+ const preloadedCommands = parseCsvList(meta.preload_commands)
115
+ for (const commandName of preloadedCommands) {
116
+ const command = commands[commandName]
117
+ if (!command || !command.template) continue
118
+ const rendered = materializePreloadedTemplate(command.template)
119
+ promptParts.push(`[Preloaded command /${commandName}]\n${rendered}`)
120
+ }
121
+
122
+ const agent = {
123
+ prompt: promptParts.join("\n\n").trim(),
124
+ }
125
+
126
+ if (meta.description) agent.description = meta.description
127
+ if (meta.mode) agent.mode = meta.mode
128
+ if (meta.model) agent.model = meta.model
129
+ if (meta.color) agent.color = meta.color
130
+
131
+ agents[name] = agent
132
+ }
133
+
134
+ return agents
135
+ }
136
+
137
+ export const OpencodeSkillzPlugin = async () => {
138
+ return {
139
+ config: async (config) => {
140
+ config.command = config.command || {}
141
+ config.agent = config.agent || {}
142
+
143
+ const commands = loadCommands()
144
+ for (const [name, command] of Object.entries(commands)) {
145
+ const commandName = buildCommandName(name)
146
+ if (!config.command[commandName]) {
147
+ config.command[commandName] = command
148
+ }
149
+ }
150
+
151
+ const agents = loadAgents(commands)
152
+ for (const [name, agent] of Object.entries(agents)) {
153
+ if (!config.agent[name]) {
154
+ config.agent[name] = agent
155
+ }
156
+ }
157
+
158
+ config.agent.build = {
159
+ ...(config.agent.build || {}),
160
+ disable: true,
161
+ }
162
+
163
+ config.agent.plan = {
164
+ ...(config.agent.plan || {}),
165
+ disable: true,
166
+ }
167
+
168
+ if (!config.default_agent && config.agent.default) {
169
+ config.default_agent = "default"
170
+ }
171
+ },
172
+ }
173
+ }
174
+
175
+ export default OpencodeSkillzPlugin
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@nt-ai-lab/opencode-skillz",
3
+ "version": "0.2.1",
4
+ "description": "Bundled OpenCode commands and agents",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "commands",
13
+ "agents",
14
+ "AGENTS.md",
15
+ "README.md"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ }
20
+ }