claude-lean 0.1.0__py3-none-any.whl

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,9 @@
1
+ """claude-lean — get 5x more from your Claude Code token budget."""
2
+
3
+ __version__ = "0.1.0"
4
+ __author__ = "vipin"
5
+
6
+ from claude_lean.common.tokenizer import count_tokens
7
+ from claude_lean.common.claude_paths import ClaudePaths
8
+
9
+ __all__ = ["__version__", "count_tokens", "ClaudePaths"]
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m claude_lean`."""
2
+
3
+ from claude_lean.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Apply subsystem: write recommended changes with backup + dry-run safety."""
@@ -0,0 +1,180 @@
1
+ """Generate optimized settings.json + CLAUDE.md based on wizard input."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from claude_lean.apply.wizard import WizardResult
11
+ from claude_lean.audit.rules.unused_plugins import LIKELY_UNUSED_FOR_ENGINEERING
12
+ from claude_lean.common.claude_paths import ClaudePaths
13
+
14
+
15
+ # Plugin packs to always keep enabled for engineering work
16
+ _CORE_ENG_PLUGINS = {
17
+ "engineering-skills",
18
+ "engineering-advanced-skills",
19
+ "superpowers",
20
+ "context7",
21
+ "github",
22
+ "code-review",
23
+ "feature-dev",
24
+ "code-simplifier",
25
+ "self-improving-agent",
26
+ }
27
+
28
+ # Plugin packs that depend on a specific stack
29
+ _STACK_TO_PLUGINS = {
30
+ "frontend": {"frontend-design", "playwright"},
31
+ "javascript/typescript": {"frontend-design", "playwright"},
32
+ }
33
+
34
+ # Non-engineering work areas that need their corresponding plugins
35
+ _WORK_TO_PLUGINS = {
36
+ "marketing": {"marketing-skills", "content-creator"},
37
+ "sales": {"business-growth-skills"},
38
+ "finance": {"finance-skills"},
39
+ "product": {"product-skills"},
40
+ "regulatory": {"ra-qm-skills"},
41
+ }
42
+
43
+
44
+ @dataclass
45
+ class GeneratorPlan:
46
+ """The proposed changes; render to diff before applying."""
47
+
48
+ new_settings: dict[str, Any]
49
+ old_settings: dict[str, Any]
50
+ new_claude_md: str
51
+ old_claude_md: str
52
+ plugin_disables: list[str]
53
+ plugin_keeps: list[str]
54
+ settings_path: Path
55
+ claude_md_path: Path
56
+
57
+ @property
58
+ def has_changes(self) -> bool:
59
+ return self.new_settings != self.old_settings or self.new_claude_md != self.old_claude_md
60
+
61
+
62
+ def build_plan(paths: ClaudePaths, wizard: WizardResult) -> GeneratorPlan:
63
+ """Build a GeneratorPlan from current state + wizard input."""
64
+ old_settings = _load_json(paths.settings_json)
65
+ new_settings = _optimize_settings(old_settings, wizard)
66
+
67
+ old_claude_md = paths.global_claude_md.read_text(encoding="utf-8") if paths.global_claude_md.is_file() else ""
68
+ new_claude_md = _optimize_claude_md(old_claude_md, wizard)
69
+
70
+ # Diff plugin states for the human summary
71
+ old_enabled = set(_enabled_keys(old_settings))
72
+ new_enabled = set(_enabled_keys(new_settings))
73
+ disables = sorted(old_enabled - new_enabled)
74
+ keeps = sorted(new_enabled)
75
+
76
+ return GeneratorPlan(
77
+ new_settings=new_settings,
78
+ old_settings=old_settings,
79
+ new_claude_md=new_claude_md,
80
+ old_claude_md=old_claude_md,
81
+ plugin_disables=disables,
82
+ plugin_keeps=keeps,
83
+ settings_path=paths.settings_json,
84
+ claude_md_path=paths.global_claude_md,
85
+ )
86
+
87
+
88
+ def write_plan(plan: GeneratorPlan) -> None:
89
+ """Write the plan to disk. Caller is responsible for creating backups first."""
90
+ plan.settings_path.parent.mkdir(parents=True, exist_ok=True)
91
+ plan.settings_path.write_text(json.dumps(plan.new_settings, indent=2) + "\n", encoding="utf-8")
92
+ plan.claude_md_path.parent.mkdir(parents=True, exist_ok=True)
93
+ plan.claude_md_path.write_text(plan.new_claude_md, encoding="utf-8")
94
+
95
+
96
+ # ---- internals ----
97
+
98
+
99
+ def _load_json(path: Path) -> dict[str, Any]:
100
+ if not path.is_file():
101
+ return {}
102
+ try:
103
+ return json.loads(path.read_text(encoding="utf-8"))
104
+ except (OSError, json.JSONDecodeError):
105
+ return {}
106
+
107
+
108
+ def _enabled_keys(settings: dict[str, Any]) -> list[str]:
109
+ raw = settings.get("enabledPlugins", {})
110
+ if not isinstance(raw, dict):
111
+ return []
112
+ return [k for k, v in raw.items() if v]
113
+
114
+
115
+ def _optimize_settings(old: dict[str, Any], wizard: WizardResult) -> dict[str, Any]:
116
+ """Produce a new settings.json keeping the structure but trimming plugins."""
117
+ out = dict(old) # shallow copy
118
+ plugins = dict(out.get("enabledPlugins", {}))
119
+
120
+ # Decide which plugin keys to keep
121
+ needed_plugin_names = set(_CORE_ENG_PLUGINS)
122
+ for stack in wizard.primary_stacks:
123
+ needed_plugin_names.update(_STACK_TO_PLUGINS.get(stack, set()))
124
+ for work in wizard.non_eng_work:
125
+ needed_plugin_names.update(_WORK_TO_PLUGINS.get(work, set()))
126
+
127
+ aggressive = wizard.aggressiveness == "aggressive"
128
+ conservative = wizard.aggressiveness == "conservative"
129
+
130
+ for key in list(plugins.keys()):
131
+ plugin_name = key.split("@", 1)[0]
132
+ if plugin_name in needed_plugin_names:
133
+ plugins[key] = True
134
+ continue
135
+ if plugin_name in LIKELY_UNUSED_FOR_ENGINEERING and not conservative:
136
+ plugins[key] = False
137
+ continue
138
+ if aggressive and plugin_name not in _CORE_ENG_PLUGINS:
139
+ plugins[key] = False
140
+ continue
141
+ # Otherwise leave as-is
142
+
143
+ out["enabledPlugins"] = plugins
144
+ return out
145
+
146
+
147
+ def _optimize_claude_md(old: str, wizard: WizardResult) -> str:
148
+ """Rewrite CLAUDE.md to remove forcing rules and add stack hints."""
149
+ lines = old.splitlines()
150
+ cleaned: list[str] = []
151
+
152
+ for line in lines:
153
+ stripped = line.strip().lower()
154
+ # Drop "always use all agents" and similar forcing rules
155
+ if "always use" in stripped and "agent" in stripped:
156
+ cleaned.append(
157
+ "- Use agents when work is specialized or parallelizable, "
158
+ "not for simple edits"
159
+ )
160
+ continue
161
+ if "always use all" in stripped:
162
+ continue # filtered out
163
+ cleaned.append(line)
164
+
165
+ # Add a stack-aware hint if not already present
166
+ stack_hint = _stack_hint(wizard.primary_stacks)
167
+ if stack_hint and stack_hint not in "\n".join(cleaned):
168
+ cleaned.append(stack_hint)
169
+
170
+ # Trailing newline discipline
171
+ text = "\n".join(cleaned).rstrip() + "\n"
172
+ return text
173
+
174
+
175
+ def _stack_hint(stacks: list[str]) -> str | None:
176
+ if "python" in stacks and "ml/ai" in stacks:
177
+ return "- Prefer MLX over PyTorch for local ML on Apple Silicon when possible"
178
+ if "rust" in stacks:
179
+ return "- Default to safe Rust; only reach for unsafe when measurably necessary"
180
+ return None
@@ -0,0 +1,77 @@
1
+ """Remove stale snapshot sections from memory files, keeping policy/why intact."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+
9
+ # Heading patterns that mark a snapshot section (case-insensitive)
10
+ _SNAPSHOT_HEADING = re.compile(
11
+ r"^(\*\*)?(setup\s+state|state\s+\(as\s+of|snapshot|installed\s+as\s+of|current\s+state)",
12
+ re.IGNORECASE,
13
+ )
14
+
15
+
16
+ def clean_memory_body(text: str) -> tuple[str, bool]:
17
+ """Remove snapshot sections from a memory file's body.
18
+
19
+ Returns ``(new_text, changed)``. The frontmatter is preserved untouched.
20
+ """
21
+ if not text.startswith("---\n"):
22
+ return text, False
23
+
24
+ end = text.find("\n---\n", 4)
25
+ if end == -1:
26
+ return text, False
27
+
28
+ frontmatter = text[: end + 5]
29
+ body = text[end + 5 :]
30
+
31
+ new_body, changed = _strip_snapshot_blocks(body)
32
+ if not changed:
33
+ return text, False
34
+ return frontmatter + new_body, True
35
+
36
+
37
+ def clean_memory_file(path: Path) -> bool:
38
+ """In-place clean a memory file. Returns True if anything changed."""
39
+ try:
40
+ text = path.read_text(encoding="utf-8")
41
+ except OSError:
42
+ return False
43
+ new_text, changed = clean_memory_body(text)
44
+ if not changed:
45
+ return False
46
+ path.write_text(new_text, encoding="utf-8")
47
+ return True
48
+
49
+
50
+ def _strip_snapshot_blocks(body: str) -> tuple[str, bool]:
51
+ """Drop sections that look like dated snapshots."""
52
+ lines = body.splitlines(keepends=True)
53
+ out: list[str] = []
54
+ skipping = False
55
+ changed = False
56
+
57
+ for line in lines:
58
+ if skipping:
59
+ # Stop skipping at the next blank-line-then-heading or end of paragraph
60
+ if line.strip() == "":
61
+ # Lookahead would help, but stay simple: treat blank as end of block
62
+ skipping = False
63
+ # Don't emit the blank line (we already consumed the section)
64
+ continue
65
+ # Still in snapshot block; drop
66
+ continue
67
+
68
+ # Detect start of snapshot section
69
+ stripped = line.strip()
70
+ if _SNAPSHOT_HEADING.search(stripped):
71
+ skipping = True
72
+ changed = True
73
+ continue
74
+
75
+ out.append(line)
76
+
77
+ return "".join(out), changed
@@ -0,0 +1,107 @@
1
+ """Interactive wizard that asks the user about their stack and returns a plan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from rich.console import Console
8
+ from rich.prompt import Prompt, Confirm
9
+
10
+
11
+ _ENGINEERING_STACKS = ["python", "javascript/typescript", "rust", "go", "ml/ai", "devops", "frontend", "mobile", "other"]
12
+ _NON_ENG_WORK = ["marketing", "sales", "finance", "product", "regulatory", "none"]
13
+
14
+
15
+ @dataclass
16
+ class WizardResult:
17
+ """User's choices, ready to feed into the generator."""
18
+
19
+ primary_stacks: list[str] = field(default_factory=list)
20
+ non_eng_work: list[str] = field(default_factory=list)
21
+ aggressiveness: str = "balanced" # conservative | balanced | aggressive
22
+ confirm_apply: bool = False
23
+
24
+
25
+ def run_wizard(*, console: Console | None = None) -> WizardResult:
26
+ """Run the interactive wizard. Returns the user's choices."""
27
+ console = console or Console()
28
+ console.print()
29
+ console.rule("[bold]claude-lean apply[/bold]")
30
+ console.print()
31
+ console.print("A few questions to tailor recommendations to your work.")
32
+ console.print()
33
+
34
+ primary = _multi_select(console, "What's your primary stack?", _ENGINEERING_STACKS)
35
+ non_eng = _multi_select(
36
+ console,
37
+ "Any non-engineering work types? ('none' is fine)",
38
+ _NON_ENG_WORK,
39
+ default_index=len(_NON_ENG_WORK) - 1,
40
+ )
41
+ aggressiveness = _single_select(
42
+ console,
43
+ "How aggressive should optimizations be?",
44
+ ["conservative", "balanced", "aggressive"],
45
+ default="balanced",
46
+ )
47
+ confirm = Confirm.ask(
48
+ "Apply changes now? (Will show diff first; nothing is destructive)",
49
+ default=False,
50
+ console=console,
51
+ )
52
+
53
+ return WizardResult(
54
+ primary_stacks=primary,
55
+ non_eng_work=non_eng,
56
+ aggressiveness=aggressiveness,
57
+ confirm_apply=confirm,
58
+ )
59
+
60
+
61
+ def _multi_select(
62
+ console: Console,
63
+ prompt: str,
64
+ choices: list[str],
65
+ *,
66
+ default_index: int | None = None,
67
+ ) -> list[str]:
68
+ """Naive stdlib-friendly multi-select: comma-separated indices."""
69
+ console.print(f"[bold]{prompt}[/bold]")
70
+ for i, c in enumerate(choices, start=1):
71
+ marker = " (default)" if default_index == i - 1 else ""
72
+ console.print(f" [cyan]{i}[/cyan]. {c}{marker}")
73
+ default_str = str(default_index + 1) if default_index is not None else ""
74
+ raw = Prompt.ask(
75
+ "Enter numbers separated by commas",
76
+ default=default_str,
77
+ console=console,
78
+ show_default=bool(default_str),
79
+ )
80
+ selected: list[str] = []
81
+ for chunk in raw.split(","):
82
+ chunk = chunk.strip()
83
+ if not chunk:
84
+ continue
85
+ try:
86
+ idx = int(chunk)
87
+ if 1 <= idx <= len(choices):
88
+ selected.append(choices[idx - 1])
89
+ except ValueError:
90
+ pass
91
+ console.print()
92
+ return selected
93
+
94
+
95
+ def _single_select(
96
+ console: Console,
97
+ prompt: str,
98
+ choices: list[str],
99
+ *,
100
+ default: str,
101
+ ) -> str:
102
+ return Prompt.ask(
103
+ prompt,
104
+ choices=choices,
105
+ default=default,
106
+ console=console,
107
+ )
@@ -0,0 +1,7 @@
1
+ """Audit subsystem: read-only inspection of ~/.claude/."""
2
+
3
+ from claude_lean.audit.scanner import scan
4
+ from claude_lean.audit.analyzer import analyze
5
+ from claude_lean.audit.report import render_terminal, render_json
6
+
7
+ __all__ = ["scan", "analyze", "render_terminal", "render_json"]
@@ -0,0 +1,51 @@
1
+ """Run all registered rules over an inventory and produce a finding list."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from claude_lean.audit.rules import all_rules
8
+ from claude_lean.audit.rules._base import Finding, Severity
9
+ from claude_lean.audit.scanner import Inventory
10
+
11
+
12
+ @dataclass
13
+ class AuditResult:
14
+ """The full audit output."""
15
+
16
+ inventory: Inventory
17
+ findings: list[Finding] = field(default_factory=list)
18
+
19
+ @property
20
+ def total_estimated_savings(self) -> int:
21
+ return sum(f.estimated_savings_tokens for f in self.findings)
22
+
23
+ @property
24
+ def critical_count(self) -> int:
25
+ return sum(1 for f in self.findings if f.severity == Severity.CRITICAL)
26
+
27
+ @property
28
+ def warn_count(self) -> int:
29
+ return sum(1 for f in self.findings if f.severity == Severity.WARN)
30
+
31
+ @property
32
+ def info_count(self) -> int:
33
+ return sum(1 for f in self.findings if f.severity == Severity.INFO)
34
+
35
+ @property
36
+ def multiplier_gain(self) -> float:
37
+ """Estimated tokens-per-conversation multiplier after applying recommendations."""
38
+ baseline = self.inventory.estimated_system_prompt_tokens
39
+ if baseline <= 0:
40
+ return 1.0
41
+ saved = self.total_estimated_savings
42
+ remaining = max(baseline - saved, 1)
43
+ return round(baseline / remaining, 2)
44
+
45
+
46
+ def analyze(inventory: Inventory) -> AuditResult:
47
+ """Run every registered rule and collect findings."""
48
+ findings: list[Finding] = []
49
+ for rule in all_rules():
50
+ findings.extend(rule.evaluate(inventory))
51
+ return AuditResult(inventory=inventory, findings=findings)
@@ -0,0 +1,163 @@
1
+ """Render audit results to terminal (rich) or JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+ from rich import box
11
+
12
+ from claude_lean.audit.analyzer import AuditResult
13
+ from claude_lean.audit.rules._base import Severity
14
+ from claude_lean.common.tokenizer import accuracy_label
15
+
16
+
17
+ _SEVERITY_GLYPH = {
18
+ Severity.INFO: "[cyan]ℹ[/cyan]",
19
+ Severity.WARN: "[yellow]⚠[/yellow]",
20
+ Severity.CRITICAL: "[red]✖[/red]",
21
+ }
22
+
23
+
24
+ def render_terminal(result: AuditResult, console: Console | None = None) -> None:
25
+ """Render the audit to the terminal using rich."""
26
+ console = console or Console()
27
+ inv = result.inventory
28
+
29
+ # Header
30
+ console.print()
31
+ console.rule(f"[bold]claude-lean audit[/bold] · {inv.claude_home}")
32
+ console.print(f"[dim]Token measurement: {accuracy_label()}[/dim]")
33
+ console.print()
34
+
35
+ # Headline stats
36
+ console.print(
37
+ f" Plugins enabled: [bold]{sum(1 for p in inv.plugins if p.enabled)}[/bold] "
38
+ f"· Skills: [bold]{sum(len(p.skills) for p in inv.plugins if p.enabled)}[/bold] "
39
+ f"· Agents: [bold]{sum(len(p.agents) for p in inv.plugins if p.enabled)}[/bold]"
40
+ )
41
+ console.print(
42
+ f" Estimated system-prompt overhead per turn: "
43
+ f"[bold yellow]{inv.estimated_system_prompt_tokens:,}[/bold yellow] tokens"
44
+ )
45
+ console.print()
46
+
47
+ # Per-plugin table (top 10 by cost)
48
+ enabled_plugins = sorted(
49
+ (p for p in inv.plugins if p.enabled),
50
+ key=lambda p: p.tokens_total,
51
+ reverse=True,
52
+ )
53
+ top = enabled_plugins[:10]
54
+ if top:
55
+ table = Table(
56
+ title="Top 10 enabled plugins by token cost",
57
+ box=box.SIMPLE_HEAVY,
58
+ show_lines=False,
59
+ )
60
+ table.add_column("Plugin", style="cyan")
61
+ table.add_column("Skills", justify="right")
62
+ table.add_column("Agents", justify="right")
63
+ table.add_column("Total tokens", justify="right", style="bold")
64
+ for p in top:
65
+ table.add_row(
66
+ p.name,
67
+ f"{p.tokens_skills:,}",
68
+ f"{p.tokens_agents:,}",
69
+ f"{p.tokens_total:,}",
70
+ )
71
+ console.print(table)
72
+ console.print()
73
+
74
+ # Findings
75
+ if result.findings:
76
+ console.print(
77
+ f"[bold]Findings:[/bold] "
78
+ f"[red]{result.critical_count} critical[/red] · "
79
+ f"[yellow]{result.warn_count} warn[/yellow] · "
80
+ f"[cyan]{result.info_count} info[/cyan]"
81
+ )
82
+ console.print()
83
+ # Group findings by rule_id
84
+ by_rule: dict[str, list] = {}
85
+ for f in result.findings:
86
+ by_rule.setdefault(f.rule_id, []).append(f)
87
+ for rule_id, group in by_rule.items():
88
+ first = group[0]
89
+ glyph = _SEVERITY_GLYPH.get(first.severity, "·")
90
+ console.print(f"{glyph} [bold]{rule_id}[/bold] ({len(group)})")
91
+ for f in group:
92
+ console.print(f" {f.title}")
93
+ if f.estimated_savings_tokens:
94
+ console.print(
95
+ f" [dim]→ est. savings: {f.estimated_savings_tokens:,} tokens/turn[/dim]"
96
+ )
97
+ console.print()
98
+ else:
99
+ console.print("[green]✓[/green] No anti-patterns detected. Your setup looks clean.")
100
+ console.print()
101
+
102
+ # Bottom line
103
+ savings = result.total_estimated_savings
104
+ if savings > 0:
105
+ console.print(
106
+ f" [bold]Estimated tokens saved per turn if applied:[/bold] "
107
+ f"[green]{savings:,}[/green] tokens "
108
+ f"(≈ [bold green]{result.multiplier_gain}×[/bold green] more headroom)"
109
+ )
110
+ console.print()
111
+ console.print(
112
+ " Next: run [bold cyan]claude-lean apply[/bold cyan] to act on these recommendations."
113
+ )
114
+ console.print()
115
+
116
+
117
+ def render_json(result: AuditResult) -> str:
118
+ """Render the audit as a stable JSON document."""
119
+ inv = result.inventory
120
+ doc = {
121
+ "schema_version": 1,
122
+ "generated_at": datetime.now(timezone.utc).isoformat(),
123
+ "claude_home": str(inv.claude_home),
124
+ "tokenizer_accuracy": accuracy_label(),
125
+ "totals": {
126
+ "plugins_enabled": sum(1 for p in inv.plugins if p.enabled),
127
+ "plugins_installed": len(inv.plugins),
128
+ "skills_loaded": sum(len(p.skills) for p in inv.plugins if p.enabled),
129
+ "agents_loaded": sum(len(p.agents) for p in inv.plugins if p.enabled),
130
+ "estimated_system_prompt_tokens": inv.estimated_system_prompt_tokens,
131
+ "claude_md_tokens": inv.claude_md_tokens,
132
+ },
133
+ "by_plugin": [
134
+ {
135
+ "name": p.name,
136
+ "marketplace": p.marketplace,
137
+ "enabled": p.enabled,
138
+ "tokens_skills": p.tokens_skills,
139
+ "tokens_agents": p.tokens_agents,
140
+ "tokens_mcp": p.tokens_mcp,
141
+ "tokens_total": p.tokens_total,
142
+ }
143
+ for p in sorted(inv.plugins, key=lambda x: x.tokens_total, reverse=True)
144
+ ],
145
+ "findings": [
146
+ {
147
+ "rule_id": f.rule_id,
148
+ "severity": f.severity.value,
149
+ "title": f.title,
150
+ "evidence": f.evidence,
151
+ "suggested_action": f.suggested_action,
152
+ "estimated_savings_tokens": f.estimated_savings_tokens,
153
+ "target": f.target,
154
+ "metadata": f.metadata,
155
+ }
156
+ for f in result.findings
157
+ ],
158
+ "recommendations_summary": {
159
+ "estimated_tokens_saved_per_turn": result.total_estimated_savings,
160
+ "estimated_multiplier_gain": result.multiplier_gain,
161
+ },
162
+ }
163
+ return json.dumps(doc, indent=2, default=str)
@@ -0,0 +1,22 @@
1
+ """Rule modules — one anti-pattern detector per file."""
2
+
3
+ from claude_lean.audit.rules._base import Rule, Finding, Severity
4
+ from claude_lean.audit.rules.unused_plugins import UnusedPluginsRule
5
+ from claude_lean.audit.rules.forcing_rules import ForcingRulesRule
6
+ from claude_lean.audit.rules.stale_memory import StaleMemoryRule
7
+ from claude_lean.audit.rules.vague_descriptions import VagueDescriptionsRule
8
+ from claude_lean.audit.rules.memory_near_cap import MemoryNearCapRule
9
+
10
+
11
+ def all_rules() -> list[Rule]:
12
+ """Return all built-in rules, in fire order."""
13
+ return [
14
+ UnusedPluginsRule(),
15
+ ForcingRulesRule(),
16
+ StaleMemoryRule(),
17
+ VagueDescriptionsRule(),
18
+ MemoryNearCapRule(),
19
+ ]
20
+
21
+
22
+ __all__ = ["Rule", "Finding", "Severity", "all_rules"]
@@ -0,0 +1,41 @@
1
+ """Base types for audit rules. A rule looks at the inventory and emits findings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Protocol, TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from claude_lean.audit.scanner import Inventory
11
+
12
+
13
+ class Severity(str, Enum):
14
+ INFO = "info"
15
+ WARN = "warn"
16
+ CRITICAL = "critical"
17
+
18
+
19
+ @dataclass
20
+ class Finding:
21
+ """A single anti-pattern instance discovered by a rule."""
22
+
23
+ rule_id: str
24
+ severity: Severity
25
+ title: str
26
+ evidence: str
27
+ suggested_action: str
28
+ estimated_savings_tokens: int = 0
29
+ target: str | None = None # plugin name / file path / etc.
30
+ metadata: dict = field(default_factory=dict)
31
+
32
+
33
+ class Rule(Protocol):
34
+ """Audit rule contract."""
35
+
36
+ rule_id: str
37
+ severity: Severity
38
+ title: str
39
+
40
+ def evaluate(self, inventory: "Inventory") -> list[Finding]:
41
+ ...