SLOPSHOPPER

blast-radius-th

ก่อน Claude แก้ไฟล์ บอกว่ามีไฟล์ไหน import ไฟล์นั้นอยู่บ้าง พร้อมระดับความเสี่ยง (ใช้ git grep)

newguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blast-radius-th
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /blast ⎿ blast-radius-th: วิธีใช้: /blast src/utils/helpers.ts หรือ /blast list ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Mod 06: Blast Radius TH

ก่อน Claude แก้ไฟล์ที่มีอยู่แล้ว (Edit/Write) Mod จะหาว่ามีไฟล์ไหน import ไฟล์นั้นอยู่ด้วย git grep แล้วให้ระดับความเสี่ยง 🟢 LOW / 🟡 MEDIUM / 🟠 HIGH / 🔴 CRITICAL ถ้าเสี่ยงสูงจะเด้งเตือนคุณ และแนบรายชื่อไฟล์ที่ได้รับผลกระทบให้ Claude อ่านด้วย Claude จะได้รู้ว่าต้องเช็คไฟล์ไหนต่อ

คำสั่ง

คำสั่งทำอะไร
/blast <ไฟล์>วิเคราะห์ไฟล์นั้น
/blast listดูไฟล์ที่วิเคราะห์ไปแล้ว

ติดตั้ง

/plugin install blast-radius-th --marketplace tksoft11/claude-code-mods-th

ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)

โค้ดสอนอะไร

เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:

  1. รันคำสั่งของเครื่องด้วย $.process.run (ส่ง argv ไม่ต่อสตริง)
  2. cache ผลด้วย Map 5 นาที
  3. ส่งข้อความให้ Claude อ่านผ่าน context ของผลลัพธ์ tool
  4. วิธีบล็อก tool ด้วย { deny } (อยู่ในคอมเมนต์)

หมายเหตุ

ต้องอยู่ใน git repo และหาเฉพาะ import แบบ relative (./, ../) ที่อ้างชื่อไฟล์ตรงๆ ไม่ใช่ dependency graph เต็ม

