slop-ai-indo 1.0.2 ā 1.2.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/DESIGN.md +11 -0
- package/bin/slop-lint.mjs +72 -0
- package/bin/slop-mcp.mjs +274 -0
- package/index.mjs +58 -5
- package/lib/banner.mjs +1 -1
- package/lib/hook.mjs +128 -0
- package/lib/install.mjs +37 -18
- package/lib/linter.mjs +303 -0
- package/package.json +11 -4
- package/presets/dark-modern.md +33 -0
- package/presets/default.md +97 -0
- package/presets/editorial.md +32 -0
- package/presets/enterprise.md +45 -0
- package/presets/utilitarian.md +27 -0
- package/skills/slop-ai-indo/VERSION +1 -1
- package/skills/slop-ai-indo-backend/SKILL.md +158 -0
- package/skills/slop-ai-indo-code/SKILL.md +31 -5
- package/skills/slop-ai-indo-human/contrast-mcp.py +1 -1
- package/skills/slop-ai-indo-test/SKILL.md +55 -0
package/DESIGN.md
CHANGED
|
@@ -69,6 +69,13 @@ Untuk aplikasi otomatisasi alur, diagram, atau kanvas multi-agent:
|
|
|
69
69
|
* **Laravel / PHP:**
|
|
70
70
|
* Kode PSR-12 bersih.
|
|
71
71
|
* Hapus komentar redundan yang hanya mengulang nama fungsi.
|
|
72
|
+
* DILARANG membuat DocBlock/PHPDoc boilerplate (`/** ... */`) yang hanya memparafrase nama fungsi atau mengulang tipe data (seperti `/** Get all setup menus... @return Collection */`). Gunakan native type hint (PHP 8+). DocBlock HANYA diizinkan untuk tipe generic kompleks static analysis (PHPStan/Psalm) tanpa kalimat deskripsi hampa.
|
|
73
|
+
* Terapkan Skinny Controller: validasi wajib dipisah ke Form Request, logika bisnis diekstrak ke Action Class / Service Layer.
|
|
74
|
+
* Wajib bungkus response publik dengan API Resource (`JsonResource`) atau DTO; DILARANG mengembalikan Model Eloquent mentah ke JSON client.
|
|
75
|
+
* Gunakan Guard Clauses / Early Return untuk menjaga kedalaman indentasi logika maksimal 2 tingkat.
|
|
76
|
+
* Data Master & Konfigurasi: Wajib buatkan tabel database (Master / Lookup / Reference Tables) untuk data opsi, kategori, atau konfigurasi yang berpotensi dinamis agar mudah dirawat tanpa redeploy kode. Hardcode (Backed Enum / Config) HANYA untuk alur status tertutup (state machine) yang terikat langsung dengan percabangan logika kode.
|
|
77
|
+
* DILARANG menggunakan `protected $guarded = []`. Wajib deklarasikan `$fillable` secara eksplisit atau hanya passing `$request->validated()` untuk mencegah celah Mass Assignment.
|
|
78
|
+
* Migrasi Database: Foreign key wajib gunakan `$table->foreignId()->constrained()->cascadeOnDelete()` dan beri `->index()` pada kolom yang sering disaring/diurutkan (`status`, `slug`, `created_at`).
|
|
72
79
|
* Blade/Inertia views menggunakan utilitas CSS dengan siku tajam (`rounded-sm` atau `rounded`, jangan `rounded-full`).
|
|
73
80
|
* **Flutter:**
|
|
74
81
|
* Widget menggunakan `RoundedRectangleBorder(borderRadius: BorderRadius.circular(4))`.
|
|
@@ -77,6 +84,10 @@ Untuk aplikasi otomatisasi alur, diagram, atau kanvas multi-agent:
|
|
|
77
84
|
* **CSS / Tailwind:**
|
|
78
85
|
* Gunakan `border-radius: 4px` atau `border-radius: 6px`.
|
|
79
86
|
* Hindari class `rounded-2xl`, `rounded-3xl`, `rounded-full` kecuali untuk avatar pengguna berbentuk lingkaran.
|
|
87
|
+
* **URL & Routing (Clean / Semantic URLs):**
|
|
88
|
+
* Wajib gunakan URL semantik RESTful dan slug (`/articles/{article:slug}`, `/users/{id}/edit`), DILARANG menggunakan query string untuk routing halaman atau identifier entitas (`?id=123`, `?page=settings`, `?action=delete`).
|
|
89
|
+
* Query string (`?`) HANYA diizinkan untuk filter opsional, pencarian, dan paginasi (`?category=tech&page=2`).
|
|
90
|
+
* Seluruh parameter URL wajib diproses via parameter binding (Prepared Statements / ORM) untuk proteksi mutlak dari SQL Injection dan IDOR.
|
|
80
91
|
|
|
81
92
|
---
|
|
82
93
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import pc from 'picocolors'
|
|
4
|
+
import { lintPath } from '../lib/linter.mjs'
|
|
5
|
+
|
|
6
|
+
const args = process.argv.slice(2)
|
|
7
|
+
const isJson = args.includes('--json')
|
|
8
|
+
const isQuiet = args.includes('--quiet')
|
|
9
|
+
const tierArg = args.find((a) => a.startsWith('--tier='))
|
|
10
|
+
const tier = tierArg ? tierArg.split('=')[1] : 'all'
|
|
11
|
+
|
|
12
|
+
const targetArg = args.find((a) => !a.startsWith('--')) || '.'
|
|
13
|
+
const targetPath = path.resolve(process.cwd(), targetArg)
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const result = lintPath(targetPath, { tier })
|
|
17
|
+
|
|
18
|
+
if (isJson) {
|
|
19
|
+
console.log(JSON.stringify(result, null, 2))
|
|
20
|
+
process.exit(result.passed ? 0 : 1)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!isQuiet) {
|
|
24
|
+
console.log(pc.bold(pc.cyan('\nš slop-lint: Mesin Linter Deterministik Slop AI Indo')))
|
|
25
|
+
console.log(pc.dim(`Memeriksa: ${targetPath}\n`))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (result.findings.length === 0) {
|
|
29
|
+
if (!isQuiet) {
|
|
30
|
+
console.log(pc.green('ā BERSIH:') + pc.dim(` ${result.scannedFiles} file diperiksa, nol slop terdeteksi.`))
|
|
31
|
+
console.log(pc.bold(pc.green('Status: PASSED (Lolos Delivery Gate)\n')))
|
|
32
|
+
}
|
|
33
|
+
process.exit(0)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Group findings by file
|
|
37
|
+
const byFile = new Map()
|
|
38
|
+
for (const f of result.findings) {
|
|
39
|
+
if (!byFile.has(f.file)) byFile.set(f.file, [])
|
|
40
|
+
byFile.get(f.file).push(f)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const [file, items] of byFile.entries()) {
|
|
44
|
+
const relFile = path.relative(process.cwd(), file)
|
|
45
|
+
console.log(pc.bold(pc.underline(relFile)))
|
|
46
|
+
for (const item of items) {
|
|
47
|
+
const isHard = item.tier === 'Hard Gate'
|
|
48
|
+
const tierBadge = isHard ? pc.red(`[${item.tier}]`) : pc.yellow(`[${item.tier}]`)
|
|
49
|
+
const ruleCode = pc.bold(item.ruleId)
|
|
50
|
+
console.log(` ${pc.dim(`baris ${item.line}`)} ${tierBadge} ${ruleCode} ${pc.bold(item.name)}`)
|
|
51
|
+
console.log(` ${pc.dim('Kode:')} ${pc.italic(item.snippet)}`)
|
|
52
|
+
console.log(` ${pc.cyan('Solusi:')} ${item.fix}\n`)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log(pc.dim('ā'.repeat(60)))
|
|
57
|
+
console.log(
|
|
58
|
+
`Total diperiksa: ${pc.bold(result.scannedFiles)} file | Ditemukan: ${pc.bold(result.counts.total)} temuan ` +
|
|
59
|
+
`(${pc.red(`${result.counts.hardGate} Hard Gate`)}, ${pc.yellow(`${result.counts.qualityLocks} Quality Lock`)})`
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if (result.passed) {
|
|
63
|
+
console.log(pc.bold(pc.yellow('Status: WARNING (Peringatan Kualitas, Hard Gate lolos)\n')))
|
|
64
|
+
process.exit(0)
|
|
65
|
+
} else {
|
|
66
|
+
console.log(pc.bold(pc.red('Status: FAILED (Gagal Hard Gate, perbaiki sebelum serah terima)\n')))
|
|
67
|
+
process.exit(1)
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.error(pc.red(`Error: ${err.message}`))
|
|
71
|
+
process.exit(1)
|
|
72
|
+
}
|
package/bin/slop-mcp.mjs
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Slop AI Indo - Server MCP Terpadu (slop-mcp)
|
|
4
|
+
* Protokol Model Context Protocol (MCP) standar via stdio.
|
|
5
|
+
* Mengekspos tool linter, kontras WCAG, validasi Delivery Gate, dan katalog aturan ke IDE.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import readline from 'node:readline'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { lintContent, lintPath, RULES } from '../lib/linter.mjs'
|
|
11
|
+
|
|
12
|
+
const PROTOCOL_VERSION = '2024-11-05'
|
|
13
|
+
const SERVER_NAME = 'slop-ai-indo-mcp'
|
|
14
|
+
const SERVER_VERSION = '1.1.0'
|
|
15
|
+
|
|
16
|
+
// --- WCAG CONTRAST ALGORITHM ---
|
|
17
|
+
function channel(c) {
|
|
18
|
+
c = c / 255.0
|
|
19
|
+
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function relativeLuminance(hexColor) {
|
|
23
|
+
let h = hexColor.trim().replace(/^#/, '')
|
|
24
|
+
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
|
|
25
|
+
const r = parseInt(h.slice(0, 2), 16)
|
|
26
|
+
const g = parseInt(h.slice(2, 4), 16)
|
|
27
|
+
const b = parseInt(h.slice(4, 6), 16)
|
|
28
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function computeContrast(fg, bg) {
|
|
32
|
+
try {
|
|
33
|
+
const l1 = relativeLuminance(fg)
|
|
34
|
+
const l2 = relativeLuminance(bg)
|
|
35
|
+
const lighter = Math.max(l1, l2)
|
|
36
|
+
const darker = Math.min(l1, l2)
|
|
37
|
+
const ratio = Math.round(((lighter + 0.05) / (darker + 0.05)) * 100) / 100
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
foreground: fg,
|
|
41
|
+
background: bg,
|
|
42
|
+
ratio,
|
|
43
|
+
normalText: { threshold: 4.5, pass: ratio >= 4.5 },
|
|
44
|
+
largeText: { threshold: 3.0, pass: ratio >= 3.0 },
|
|
45
|
+
verdict: ratio >= 4.5 ? 'PASS (WCAG AA)' : ratio >= 3.0 ? 'PASS (Large Text Only)' : 'FAIL (Insufficient Contrast)',
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
throw new Error(`Invalid hex color input: ${err.message}`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- DELIVERY GATE VALIDATOR ---
|
|
53
|
+
export function validateDeliveryGate(report) {
|
|
54
|
+
const blocks = {
|
|
55
|
+
testedRules: /aturan\s+yang\s+diuji|\brules?\s+tested\b/i.test(report),
|
|
56
|
+
smokeTest: /smoke\s+test|uji\s+interaktif|click-through/i.test(report),
|
|
57
|
+
contrastAudit: /kontras|wcag|contrast/i.test(report),
|
|
58
|
+
verdict: /\b(?:PASS|FAIL|LULUS|GAGAL)\b/.test(report),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const missing = []
|
|
62
|
+
if (!blocks.testedRules) missing.push('Blok 1: Aturan yang Diuji (Daftar R-xx yang dipatuhi)')
|
|
63
|
+
if (!blocks.smokeTest) missing.push('Blok 2: Bukti Smoke Test Interaktif (Uji klik elemen/form)')
|
|
64
|
+
if (!blocks.contrastAudit) missing.push('Blok 3: Pemeriksaan Kontras WCAG')
|
|
65
|
+
if (!blocks.verdict) missing.push('Blok 4: Status Kelulusan (PASS/FAIL)')
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
valid: missing.length === 0,
|
|
69
|
+
blocks,
|
|
70
|
+
missing,
|
|
71
|
+
verdict: missing.length === 0 ? 'VALID' : 'INVALID (Incomplete Delivery Gate)',
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- MCP TOOL DEFINITIONS ---
|
|
76
|
+
const TOOLS = [
|
|
77
|
+
{
|
|
78
|
+
name: 'check_contrast',
|
|
79
|
+
description: 'Menghitung rasio kontras WCAG 2.1 antara dua warna heksadesimal (foreground & background) dan menentukan apakah memenuhi ambang batas AA.',
|
|
80
|
+
inputSchema: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: {
|
|
83
|
+
foreground: { type: 'string', description: 'Kode heksadesimal warna teks/foreground, misal: #0f172a' },
|
|
84
|
+
background: { type: 'string', description: 'Kode heksadesimal warna latar/background, misal: #ffffff' },
|
|
85
|
+
},
|
|
86
|
+
required: ['foreground', 'background'],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'lint_snippet',
|
|
91
|
+
description: 'Memeriksa potongan kode atau teks secara instan dari AI slop (hype marketing, transition-all, pill button, komentar hampa, placeholder malas).',
|
|
92
|
+
inputSchema: {
|
|
93
|
+
type: 'object',
|
|
94
|
+
properties: {
|
|
95
|
+
code: { type: 'string', description: 'Konten kode atau teks yang ingin diperiksa' },
|
|
96
|
+
filename: { type: 'string', description: 'Nama berkas indikatif untuk deteksi ekstensi, misal: Button.tsx atau page.blade.php', default: 'snippet.html' },
|
|
97
|
+
},
|
|
98
|
+
required: ['code'],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'lint_file',
|
|
103
|
+
description: 'Menjalankan linter deterministik terhadap berkas atau direktori di komputer lokal.',
|
|
104
|
+
inputSchema: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
targetPath: { type: 'string', description: 'Path absolut atau relatif ke file atau folder yang ingin diperiksa' },
|
|
108
|
+
tier: { type: 'string', description: 'Filter tingkat keparahan: all, hard-gate, atau quality-lock', default: 'all' },
|
|
109
|
+
},
|
|
110
|
+
required: ['targetPath'],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'validate_delivery_gate',
|
|
115
|
+
description: 'Memvalidasi kelengkapan 4 blok wajib laporan Delivery Gate sebelum kode diserahkan ke pengguna.',
|
|
116
|
+
inputSchema: {
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: {
|
|
119
|
+
report: { type: 'string', description: 'Teks laporan evaluasi Delivery Gate yang dibuat agen' },
|
|
120
|
+
},
|
|
121
|
+
required: ['report'],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'get_rule',
|
|
126
|
+
description: 'Mengambil informasi lengkap, alasan, dan solusi perbaikan dari suatu aturan slop-ai-indo berdasarkan Rule ID (misal: R-01, R-09, R-12, B-01, T-01).',
|
|
127
|
+
inputSchema: {
|
|
128
|
+
type: 'object',
|
|
129
|
+
properties: {
|
|
130
|
+
ruleId: { type: 'string', description: 'Kode nomor aturan, misal: R-09 atau R-12' },
|
|
131
|
+
},
|
|
132
|
+
required: ['ruleId'],
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
// --- JSON-RPC STDIO TRANSPORT ---
|
|
138
|
+
function send(msg) {
|
|
139
|
+
process.stdout.write(JSON.stringify(msg) + '\n')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function reply(id, result) {
|
|
143
|
+
send({ jsonrpc: '2.0', id, result })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function error(id, code, message) {
|
|
147
|
+
send({ jsonrpc: '2.0', id, error: { code, message } })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function handleToolCall(name, args = {}) {
|
|
151
|
+
switch (name) {
|
|
152
|
+
case 'check_contrast': {
|
|
153
|
+
const { foreground, background } = args
|
|
154
|
+
if (!foreground || !background) throw new Error('Parameter foreground dan background wajib diisi.')
|
|
155
|
+
const res = computeContrast(foreground, background)
|
|
156
|
+
return {
|
|
157
|
+
content: [{ type: 'text', text: JSON.stringify(res, null, 2) }],
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
case 'lint_snippet': {
|
|
162
|
+
const { code, filename = 'snippet.html' } = args
|
|
163
|
+
if (typeof code !== 'string') throw new Error('Parameter code wajib diisi.')
|
|
164
|
+
const findings = lintContent(code, filename)
|
|
165
|
+
const passed = findings.filter((f) => f.tier === 'Hard Gate').length === 0
|
|
166
|
+
return {
|
|
167
|
+
content: [{
|
|
168
|
+
type: 'text',
|
|
169
|
+
text: JSON.stringify({
|
|
170
|
+
passed,
|
|
171
|
+
totalFindings: findings.length,
|
|
172
|
+
findings,
|
|
173
|
+
}, null, 2),
|
|
174
|
+
}],
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
case 'lint_file': {
|
|
179
|
+
const { targetPath, tier = 'all' } = args
|
|
180
|
+
if (!targetPath) throw new Error('Parameter targetPath wajib diisi.')
|
|
181
|
+
const resolved = path.resolve(process.cwd(), targetPath)
|
|
182
|
+
const res = lintPath(resolved, { tier })
|
|
183
|
+
return {
|
|
184
|
+
content: [{ type: 'text', text: JSON.stringify(res, null, 2) }],
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
case 'validate_delivery_gate': {
|
|
189
|
+
const { report } = args
|
|
190
|
+
if (!report) throw new Error('Parameter report wajib diisi.')
|
|
191
|
+
const res = validateDeliveryGate(report)
|
|
192
|
+
return {
|
|
193
|
+
content: [{ type: 'text', text: JSON.stringify(res, null, 2) }],
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
case 'get_rule': {
|
|
198
|
+
const { ruleId } = args
|
|
199
|
+
if (!ruleId) throw new Error('Parameter ruleId wajib diisi.')
|
|
200
|
+
const normalized = ruleId.trim().toUpperCase()
|
|
201
|
+
const matched = Object.values(RULES).filter((r) => r.id === normalized)
|
|
202
|
+
if (matched.length === 0) {
|
|
203
|
+
return {
|
|
204
|
+
content: [{ type: 'text', text: `Aturan dengan ID "${ruleId}" tidak ditemukan dalam katalog linter.` }],
|
|
205
|
+
isError: true,
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
content: [{ type: 'text', text: JSON.stringify(matched, null, 2) }],
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
default:
|
|
214
|
+
throw new Error(`Tool "${name}" tidak dikenal.`)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function startMcpServer() {
|
|
219
|
+
const rl = readline.createInterface({
|
|
220
|
+
input: process.stdin,
|
|
221
|
+
output: process.stdout,
|
|
222
|
+
terminal: false,
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
rl.on('line', (line) => {
|
|
226
|
+
const trimmed = line.trim()
|
|
227
|
+
if (!trimmed) return
|
|
228
|
+
|
|
229
|
+
let msg
|
|
230
|
+
try {
|
|
231
|
+
msg = JSON.parse(trimmed)
|
|
232
|
+
} catch {
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const { id, method, params } = msg
|
|
237
|
+
|
|
238
|
+
if (method === 'initialize') {
|
|
239
|
+
reply(id, {
|
|
240
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
241
|
+
capabilities: { tools: {} },
|
|
242
|
+
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
|
243
|
+
})
|
|
244
|
+
} else if (method === 'notifications/initialized') {
|
|
245
|
+
// Client ready
|
|
246
|
+
} else if (method === 'ping') {
|
|
247
|
+
reply(id, {})
|
|
248
|
+
} else if (method === 'tools/list') {
|
|
249
|
+
reply(id, { tools: TOOLS })
|
|
250
|
+
} else if (method === 'tools/call') {
|
|
251
|
+
const { name, arguments: toolArgs } = params || {}
|
|
252
|
+
try {
|
|
253
|
+
const result = handleToolCall(name, toolArgs)
|
|
254
|
+
reply(id, result)
|
|
255
|
+
} catch (err) {
|
|
256
|
+
reply(id, {
|
|
257
|
+
content: [{ type: 'text', text: `Error executing ${name}: ${err.message}` }],
|
|
258
|
+
isError: true,
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
if (id !== undefined) {
|
|
263
|
+
error(id, -32601, `Method not found: ${method}`)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
process.stderr.write(`${SERVER_NAME} v${SERVER_VERSION} listening on stdio\n`)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Run directly if invoked via CLI
|
|
272
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1'))) {
|
|
273
|
+
startMcpServer()
|
|
274
|
+
}
|
package/index.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import path from 'node:path'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
3
6
|
import pc from 'picocolors'
|
|
4
7
|
import { intro, outro, select, multiselect, confirm, isCancel, cancel, log, spinner } from '@clack/prompts'
|
|
5
8
|
import { banner } from './lib/banner.mjs'
|
|
@@ -16,7 +19,8 @@ import {
|
|
|
16
19
|
installedVersion,
|
|
17
20
|
updateAll,
|
|
18
21
|
updatePointers,
|
|
19
|
-
VERSION,
|
|
22
|
+
VERSION,
|
|
23
|
+
DESIGN_PRESETS,
|
|
20
24
|
} from './lib/install.mjs'
|
|
21
25
|
|
|
22
26
|
const EXTRA_SKILLS = [
|
|
@@ -27,7 +31,9 @@ const EXTRA_SKILLS = [
|
|
|
27
31
|
{ value: 'slop-ai-indo-code', label: 'slop-ai-indo-code', hint: 'code comment rules' },
|
|
28
32
|
{ value: 'slop-ai-indo-security', label: 'slop-ai-indo-security', hint: 'deep security audit and vulnerability review' },
|
|
29
33
|
{ value: 'slop-ai-indo-docs', label: 'slop-ai-indo-docs', hint: 'technical documentation, README, and API hygiene' },
|
|
30
|
-
{ value: 'slop-ai-indo-identity', label: 'slop-ai-indo-identity', hint: 'brand identity, visual character, and anti-cloning' },
|
|
34
|
+
{ value: 'slop-ai-indo-identity', label: 'slop-ai-indo-identity', hint: 'brand identity, visual character, and anti-cloning' },
|
|
35
|
+
{ value: 'slop-ai-indo-backend', label: 'slop-ai-indo-backend', hint: 'backend architecture, API, and database hygiene' },
|
|
36
|
+
{ value: 'slop-ai-indo-test', label: 'slop-ai-indo-test', hint: 'testing rules, anti-fake test and mock hygiene' },
|
|
31
37
|
]
|
|
32
38
|
|
|
33
39
|
function stop(message) {
|
|
@@ -52,7 +58,42 @@ function printPluginDoors(found) {
|
|
|
52
58
|
}
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
async function main() {
|
|
61
|
+
async function main() {
|
|
62
|
+
if (process.argv[2] === 'lint' || process.argv.includes('--lint')) {
|
|
63
|
+
const { spawnSync } = await import('node:child_process')
|
|
64
|
+
const lintBin = path.join(__dirname, 'bin', 'slop-lint.mjs')
|
|
65
|
+
const lintArgs = process.argv.slice(2).filter((a) => a !== 'lint' && a !== '--lint')
|
|
66
|
+
const res = spawnSync(process.execPath, [lintBin, ...lintArgs], { stdio: 'inherit' })
|
|
67
|
+
process.exit(res.status ?? 0)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (process.argv[2] === 'mcp' || process.argv.includes('--mcp')) {
|
|
71
|
+
const { spawnSync } = await import('node:child_process')
|
|
72
|
+
const mcpBin = path.join(__dirname, 'bin', 'slop-mcp.mjs')
|
|
73
|
+
const res = spawnSync(process.execPath, [mcpBin], { stdio: 'inherit' })
|
|
74
|
+
process.exit(res.status ?? 0)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (process.argv[2] === 'hook' || process.argv.includes('--hook')) {
|
|
78
|
+
const { installGitHook, runPreCommitHook } = await import('./lib/hook.mjs')
|
|
79
|
+
if (process.argv.includes('--run')) {
|
|
80
|
+
const exitCode = runPreCommitHook()
|
|
81
|
+
process.exit(exitCode)
|
|
82
|
+
} else {
|
|
83
|
+
try {
|
|
84
|
+
const file = installGitHook(process.cwd())
|
|
85
|
+
console.log(pc.green('ā Git pre-commit hook berhasil dipasang: ') + pc.dim(file))
|
|
86
|
+
console.log(pc.cyan('Setiap kali Anda menjalankan `git commit`, slop-lint akan otomatis memvalidasi staged files.'))
|
|
87
|
+
process.exit(0)
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error(pc.red(`Gagal memasang hook: ${err.message}`))
|
|
90
|
+
process.exit(1)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
56
97
|
if (process.argv.includes('--version') || process.argv.includes('-v')) {
|
|
57
98
|
console.log(`slop-ai-indo ${VERSION}`)
|
|
58
99
|
return
|
|
@@ -115,6 +156,18 @@ async function main() {
|
|
|
115
156
|
})
|
|
116
157
|
if (isCancel(location)) stop('Install cancelled.')
|
|
117
158
|
|
|
159
|
+
let chosenPreset = 'enterprise'
|
|
160
|
+
if (location === 'project') {
|
|
161
|
+
log.step('Pilih Preset Desain (DESIGN.md)')
|
|
162
|
+
const preset = await select({
|
|
163
|
+
message: 'Pilih arah visual dan standar estetika untuk proyek ini:',
|
|
164
|
+
options: DESIGN_PRESETS,
|
|
165
|
+
initialValue: 'enterprise',
|
|
166
|
+
})
|
|
167
|
+
if (isCancel(preset)) stop('Install cancelled.')
|
|
168
|
+
chosenPreset = preset
|
|
169
|
+
}
|
|
170
|
+
|
|
118
171
|
log.step('Choose which agents get antislop')
|
|
119
172
|
const detected = detectAgents(location)
|
|
120
173
|
const chosen = await multiselect({
|
|
@@ -164,7 +217,7 @@ async function main() {
|
|
|
164
217
|
const spin = spinner()
|
|
165
218
|
spin.start('Installing skills...')
|
|
166
219
|
const written = installSkills({ skills, targets, overwrite })
|
|
167
|
-
const pointers = location === 'project' ? updatePointers({ targets, skills }) : []
|
|
220
|
+
const pointers = location === 'project' ? updatePointers({ targets, skills, designPreset: chosenPreset }) : []
|
|
168
221
|
spin.stop('Done.')
|
|
169
222
|
|
|
170
223
|
if (pointers.length > 0) {
|
package/lib/banner.mjs
CHANGED
package/lib/hook.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { execSync } from 'node:child_process'
|
|
4
|
+
import pc from 'picocolors'
|
|
5
|
+
import { lintContent } from '../lib/linter.mjs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Memeriksa apakah path berkas harus dikecualikan dari audit hook.
|
|
9
|
+
*/
|
|
10
|
+
function shouldIgnoreHookFile(filePath) {
|
|
11
|
+
const norm = filePath.replace(/\\/g, '/')
|
|
12
|
+
const base = path.basename(norm)
|
|
13
|
+
|
|
14
|
+
// Abaikan folder dependensi, konfigurasi agen, artefak build, dan internal dev tools
|
|
15
|
+
if (/(?:^|\/)(node_modules|\.git|\.agents|\.claude|\.cursor|\.codex|\.opencode|\.cline|\.gemini|vendor|coverage|\.next|\.nuxt|dist|build|scripts|presets|skills)(?:\/|$)/i.test(norm)) {
|
|
16
|
+
return true
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Abaikan berkas test fixtures
|
|
20
|
+
if (/\.(?:test|spec)\.[a-z0-9]+$/i.test(base) || /^test-[^/]+\.(?:mjs|js|ts)$/i.test(base)) {
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Abaikan berkas dokumentasi internal repositori aturan itu sendiri
|
|
25
|
+
if (['README.md', 'PRD.md', 'GUIDE.md', 'WORKFLOW.md', 'ROADMAP.md', 'DESIGN.md', 'slop-ai-indo.md'].includes(base)) {
|
|
26
|
+
return true
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Memasang git pre-commit hook ke dalam repositori target.
|
|
34
|
+
*/
|
|
35
|
+
export function installGitHook(targetDir = process.cwd()) {
|
|
36
|
+
const gitDir = path.join(targetDir, '.git')
|
|
37
|
+
if (!fs.existsSync(gitDir) || !fs.statSync(gitDir).isDirectory()) {
|
|
38
|
+
throw new Error(`Direktori "${targetDir}" bukan repositori Git (.git tidak ditemukan).`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const hooksDir = path.join(gitDir, 'hooks')
|
|
42
|
+
fs.mkdirSync(hooksDir, { recursive: true })
|
|
43
|
+
|
|
44
|
+
const hookFile = path.join(hooksDir, 'pre-commit')
|
|
45
|
+
const hookScript = `#!/bin/sh
|
|
46
|
+
# Slop AI Indo - Git Pre-commit Hook
|
|
47
|
+
# Memverifikasi staged files sebelum commit diizinkan.
|
|
48
|
+
|
|
49
|
+
if [ "$SLOP_SKIP_HOOK" = "1" ]; then
|
|
50
|
+
exit 0
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
# Prioritaskan binary lokal repositori agar instan tanpa download
|
|
54
|
+
if [ -f "./cli/index.mjs" ]; then
|
|
55
|
+
node ./cli/index.mjs hook --run
|
|
56
|
+
elif [ -f "./node_modules/slop-ai-indo/index.mjs" ]; then
|
|
57
|
+
node ./node_modules/slop-ai-indo/index.mjs hook --run
|
|
58
|
+
else
|
|
59
|
+
npx slop-ai-indo hook --run
|
|
60
|
+
fi
|
|
61
|
+
exit $?
|
|
62
|
+
`
|
|
63
|
+
|
|
64
|
+
fs.writeFileSync(hookFile, hookScript, { mode: 0o755 })
|
|
65
|
+
return hookFile
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Menjalankan inspeksi linter terhadap berkas yang sedang di-stage (git index).
|
|
70
|
+
*/
|
|
71
|
+
export function runPreCommitHook() {
|
|
72
|
+
let staged = []
|
|
73
|
+
try {
|
|
74
|
+
const raw = execSync('git diff --cached --name-only --diff-filter=ACM', { encoding: 'utf8' })
|
|
75
|
+
staged = raw.split(/\r?\n/).map((f) => f.trim()).filter(Boolean)
|
|
76
|
+
} catch {
|
|
77
|
+
// Bukan lingkungan git atau git command tidak ditemukan
|
|
78
|
+
return 0
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (staged.length === 0) {
|
|
82
|
+
return 0
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const filesToCheck = staged.filter((file) => !shouldIgnoreHookFile(file))
|
|
86
|
+
|
|
87
|
+
if (filesToCheck.length === 0) {
|
|
88
|
+
return 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log(pc.cyan(`š [slop-hook] Memeriksa ${filesToCheck.length} staged file(s) sebelum commit...`))
|
|
92
|
+
|
|
93
|
+
let hardGateCount = 0
|
|
94
|
+
const findings = []
|
|
95
|
+
|
|
96
|
+
for (const file of filesToCheck) {
|
|
97
|
+
const fullPath = path.resolve(process.cwd(), file)
|
|
98
|
+
if (!fs.existsSync(fullPath)) continue
|
|
99
|
+
try {
|
|
100
|
+
const content = fs.readFileSync(fullPath, 'utf8')
|
|
101
|
+
const fileFindings = lintContent(content, fullPath)
|
|
102
|
+
const hardGates = fileFindings.filter((f) => f.tier === 'Hard Gate')
|
|
103
|
+
if (hardGates.length > 0) {
|
|
104
|
+
hardGateCount += hardGates.length
|
|
105
|
+
findings.push({ file, hardGates })
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// Abaikan berkas biner tak terbaca
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (hardGateCount > 0) {
|
|
113
|
+
console.error(pc.bold(pc.red(`\nā [slop-hook] Commit Ditolak! Ditemukan ${hardGateCount} pelanggaran Hard Gate:`)))
|
|
114
|
+
for (const f of findings) {
|
|
115
|
+
console.error(pc.bold(pc.underline(`\n${f.file}`)))
|
|
116
|
+
for (const item of f.hardGates) {
|
|
117
|
+
console.error(` baris ${item.line} ${pc.red(`[${item.ruleId}]`)} ${item.name}`)
|
|
118
|
+
console.error(` Kode: ${pc.dim(item.snippet)}`)
|
|
119
|
+
console.error(` Solusi: ${pc.cyan(item.fix)}`)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
console.error(pc.dim('\nPerbaiki pelanggaran di atas sebelum commit, atau gunakan `git commit --no-verify` jika darurat.\n'))
|
|
123
|
+
return 1
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log(pc.green(`ā [slop-hook] ${filesToCheck.length} staged file(s) bersih dari slop. Commit diizinkan.\n`))
|
|
127
|
+
return 0
|
|
128
|
+
}
|
package/lib/install.mjs
CHANGED
|
@@ -7,7 +7,15 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
|
7
7
|
|
|
8
8
|
export const CORE = 'slop-ai-indo'
|
|
9
9
|
|
|
10
|
-
export const VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version
|
|
10
|
+
export const VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version
|
|
11
|
+
|
|
12
|
+
export const DESIGN_PRESETS = [
|
|
13
|
+
{ value: 'enterprise', label: 'Enterprise SaaS (Rekomendasi)', hint: 'Kanvas terang, radius 4-6px, presisi ala Linear / n8n' },
|
|
14
|
+
{ value: 'dark-modern', label: 'Dark Modern', hint: 'Kanvas gelap matte obsidian, monokrom presisi ala Vercel / Supabase' },
|
|
15
|
+
{ value: 'editorial', label: 'Editorial & Content', hint: 'Warm canvas, serif typography ala Substack / Stripe Press' },
|
|
16
|
+
{ value: 'utilitarian', label: 'Utilitarian & Dense Data', hint: 'High-density grid, border tegas, tabular data ala Grafana' },
|
|
17
|
+
{ value: 'default', label: 'Bawaan (Default)', hint: 'Gunakan standar DESIGN.md asli slop-ai-indo' },
|
|
18
|
+
]
|
|
11
19
|
|
|
12
20
|
/** The release an existing install came from, or null when it predates the VERSION file.
|
|
13
21
|
* Nothing else on disk names it, so without this an update cannot tell the user what
|
|
@@ -253,7 +261,9 @@ const SKILL_LINES = {
|
|
|
253
261
|
'slop-ai-indo-code': 'Code comments: `slop-ai-indo-code`',
|
|
254
262
|
'slop-ai-indo-security': 'Security audit: `slop-ai-indo-security`',
|
|
255
263
|
'slop-ai-indo-docs': 'Technical documentation: `slop-ai-indo-docs`',
|
|
256
|
-
'slop-ai-indo-identity': 'Brand & visual character: `slop-ai-indo-identity`',
|
|
264
|
+
'slop-ai-indo-identity': 'Brand & visual character: `slop-ai-indo-identity`',
|
|
265
|
+
'slop-ai-indo-backend': 'Backend & API: `slop-ai-indo-backend`',
|
|
266
|
+
'slop-ai-indo-test': 'Testing & mocks: `slop-ai-indo-test`',
|
|
257
267
|
}
|
|
258
268
|
|
|
259
269
|
export const SKILL_NAMES = Object.keys(SKILL_LINES)
|
|
@@ -342,22 +352,31 @@ function writeBlock(entry, block) {
|
|
|
342
352
|
fs.writeFileSync(entry, body + eol)
|
|
343
353
|
}
|
|
344
354
|
|
|
345
|
-
export function updatePointers({ targets, skills }) {
|
|
346
|
-
const entries = new Set()
|
|
347
|
-
for (const t of targets) {
|
|
348
|
-
if (fs.existsSync(path.join(t.path, CORE))) for (const a of t.agents) entries.add(a.entry)
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// Copy DESIGN.md and slop-ai-indo.md into current project if bundled
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
355
|
+
export function updatePointers({ targets, skills, designPreset = 'enterprise' } = {}) {
|
|
356
|
+
const entries = new Set()
|
|
357
|
+
for (const t of targets) {
|
|
358
|
+
if (fs.existsSync(path.join(t.path, CORE))) for (const a of t.agents) entries.add(a.entry)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Copy chosen DESIGN.md preset and slop-ai-indo.md into current project if bundled
|
|
362
|
+
const presetFile = `${designPreset}.md`
|
|
363
|
+
const bundledPreset = path.join(__dirname, '..', 'presets', presetFile)
|
|
364
|
+
const repoPreset = path.join(__dirname, '..', '..', 'presets', presetFile)
|
|
365
|
+
const bundledDesign = path.join(__dirname, '..', 'DESIGN.md')
|
|
366
|
+
|
|
367
|
+
let sourceDesign = null
|
|
368
|
+
if (fs.existsSync(bundledPreset)) sourceDesign = bundledPreset
|
|
369
|
+
else if (fs.existsSync(repoPreset)) sourceDesign = repoPreset
|
|
370
|
+
else if (fs.existsSync(bundledDesign)) sourceDesign = bundledDesign
|
|
371
|
+
|
|
372
|
+
if (sourceDesign) {
|
|
373
|
+
fs.copyFileSync(sourceDesign, path.join(process.cwd(), 'DESIGN.md'))
|
|
374
|
+
}
|
|
375
|
+
const bundledSlop = path.join(__dirname, '..', 'slop-ai-indo.md')
|
|
376
|
+
if (fs.existsSync(bundledSlop)) {
|
|
377
|
+
fs.copyFileSync(bundledSlop, path.join(process.cwd(), 'slop-ai-indo.md'))
|
|
378
|
+
}
|
|
379
|
+
|
|
361
380
|
const block = pointerBlock(skills)
|
|
362
381
|
const written = []
|
|
363
382
|
for (const name of entries) {
|