A code smell is a sign in the code that something underneath may need to change. Martin Fowler’s CodeSmell (2006) describes it as something you notice on the surface that usually points to a deeper problem in the system. The smell tells you where to look, and what you find there decides whether anything changes.
Seven common code smells have lint rules in the Prickles packs: long functions, long parameter lists, deep nesting, complex conditionals, duplicated code, large classes and magic numbers. Each gets its usual fix below, with a Prickles tenet arguing for that fix and a rule in each Prickles lint pack to fail the build. The rest of the list needs someone, or an agent, to read the code.
Every rule and setting here comes from the TypeScript, JavaScript, Python, Java and PHP packs on the implementation page, as of Prickles release 3.0.0. Each tool’s documentation was checked on 12 October 2026.
Where the code smells list comes from
Kent Beck coined the term while helping Fowler with his book on refactoring, according to the same CodeSmell entry. The list itself is chapter 3 of Refactoring: Improving the Design of Existing Code, “Bad Smells in Code”, which the two wrote together. The first edition came out in 1999 and the second in 2018.
In the second edition the chapter names 24 smells, from Mysterious Name and Duplicated Code to Comments, as InformIT’s excerpt of the chapter (2018) shows. Fowler’s Changes for the 2nd Edition of Refactoring (2018) says the chapter had a thorough overhaul. Each smell points to refactorings in the book’s catalogue, online at refactoring.com.
Fowler and Beck give no precise criteria for when to refactor. Chapter 3 says why: “In our experience, no set of metrics rivals informed human intuition.”
A lint rule can’t run on intuition, so the Prickles canon picks the numbers. S2 Cyclomatic Caps calls the number editorial and says to pick one and put it in the linter. F1 Single Responsibility Principle calls the caps “linter smoke alarms” that catch tangles early, and says they never replace asking who pays when the code changes.
Seven code smells and the usual fix for each
Four of the seven are headings in Fowler and Beck’s chapter. Deep nesting, complex conditionals and magic numbers aren’t, though the catalogue has a refactoring for each.
| Smell | What it looks like | Usual fix | Tenet |
|---|---|---|---|
| Long function | A function you can’t read in one go, doing several jobs | Extract Function | F1 Single Responsibility Principle |
| Long parameter list | Four or more parameters, some of which travel together | Introduce Parameter Object | S3 Parameter Object |
| Deep nesting | An if inside an if, with the main path at the bottom |
Replace Nested Conditional with Guard Clauses | S4 Guard Clauses |
| Complex conditional | Many paths through one function | Decompose Conditional, or split the function | S2 Cyclomatic Caps |
| Duplicated code | Logic repeated in two or more places | Extract Function, called from each place | F3 Don’t Repeat Yourself and TA2 Duplication Detection |
| Large class | A class or file that has taken on several jobs | Extract Class | F1 Single Responsibility Principle |
| Magic number | A number whose meaning the reader has to guess | Replace Magic Literal | S5 Inline-First |
Which lint rule catches each smell
Every pack sets each rule as an error, so the build fails when one trips, as TA1 Linter as Law asks.
| Smell | TypeScript and JavaScript | Python | Java | PHP |
|---|---|---|---|---|
| Long function | max-lines-per-function 15 |
Ruff and Pylint max-statements 10 |
PMD NcssCount 15 |
PHPMD ExcessiveMethodLength 15 |
| Long parameter list | max-params 3 |
Ruff and Pylint max-args 3 |
PMD ExcessiveParameterList 4 |
PHPMD ExcessiveParameterList 3 |
| Deep nesting | max-depth 3 |
No limit set | PMD AvoidDeeplyNestedIfStmts 4 |
PHP_CodeSniffer Generic.Metrics.NestingLevel 3 |
| Complex conditional | complexity 3 |
Ruff C901 3 and max-branches 3 |
PMD CyclomaticComplexity 4 |
PHP_CodeSniffer Generic.Metrics.CyclomaticComplexity 3 |
| Duplicated code | jscpd with --threshold 0 |
jscpd with --threshold 0 |
PMD’s CPD, 50 tokens | PMD’s CPD, 50 tokens |
| Large class | max-lines 150 a file |
Pylint max-public-methods 7 |
PMD NcssCount 300 a class |
PHPMD ExcessiveClassLength 150 |
| Magic number | no-magic-numbers, or @typescript-eslint/no-magic-numbers in TypeScript |
Ruff PLR2004 |
PMD AvoidLiteralsInIfCondition |
phpmnd |
- PMD reports a method that reaches the number it’s given, as its design rules set out. So for complexity, nesting and parameters, the Java pack sets 4 to allow the canon’s 3.
- The Python pack sets no nesting limit of its own. Its complexity caps of 3 leave a function little room to nest.
- The Java and PHP packs name PMD’s copy-paste detector, CPD, to run beside the lint with a minimum of 50 tokens.
Long functions and large classes
Fowler and Beck’s fix for a long function is nearly always Extract Function. It moves a group of lines that does one thing into a function named after it. A class that does too much tends to collect fields, and Fowler and Beck fix it with Extract Class.
F1 Single Responsibility Principle asks for one obvious job per function, class or module. The packs measure size in different units. TypeScript, JavaScript and PHP allow 15 lines a function, and TypeScript and JavaScript also allow 10 statements. Python allows 10 statements a function, and PMD’s NcssCount in Java counts statements too.
Long parameter list
A call such as confirmationFor(hotelName, guestName, arrival, nights) passes four values, and three of them describe one stay. Fowler and Beck call a group of values that keeps turning up together a data clump, and suggest giving it a home of its own.
Introduce Parameter Object fixes it. S3 Parameter Object caps a function at three parameters, so the fourth fails max-params or its equivalent in each pack. The stay becomes one type:
type Stay = Readonly<{ guestName: string; arrival: string; nights: number }>;
export const confirmationFor = (hotelName: string, stay: Stay): string =>
`${stay.guestName}: ${stay.nights} nights at ${hotelName} from ${stay.arrival}`; type Stay = Readonly<{ guestName: string; arrival: string; nights: number }>;
export const confirmationFor = (hotelName: string, stay: Stay): string =>
`${stay.guestName}: ${stay.nights} nights at ${hotelName} from ${stay.arrival}`; export const confirmationFor = (hotelName, stay) =>
`${stay.guestName}: ${stay.nights} nights at ${hotelName} from ${stay.arrival}`; from dataclasses import dataclass
@dataclass(frozen=True)
class Stay:
guest_name: str
arrival: str
nights: int
def confirmation_for(hotel_name: str, stay: Stay) -> str:
return f"{stay.guest_name}: {stay.nights} nights at {hotel_name} from {stay.arrival}" public record Stay(String guestName, String arrival, int nights) {}
public static String confirmationFor(String hotelName, Stay stay) {
return stay.guestName() + ": " + stay.nights() + " nights at " + hotelName
+ " from " + stay.arrival();
} final class Stay
{
public function __construct(
public readonly string $guestName,
public readonly string $arrival,
public readonly int $nights,
) {
}
}
function confirmationFor(string $hotelName, Stay $stay): string
{
return "{$stay->guestName}: {$stay->nights} nights at {$hotelName} from {$stay->arrival}";
} confirmationFor takes two parameters now. The next detail about a stay goes into Stay instead of onto the parameter list of every function that handles one.
Deep nesting and complex conditionals
Nested conditions push the main path of a function to the right, under every check that comes before it. ESLint’s max-depth limits how deep blocks can nest, and its complexity rule counts cyclomatic complexity, the number of independent paths through the code.
Replace Nested Conditional with Guard Clauses fixes it. Each condition is tested at the top and returns early when it fails, so the main path runs unindented. S4 Guard Clauses argues for it. S2 Cyclomatic Caps sets a cap of 3, leaving room for two decision points. The sample spends one on a guard and one on a ternary, because the TypeScript and JavaScript packs reject a guard that only the final return follows. More checks than that belong in a function of their own. Fowler’s catalogue calls that move Decompose Conditional.
type Order = Readonly<{ paid: boolean; items: readonly string[] }>;
export const dispatchStatus = (order: Order): string => {
if (!order.paid) {
return "Awaiting payment";
}
return order.items.length === 0 ? "Nothing to send" : `Ready to send ${order.items.join(", ")}`;
}; type Order = Readonly<{ paid: boolean; items: readonly string[] }>;
export const dispatchStatus = (order: Order): string => {
if (!order.paid) {
return "Awaiting payment";
}
return order.items.length === 0 ? "Nothing to send" : `Ready to send ${order.items.join(", ")}`;
}; export const dispatchStatus = (order) => {
if (!order.paid) {
return "Awaiting payment";
}
return order.items.length === 0 ? "Nothing to send" : `Ready to send ${order.items.join(", ")}`;
}; from dataclasses import dataclass
@dataclass(frozen=True)
class Order:
paid: bool
items: tuple[str, ...]
def dispatch_status(order: Order) -> str:
if not order.paid:
return "Awaiting payment"
return "Nothing to send" if not order.items else f"Ready to send {', '.join(order.items)}" public record Order(boolean paid, List<String> items) {}
public static String dispatchStatus(Order order) {
if (!order.paid()) {
return "Awaiting payment";
}
return order.items().isEmpty()
? "Nothing to send"
: "Ready to send " + String.join(", ", order.items());
} final class Order
{
public function __construct(
public readonly bool $paid,
public readonly array $items,
) {
}
}
function dispatchStatus(Order $order): string
{
if (!$order->paid) {
return 'Awaiting payment';
}
return $order->items === []
? 'Nothing to send'
: 'Ready to send ' . implode(', ', $order->items);
} Duplicate code detection
Duplicated Code comes second in the chapter’s list. Fowler and Beck fix it by extracting a function and calling it from each place. F3 Don’t Repeat Yourself says to extract on the second copy, inside one bounded context, without waiting for a third.
Copies are easy to miss in a diff. TA2 Duplication Detection starts from that, so the packs hand detection to a tool. jscpd finds repeated runs of tokens across files, 50 tokens or more by default, and exits with an error when duplication passes its --threshold percentage. The TypeScript, JavaScript and Python packs run it with --threshold 0, so any clone fails, as TA2 asks for new code. The TypeScript and JavaScript command is:
npx jscpd . \
--ignore "**/node_modules/**,**/dist/**,**/build/**,**/coverage/**,**/.nuxt/**,**/.output/**" \
--format "typescript,tsx,javascript,jsx,vue,svelte,astro" \
--threshold 0
In a test on 12 October 2026, jscpd 5.4.1 with --threshold 0 exited with code 1 on two files that held the same seven-line function under different names.
Python adds Pylint’s duplicate-code check, and TypeScript and JavaScript add sonarjs’s no-identical-functions. The Java and PHP packs name PMD’s CPD, with the same 50-token minimum.
Magic numbers
A magic number is a numeric literal whose meaning isn’t clear to the reader, as Wikipedia’s article on magic numbers defines it. Wikipedia’s Code smell page lists excessive literals among its examples. Fowler’s catalogue fixes one with Replace Magic Literal. His changes page says it appears only in the second edition’s web version. The first edition called it Replace Magic Number with Symbolic Constant.
In basketTotalPence >= 5000, the reader has to work out that 5000 is the basket total for free delivery, in pence. A named constant records that once. S5 Inline-First puts it as “Literals with meaning earn a const NAME”, and F3 says “Constants over magic literals”.
const FREE_DELIVERY_FROM_PENCE = 5000;
export const isDeliveryFree = (basketTotalPence: number): boolean =>
basketTotalPence >= FREE_DELIVERY_FROM_PENCE; const FREE_DELIVERY_FROM_PENCE = 5000;
export const isDeliveryFree = (basketTotalPence: number): boolean =>
basketTotalPence >= FREE_DELIVERY_FROM_PENCE; const FREE_DELIVERY_FROM_PENCE = 5000;
export const isDeliveryFree = (basketTotalPence) => basketTotalPence >= FREE_DELIVERY_FROM_PENCE; FREE_DELIVERY_FROM_PENCE = 5000
def is_delivery_free(basket_total_pence: int) -> bool:
return basket_total_pence >= FREE_DELIVERY_FROM_PENCE private static final int FREE_DELIVERY_FROM_PENCE = 5000;
public static boolean isDeliveryFree(int basketTotalPence) {
return basketTotalPence >= FREE_DELIVERY_FROM_PENCE;
} const FREE_DELIVERY_FROM_PENCE = 5000;
function isDeliveryFree(int $basketTotalPence): bool
{
return $basketTotalPence >= FREE_DELIVERY_FROM_PENCE;
} From release 3.0.0 every pack has a magic-number rule, and each one looks in different places:
| Language | Rule | What it flags | What it lets through |
|---|---|---|---|
| TypeScript | @typescript-eslint/no-magic-numbers, base rule off |
Any number except a const’s value or an object’s property |
-1, 0 and 1, array indexes, default values, enums, numeric literal types, readonly class properties and type indexes |
| JavaScript | no-magic-numbers |
Any number except a const’s value or an object’s property |
-1, 0 and 1, array indexes and default values |
| Python | Ruff PLR2004 |
Numbers in comparisons | Strings, bytes and the numbers 0, 1 and -1 |
| Java | PMD AvoidLiteralsInIfCondition |
Literals in a simple if condition |
-1, 0 and 1 as well as null, true and false |
| PHP | phpmnd | Numbers in conditions, returns, case labels, arguments, assignments, operations and property defaults | 0, 1 and -1, default parameter values and numbers in arrays or array indexes |
The Python and Java rules look at fewer places than the others. PLR2004 checks comparisons and PMD checks if conditions, so a number in a calculation passes both. The Java pack names Checkstyle’s MagicNumber check to pair with PMD for the rest, set to allow -1, 0 and 1.
Code smells the packs leave to review
Most of Fowler and Beck’s list is about design. The packs have no rule aimed at these smells:
| Smell | What it looks like |
|---|---|
| Mysterious name | A name that doesn’t say what it holds or does |
| Feature envy | A function that spends more time with another module’s data than with its own |
| Shotgun surgery | A change that needs small edits in many classes |
| Divergent change | One module that changes for several unrelated reasons |
| Primitive obsession | Plain numbers and strings standing in for things such as money or a phone number |
| Data clumps | Values that keep travelling together |
| Message chains | Code that asks one object for another, then asks that one for a third |
Each pack checks how a name is written, such as its case, and leaves its meaning to a reviewer. max-params catches a data clump only once a function has four parameters.
Comments are on the chapter’s list too. Fowler and Beck treat a comment that explains unclear code as a cover for a smell. Every pack fails on any comment except tool directives, SPDX licence lines and generated-file banners. That removes the comment, but the smell it covered stays until someone refactors. F5 Self-Documenting Code asks for the refactoring that makes the comment unnecessary.
A reviewer has to look for all of these. The AI code review checklist covers what a reviewer, person or agent, should look for. Refactoring with a coding agent takes the fixes one small step at a time behind tests.
How to fix a code smell the linter finds
- Find the smell behind the rule named in the error, using the table of lint rules above.
- Check that tests cover the behaviour you’re about to change, and add them if they don’t.
- Apply the matching refactoring from the catalogue, one at a time.
- Run the tests and the linter again.
- Leave the limit and the allowed list as they are, and don’t add a comment that disables the rule. TA1 Linter as Law calls the disable comment the bug.
