Maintainability
Seven maintainability patterns covering complexity, duplication, dead code, missing tests, and coverage tracking. Warns only, never blocks merge.
Maintainability Patterns
Maintainability patterns track code quality metrics that accumulate over time. Unlike architecture, runtime, and performance rules, maintainability violations never block merge -- they produce warnings or info-level findings that appear in PR comments and the dashboard.
Pattern Reference
| Rule ID | Description | Severity | Debt Points |
|---|---|---|---|
high-complexity | Cyclomatic complexity exceeds threshold | warning | 1 per point over threshold |
code-duplication | Copy-pasted code blocks across files | warning/info | 1-3 based on block size |
missing-test-file | Source file has no corresponding test file | warning | 1 |
unused-export | Exported symbol not imported anywhere | info | 1 |
low-test-coverage | Line coverage below threshold | warning | 1-3 based on coverage level |
low-branch-coverage | Branch coverage below threshold | info | 1 |
coverage-drop | Coverage decreased beyond threshold | warning | 1-3 based on drop size |
1. Cyclomatic Complexity (high-complexity)
Cyclomatic complexity measures the number of independent paths through a function. The default threshold is 10 -- functions exceeding this threshold generate a warning.
How Complexity Is Calculated
The complexity calculator starts at 1 (the function itself) and increments for each decision point:
| Construct | Complexity Added |
|---|---|
if statement | +1 |
for / for...in / for...of loop | +1 |
while / do...while loop | +1 |
case clause (in switch) | +1 |
catch clause | +1 |
Ternary expression (? :) | +1 |
&& (logical AND) | +1 |
|| (logical OR) | +1 |
Nested functions are excluded -- they have their own complexity score.
Delta Tracking
Radar tracks complexity changes between the PR's base and head commits. The previous version of each file is reconstructed by reverse-applying the git diff patch. This allows the PR comment to show complexity deltas:
Function 'processOrder' complexity: 8 → 14 (+6)
Violation Example
// Complexity: 15 (threshold: 10)
function validateOrder(order: Order, config: Config): ValidationResult {
const errors: string[] = [];
if (!order.items || order.items.length === 0) { // +1
errors.push('Order must have items');
}
for (const item of order.items) { // +1
if (item.quantity <= 0) { // +1
errors.push(`Invalid quantity for ${item.name}`);
} else if (item.quantity > config.maxQuantity) { // +1 (else-if = new if)
errors.push(`Quantity exceeds max for ${item.name}`);
}
if (item.price < 0) { // +1
errors.push(`Invalid price for ${item.name}`);
}
if (config.requireSKU && !item.sku) { // +1 (if) +1 (&&)
errors.push(`Missing SKU for ${item.name}`);
}
}
if (order.total > config.maxOrderTotal) { // +1
if (order.customerTier === 'vip' || order.override) { // +1 (if) +1 (||)
// VIP exception
} else { // +1 (implicit else-if)
errors.push('Order total exceeds maximum');
}
}
const isValid = errors.length === 0; // +1 (ternary below)
return { valid: isValid, errors: isValid ? [] : errors };
// Total: 1 (base) + 14 (decision points) = 15
}
Refactored Version
function validateOrder(order: Order, config: Config): ValidationResult {
const errors = [
...validateItems(order.items, config),
...validateTotal(order, config),
];
return { valid: errors.length === 0, errors };
}
function validateItems(items: OrderItem[], config: Config): string[] {
if (!items?.length) return ['Order must have items'];
return items.flatMap(item => [
item.quantity <= 0 ? `Invalid quantity for ${item.name}` : null,
item.quantity > config.maxQuantity ? `Quantity exceeds max for ${item.name}` : null,
item.price < 0 ? `Invalid price for ${item.name}` : null,
config.requireSKU && !item.sku ? `Missing SKU for ${item.name}` : null,
].filter(Boolean) as string[]);
}
function validateTotal(order: Order, config: Config): string[] {
if (order.total <= config.maxOrderTotal) return [];
if (order.customerTier === 'vip' || order.override) return [];
return ['Order total exceeds maximum'];
}
2. Code Duplication (code-duplication)
Token-based copy-paste detection across files. The detector normalizes code (strips comments, collapses whitespace, replaces string literals) and uses MD5 hashing to find identical code blocks.
How Detection Works
- Normalize each source file: strip comments, collapse whitespace, replace string contents with empty quotes, remove import lines
- Extract chunks of
minLines(default: 6) consecutive normalized lines - Hash each chunk with MD5
- Group chunks by hash -- if the same hash appears in 2+ different files, it is a duplicate
- Merge adjacent matching chunks into larger blocks
- Deduplicate overlapping block reports
Configuration Defaults
| Parameter | Default | Description |
|---|---|---|
minLines | 6 | Minimum number of consecutive lines to count as duplication |
minTokens | 15 | Minimum token count (identifiers + keywords) in the block |
ignoreImports | true | Exclude import/require lines from comparison |
ignoreTests | true | Exclude test files from duplication analysis |
Severity Scaling
| Block Size | Severity | Debt Points |
|---|---|---|
| 6-15 lines | info | 1 |
| 16-30 lines | warning | 2 |
| 31+ lines | warning | 3 |
What Is Excluded
The normalizer skips these constructs to avoid false positives:
- Empty lines, braces-only lines (
{,},});) - Comments (single-line
//, block/* */) - Decorators (
@Injectable,@Get, etc.) - Type definitions (
interface,type,enum) - Import/export type declarations
Violation Example
6 lines duplicated from src/orders/services/order.service.ts:42
Suggestion: Extract the duplicated logic into a shared function or module to reduce maintenance burden.
3. Missing Test File (missing-test-file)
Detects source files that have no corresponding test file. The detector looks for .spec.ts or .test.ts files in these locations:
- Same directory:
user.service.spec.ts __tests__subdirectory:__tests__/user.service.spec.ts- Parallel
test/ortests/directory:test/user/user.service.spec.ts
Testable File Types
Only files matching these NestJS conventions are checked:
| Suffix | Example |
|---|---|
.service.ts | order.service.ts |
.controller.ts | order.controller.ts |
.resolver.ts | order.resolver.ts |
.guard.ts | auth.guard.ts |
.pipe.ts | validation.pipe.ts |
.interceptor.ts | logging.interceptor.ts |
.middleware.ts | auth.middleware.ts |
.gateway.ts | events.gateway.ts |
.use-case.ts | create-order.use-case.ts |
.handler.ts | process-payment.handler.ts |
.repository.ts | order.repository.ts |
Excluded From Testing Requirements
These files are not expected to have test files:
*.module.ts,*.entity.ts,*.model.ts,*.dto.ts*.interface.ts,*.type.ts,*.types.ts,*.enum.ts*.constant.ts,*.constants.ts,*.config.ts*.decorator.ts,index.ts,*.d.ts- Test files themselves (
*.spec.ts,*.test.ts)
Violation Example
// src/billing/services/invoice.service.ts exists
// But there is no:
// - src/billing/services/invoice.service.spec.ts
// - src/billing/services/__tests__/invoice.service.spec.ts
// - test/billing/services/invoice.service.spec.ts
// Violation message:
// "invoice.service.ts has no corresponding test file -- consider adding invoice.service.spec.ts"
4. Unused Exports / Dead Code (unused-export)
Detects exported functions, classes, and variables that are never imported by any other file in the project.
How Detection Works
- Extract exports from all non-excluded source files using
ts-morph - Extract import references from all files (including excluded ones -- they can consume exports)
- Cross-reference to find exports with zero usage count
- Report unused exports as violations
Excluded From Analysis
These files are never checked for unused exports (they are expected to export things consumed externally):
- Entry points:
main.ts,server.ts,app.ts,bootstrap.ts,cli.ts - Barrel files:
index.ts,index.js - NestJS modules:
*.module.ts - Test files:
*.spec.ts,*.test.ts,__tests__/* - Config files:
*.config.ts - Declaration files:
*.d.ts
Type exports (interface, type, enum) are excluded by default to avoid noise from TypeScript-only constructs.
Namespace Import Handling
A import * as utils from './utils' marks all exports from ./utils as used. This is correct because the consumer has access to every exported member.
Violation Example
Exported function 'formatLegacyDate' is not imported anywhere -- consider removing it
Suggestion: Remove the unused export or convert it to a non-exported declaration if still used locally.
5. Low Test Coverage (low-test-coverage)
Detects files where line coverage falls below the configured threshold. Supports Istanbul/c8/nyc coverage reports in both coverage-summary.json and lcov.info formats.
Coverage Report Discovery
Radar searches for coverage files in this order:
- Custom paths specified in config
coverage/coverage-summary.jsoncoverage/lcov.info.nyc_output/coverage-summary.jsoncoverage/lcov/lcov.info
Default Thresholds
| Metric | Default Threshold |
|---|---|
| Line coverage | 60% |
| Branch coverage | 50% |
| Max coverage drop | 5% |
Severity Scaling
| Line Coverage | Debt Points |
|---|---|
| < 30% | 3 |
| 30% - 50% | 2 |
| 50% - threshold | 1 |
Excluded From Coverage Checks
excludePatterns:
- "**/*.dto.ts"
- "**/*.entity.ts"
- "**/*.module.ts"
6. Low Branch Coverage (low-branch-coverage)
Detects files where branch coverage falls below the configured threshold (default: 50%). Branch coverage measures whether both sides of every conditional (if/else, ternary, switch) have been exercised.
Severity: info (lighter than line coverage)
7. Coverage Drop (coverage-drop)
Detects files where coverage decreased compared to the baseline. A drop of more than 5% (default) generates a warning. This catches PRs that add untested code to previously well-tested files.
Severity Scaling
| Coverage Drop | Debt Points |
|---|---|
| > 20% | 3 |
| 10% - 20% | 2 |
| 5% - 10% | 1 |
Violation Example
Coverage dropped 12.3% (85.0% -> 72.7%) -- exceeds 5% threshold
Suggestion: Add tests to restore coverage -- 12.3% drop likely from untested new code.
Scoring
Maintainability violations have lighter scoring than blocking categories:
scoring:
complexity_point: 1 # Per point over threshold
missing_tests: 3 # Per missing test file
coverage_drop_per_pct: 2 # Per percentage point of coverage drop
complexity_reduced: -1 # Credit for reducing complexity
Configuration
Maintainability thresholds are configurable:
# In radar.yml or rules.yml
gates:
warn:
- metric: complexity_increase
operator: ">"
value: 0
- metric: duplication_percentage
operator: ">"
value: 5
- metric: missing_test_files
operator: ">"
value: 0
Tip: While maintainability rules never block by default, you can configure gates to block on extreme values. For example, adding
metric: complexity_increase, operator: ">", value: 20toblock_mergewould block PRs that increase total complexity by more than 20 points.