SLOPSHOPPER

complexity-lens

Shows complexity, density and hotspot score for files Claude reads or edits.

newbandguardtoastpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · complexity-lens
› 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 ⟨Claude Code's own drawing⟩ complexity-lens: rank failed: SyntaxError: JSON Parse error: Unexpected identifier "dev" ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ complexity-lens: rank failed: SyntaxError: JSON Parse error: Unexpected identifier "dev"
README

complexity-lens

A Claude Code mod. It runs scripts/complexity_rank.py on each code file Claude reads, edits, or writes for the current message, and shows the four that most need attention above the prompt. Each new message starts the list over.

  • Red, bold: this request added a function over the per-function limit (3 over limit (+1)).
  • Yellow: the file has functions over the limit.
  • Dim: nothing over the limit.

Each row also shows file complexity, density, recent churn, and hotspot score.

After an Edit or Write, the model also gets one extra context line when the file has a function over the limit. A toast appears when an edit adds one.

scripts/complexity_rank.py is a copy of plugins/personal-skills/skills/whittle/scripts/complexity_rank.py. python3 scripts/release_metadata.py set copies it and check fails when the two differ. Edit the whittle copy only.

Needs scc on PATH, and biome or lizard for per-function counts (see the script). Needs Claude Code 2.1.287 or later (mods are on by default). Test with claude plugin test plugins/complexity-lens.

