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 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
+ }
@@ -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
@@ -15,7 +15,7 @@ export function banner() {
15
15
  ...LOGO,
16
16
  '',
17
17
  ' ' + pc.dim('Slop AI Indo: Rules for AI Coding Agents'),
18
- ' ' + pc.dim('installer v1.0.2'),
18
+ ' ' + pc.dim('installer v1.2.0'),
19
19
  '',
20
20
  ].join('\n')
21
21
  }
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 bundledDesign = path.join(__dirname, '..', 'DESIGN.md')
353
- if (fs.existsSync(bundledDesign)) {
354
- fs.copyFileSync(bundledDesign, path.join(process.cwd(), 'DESIGN.md'))
355
- }
356
- const bundledSlop = path.join(__dirname, '..', 'slop-ai-indo.md')
357
- if (fs.existsSync(bundledSlop)) {
358
- fs.copyFileSync(bundledSlop, path.join(process.cwd(), 'slop-ai-indo.md'))
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) {