mess-detector
v0.0.0
Published
PHPMD rules ported to TypeScript/JavaScript as an oxlint plugin
Readme
TypeScript Mess Detector
This project ports PHPMD (PHP Mess Detector) rules to TypeScript and JavaScript. It runs as an oxlint plugin. If you have used PHPMD, you will recognize the rules. Examples include cyclomatic complexity, unused private members, boolean flag arguments, and naming conventions. Each rule works on TS and JS code. The port adapts a rule where the two languages differ.
PHPMD groups its rules into six sets: naming, codesize, design, clean code,
controversial, and unused code. This plugin uses the same six groups. Each rule name
uses kebab-case. For example, PHPMD's ShortVariable becomes
mess-detector/short-variable.
Install
npm install --save-dev mess-detector oxlint// .oxlintrc.json
{
"jsPlugins": ["mess-detector"],
"rules": {
"mess-detector/short-variable": "error",
"mess-detector/boolean-argument-flag": "error",
"mess-detector/cyclomatic-complexity": "error"
}
}Most rules take options. Pass options the same way ESLint and oxlint rules do: an array whose first item is a config object. The config object mirrors the properties of the source PHPMD rule.
{
"rules": {
"mess-detector/short-variable": ["error", { "minimum": 3, "exceptions": "id,ok" }]
}
}Each section below documents one rule. Each entry names the PHPMD rule it comes from,
lists its options, and shows a bad example next to a good example. The
examples directory holds a small runnable file for each rule.
Naming rules
These rules check names. They flag identifiers that are too short, identifiers that are too long, and getter names that do not match what the getter returns.
short-variable
This rule flags variable names, function parameters, and class fields that are too
short. The default minimum length is 3 characters. Loop counters such as i and
caught exceptions such as e are exempt by default. A short name gives the reader no
clue what the value holds.
(ShortVariable)
Options:
minimum. Default3.exceptions. A comma-separated list of names to always allow.
Avoid
const q = 15;Prefer
const index = 15;long-variable
This rule flags variable names, function parameters, and class fields that are too
long. The default maximum length is 20 characters. A long name often does too much
work, or could use a shorter, clearer word.
(LongVariable)
Options:
maximum. Default20.subtract-prefixesandsubtract-suffixes. Substrings to remove before the rule measures the name's length.
Avoid
function main(interestingArgumentsList) {}Prefer
function main(args) {}short-method-name
This rule flags function and method names shorter than a configured minimum. The
default minimum is 3 characters. A name such as go() does not say what the function
does, so the reader must read the body to find out. Constructors are always exempt.
(ShortMethodName)
Options:
minimum. Default3.exceptions. A comma-separated list of names to always allow.
Avoid
function go() {}Prefer
function doWork() {}long-class-name
This rule flags class and interface names longer than a configured maximum. The
default maximum is 40 characters. A very long class name often means the class has
too many jobs.
(LongClassName)
Options:
maximum. Default40.subtract-prefixesandsubtract-suffixes.
Avoid
class ATooLongClassNameThatHintsAtADesignProblem {}Prefer
class DesignProblem {}short-class-name
This rule flags class and interface names shorter than a configured minimum. The
default minimum is 3 characters. A name such as Fo does not tell the reader what the
type represents.
(ShortClassName)
Options:
minimum. Default3.exceptions.
Avoid
class Fo {}Prefer
class Foo {}constant-naming-conventions
This rule requires constant-like class members to use uppercase names. It checks
static readonly fields and enum members, and requires names such as MY_NUM
instead of myTest. TypeScript has no dedicated syntax for a class constant, so this
rule treats static readonly fields and enum members as the closest match.
(ConstantNamingConventions)
Avoid
class Foo {
static readonly myTest = "";
}Prefer
class Foo {
static readonly MY_NUM = 0;
}boolean-get-method-name
This rule flags a method named getX() or _getX() that returns a boolean. A
boolean-returning method reads better as a question, so name it isX() or hasX()
instead.
(BooleanGetMethodName)
Options:
checkParameterizedMethods. Defaultfalse. Whenfalse, the rule still checks methods that take parameters. Set it totrueto skip methods with parameters.
Avoid
class Foo {
getFoo(): boolean {
return true;
}
}Prefer
class Foo {
isFoo(): boolean {
return true;
}
}Not ported: ConstructorWithNameAsEnclosingClass. This is PHP-only. A JS or TS
constructor always uses the constructor keyword, so this problem cannot happen.
Controversial rules
PHPMD calls these rules "controversial" because not every team wants a linter to enforce them. All six check camelCase or PascalCase naming.
camelcase-class-name
This rule flags a class, interface, or enum name that is not PascalCase.
(CamelCaseClassName)
Options:
camelcase-abbreviations. Also forbids two capital letters in a row, such asHTMLParser.
Avoid
class class_name {}Prefer
class ClassName {}camelcase-method-name
This rule flags a class method name that is not camelCase. It checks class methods
only, not free functions. It always skips constructor. (CamelCaseMethodName)
Options:
allow-underscore. Allows a leading underscore.allow-underscore-test. Allows underscore-separated segments after atestprefix.camelcase-abbreviations.
Avoid
class ClassName {
get_name() {}
}Prefer
class ClassName {
getName() {}
}camelcase-property-name
This rule flags a class field name that is not camelCase. It checks only
non-computed properties. (CamelCasePropertyName)
Options:
allow-underscore.camelcase-abbreviations.
Avoid
class ClassName {
property_name = 1;
}Prefer
class ClassName {
propertyName = 1;
}camelcase-parameter-name
This rule flags a function, arrow function, or method parameter name that is not
camelCase. It also checks a default-value pattern such as (user_name = "x"), where
it checks the name user_name. (CamelCaseParameterName)
Options:
allow-underscore.camelcase-abbreviations.
Avoid
function doSomething(user_name) {}Prefer
function doSomething(userName) {}camelcase-variable-name
This rule flags a local var, let, or const declaration whose name is not
camelCase. It checks declarators only, not parameters. The rule
camelcase-parameter-name covers parameters, so a parameter never triggers two
warnings. (CamelCaseVariableName)
Options:
exceptions. A comma-separated list of exact names to skip. Use this for a name you cannot change, such as a field from an external API response.allow-underscore.camelcase-abbreviations.
Avoid
function doSomething() {
const data_module = 1;
}Prefer
function doSomething() {
const dataModule = 1;
}camelcase-namespace
This rule flags a TypeScript namespace or module declaration whose name is not
PascalCase. It checks each dot-separated segment on its own. PHP joins namespace
segments with a backslash. TypeScript joins them with a dot, as in
namespace Foo.Bar {}, and this rule checks the same way. (CamelCaseNamespace)
Options:
exceptions. A comma-separated list of segment names to skip.camelcase-abbreviations.
Avoid
namespace Example.name_space {}Prefer
namespace Example.NameSpace {}Not ported: Superglobals. This is PHP-only. Variables such as $_POST and
$_SESSION have no JS or TS equivalent.
Design rules
These rules check structural problems. They cover abrupt process termination, runtime evaluation of code, classes coupled to too much of the codebase, and debug code left in place.
exit-expression
This rule flags a call to process.exit(). Such a call ends the process at once and
skips normal control flow, such as cleanup code and return values. This makes the
calling code hard to test. Throw an error instead in almost every case.
(ExitExpression)
Avoid
function bar() {
process.exit(1);
}Prefer
function bar() {
throw new Error("bar failed");
}eval-expression
This rule flags a call to the global eval() function. Evaluating a string as code
at runtime is a security risk, because it can run attacker-controlled input. It is
also hard to test, because static tools cannot see what code will actually run. A
call to a method that only happens to be named eval on some object is not flagged.
(EvalExpression)
Avoid
function bar() {
eval("1+1");
}Prefer
function bar() {
return 1 + 1;
}coupling-between-objects
This rule counts how many other distinct types a class depends on. It counts types
from extends and implements, from property, parameter, and return type
annotations, and from any type built with new. A high count means the class depends
on a large part of the rest of the system. This makes the class hard to change and
hard to reuse on its own.
(CouplingBetweenObjects)
Options:
maximum. Default13.
Avoid
// with { maximum: 3 }
class Foo {
private a: A;
private b: B;
private c: C;
private d: D;
}Prefer
// with { maximum: 3 }
class Foo {
private a: A;
private b: B;
private c: C;
}development-code-fragment
This rule flags debug code that should not reach production. By default it flags
calls to console.log and console.debug, plus the debugger statement. Debug code
is useful during development, but it is easy to forget to remove.
(DevelopmentCodeFragment)
Options:
unwanted-functions. A comma-separated list of function names to flag. The rule checksdebuggeronly when this list includes it.
Avoid
function bar() {
console.log("hi");
debugger;
}Prefer
function bar() {
logger.info("hi");
}empty-catch-block
This rule flags a catch block with no statements in it, including a block that
holds only a comment. An empty catch block hides an error. The failure disappears
with no log message, no handling code, and no re-throw.
(EmptyCatchBlock)
Avoid
try {
doSomething();
} catch (e) {}Prefer
try {
doSomething();
} catch (e) {
handle(e);
}count-in-loop-expression
This rule flags a .length read on a value built inside a loop condition, for
example Object.keys(x).length, Array.from(x).length, or arr.filter(fn).length.
It checks for, while, and do...while loops. The call that builds the value runs
again on every pass through the loop.
This port is narrower than PHPMD's original rule. In PHP, count() and sizeof()
recompute the count each time, so PHPMD flags any use of them in a loop condition. In
JS, a plain array.length read costs no more than any other property read, so
flagging it would only add noise. This rule flags .length only when it follows a
call expression, since that is the case where the collection is rebuilt each pass.
(CountInLoopExpression)
Avoid
for (let i = 0; i < Object.keys(obj).length; i++) {}Prefer
const len = Object.keys(obj).length;
for (let i = 0; i < len; i++) {}Not ported: NumberOfChildren and DepthOfInheritance. Both need knowledge of the
whole program. A single-file oxlint rule cannot compute this. GotoStatement is
PHP-only. JS and TS have no goto keyword.
Clean code rules
These rules check patterns that are valid code, but tend to hide bugs or bad design.
Examples include hidden branching behind a boolean flag, an = typo in a condition,
and duplicate object keys.
boolean-argument-flag
This rule flags a boolean parameter. It flags a parameter with a boolean literal
default, such as true or false. In TypeScript, it also flags a parameter with an
explicit boolean type and no default. A boolean parameter often means the function
does two different jobs depending on the flag's value. Split the function into two
functions with clear names, or pass an options object instead. The reader can then
see the caller's intent without reading the function body.
(BooleanArgumentFlag)
Options:
exceptions. A comma-separated list of class names. The rule skips flags inside these classes.ignorepattern. A regular expression. The rule skips a function whose name matches it.
Avoid
function bar(flag = true) {}Prefer
function barEnabled() {}
function barDisabled() {}duplicated-array-key
Repeating the same key in an object literal is almost always a mistake. The later
entry overwrites the earlier one with no warning, so the first value is lost. This
rule flags a key that resolves to the same property name more than once. This
includes a string and a number that produce the same key, such as foo and 'foo',
or 0 and '0'. A get x() and set x() pair that share one name is one property,
not a duplicate, so the rule allows it.
(DuplicatedArrayKey)
Avoid
const obj = {
foo: "bar",
foo: "baz",
};Prefer
const obj = {
foo: "bar",
baz: "qux",
};else-expression
An else block after an if often means the logic could read top to bottom instead,
using early returns or guard clauses. Code written this way is easier to follow than
code with nested branches. This rule reports a true trailing else. It does not
report an else if link in a chain, because that link is just another if with its
own, possibly absent, else.
(ElseExpression)
Avoid
function bar(flag) {
if (flag) {
return 1;
} else {
return 2;
}
}Prefer
function bar(flag) {
if (flag) {
return 1;
}
return 2;
}if-statement-assignment
Writing = inside a condition is usually a typo for == or ===. Instead of
comparing a value, the code silently assigns it. This is a common bug that is easy to
miss during review. This rule reports any assignment found inside the condition,
including one nested inside a logical or binary expression, and a chained assignment
such as if (a = b = c), which reports both assignments. PHPMD's original rule checks
only if and elseif. This port also checks while, do-while, and for, because
the same typo risk applies there too.
(IfStatementAssignment)
Avoid
let foo;
if ((foo = "bar")) {
}Prefer
let foo;
if (foo === "bar") {
}static-access
Calling a method directly on what looks like a class reference, such as Baz.qux(),
creates a fixed, hidden dependency on that exact class. This makes the calling code
hard to test or replace. Oxlint's plugin API has no type information, so this rule
cannot confirm that Baz is really a class. Instead it uses a rule of thumb: flag a
non-computed method call whose object is a bare identifier written in PascalCase.
This rule of thumb can miss cases and can also flag code that is not a problem. A
PascalCase variable that is not a class will trigger a false report. A static call
reached through a lowercase alias will not be caught at all.
(StaticAccess)
Options:
exceptions. Exact names, or patterns using*as a wildcard, to exempt.ignorepattern. A regular expression. The rule skips a call inside a method whose name matches it.
Avoid
function bar() {
Baz.qux();
}Prefer
function bar(service) {
service.baz();
}Not ported: ErrorControlOperator. PHP's @ suppression operator has no JS or TS
equivalent. MissingImport. PHP's fully-qualified-name and use system does not map
onto TS modules. UndefinedVariable. The TypeScript compiler already checks this, and
does so more accurately than a syntax-only rule could.
Unused code rules
unused-private-field
A private field that no code ever reads, writes, or otherwise references is dead
weight. It is usually left over from a refactor, or it points to a typo that broke
the intended logic without anyone noticing. This rule fills a gap in oxlint's own
built-in no-unused-private-class-members rule, which understands only ES #private
fields, not TypeScript's private keyword.
(UnusedPrivateField)
Avoid
class Foo {
private unused = 1;
}Prefer
class Foo {
private used = 1;
method() {
return this.used;
}
}unused-private-method
A private method that no code ever calls or otherwise references is dead code. It
clutters the class, and it can hide a case where a method was meant to be wired up
but never was. This rule fills the same gap as unused-private-field.
(UnusedPrivateMethod)
Avoid
class Foo {
private unused() {}
}Prefer
class Foo {
private used() {}
public call() {
return this.used();
}
}Not ported: UnusedLocalVariable and UnusedFormalParameter. Both duplicate oxlint's
own built-in rule no-unused-vars, which runs a real scope analysis. This is more
accurate than PHPMD's AST-based checks here. Use its varsIgnorePattern and
argsIgnorePattern options in place of PHPMD's exceptions properties.
Code size rules
These rules use a threshold. Each one flags a function, method, or class once it grows past a configured size or complexity limit. The examples below use small numbers for readability. Each description states the real default threshold, which is much higher.
cyclomatic-complexity
This rule counts the number of independent paths through a function or method's
control flow. The count starts at 1. It adds 1 for each if, loop, case, catch,
and short-circuit operator (&& or ||). A high count means more branches to read
and more paths that need test coverage. Default threshold: 10.
(CyclomaticComplexity)
Avoid
function classifyOrder(status, amount, isVip, hasCoupon) {
if (status === "cancelled") return "cancelled";
if (status === "pending") return "pending";
if (amount > 1000 && isVip) return "priority";
if (amount > 1000 || hasCoupon) return "discounted";
return "standard";
}Prefer
const STATUS_HANDLERS = {
cancelled: () => "cancelled",
pending: () => "pending",
};
function classifyOrder(status, amount, isVip, hasCoupon) {
const handler = STATUS_HANDLERS[status];
if (handler) return handler();
return classifyByAmount(amount, isVip, hasCoupon);
}
function classifyByAmount(amount, isVip, hasCoupon) {
const isBigOrder = amount > 1000;
if (isBigOrder && isVip) return "priority";
if (isBigOrder || hasCoupon) return "discounted";
return "standard";
}npath-complexity
This rule counts the number of distinct paths through a function's body. Cyclomatic
complexity adds 1 per branch. NPath complexity multiplies path counts through nested
branches instead, so the number grows fast and exposes deeply nested logic better.
Default threshold: 200.
(NPathComplexity)
Avoid
function validate(a, b, c) {
if (a) doA();
if (b) doB();
if (c) doC();
}Prefer
function validate(a, b, c) {
runIf(a, doA);
runIf(b, doB);
runIf(c, doC);
}
function runIf(condition, action) {
if (condition) action();
}long-method
This rule measures how many lines a function or method body spans. A very long
method usually does too many jobs at once, and it is harder to read, test, and change
safely. Default threshold: 100 lines.
(ExcessiveMethodLength)
Options:
minimum. Default100.ignore-whitespace. Counts only non-blank lines.
Avoid
function processOrder(order) {
validateOrder(order);
const total = order.items.reduce((sum, item) => sum + item.price, 0);
applyDiscount(order, total);
chargeCustomer(order, total);
sendConfirmationEmail(order);
updateInventory(order);
// ...100+ lines of order-processing logic
}Prefer
function processOrder(order) {
validateOrder(order);
const total = calculateTotal(order);
applyDiscount(order, total);
chargeCustomer(order, total);
sendConfirmationEmail(order);
updateInventory(order);
}
function calculateTotal(order) {
return order.items.reduce((sum, item) => sum + item.price, 0);
}long-class
This rule measures how many lines a class declaration spans, from its opening brace
to its closing brace. A class this large is likely to hold many unrelated jobs. It
checks classes only, not interfaces. Default threshold: 1000 lines.
(ExcessiveClassLength)
Options:
minimum. Default1000.ignore-whitespace.
Avoid
class OrderManager {
createOrder(data) {
/* ... */
}
cancelOrder(id) {
/* ... */
}
sendInvoice(order) {
/* ... */
}
trackShipment(order) {
/* ...1000+ lines across unrelated jobs... */
}
}Prefer
class OrderManager {
createOrder(data) {
/* ... */
}
cancelOrder(id) {
/* ... */
}
}
class ShippingService {
sendInvoice(order) {
/* ... */
}
trackShipment(order) {
/* ... */
}
}long-parameter-list
This rule counts a function or method's declared parameters. A long parameter list
is easy to call in the wrong order, and it usually points to related values that
should be grouped into one object. Default threshold: 10 parameters.
(ExcessiveParameterList)
Avoid
function createUser(firstName, lastName, email, phone, street, city, state, zip, country, dob) {
// ...
}Prefer
function createUser(user) {
const { firstName, lastName, email, phone, address, dob } = user;
// ...
}excessive-public-count
This rule counts a class's public surface. It adds every public method, including
the constructor, to every public field or accessor property. A large public surface
is hard to understand, and it is risky to change, because callers depend on more of
it. Default threshold: 45 public members.
(ExcessivePublicCount)
Avoid
class UserAccount {
name = "";
email = "";
updateName() {}
updateEmail() {}
sendWelcomeEmail() {}
// ...45+ public members
}Prefer
class UserAccount {
#name = "";
#email = "";
updateName() {}
updateEmail() {}
}
class WelcomeMailer {
send(account) {}
}too-many-fields
This rule counts every field a class declares directly, including accessor
properties, no matter its visibility. A class with too many fields usually tracks
more state than one object should own. Default threshold: 15 fields. The rule
flags a class with more than 15.
(TooManyFields)
Avoid
class Invoice {
id = "";
customerName = "";
customerEmail = "";
customerAddress = "";
lineItems = [];
taxRate = 0;
// ...15+ fields
}Prefer
class Invoice {
id = "";
customer;
lineItems = [];
taxRate = 0;
}
class Customer {
name = "";
email = "";
address = "";
}too-many-methods
This rule counts a class's methods, excluding getters and setters. It always
excludes native get/set accessors. By default it also excludes a method named
getFoo, setFoo, isFoo, hasFoo, or withFoo, since a simple accessor should
not count against the limit. Too many methods usually means the class has taken on
more than one job. Default threshold: 25 methods. The rule flags a class with
more than 25.
(TooManyMethods)
Options:
maxmethods. Default25.ignorepattern. Default excludes names that start withget,set,is,has, orwith.
Avoid
class ReportGenerator {
loadData() {}
parseData() {}
validateData() {}
formatAsPdf() {}
formatAsCsv() {}
emailReport() {}
// ...25+ methods
}Prefer
class ReportLoader {
loadData() {}
parseData() {}
validateData() {}
}
class ReportExporter {
formatAsPdf() {}
formatAsCsv() {}
emailReport() {}
}too-many-public-methods
This rule counts the same way as too-many-methods, but it counts public methods
only. It excludes private and protected methods, native accessors, and names that
match ignorepattern. A large public method count means callers depend on a lot of
behavior, which makes the class fragile to change. Default threshold: 10 public
methods. The rule flags a class with more than 10.
(TooManyPublicMethods)
Options:
maxmethods. Default10.ignorepattern. Same default astoo-many-methods.
Avoid
class PaymentProcessor {
charge() {}
refund() {}
authorize() {}
capture() {}
void() {}
// ...10+ public methods
}Prefer
class PaymentProcessor {
charge() {}
refund() {}
private authorize() {}
private capture() {}
private void() {}
}excessive-class-complexity
This rule computes a class's Weighted Method Count (WMC). WMC is the sum of the
cyclomatic complexity of every method the class declares directly. A high total means
the class handles a lot of branching logic overall, even when no single method looks
complex on its own. Default threshold: 50.
(ExcessiveClassComplexity)
Avoid
class OrderValidator {
checkStock(item) {
if (item.quantity <= 0) return false;
if (item.discontinued) return false;
return true;
}
checkPayment(order) {
if (!order.paymentMethod) return false;
if (order.total <= 0) return false;
return true;
}
// ...enough branching across methods to sum past 50
}Prefer
class StockChecker {
check(item) {
if (item.quantity <= 0) return false;
if (item.discontinued) return false;
return true;
}
}
class PaymentChecker {
check(order) {
if (!order.paymentMethod) return false;
if (order.total <= 0) return false;
return true;
}
}A note on the complexity metrics. PHPMD delegates cyclomatic and NPath complexity
to pdepend, a separate library. This checkout does not vendor
pdepend, so its exact algorithm could not be read directly. This port instead
implements the published formulas that pdepend's own documentation describes. It does
not count ?? toward cyclomatic complexity, because pdepend's PHP-derived metric has
no equivalent operator. One known simplification applies to NPath: sibling nested
ternary or logical expressions within one statement are summed rather than
multiplied together. This pattern is rare, and NPath is a heuristic in any case.
All 10 rules from codesize.xml are ported. None are skipped.
Development
bun install
bun run typecheck # tsc --noEmit
bun run test # vitest, using oxlint's RuleTester
bun run build # tsup -> dist/index.js (+ .d.ts)