Source 1 files
hooks/register.tsx 174 lines
1/**
2 * ============================================================================
3 * MOD 06: Blast Radius TH - แก้ไฟล์นี้แล้วจะกระทบไปถึงไหน (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: ปลั๊กอิน "blast radius" ที่ Anthropic ทำเป็นตัวอย่าง
6 *
7 * ปัญหา: ถ้าแก้ src/utils/helpers.ts ที่ถูก import อยู่ 50 ไฟล์ พลาดนิดเดียวพังทั้งโปรเจกต์
8 *
9 * หน้าที่ของ Mod นี้:
10 * 1. ก่อน Claude Edit/Write ไฟล์ที่มีอยู่แล้ว หาว่าไฟล์ไหน import ไฟล์นั้น (ด้วย git grep)
11 * 2. ประเมินความเสี่ยง 4 ระดับ: LOW / MEDIUM / HIGH / CRITICAL
12 * 3. ถ้าเสี่ยงสูง เด้ง toast เตือนคุณ และแนบรายชื่อไฟล์ให้ Claude อ่านด้วย
13 *    (Claude จะได้รู้ว่าควรเช็คไฟล์ไหนต่อ)
14 * 4. คำสั่ง /blast <ไฟล์> วิเคราะห์เอง, /blast list ดูที่วิเคราะห์ไปแล้ว
15 *
16 * ข้อจำกัด: ต้องอยู่ใน git repo และหาจากชื่อไฟล์ใน import/require เท่านั้น (ไม่ใช่ dependency graph เต็ม)
17 */
18
19import type { EngineInterface, Register } from 'claude-code'
20
21type Risk = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
22type Analysis = { impacted: string[]; risk: Risk; analyzedAt: number; error?: string }
23
24const CACHE_MS = 5 * 60 * 1000 // ผลวิเคราะห์ใช้ซ้ำได้ 5 นาที
25const CORE_WORDS = ['utils', 'helpers', 'types', 'config', 'constants', 'lib']
26const RISK_ICON: Record<Risk, string> = { LOW: '🟢', MEDIUM: '🟡', HIGH: '🟠', CRITICAL: '🔴' }
27
28let projectRoot = ''
29
30// ==========================================================================
31// บทที่ 1: ใช้ Map เป็น cache - ไม่ต้องวิเคราะห์ไฟล์เดิมซ้ำทุกครั้ง
32// ==========================================================================
33const analysisCache = new Map<string, Analysis>()
34
35function toRelative(path: string): string {
36  const p = path.replace(/\\/g, '/')
37  const root = projectRoot.replace(/\\/g, '/').replace(/\/$/, '')
38  return root && p.toLowerCase().startsWith(root.toLowerCase() + '/') ? p.slice(root.length + 1) : p
39}
40
41// ==========================================================================
42// บทที่ 2: เช็คว่าเป็นไฟล์ Core ไหม + ประเมินความเสี่ยง
43// ==========================================================================
44function isCoreFile(path: string): boolean {
45  return CORE_WORDS.some(word => path.toLowerCase().includes(word))
46}
47
48function riskOf(count: number, isCore: boolean): Risk {
49  if (count > 20 || (isCore && count > 5)) return 'CRITICAL'
50  if (count > 10) return 'HIGH'
51  if (count > 3) return 'MEDIUM'
52  return 'LOW'
53}
54
55function escapeRegex(text: string): string {
56  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
57}
58
59// ==========================================================================
60// บทที่ 3: หาไฟล์ที่ import ไฟล์เป้าหมาย ด้วย git grep ($.process.run)
61// ==========================================================================
62async function analyze($: EngineInterface, filePath: string): Promise<Analysis> {
63  const rel = toRelative(filePath)
64  const now = await $.clock.now()
65  const cached = analysisCache.get(rel)
66  if (cached && now - cached.analyzedAt < CACHE_MS) return cached
67
68  // ชื่อที่ใช้ใน import: ตัดนามสกุลออก, ถ้าเป็น index ใช้ชื่อโฟลเดอร์แทน
69  const parts = rel.split('/')
70  let name = (parts.at(-1) ?? rel).replace(/\.[^.]+$/, '')
71  if (name === 'index' && parts.length > 1) name = parts.at(-2) ?? name
72
73  // จับ from '.../helpers' , import('./helpers.js') , require("../helpers")
74  const pattern = `(from|import|require)[ (]*['"][^'"]*/${escapeRegex(name)}(\\.[a-z]+)?['"]`
75
76  let result: Analysis
77  try {
78    const run = await $.process.run(['git', 'grep', '-l', '-I', '-E', pattern], { timeoutMs: 10_000 })
79    // exit 1 = หาไม่เจอ (ไม่ใช่ error), อย่างอื่นที่ไม่ใช่ 0 = ไม่ใช่ git repo หรือ git มีปัญหา
80    if (run.exitCode > 1) throw new Error(run.stderr.trim() || `git exit ${run.exitCode}`)
81
82    const impacted = run.stdout.split('\n').map(s => s.trim()).filter(s => s && s !== rel)
83    result = { impacted, risk: riskOf(impacted.length, isCoreFile(rel)), analyzedAt: now }
84  } catch (err) {
85    result = { impacted: [], risk: 'LOW', analyzedAt: now, error: String(err) }
86  }
87
88  analysisCache.set(rel, result)
89  return result
90}
91
92function buildReport(rel: string, a: Analysis): string {
93  if (a.error) return `💥 Blast Radius: ${rel}\n❌ วิเคราะห์ไม่ได้ (ต้องอยู่ใน git repo): ${a.error}`
94
95  const shown = a.impacted.slice(0, 10)
96  const lines = [
97    `💥 Blast Radius Analysis`,
98    '----------------------------------------',
99    `🎯 ไฟล์เป้าหมาย: ${rel}`,
100    `${RISK_ICON[a.risk]} ระดับความเสี่ยง: ${a.risk} (ไฟล์ที่ import อยู่ ${a.impacted.length} ไฟล์)`,
101  ]
102  if (isCoreFile(rel)) lines.push('⚠️ ไฟล์นี้ดูเหมือนเป็น CORE FILE - แก้แล้วมักกระทบเยอะ')
103  if (shown.length > 0) {
104    lines.push('', '📊 ไฟล์ที่ได้รับผลกระทบ:')
105    shown.forEach((f, i) => lines.push(`  ${i === shown.length - 1 && a.impacted.length <= 10 ? '└──' : '├──'} ${f}`))
106    if (a.impacted.length > 10) lines.push(`  └── ... และอีก ${a.impacted.length - 10} ไฟล์`)
107  }
108  if (a.risk === 'HIGH' || a.risk === 'CRITICAL') {
109    lines.push('', '💡 คำแนะนำ: สร้าง branch ใหม่, รัน test ก่อนและหลังแก้, แก้ทีละน้อย')
110  }
111  return lines.join('\n')
112}
113
114export const register: Register = on => {
115  on('session.start', async ($, e, next) => {
116    projectRoot = e.cwd
117    analysisCache.clear()
118    await $.command.register({ name: 'blast', description: 'วิเคราะห์ว่าแก้ไฟล์นี้แล้วกระทบไฟล์ไหนบ้าง (/blast list)', argumentHint: '<ไฟล์>|list' })
119    return next(e)
120  })
121
122  // ==========================================================================
123  // บทที่ 4: ดักก่อนแก้จริง แล้วแนบผลวิเคราะห์ให้ Claude อ่าน (context)
124  // ==========================================================================
125  on('tool.call', async ($, e, next) => {
126    const target = e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : undefined
127    if (!target || !(await $.fs.exists(target))) return next(e) // ไฟล์ใหม่ไม่มีใคร import
128
129    const analysis = await analyze($, target)
130    const rel = toRelative(target)
131    const isRisky = analysis.risk === 'HIGH' || analysis.risk === 'CRITICAL'
132
133    if (isRisky) {
134      $.ui.toast(`${RISK_ICON[analysis.risk]} Blast Radius ${analysis.risk}: ${rel} ถูก import อยู่ ${analysis.impacted.length} ไฟล์`)
135      // ถ้าอยากบล็อกการแก้ไฟล์ที่เสี่ยงมาก ให้ return { deny: "เหตุผล" } แทน next(e)
136    }
137
138    const ran = await next(e)
139
140    // context = ข้อความที่ Claude อ่านต่อท้ายผลของ tool (ผู้ใช้ไม่เห็น)
141    if (isRisky && ran.deny === undefined && !ran.isError) {
142      const note = `[blast-radius-th] ${rel} is imported by ${analysis.impacted.length} files, for example: ${analysis.impacted.slice(0, 15).join(', ')}. Check that these still work with the change.`
143      return { ...ran, context: [...(ran.context ?? []), note] }
144    }
145    return ran
146  })
147
148  // ==========================================================================
149  // บทที่ 5: คำสั่ง /blast
150  // ==========================================================================
151  on('command.run', { command: 'blast' }, async ($, e) => {
152    const arg = e.args.trim()
153
154    if (arg === '') return { text: 'วิธีใช้: /blast src/utils/helpers.ts หรือ /blast list' }
155    if (arg === 'list') {
156      if (analysisCache.size === 0) return { text: 'ยังไม่ได้วิเคราะห์ไฟล์ไหนเลย' }
157      const rows = [...analysisCache.entries()].map(([f, a]) => `${RISK_ICON[a.risk]} ${a.risk.padEnd(8)} ${String(a.impacted.length).padStart(3)} ไฟล์  ${f}`)
158      return { text: ['💥 ไฟล์ที่วิเคราะห์ไปแล้ว', '----------------------------------------', ...rows].join('\n') }
159    }
160
161    analysisCache.delete(toRelative(arg)) // สั่งเองให้วิเคราะห์ใหม่เสมอ
162    return { text: buildReport(toRelative(arg), await analyze($, arg)) }
163  })
164}
165
166/**
167 * ========================================================================
168 * การบ้าน / ต่อยอด
169 * ========================================================================
170 * 1. ใช้ madge หรือ dependency-cruiser สร้าง dependency graph จริง (นับ import ทางอ้อมด้วย)
171 * 2. ดู git log ว่าไฟล์ไหนไม่ได้แก้นาน = อาจเป็น dead code
172 * 3. ทำเป็นด่านกั้น: ถ้า CRITICAL ให้ return { deny } แล้วต้องให้คนยืนยันก่อน
173 */
174