@nankonyo/testnan 0.0.0-stage → 0.1.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.
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: Control testnan (on|off|version, default status)
3
+ ---
4
+
5
+ Switch testnan. Subcommand = $ARGUMENTS (satu kata, case-insensitive).
6
+
7
+ - `` (kosong): lapor mode aktif + versi. Tanpa ubah apa pun.
8
+ - `on`: nyalakan kewajiban test (berlaku pesan berikut).
9
+ - `off`: matikan sampai dinyalakan lagi.
10
+ - `version`: lapor versi terpasang (dari package.json). Tanpa ubah apa pun.
11
+ - Selain itu: abaikan, beri tahu pemakaian valid.
12
+
13
+ When on: tutup setiap eksekusi ubah kode (semua bahasa) dengan test di `test/test_<slug>.*` (flat di root, tanpa tanggal, tanpa subfolder). Sebelum buat file baru, cek duplikat via `ls test/test_*` + `grep <keyword> test/test_*`. File cocok ada: tambah case di file itu, jangan buat baru. Belum ada: buat `test/test_<slug>.<ext>` dari template di SKILL.md (ext ikut bahasa). Runner: runner bawaan project (`python3 test/run.py`, `npm test`, `go test ./...`, dst). Stdlib/native saja, tanpa deps baru bila tak perlu.
@@ -0,0 +1,5 @@
1
+ {
2
+ "dependencies": {
3
+ "@opencode-ai/plugin": "1.18.33"
4
+ }
5
+ }
@@ -0,0 +1,198 @@
1
+ // testnan — OpenCode plugin (dual API, satu file). Mirror docsnan.mjs.
2
+ // V2: `id` + `setup`. V1: `server()`. Persist mode di `<config>/opencode/.testnan-active`.
3
+
4
+ import { createRequire } from 'module';
5
+ import fs from 'fs';
6
+ import os from 'os';
7
+ import path from 'path';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const { getTestnanInstructions } = require('../../hooks/testnan-instructions.cjs');
14
+ const { getDefaultMode, normalizePersistedMode } = require('../../hooks/testnan-config.cjs');
15
+ const { routeCommand } = require('../../hooks/testnan-command.cjs');
16
+ import { readFileSync } from 'fs';
17
+
18
+ function pluginVersion() {
19
+ try {
20
+ const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '../../package.json'), 'utf8'));
21
+ return typeof pkg.version === 'string' ? pkg.version : '?';
22
+ } catch (e) {
23
+ return '?';
24
+ }
25
+ }
26
+
27
+ // ponytail: simpan state beside opencode config, sama seperti .docsnan-active.
28
+ const statePath = path.join(
29
+ process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'),
30
+ 'opencode',
31
+ '.testnan-active',
32
+ );
33
+
34
+ function readMode() {
35
+ try {
36
+ return normalizePersistedMode(fs.readFileSync(statePath, 'utf8').trim()) || getDefaultMode();
37
+ } catch (e) {
38
+ return getDefaultMode();
39
+ }
40
+ }
41
+
42
+ function writeMode(mode) {
43
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
44
+ fs.writeFileSync(statePath, mode);
45
+ }
46
+
47
+ export function parseCommandFile(filePath) {
48
+ const content = fs.readFileSync(filePath, 'utf8');
49
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
50
+ if (!match) return null;
51
+ const description = match[1].match(/description:\s*(.+)/)?.[1]?.trim();
52
+ return { description, template: match[2].trim() };
53
+ }
54
+
55
+ function frontmatterField(frontmatter, key) {
56
+ const lines = frontmatter.split(/\r?\n/);
57
+ const at = lines.findIndex((line) => line.startsWith(key + ':'));
58
+ if (at === -1) return undefined;
59
+ const value = lines[at].slice(key.length + 1).trim();
60
+ if (value[0] !== '>' && value[0] !== '|') return value;
61
+ const block = [];
62
+ for (const line of lines.slice(at + 1)) {
63
+ if (line.trim() && !/^\s/.test(line)) break;
64
+ block.push(line.trim());
65
+ }
66
+ while (block.length && !block[block.length - 1]) block.pop();
67
+ const joined = block.join(value[0] === '|' ? '\n' : ' ');
68
+ return value.endsWith('-') ? joined : joined + '\n';
69
+ }
70
+
71
+ function readSkill() {
72
+ const file = path.resolve(__dirname, '../../skills/testnan/SKILL.md');
73
+ try {
74
+ const content = fs.readFileSync(file, 'utf8');
75
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---[^\S\n]*\r?\n?([\s\S]*)$/);
76
+ if (!match) return null;
77
+ return {
78
+ id: 'testnan',
79
+ name: frontmatterField(match[1], 'name') || 'testnan',
80
+ description:
81
+ frontmatterField(match[1], 'description') ||
82
+ 'Wajibkan tiap ubah kode (semua bahasa) ditutup test di test/test_<slug>.*.',
83
+ path: file,
84
+ content: match[2],
85
+ };
86
+ } catch (e) {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ function readCommands() {
92
+ const dir = path.join(__dirname, '..', 'command');
93
+ try {
94
+ return fs
95
+ .readdirSync(dir)
96
+ .filter((file) => file.endsWith('.md'))
97
+ .map((file) => {
98
+ const parsed = parseCommandFile(path.join(dir, file));
99
+ return parsed && { name: path.basename(file, '.md'), ...parsed };
100
+ })
101
+ .filter(Boolean);
102
+ } catch (e) {
103
+ return [];
104
+ }
105
+ }
106
+
107
+ function versionTag() {
108
+ return `(testnan ${readMode()} v${pluginVersion()})`;
109
+ }
110
+
111
+ async function server({ client } = {}) {
112
+ const log = (level, message) => {
113
+ try { client && client.app && client.app.log({ body: { service: 'testnan', level, message } }); } catch (e) {}
114
+ };
115
+
116
+ const skillsDir = path.resolve(__dirname, '../../skills');
117
+
118
+ return {
119
+ config: async (config) => {
120
+ if (!config.command) config.command = {};
121
+ for (const command of readCommands()) {
122
+ config.command[command.name] = { description: command.description, template: command.template };
123
+ }
124
+
125
+ config.skills = config.skills || {};
126
+ config.skills.paths = config.skills.paths || [];
127
+ if (!config.skills.paths.includes(skillsDir)) {
128
+ config.skills.paths.push(skillsDir);
129
+ }
130
+ },
131
+
132
+ 'experimental.chat.system.transform': async (_input, output) => {
133
+ const mode = readMode();
134
+ if (mode === 'off') return;
135
+ output.system.push(getTestnanInstructions(mode));
136
+ },
137
+
138
+ 'command.execute.before': async (input) => {
139
+ if (!input || input.command !== 'testnan') return;
140
+ const routed = routeCommand(input.arguments || '');
141
+ if (routed.action === 'mode') {
142
+ writeMode(routed.mode);
143
+ log('info', 'testnan ' + routed.mode);
144
+ } else if (routed.action === 'status') {
145
+ log('info', 'testnan ' + readMode() + ' (v' + pluginVersion() + ')');
146
+ } else if (routed.action === 'version') {
147
+ log('info', 'testnan v' + pluginVersion());
148
+ } else {
149
+ log('info', 'testnan: unknown arg "' + routed.arg + '". Use on|off|version.');
150
+ }
151
+ },
152
+ };
153
+ }
154
+
155
+ export default {
156
+ id: 'testnan',
157
+
158
+ async setup(ctx) {
159
+ const skill = readSkill();
160
+ if (skill) {
161
+ await ctx.skill.transform((editor) => {
162
+ editor.add(skill);
163
+ });
164
+ }
165
+
166
+ const commands = readCommands();
167
+ await ctx.command.transform((editor) => {
168
+ for (const command of commands) {
169
+ editor.add({
170
+ name: command.name,
171
+ description: command.description,
172
+ execute: async ({ sessionID, prompt, delivery }) => {
173
+ const routed = routeCommand(prompt.text || '');
174
+ if (routed.action === 'mode') writeMode(routed.mode);
175
+ let extra = versionTag();
176
+ if (routed.action === 'unknown') {
177
+ extra += `. Unknown arg "${routed.arg}". Use on|off|version.`;
178
+ }
179
+ await ctx.session.prompt({
180
+ ...prompt,
181
+ sessionID,
182
+ text: command.template.replaceAll('$ARGUMENTS', prompt.text || '') + `\n\n${extra}`,
183
+ delivery,
184
+ });
185
+ },
186
+ });
187
+ }
188
+ });
189
+
190
+ await ctx.session.hook('context', (event) => {
191
+ const mode = readMode();
192
+ if (mode === 'off') return;
193
+ event.system.push({ type: 'text', text: getTestnanInstructions(mode) });
194
+ });
195
+ },
196
+
197
+ server,
198
+ };
package/AGENTS.md ADDED
@@ -0,0 +1,20 @@
1
+ # Testnan — 1 area = 1 file test/test_<slug>.*
2
+
3
+ Aturan wajib, bukan opsional. Setiap eksekusi ubah kode (bahasa apa
4
+ pun) tutup dengan test di `test/` folder project. Hanya saat eksekusi
5
+ (ada file kode diubah). Skip untuk obrolan biasa, plan tanpa eksekusi,
6
+ dan baca kode tanpa perubahan.
7
+
8
+ Format nama: `test/test_<slug>.<ext>` (flat, tanpa tanggal)
9
+ Contoh: `test/test_auth_login.py`, `test/test_auth_login.test.js`
10
+ Ext ikut bahasa + konvensi native. Cek duplikat dulu:
11
+ `ls test/test_*` + `grep -l <keyword> test/test_*`.
12
+ Ada cocok: tambah case di file itu. Jangan buat baru.
13
+
14
+ Runner: pakai runner bawaan project (`python3 test/run.py`,
15
+ `npm test`, `go test ./...`, `cargo test`, dst). Stdlib/native saja,
16
+ tanpa deps baru bila tak perlu.
17
+
18
+ Template isi ada di `skills/testnan/SKILL.md`.
19
+
20
+ Kontrol: `/testnan on` | `/testnan off`. Default: `on`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 docsnan contributors
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,166 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="testnan-cover.jpeg" width="700" alt="Testnan, tiap ubah kode wajib ada test">
3
+ </p>
2
4
 
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.
5
+ <h1 align="center">Testnan</h1>
6
+
7
+ <p align="center">
8
+ <em>Satu area, satu file test. Tidak ada ubah kode tanpa bukti jalan.</em>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <img src="https://img.shields.io/badge/works%20with-opencode-111111?style=flat-square" alt="Works with opencode">
13
+ <img src="https://img.shields.io/badge/tests-stdlib_native-111111?style=flat-square" alt="Stdlib native tests">
14
+ <img src="https://img.shields.io/badge/license-MIT-111111?style=flat-square" alt="MIT license">
15
+ </p>
16
+
17
+ ---
18
+
19
+ AI rajin nulis kode, malas nulis test. Testnan memaksanya.
20
+
21
+ Setiap kali agent mengubah kode (bahasa apa pun) — fitur, fix, debug,
22
+ refactor — ia wajib menutupnya dengan test di folder `test/` project
23
+ tersebut. Satu area, satu file. Obrolan biasa dan plan tanpa eksekusi
24
+ tidak butuh test.
25
+
26
+ ## Contoh hasil
27
+
28
+ Selesai fix login, agent tulis `test/test_auth_login.py`:
29
+
30
+ ```python
31
+ import unittest
32
+
33
+ from auth.login import guard_kosong
34
+
35
+
36
+ class TestGuardKosong(unittest.TestCase):
37
+ def test_tolak_password_kosong(self):
38
+ self.assertFalse(guard_kosong(""))
39
+ ```
40
+
41
+ Lalu bukti jalan:
42
+
43
+ ```bash
44
+ $ python3 test/run.py
45
+ ......
46
+ Ran 6 tests in 0.071s
47
+ OK
48
+ ```
49
+
50
+ ## Cara kerja
51
+
52
+ 1. Plugin injeksi aturan testnan ke system prompt setiap turn (bila mode `on`).
53
+ 2. Agent kerja seperti biasa.
54
+ 3. Sebelum tulis test, agent cek duplikat: `ls test/test_*` +
55
+ `grep -l <keyword> test/test_*`.
56
+ 4. File cocok ada: tambah case di file itu, jangan buat baru. Belum ada:
57
+ buat `test/test_<slug>.<ext>` (ext ikut bahasa, contoh `.py`,
58
+ `.test.js`). Slug snake_case, maks 50 char, tanpa
59
+ tanggal, tanpa subfolder.
60
+ 5. Verifikasi: runner bawaan project (`python3 test/run.py`,
61
+ `npm test`, `go test ./...`, dst). Harus hijau.
62
+
63
+ Kapan wajib: ada file kode diubah/dibuat/dihapus (bahasa apa pun).
64
+ Kapan skip: obrolan biasa, tanya jawab, plan tanpa eksekusi, baca kode
65
+ tanpa perubahan, ubah non-kode saja (docs, config, markdown).
66
+ Aturan putus: tidak ada file kode diubah = tidak ada test baru.
67
+ Tapi test lama yang relevan tetap harus lolos.
68
+
69
+ ## Install
70
+
71
+ Satu file plugin dukung OpenCode 1.x (`server()`) dan 2.x (`id`+`setup`).
72
+
73
+ ### OpenCode 2.x (dari checkout)
74
+
75
+ Entry harus direktori, bukan file:
76
+
77
+ ```json
78
+ { "plugins": ["/abs/path/testnan-checkout"] }
79
+ ```
80
+
81
+ Path `./` relatif terhadap `opencode.json` project. Untuk satu checkout
82
+ dipakai banyak project, isi path absolut ke root checkout (ia temukan
83
+ `hooks/` dan `skills/` relatif ke lokasi plugin).
84
+
85
+ Aktifkan ulang config tanpa restart:
86
+
87
+ ```bash
88
+ opencode reload
89
+ ```
90
+
91
+ ### OpenCode 1.x
92
+
93
+ ```json
94
+ { "plugin": ["./.opencode/plugins/testnan.mjs"] }
95
+ ```
96
+
97
+ ## Perintah
98
+
99
+ | Perintah | Efek |
100
+ |---|---|
101
+ | `/testnan` | Lapor mode + versi, tanpa ubah apa pun |
102
+ | `/testnan on` | Nyalakan kewajiban test (default) |
103
+ | `/testnan off` | Matikan sampai dinyalakan lagi |
104
+ | `/testnan version` | Lapor versi terpasang (dari `package.json`) |
105
+
106
+ Env override default: `TESTNAN_DEFAULT_MODE=off`.
107
+
108
+ ## Isi test
109
+
110
+ Stdlib/native saja, tanpa deps baru bila tak perlu. Python pakai
111
+ `unittest`, JS pakai `node:test`, Go pakai `testing`, dst.
112
+
113
+ - 1 file = 1 area. Class per unit, method `test_*` per case.
114
+ - Import modul target langsung. Tak boleh ada test kosong tanpa assert.
115
+ - Dua file test untuk satu area = salah. Extend, jangan duplikat.
116
+
117
+ ## Runner
118
+
119
+ Pakai runner bawaan project. Stdlib saja.
120
+
121
+ ```bash
122
+ python3 test/run.py # python semua
123
+ python3 test/run.py auth_login # python satuan
124
+ npm test # js semua
125
+ go test ./... # go semua
126
+ cargo test # rust semua
127
+ ```
128
+
129
+ Keluar 0 bila lolos semua, non-0 bila ada gagal. Jangan tambah
130
+ runner bash/Makefile/pytest bila sudah ada.
131
+
132
+ ## Tes plugin ini
133
+
134
+ Repo ini dogfood pakai aturannya sendiri:
135
+
136
+ ```bash
137
+ python3 test/run.py # python semua
138
+ python3 test/run.py naming # python satuan
139
+ npm test # js semua
140
+ ```
141
+
142
+ `test/test_naming.py`: kontrak nama flat tanpa tanggal (semua bahasa).
143
+ `test/test_runner.py`: kontrak runner python semua/satuan.
144
+ `test/test_testnan_command.test.js`: kontrak router command JS.
145
+
146
+ ## Struktur
147
+
148
+ ```text
149
+ index.js # entrypoint root (resolve direktori plugin)
150
+ .opencode/plugins/testnan.mjs # plugin: command, skills, injeksi prompt
151
+ .opencode/command/testnan.md # template /testnan
152
+ skills/testnan/SKILL.md # aturan test + anti-duplikat (sumber injeksi)
153
+ hooks/testnan-config.cjs # mode on/off
154
+ hooks/testnan-instructions.cjs # bangun teks injeksi
155
+ hooks/testnan-command.cjs # router bare/on/off/version
156
+ test/test_naming.py # kontrak nama flat tanpa tanggal (semua bahasa)
157
+ test/test_runner.py # kontrak runner python semua/satuan
158
+ test/test_testnan_command.test.js # kontrak router command JS
159
+ test/run.py # runner python: semua atau satuan via -k
160
+ testnan-cover.jpeg # cover
161
+ docs/YYYYMMDD/ # log eksekusi harian (ala docsnan)
162
+ ```
163
+
164
+ ## Lisensi
165
+
166
+ MIT. Lihat [LICENSE](LICENSE).
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ // testnan — command router (pure, no deps).
3
+ // Distinguishes: bare, on, off, version. No ambiguous behavior.
4
+
5
+ const { normalizeMode } = require('./testnan-config.cjs');
6
+
7
+ function routeCommand(rawArgs) {
8
+ const arg = String(rawArgs || '').trim().toLowerCase();
9
+ if (arg === '') return { action: 'status' };
10
+ if (normalizeMode(arg)) return { action: 'mode', mode: normalizeMode(arg) };
11
+ if (arg === 'version' || arg === '-v' || arg === '--version') return { action: 'version' };
12
+ return { action: 'unknown', arg: String(rawArgs || '').trim() };
13
+ }
14
+
15
+ module.exports = { routeCommand };
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ // testnan — shared configuration resolver (mirip docsnan-config.js).
3
+ //
4
+ // Resolution order:
5
+ // 1. TESTNAN_DEFAULT_MODE env (on|off)
6
+ // 2. $XDG_CONFIG_HOME/testnan/config.json atau ~/.config/testnan/config.json
7
+ // 3. 'on'
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const os = require('os');
12
+
13
+ const DEFAULT_MODE = 'on';
14
+ const VALID_MODES = ['on', 'off'];
15
+
16
+ function normalizeMode(mode) {
17
+ if (typeof mode !== 'string') return null;
18
+ const m = mode.trim().toLowerCase();
19
+ return VALID_MODES.includes(m) ? m : null;
20
+ }
21
+
22
+ function normalizePersistedMode(mode) {
23
+ return normalizeMode(mode);
24
+ }
25
+
26
+ function getConfigDir() {
27
+ if (process.env.XDG_CONFIG_HOME) {
28
+ return path.join(process.env.XDG_CONFIG_HOME, 'testnan');
29
+ }
30
+ return path.join(os.homedir(), '.config', 'testnan');
31
+ }
32
+
33
+ function getConfigPath() {
34
+ return path.join(getConfigDir(), 'config.json');
35
+ }
36
+
37
+ function getDefaultMode() {
38
+ const env = process.env.TESTNAN_DEFAULT_MODE;
39
+ if (env && VALID_MODES.includes(String(env).toLowerCase())) {
40
+ return String(env).toLowerCase();
41
+ }
42
+ try {
43
+ const raw = fs.readFileSync(getConfigPath(), 'utf8');
44
+ const cfg = JSON.parse(raw);
45
+ if (cfg && normalizeMode(cfg.defaultMode)) return normalizeMode(cfg.defaultMode);
46
+ } catch (e) {}
47
+ return DEFAULT_MODE;
48
+ }
49
+
50
+ module.exports = {
51
+ DEFAULT_MODE,
52
+ VALID_MODES,
53
+ normalizeMode,
54
+ normalizePersistedMode,
55
+ getConfigDir,
56
+ getConfigPath,
57
+ getDefaultMode,
58
+ };
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // testnan — shared instruction builder (mirip docsnan-instructions.js).
3
+ // SKILL.md sumber tunggal. Fallback bila file tak terbaca.
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { DEFAULT_MODE, normalizePersistedMode } = require('./testnan-config.cjs');
8
+
9
+ const SKILL_PATH = path.join(__dirname, '..', 'skills', 'testnan', 'SKILL.md');
10
+
11
+ function getFallbackInstructions() {
12
+ return 'TESTNAN MODE ACTIVE — level: on\n\n' +
13
+ 'Tutup setiap eksekusi ubah kode (semua bahasa) dengan test di test/test_<slug>.* (flat, tanpa tanggal). ' +
14
+ 'Cek duplikat dulu via `ls test/test_*` + `grep`. Ada yang cocok: tambah case di file itu. ' +
15
+ 'Jalankan via runner bawaan project (`python3 test/run.py`, `npm test`, `go test ./...`, dst). Stdlib/native saja.';
16
+ }
17
+
18
+ function getTestnanInstructions(mode) {
19
+ const m = normalizePersistedMode(mode) || DEFAULT_MODE;
20
+ if (m === 'off') return '';
21
+ try {
22
+ const body = String(fs.readFileSync(SKILL_PATH, 'utf8')).replace(/^---[\s\S]*?---\s*/, '');
23
+ return 'TESTNAN MODE ACTIVE — level: on\n\n' + body.trim();
24
+ } catch (e) {
25
+ return getFallbackInstructions();
26
+ }
27
+ }
28
+
29
+ module.exports = { getTestnanInstructions, getFallbackInstructions };
package/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // testnan — root entrypoint untuk OpenCode V2.
2
+ // Loader opencode resolve direktori plugin lokal ke <dir>/index.js
3
+ // (pola sama seperti caveman/docsnan). Teruskan ke entry .mjs.
4
+ import plugin, { parseCommandFile } from './.opencode/plugins/testnan.mjs';
5
+
6
+ export default plugin;
7
+ export { parseCommandFile };
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "@nankonyo/testnan",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Wajibkan tiap ubah kode (semua bahasa) ditutup test di test/test_<slug>.*. Mirip docsnan untuk test.",
6
+ "keywords": [
7
+ "opencode-plugin",
8
+ "opencode",
9
+ "testnan",
10
+ "skills"
11
+ ],
12
+ "license": "MIT",
13
+ "author": {
14
+ "name": "nankonyo"
15
+ },
16
+ "scripts": {
17
+ "test": "node --test test/*.test.js"
18
+ },
19
+ "main": "./.opencode/plugins/testnan.mjs",
20
+ "exports": {
21
+ ".": "./.opencode/plugins/testnan.mjs",
22
+ "./plugin": "./.opencode/plugins/testnan.mjs"
23
+ },
24
+ "files": [
25
+ "AGENTS.md",
26
+ "index.js",
27
+ "hooks/",
28
+ "skills/",
29
+ ".opencode/",
30
+ "testnan-cover.jpeg",
31
+ "README.md",
32
+ "LICENSE"
33
+ ]
34
+ }
@@ -0,0 +1,108 @@
1
+ ---
2
+ name: testnan
3
+ description: >
4
+ Wajibkan tiap ubah kode (semua bahasa) ditutup test di test/test_<slug>.*.
5
+ Flat di root tanpa tanggal. Cek duplikat dulu, reuse file cocok.
6
+ Runner bawaan project untuk semua atau satuan. Stdlib/native saja.
7
+ argument-hint: "[on|off]"
8
+ license: MIT
9
+ ---
10
+
11
+ # Testnan
12
+
13
+ Tutup setiap eksekusi ubah kode (semua bahasa) dengan test. 1 area = 1 file.
14
+
15
+ ## Kapan wajib test
16
+
17
+ Hanya saat ada file kode diubah/dibuat/dihapus — bahasa apa pun
18
+ (`.py`, `.js`, `.ts`, `.go`, `.rs`, `.java`, dst), atau bug difix,
19
+ fitur ditambah, refactor dijalankan. Berlaku untuk semua jenis
20
+ eksekusi yang sentuh kode.
21
+
22
+ ## Kapan skip (tanpa test)
23
+
24
+ - Obrolan biasa: salam, tanya jawab, penjelasan, diskusi konsep.
25
+ - Plan / rencana saja tanpa eksekusi: belum ada file diubah.
26
+ - Baca/browse kode saja tanpa perubahan.
27
+ - Ubah non-kode saja (docs, config, markdown): test tak wajib, tulis
28
+ alasan di log bila pakai docsnan.
29
+
30
+ Aturan putus: tidak ada file kode diubah = tidak ada test baru.
31
+ Tapi test lama yang relevan tetap harus lolos.
32
+
33
+ ## Lokasi dan nama
34
+
35
+ - Folder: `test/` di root project. Flat. Tanpa subfolder tanggal.
36
+ - Nama: `test_<slug>.<ext>`. Contoh: `test_auth_login.py`,
37
+ `test_auth_login.test.js`, `test_auth_login_test.go`.
38
+ - Ext ikut bahasa + konvensi native. Slug: dari area/fitur. Lowercase
39
+ snake_case. Non-alfanumerik jadi `_`. Maks 50 char. Contoh:
40
+ "Auth Login" jadi `auth_login`.
41
+ - Jangan pakai tanggal/jam di nama file. Jangan buat file baru bila
42
+ slug sama sudah ada.
43
+
44
+ ## Anti-duplikat (wajib sebelum tulis)
45
+
46
+ 1. `ls test/test_*` — lihat file yang ada.
47
+ 2. `grep -l <keyword> test/test_*` — cari area cocok
48
+ (nama fungsi, modul, keyword fitur).
49
+ 3. Kena: tambah `TestCase`/`test_*` baru di file itu. Jangan buat
50
+ file baru.
51
+ 4. Tak kena: buat `test/test_<slug>.<ext>` baru dari template bawah.
52
+ 5. Satu area = satu file. Dua file test satu area = salah.
53
+
54
+ ## Isi (stdlib/native saja)
55
+
56
+ Tanpa deps tambahan bila stdlib cukup. Python 3.8+ pakai `unittest`,
57
+ JS pakai `node:test`, Go pakai `testing`, dst.
58
+
59
+ Template (Python):
60
+
61
+ ```python
62
+ import unittest
63
+
64
+ from auth.login import guard_kosong
65
+
66
+
67
+ class TestGuardKosong(unittest.TestCase):
68
+ def test_tolak_password_kosong(self):
69
+ self.assertFalse(guard_kosong(""))
70
+
71
+
72
+ if __name__ == "__main__":
73
+ unittest.main()
74
+ ```
75
+
76
+ Template (JS):
77
+
78
+ ```js
79
+ import { test } from 'node:test';
80
+ import assert from 'node:assert/strict';
81
+
82
+ test('tolak password kosong', () => {
83
+ assert.equal(guardKosong(''), false);
84
+ });
85
+ ```
86
+
87
+ Aturan: 1 file = 1 area. Class per unit, method `test_*` per case.
88
+ Import modul target langsung, bukan via path hack bila bisa.
89
+ Tak boleh ada test kosong tanpa assert.
90
+
91
+ ## Runner
92
+
93
+ Pakai runner bawaan project. Stdlib saja, jangan tambah
94
+ runner bash/Makefile/pytest bila sudah ada.
95
+
96
+ - Python: `python3 test/run.py` (semua) atau
97
+ `python3 test/run.py <keyword>` (satuan, cocok nama file/class/method)
98
+ - JS: `npm test` atau `node --test test/*.test.js`
99
+ - Go: `go test ./...`
100
+ - Rust: `cargo test`
101
+
102
+ Keluar 0 bila lolos semua, non-0 bila ada gagal.
103
+
104
+ ## Batas
105
+
106
+ - `/testnan off` hentikan kewajiban sampai `/testnan on` lagi.
107
+ - Mode default `on`. Env `TESTNAN_DEFAULT_MODE=off` ubah default.
108
+ - Test tulis normal, jelas. Bukan gaya caveman/ponytail.
Binary file