Source 2 files
hooks/register.tsx 156 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Row, Shown } from '../types'
5
6const rows = atom({ plugin: 'complexity-lens', key: 'rows' } as const, [])
7const problem = atom({ plugin: 'complexity-lens', key: 'problem' } as const, null)
8
9const WATCHED = ['Read', 'Edit', 'Write'] as const
10const SHOWN = 4
11const CODE = /\.(rs|ts|tsx|js|jsx|py|go)$/
12
13async function measure($: EngineInterface, path: string): Promise<Shown | undefined> {
14  try {
15    const ran = await $.process.run(
16      ['python3', `${$.plugin.root}/scripts/complexity_rank.py`, '--json', path],
17      { timeoutMs: 20000, cwd: path.slice(0, path.lastIndexOf('/')) || '/' },
18    )
19    if (ran.exitCode !== 0) {
20      await update($, problem, () => `rank exit ${ran.exitCode}`)
21      return undefined
22    }
23    const [measured] = JSON.parse(ran.stdout) as Row[]
24    if (!measured) return undefined
25    const before = (await read($, rows)).find(r => r.file === measured.file)
26    const row: Shown = { ...measured, baseline: before ? before.baseline : measured.over_threshold_fns }
27    await update($, rows, list => [row, ...list.filter(r => r.file !== row.file)].slice(0, 12))
28    if (grew(row)) {
29      $.ui.toast(`${row.file}: functions over limit ${row.baseline} → ${row.over_threshold_fns}`)
30    }
31    await update($, problem, () => null)
32    return row
33  } catch (err) {
34    await update($, problem, () => `rank failed: ${String(err).slice(0, 60)}`)
35    return undefined
36  }
37}
38
39function grew(r: Shown): boolean {
40  return r.over_threshold_fns !== null && r.baseline !== null && r.over_threshold_fns > r.baseline
41}
42
43type Level = 'grew' | 'over' | 'calm'
44
45function level(r: Shown): Level {
46  if (grew(r)) return 'grew'
47  return (r.over_threshold_fns ?? 0) > 0 ? 'over' : 'calm'
48}
49
50const LEVEL_RANK: Record<Level, number> = { grew: 0, over: 1, calm: 2 }
51const LEVEL_COLOR: Record<Level, string | undefined> = { grew: 'red', over: 'yellow', calm: undefined }
52
53function agentNote(row: Shown): string | undefined {
54  const over = row.over_threshold_fns ?? 0
55  if (over === 0 && row.score === 0 && !grew(row)) return undefined
56  const change = grew(row) ? ` (was ${row.baseline} at the start of this request)` : ''
57  const limit =
58    row.per_function_source === 'lizard'
59      ? 'cyclomatic 10, lizard'
60      : row.per_function_source === 'biome'
61        ? 'cognitive 20, Biome'
62        : 'not counted'
63  return (
64    `complexity-lens: ${row.file} has ${row.over_threshold_fns ?? 'unknown'} function(s) over the per-function limit (${limit})${change}; ` +
65    `file complexity ${row.complexity}, ${row.density} per 100 lines, recent churn ${row.churn ?? 'n/a'}, hotspot score ${row.score}` +
66    `${row.flags.length ? ` [${row.flags.join(', ')}]` : ''}. Keep new functions under the limit, or name the reason.`
67  )
68}
69
70function overText(r: Shown): string | undefined {
71  if (r.over_threshold_fns === null) return undefined
72  return grew(r) ? `${r.over_threshold_fns} over limit (+${r.over_threshold_fns - (r.baseline ?? 0)})` : `${r.over_threshold_fns} over limit`
73}
74
75function details(r: Shown): string {
76  const notes = r.flags.map(f => (f === 'scc-only' ? 'rough' : f === 'young' ? 'new file' : f))
77  const parts = [
78    `complexity ${r.complexity}`,
79    `${Math.round(r.density)} per 100 lines`,
80    `changed ${r.churn ?? '?'}×`,
81    `score ${r.score}`,
82  ]
83  return `${parts.join(' · ')}${notes.length ? `  (${notes.join(', ')})` : ''}`
84}
85
86export const register: Register = on => {
87  let scc = true
88
89  on('session.start', async ($, e, next) => {
90    try {
91      await $.process.run(['scc', '--version'], { timeoutMs: 5000 })
92    } catch (err) {
93      scc = false
94      await update($, problem, () => `scc unavailable: ${String(err).slice(0, 60)}`)
95    }
96    return next(e)
97  })
98
99  on('prompt.submit', async ($, e, next) => {
100    await update($, rows, () => [])
101    await update($, problem, () => null)
102    return next(e)
103  })
104
105  on('tool.call', { tool: WATCHED }, async ($, e, next) => {
106    const result = await next(e)
107    const path = e.file_path
108    if (!scc || !path || result.deny !== undefined || result.isError || !CODE.test(path)) return result
109    if (e.tool === 'Read') {
110      void measure($, path)
111      return result
112    }
113    const measured = await measure($, path)
114    const note = measured ? agentNote(measured) : undefined
115    return note ? { ...result, context: [...(result.context ?? []), note] } : result
116  })
117
118  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
119    const list = await read($, rows)
120    const issue = await read($, problem)
121    if (list.length === 0 && issue === null) return next(e)
122
123    const below = await next(e)
124    const { Box, Text } = $.ui.resolve(e)
125    const top = [...list]
126      .sort((a, b) => LEVEL_RANK[level(a)] - LEVEL_RANK[level(b)] || b.score - a.score || b.complexity - a.complexity)
127      .slice(0, SHOWN)
128
129    return (
130      <Box flexDirection="column">
131        {below}
132        {issue !== null ? <Text color="yellow">complexity-lens: {issue}</Text> : null}
133        {top.map(r => {
134          const lvl = level(r)
135          const over = overText(r)
136          return (
137            <Box key={r.file} flexDirection="row" columnGap={1}>
138              <Text color={LEVEL_COLOR[lvl]} bold={lvl === 'grew'} dimColor={lvl === 'calm'}>
139                {r.file.split('/').slice(-2).join('/')}
140              </Text>
141              {over === undefined ? null : (
142                <Text color={LEVEL_COLOR[lvl]} bold={lvl === 'grew'} dimColor={lvl === 'calm'}>
143                  {over}
144                </Text>
145              )}
146              <Text dimColor wrap="truncate-end">
147                {details(r)}
148              </Text>
149            </Box>
150          )
151        })}
152      </Box>
153    )
154  })
155}
156
types/index.d.ts 20 lines
1export type Row = {
2  file: string
3  code: number
4  complexity: number
5  density: number
6  churn: number | null
7  over_threshold_fns: number | null
8  per_function_source: 'biome' | 'lizard' | null
9  score: number
10  flags: string[]
11}
12
13export type Shown = Row & { baseline: number | null }
14
15declare module 'claude-code' {
16  interface PluginState {
17    'complexity-lens': { rows: Shown[]; problem: string | null }
18  }
19}
20