energy-state-analyzer 0.2.0 → 0.4.0

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.
package/docs/cli.md ADDED
@@ -0,0 +1,136 @@
1
+ # Command-Line Usage
2
+
3
+ The same detectors also run headlessly, without VS Code, useful for CI or for an AI coding agent that wants to check the complexity of code it just generated and keep refactoring until it's clean. Published to npm, so no clone or install step is required:
4
+
5
+ ```bash
6
+ npx energy-state-analyzer path/to/file.py # or .fs / .fsx / .ts
7
+ ```
8
+
9
+ Or install it as a project/global dependency and call it directly:
10
+
11
+ ```bash
12
+ npm install --save-dev energy-state-analyzer
13
+ npx energy-state-analyzer path/to/file.py
14
+ ```
15
+
16
+ It prints violations as JSON to stdout and exits `1` if any medium/high-severity violation was found (`0` otherwise), so it can gate a loop:
17
+
18
+ ```bash
19
+ npx energy-state-analyzer path/to/file.py \
20
+ --medium-cyclomatic 8 --high-cyclomatic 12 \
21
+ --medium-cognitive 12 --high-cognitive 20
22
+ ```
23
+
24
+ All threshold flags are optional: `--medium-nesting`, `--high-nesting`, `--medium-cyclomatic`, `--high-cyclomatic`, `--medium-cognitive`, `--high-cognitive`.
25
+
26
+ ## Scanning a repo or subtree
27
+
28
+ Pass more than one path, a directory, or a `dir/**/*.ext`-style pattern to scan every supported file underneath it (skipping `node_modules`, `.git`, `dist`, `out`, `build`, `.next`, `coverage`, `.vscode-test`) and get an aggregated report instead of a single file's violations:
29
+
30
+ ```bash
31
+ npx energy-state-analyzer src --report md
32
+ ```
33
+
34
+ ```
35
+ # Energy State Report
36
+
37
+ **3 files scanned** — 2 clean, 1 with violations
38
+
39
+ | File | Score | High | Medium | Low |
40
+ | --- | --- | --- | --- | --- |
41
+ | src/foo.py | 13 | 1 | 1 | 0 |
42
+ | src/bar.ts | 0 | 0 | 0 | 0 |
43
+ | src/baz.fs | 0 | 0 | 0 | 0 |
44
+
45
+ **Total score: 13** (1 high, 1 medium, 0 low)
46
+ ```
47
+
48
+ `--report json` prints the same data as a structured `{ files, totalScore, totalCounts }` object instead. The per-file **score** is a simple heuristic, `1×low + 4×medium + 9×high` violation counts, meant for spotting hotspots and tracking direction over time, not a certified complexity metric.
49
+
50
+ Only one glob shape is supported: a trailing `**/*.ext` pattern on an otherwise literal directory prefix (e.g. `src/**/*.py`). There's no brace expansion, negation, or mid-path wildcards, pass explicit directories/files for anything more complex.
51
+
52
+ ### Excluding files and folders: `.esaignore`
53
+
54
+ Add a `.esaignore` file next to where you run the CLI (or the extension's workspace root) to exclude paths from both `--report`/scan mode and `--base-ref` diff mode. One pattern per line:
55
+
56
+ ```
57
+ # comment
58
+ src/test/fixtures # a literal path — matches it and everything under it
59
+ generated # a bare name with no '/' matches at any depth
60
+ *.generated.ts # a basename glob
61
+ ```
62
+
63
+ This isn't a full `.gitignore` engine: no negation, no `**`, no brace expansion — just literal path/prefix matches and single-segment basename globs. A richer, TOML-based config file (`.esaconfig.toml`) covering ignore patterns plus other project-wide settings is a likely follow-up.
64
+
65
+ ### A report for humans: `--report human`
66
+
67
+ `--report md`/`--report json` are compact, built for scripts and PR comments. `--report human` produces a longer, prose-and-tables report meant to be read by a person auditing a repo or subtree: a section per flagged file, each with its findings translated into plain language, followed by a repo-wide "Total evaluation":
68
+
69
+ ```bash
70
+ npx energy-state-analyzer src --report human
71
+ ```
72
+
73
+ ```
74
+ # Energy State Report
75
+
76
+ ## Score legend
77
+
78
+ | Score | Risk | Roughly | Cyclomatic/cognitive complexity |
79
+ | --- | --- | --- | --- |
80
+ | 0.0 | None | No violations found | — |
81
+ | 0.1–3.9 | Low | Simple, easy to test exhaustively | 1–10 |
82
+ | 4.0–6.9 | Medium | Getting harder to cover with tests | 11–20 |
83
+ | 7.0–8.9 | High | Complex, testing all paths is impractical | 21–50 |
84
+ | 9.0–10.0 | Critical | Effectively untestable | 50+ |
85
+
86
+ **25 files scanned** — 8 clean, 17 flagged
87
+
88
+ ## src/foo.py — High (score 7.8)
89
+
90
+ - **Cyclomatic complexity**: 1 function scores 34 — score 7.8 (High): complex, testing all paths is impractical.
91
+ - **Primitive obsession**: 2 findings (2 medium) — adjacent same-typed values a caller could silently swap without the compiler noticing.
92
+
93
+ ...
94
+
95
+ ## Total evaluation
96
+
97
+ **Repo score: 7.8 (High)** — driven by the worst file in the scan, `src/foo.py` (complex, testing all paths is impractical).
98
+
99
+ | Risk | Files |
100
+ | --- | --- |
101
+ | None | 8 |
102
+ | Low | 12 |
103
+ | Medium | 3 |
104
+ | High | 2 |
105
+ | Critical | 0 |
106
+
107
+ **51 total findings** (1 high, 25 medium, 25 low) — breadth of issues across the scan, independent of peak severity.
108
+ ```
109
+
110
+ Risk is reported on a 0.0–10.0 complexity score, sorted into the same None/Low/Medium/High/Critical levels used elsewhere in this tool, rather than a bespoke label set. The score is a direct re-expression of the McCabe risk table (see [Interpreting the score](detectors/cyclomatic-complexity.md#interpreting-the-score)): cyclomatic/cognitive complexity numbers are converted onto it by linear interpolation anchored at the same 10/20/50 breakpoints, so "High" here means the same thing it always has in this project, just expressed as a single number. Every other detector reports a finding count and severity instead, since it flags a pattern rather than a path count, a file with only non-complexity findings gets a fixed score from its worst one (Low 2.0 / Medium 5.0 / High 7.5), which can never reach Critical (Critical is reserved for genuinely extreme complexity).
111
+
112
+ Both the per-file score and the repo-wide "Repo score" are the **maximum** found, not an average. Averaging a file's (or a repo's) scores lets one severely complex function or file hide behind many trivial ones, nine functions at complexity 2 and one at 60 average to about 8 (which itself would still misleadingly read as "Low"), hiding exactly the function most worth fixing. Total finding counts are reported separately as a breadth indicator, deliberately not folded into the same number. Flagged files are listed worst-first.
113
+
114
+ ## Diffing a PR against a base branch
115
+
116
+ `--base-ref <ref>` compares the current working tree against a git ref, so a GitHub Actions job can report whether a PR increased or decreased complexity relative to its base branch:
117
+
118
+ ```bash
119
+ npx energy-state-analyzer --base-ref origin/main --report md
120
+ ```
121
+
122
+ With no path arguments, changed files are discovered via `git diff --name-only <ref>...HEAD`; pass explicit paths to override that. Each changed file's pre-PR content is read with `git show <ref>:<path>` and re-analyzed in memory, a file that doesn't exist at the base ref (new file, or a rename `git diff` didn't resolve) is reported as `new` rather than erroring out.
123
+
124
+ ```
125
+ # Energy State Diff vs `origin/main`
126
+
127
+ | File | Base | Head | Δ | Status |
128
+ | --- | --- | --- | --- | --- |
129
+ | src/foo.py | 4 | 13 | +9 | 🔴 worsened |
130
+ | src/bar.ts | 9 | 0 | -9 | 🟢 improved |
131
+ | src/new.py | — | 5 | — | 🆕 new |
132
+
133
+ _2 files changed, 1 worsened, 1 improved, 1 new._
134
+ ```
135
+
136
+ The exit code in every mode (single-file, scan, or diff) follows the same rule: `1` if any medium/high-severity violation exists in the current (head) code, `0` otherwise, whether a diff made things better or worse is visible in the report, not encoded as a separate exit code. `energy-state-cli <single-file>` with no other flags keeps its original behavior (flat JSON violation array, same exit rule) unchanged.
@@ -0,0 +1,30 @@
1
+ # Detectors
2
+
3
+ Each detector is documented in its own file: what it flags, an example, its configuration, and known limitations.
4
+
5
+ ## Complexity and structure
6
+
7
+ - [Cyclomatic complexity](cyclomatic-complexity.md), too many independent execution paths.
8
+ - [Cognitive complexity](cognitive-complexity.md), too hard to read due to nesting.
9
+ - [Excessive nesting](excessive-nesting.md), control-flow blocks nested too deep.
10
+ - [Parameter explosion](parameter-explosion.md), functions with too many parameters.
11
+ - [File coherence](file-coherence.md), files that have lost a single responsibility.
12
+
13
+ ## Naming and literals
14
+
15
+ - [Magic numbers](magic-numbers.md), unnamed numeric literals.
16
+ - [Magic strings](magic-strings.md), unnamed string literals at decision points.
17
+ - [Primitive obsession](primitive-obsession.md), strings/numbers standing in for a real type.
18
+
19
+ ## Control-flow shape
20
+
21
+ - [Inversion opportunities](inversion-opportunities.md), nested conditionals that could be guard clauses.
22
+ - [Match opportunities](match-opportunities.md), if/elif chains that could be a match/switch.
23
+ - [Logical operator as control flow](logical-operator-control-flow.md), an `if` hidden behind `&&`/`||`.
24
+ - [Opaque boolean literal](opaque-boolean-literal.md), an unlabeled `true`/`false` at a call site.
25
+
26
+ ## Suppression
27
+
28
+ - [Suppression (`esa-ignore`)](suppression.md), silence a specific violation with a comment, without disabling the detector everywhere else.
29
+
30
+ See [Energy and Entropy](../energy-and-entropy.md) for the design philosophy behind why complexity and its arrangement are tracked as separate signals.
@@ -0,0 +1,45 @@
1
+ # Cognitive Complexity
2
+
3
+ Modeled on [SonarSource's metric](https://www.sonarsource.com/resources/cognitive-complexity/). It measures how hard a function is to *read*, so nesting is penalized and straight-line control flow isn't. See [Energy and Entropy](../energy-and-entropy.md) for why this is tracked as a separate score from [cyclomatic complexity](cyclomatic-complexity.md) rather than folded into it.
4
+
5
+ ## What it flags
6
+
7
+ - Each decision point (`if`, `elif`, `for`, `while`, `except`, ternary, nested named function/method, `lambda`) adds **1 + current nesting depth**.
8
+ - `else` adds a flat **+1**, no nesting penalty, since it doesn't add a new branch to reason about.
9
+ - Nesting depth only increases when descending into a block body, so an `if` inside two other `if`s scores higher than three sequential `if`s at the top level, even though both have the same cyclomatic complexity.
10
+ - Chained boolean operators of the same kind (`a and b and c`) count as a **single** increment rather than one per operator; mixing `and`/`or` starts a new increment.
11
+ - A lambda's body complexity is attributed to the enclosing function (lambdas aren't scored as their own function).
12
+
13
+ This is a simplified first pass on the SonarSource spec, not the full algorithm:
14
+
15
+ - `for`/`while` `else` clauses (where a grammar has them) are scored like `if`/`else`, even though they aren't really a decision point.
16
+ - Boolean-chain merging only checks the immediate parent operator, not the full chain direction.
17
+ - Recursive calls to the enclosing function aren't specially detected.
18
+ - Match/switch-like constructs and try/except are scored once as a whole, not per-case.
19
+
20
+ ## Example
21
+
22
+ ```python
23
+ def handle(items):
24
+ for item in items: # +1 (nesting 0 -> 1)
25
+ if item.valid: # +2 (1 + nesting 1)
26
+ if item.ready: # +3 (1 + nesting 2)
27
+ process(item)
28
+ else: # +1 (flat, no nesting penalty)
29
+ queue(item)
30
+ ```
31
+
32
+ The same four decision points written as flat, sequential `if`s (no nesting) would score far lower here, even though cyclomatic complexity treats both shapes identically.
33
+
34
+ ## Interpreting the score
35
+
36
+ There's no formal industry consensus the way there is for cyclomatic complexity, since this is a newer, vendor-originated metric, but SonarSource's own convention (and this extension's defaults) treat **15** as the point where a function is hard enough to hold in your head that it's worth splitting up, with scores past 25 or so being seriously hard to follow regardless of how testable the underlying paths are.
37
+
38
+ The two scores can diverge on the same function: a flat function with many independent branches can have high cyclomatic complexity but modest cognitive complexity (easy to read, hard to test exhaustively), while deeply nested code can be the reverse.
39
+
40
+ ## Configuration
41
+
42
+ - `energyStateAnalyzer.cognitiveComplexity.mediumThreshold` (default `15`)
43
+ - `energyStateAnalyzer.cognitiveComplexity.highThreshold` (default `25`)
44
+
45
+ A progressive heatmap is also painted across a flagged function's body, mirroring the cyclomatic-complexity heatmap but weighted by nesting-adjusted contribution instead of flat count.
@@ -0,0 +1,50 @@
1
+ # Cyclomatic Complexity
2
+
3
+ Counts the number of independent paths through a function. Flags functions that have too many execution paths to test exhaustively, regardless of how those paths are arranged.
4
+
5
+ ## What it flags
6
+
7
+ Starting from a base of **1**, every decision point adds **+1**, no matter how deeply it's nested:
8
+
9
+ - `if` / `elif` / `while` / `for` / `except`
10
+ - `and` / `or` (a chain of the same operator still counts once per operator here, unlike cognitive complexity's chain merging)
11
+ - ternary (`a if cond else b`)
12
+
13
+ A nested named function or method is scored separately, as its own violation, never folded into the enclosing function's count.
14
+
15
+ Two functions with the same number of `if`s score the same whether those `if`s are sequential or nested five deep. This metric measures *how many paths exist*, not how hard the code is to follow, that's what [cognitive complexity](cognitive-complexity.md) is for. See [Energy and Entropy](../energy-and-entropy.md) for why the two are tracked separately.
16
+
17
+ ## Example
18
+
19
+ ```python
20
+ def classify(status, region, tier, flag):
21
+ if status == "active":
22
+ if region == "eu" and tier == "gold":
23
+ pass
24
+ elif region == "us" or flag:
25
+ pass
26
+ elif status == "pending":
27
+ if tier == "silver":
28
+ pass
29
+ # ... continues for many more branches
30
+ ```
31
+
32
+ Each `if`/`elif`/`and`/`or` above adds one to the count, on top of the base of 1.
33
+
34
+ ## Interpreting the score
35
+
36
+ McCabe's original 1976 paper proposed risk bands that are still the closest thing to an industry consensus (echoed by SonarQube, ESLint's `complexity` rule, and NIST guidance):
37
+
38
+ | Score | Risk | Roughly |
39
+ | --- | --- | --- |
40
+ | 1-10 | Low | Simple, easy to test exhaustively |
41
+ | 11-20 | Moderate | Getting harder to cover with tests |
42
+ | 21-50 | High | Complex, testing all paths is impractical |
43
+ | 50+ | Very high | Effectively untestable |
44
+
45
+ ## Configuration
46
+
47
+ - `energyStateAnalyzer.cyclomaticComplexity.mediumThreshold` (default `10`)
48
+ - `energyStateAnalyzer.cyclomaticComplexity.highThreshold` (default `15`)
49
+
50
+ A progressive heatmap is also painted across a flagged function's body: each contributing line is shaded by how much it drives up the score relative to that function's own worst line, so you can see which branches to break apart first.
@@ -0,0 +1,23 @@
1
+ # Excessive Nesting
2
+
3
+ Flags control-flow blocks nested deeper than a reader can comfortably track.
4
+
5
+ ## What it flags
6
+
7
+ `if` / `for` / `while` / `with` blocks (whichever of these a language's grammar has) nested more than 3 levels deep are flagged as medium severity; past 5 levels deep, severity escalates to high. The default medium threshold of 3 is the point where tracking active conditions starts to strain working memory.
8
+
9
+ ## Example
10
+
11
+ ```python
12
+ def process(orders):
13
+ for order in orders: # depth 0
14
+ if order.active: # depth 1
15
+ for item in order.items: # depth 2
16
+ if item.in_stock: # depth 3
17
+ if item.discounted: # depth 4, flagged (medium)
18
+ apply_discount(item)
19
+ ```
20
+
21
+ ## Known limitations
22
+
23
+ Thresholds are not yet exposed as VS Code settings, unlike most other detectors. The medium/high thresholds (3/5) are currently fixed; they can only be overridden when using the [CLI](../cli.md) directly (`--medium-nesting`, `--high-nesting`).
@@ -0,0 +1,34 @@
1
+ # File Coherence
2
+
3
+ Flags files that have lost a single responsibility, the "utils/helpers sprawl" pattern, along three independent signals.
4
+
5
+ ## What it flags
6
+
7
+ **Function-count sprawl.** A file with more than 12 functions is flagged (medium; high past 15). The threshold drops to 8 if the filename itself contains `util`, `helper`, or `common`, treating the name as a proxy for "already known to be a grab-bag." A file is exempted from this check regardless of count if most of its functions share a leading name word (e.g. `extractFoo`/`extractBar`/`extractBaz`, at least a 70% share by default): that's treated as one coherent domain broken into small steps, not a grab-bag of unrelated helpers, unless the filename already admits to being utils/helper/common, which overrides the naming signal.
8
+
9
+ **Large-function sprawl.** Counted independently of the check above, on the theory that a module with 30 small functions is fine but one with 6 sprawling ones isn't. A file with more than 5 functions exceeding 20 lines is flagged (medium; high past 7.5 large functions). This gates on large-function count rather than raw function count so that languages like F#, which idiomatically have many small functions per module, aren't penalized just for having a lot of them.
10
+
11
+ **Import sprawl.** A file with more than 10 imports is flagged (medium; high past 15).
12
+
13
+ ## Example
14
+
15
+ ```python
16
+ # utils.py, 9 unrelated helper functions, well past the util-file threshold of 8
17
+ def parse_date(s): ...
18
+ def format_currency(v): ...
19
+ def slugify(s): ...
20
+ def retry(fn): ...
21
+ def hash_password(p): ...
22
+ def send_email(to, body): ...
23
+ def resize_image(img): ...
24
+ def validate_email(s): ...
25
+ def flatten(lst): ...
26
+ ```
27
+
28
+ ## Configuration
29
+
30
+ - `energyStateAnalyzer.coherence.largeFunctionLines` (default `20`), line count above which a function counts as "large."
31
+ - `energyStateAnalyzer.coherence.maxLargeFunctions` (default `5`), number of large functions a file can contain before it's flagged.
32
+ - `energyStateAnalyzer.coherence.singleDomainNameShare` (default `0.7`), share of a file's functions that must share a leading name word to be treated as one coherent domain.
33
+
34
+ The function-count sprawl thresholds themselves (8 for utils-named files, 12 generic, 15 for high severity) and the import-sprawl thresholds (10/15) are fixed heuristics, not exposed as settings.
@@ -0,0 +1,39 @@
1
+ # Inversion Opportunities
2
+
3
+ Flags patterns that could be rewritten as guard clauses with early returns, rather than nested conditional logic.
4
+
5
+ ## What it flags
6
+
7
+ Three independent patterns, checked per function:
8
+
9
+ 1. **Dominant if-block.** The function's first statement is an `if` whose body spans more than half the function's total length. A large `if` that dominates a function this way is usually better inverted into an early return plus the function's real logic at the top level.
10
+ 2. **Nested validation chain.** Two or more consecutive levels of "single `if`, no `else`" nesting (e.g. `if valid: if more_valid: if even_more_valid: ...`), capped at a 4-level scan. This is exactly the shape a chain of guard clauses replaces.
11
+ 3. **Deep if-nesting.** Three or more levels of nested `if` statements anywhere in the function body (one level below [excessive nesting](excessive-nesting.md)'s general depth-3 threshold, since this detector targets specifically flattenable if-chains).
12
+
13
+ ## Example
14
+
15
+ ```python
16
+ def handle(request):
17
+ if request.is_valid():
18
+ if request.user.is_active():
19
+ if request.user.has_permission():
20
+ return process(request)
21
+ return None
22
+ ```
23
+
24
+ Flagged as a nested validation chain (three consecutive guard-shaped `if`s, no `else`); the idiomatic fix is:
25
+
26
+ ```python
27
+ def handle(request):
28
+ if not request.is_valid():
29
+ return None
30
+ if not request.user.is_active():
31
+ return None
32
+ if not request.user.has_permission():
33
+ return None
34
+ return process(request)
35
+ ```
36
+
37
+ ## Known limitations
38
+
39
+ Only fires for Python and TypeScript. F#'s grammar has no block-boundary node to anchor this heuristic on.
@@ -0,0 +1,24 @@
1
+ # Logical Operator as Control Flow
2
+
3
+ Flags a bare `condition && doSomething()` (or `condition || fallback()`) used as a standalone statement, an `if` hidden behind a boolean operator instead of written as one.
4
+
5
+ ## What it flags
6
+
7
+ This is legal in every language whose grammar has a statement-level boolean expression, including Python's bare `and`/`or` expression statement, not just TypeScript's `&&`/`||`. It already counts toward [cyclomatic complexity](cyclomatic-complexity.md), since a boolean operator is a decision point there too; this detector exists only to name the *readability* cost separately. An if hidden as an expression is invisible to anyone skimming for branches, and can't grow past a single consequent expression without becoming unreadable.
8
+
9
+ Runs on Python and TypeScript. Not on F#, which has no such statement-level idiom in its grammar.
10
+
11
+ ## Example
12
+
13
+ ```typescript
14
+ isValid && submit(); // flagged: if-statement disguised as '&&'
15
+ retries || fallback(); // flagged: if-statement disguised as '||'
16
+ ```
17
+
18
+ ```typescript
19
+ if (isValid) {
20
+ submit();
21
+ }
22
+ ```
23
+
24
+ is the suggested rewrite in both cases.
@@ -0,0 +1,32 @@
1
+ # Magic Numbers
2
+
3
+ Flags numeric literals used outside of a named binding, an index/key position, or a default parameter value.
4
+
5
+ ## What it flags
6
+
7
+ Numbers get no free pass for "looking like prose" the way strings do, so this stays broad: any numeric literal not covered by an exemption is a candidate. A literal is exempt when it's:
8
+
9
+ - In the configured allowlist (see below).
10
+ - Bound to a module-level (or explicitly-marked constant) name, e.g. `MAX_RETRIES = 5` at module scope, or Kotlin's `const val`.
11
+ - Used as an index or subscript key, e.g. `items[0]`.
12
+ - A default parameter value, e.g. `def f(retries=3):`.
13
+
14
+ Negative literals are recognized by structural shape (a unary `-` immediately preceding the literal), so `-1` and `1` are both checked against the allowlist correctly.
15
+
16
+ ## Example
17
+
18
+ ```python
19
+ def calculate_price(base, tier):
20
+ if tier == 1:
21
+ return base * 1.15 # flagged: 1.15 is a magic number
22
+ return base * 1.05 # flagged: 1.05 is a magic number
23
+ ```
24
+
25
+ ```python
26
+ TAX_RATE_STANDARD = 1.05 # not flagged: module-level named constant
27
+ ```
28
+
29
+ ## Configuration
30
+
31
+ - `energyStateAnalyzer.magicNumber.enabled` (default `true`)
32
+ - `energyStateAnalyzer.magicNumber.allowlist` (default `[0, 1, -1, 2]`), values that recur constantly without carrying hidden meaning and are never flagged regardless of context.
@@ -0,0 +1,37 @@
1
+ # Magic Strings
2
+
3
+ Flags a string literal only where an unnamed one actually risks a silent typo, deliberately narrower in scope than [magic numbers](magic-numbers.md).
4
+
5
+ ## What it flags
6
+
7
+ A string literal is a candidate only when it sits at a decision point:
8
+
9
+ - Compared with `==`/`===`.
10
+ - Checked for membership (Python's `x in (...)`).
11
+ - Used as a dict/object key or subscript index.
12
+
13
+ A message being logged, thrown, or returned isn't a decision point, so it's left alone entirely, as is a docstring. Any f-string, template literal, `.format()`, or `%`-formatted string is exempt too, since a placeholder is itself evidence the string isn't standing in for an enum value. A single-character string is also exempt (too short to plausibly carry hidden meaning).
14
+
15
+ To cut single-use false positives further, a qualifying literal is only flagged once it recurs at a decision point at least `minDuplicates` times (default `2`) across the file, mirroring SonarSource's S1192 rule.
16
+
17
+ ## Example
18
+
19
+ ```python
20
+ def route(status):
21
+ if status == "pending": # 1st occurrence of "pending" at a decision point
22
+ queue(status)
23
+ if status == "pending": # 2nd occurrence, now flagged: recurs >= minDuplicates times
24
+ notify(status)
25
+ ```
26
+
27
+ ## Configuration
28
+
29
+ - `energyStateAnalyzer.magicString.enabled` (default `true`)
30
+ - `energyStateAnalyzer.magicString.minDuplicates` (default `2`)
31
+ - `energyStateAnalyzer.magicString.allowlist` (default `["", "utf-8", "__main__"]`)
32
+
33
+ ## Known limitations
34
+
35
+ The decision-point scan (equality/membership/dict-key) and the formatted-string exemption are fully implemented for Python, and partially for TypeScript (no `.includes()` membership support yet) and F# (no dict/subscript node, no interpolated-string exemption). See the `LanguageAdapter` fields in `src/core/language.ts` for exactly what's modeled per language.
36
+
37
+ It also doesn't (yet) special-case enum-like keyword/default arguments (e.g. `mode="fast"`) as a lower-confidence decision point, only equality, membership, and dict/index-key positions count.
@@ -0,0 +1,26 @@
1
+ # Match Opportunities
2
+
3
+ Flags an `if`/`elif`/`elif` chain (or TypeScript's nested `else if`) that's really a dispatch on one variable, and would read better as a `match`/`switch` statement.
4
+
5
+ ## What it flags
6
+
7
+ A chain of 3 or more branches (configurable), all discriminating via equality or membership checks against the *same single variable*, is flagged. An unconditional catch-all `else` at the end still qualifies, since it contributes no discriminant and isn't itself a "branch" for this check, but a chain mixing unrelated conditions across branches does not qualify, since a `match`/`switch` can't express that kind of dispatch anyway.
8
+
9
+ Runs on Python, F#, and TypeScript.
10
+
11
+ ## Example
12
+
13
+ ```python
14
+ if status == "pending":
15
+ queue()
16
+ elif status == "active":
17
+ process()
18
+ elif status == "closed":
19
+ archive()
20
+ ```
21
+
22
+ All three branches key on `status` against a literal, so this is flagged as a 3-way chain suggesting `match status:` instead.
23
+
24
+ ## Configuration
25
+
26
+ - `energyStateAnalyzer.matchOpportunity.minBranches` (default `3`), number of branches an if/elif chain must have, all keyed on the same variable, before it's flagged.
@@ -0,0 +1,22 @@
1
+ # Opaque Boolean Literal
2
+
3
+ Flags a bare `true`/`false` passed positionally into a call, since a reader can't tell what it means without checking the callee's signature.
4
+
5
+ ## What it flags
6
+
7
+ Unlike [primitive obsession](primitive-obsession.md)'s parameter-swap check, this doesn't need a second adjacent parameter to be a problem: one opaque literal is enough. It's suppressed when the boolean is labeled at the call site, whatever the language allows:
8
+
9
+ - A Python keyword argument: `configure(retries=True)`.
10
+ - A TypeScript object-literal field: `configure({ retries: true })`.
11
+ - F#'s named-argument syntax: `configure(retries = true)`.
12
+
13
+ Unlike the primitive-obsession suppression, F#'s named args count here even though they're optional at the call site, since this rule is about reader comprehension at this specific call, not about preventing a future misuse. Deliberately conservative: only literal `true`/`false` are flagged, not bare `0`/`1`, to avoid noise on ordinary numeric arguments.
14
+
15
+ ## Example
16
+
17
+ ```python
18
+ configure(True) # flagged: what does True mean here?
19
+ configure(retries=True) # not flagged: labeled at the call site
20
+ ```
21
+
22
+ The preferred fix is usually splitting into two clearly named functions (`enable_retries()`/`disable_retries()`) or an enum; naming the argument is an acceptable but weaker mitigation.
@@ -0,0 +1,21 @@
1
+ # Parameter Explosion
2
+
3
+ Flags functions with too many parameters for a caller to reliably remember the order and meaning of.
4
+
5
+ ## What it flags
6
+
7
+ Functions with more than 5 parameters are flagged (medium; high past 8). Beyond roughly 5 parameters, callers typically can no longer recall argument order or meaning without checking the signature.
8
+
9
+ ## Example
10
+
11
+ ```typescript
12
+ function createUser(name: string, email: string, age: number, city: string, country: string, phone: string) {
13
+ // flagged: 6 parameters
14
+ }
15
+ ```
16
+
17
+ The usual fix is grouping related parameters into an object, or a builder pattern.
18
+
19
+ ## Known limitations
20
+
21
+ The threshold is not yet configurable via VS Code settings; it's fixed at >5 (medium) / >8 (high). TypeScript arrow functions aren't analyzed by this detector, only named `function` declarations and class methods; Python's `lambda` has the same gap.
@@ -0,0 +1,29 @@
1
+ # Primitive Obsession
2
+
3
+ Flags strings and numbers standing in for what should be a distinct, validated type. Two independent sub-checks, both driven through the same per-language traversal.
4
+
5
+ ## What it flags
6
+
7
+ **Parameter-swap risk.** Two adjacent parameters sharing the same unqualified primitive type (e.g. `lat: float, lon: float`) are indistinguishable at the call site: nothing stops a caller from passing them in the wrong order. Runs on Python, F#, and TypeScript.
8
+
9
+ In Python, a pair is suppressed only when *both* parameters are keyword-only (after a bare `*` or `*args` in the signature), since the signature itself then makes a positional call impossible. Named-parameter naming is still a weaker mitigation than a distinct type (`NewType`, a dataclass, etc.), since nothing stops a future `**kwargs`-splat call from transposing the values by hand, but that gap isn't worth detecting. This suppression doesn't apply to TypeScript, which has no argument-labeling syntax for positional params, or F#, whose named arguments are optional at the call site and so don't prevent a positional call.
10
+
11
+ **Stringly-typed control flow.** A variable compared against 3 or more distinct string literals within one function is a de facto enum encoded as strings, with no exhaustiveness checking and no typo protection at the type level. Runs on Python, F#, and TypeScript; Python additionally flags a variable checked against a literal tuple/list/set in one `in` expression, since F# and TypeScript have no direct equivalent construct.
12
+
13
+ ## Example
14
+
15
+ ```python
16
+ def haversine(lat: float, lon: float, alt: float):
17
+ # flagged: lat/lon and lon/alt are adjacent same-typed pairs a caller can swap
18
+ ...
19
+
20
+ def handle(status: str):
21
+ if status == "pending": ...
22
+ elif status == "active": ...
23
+ elif status == "closed": ...
24
+ # flagged: 'status' compared against 3 distinct string literals
25
+ ```
26
+
27
+ ## Known limitations
28
+
29
+ The `in (a, b, c)`-style membership check for stringly-typed control flow only runs on Python; F#'s grammar has no direct equivalent, and TypeScript's idiom (`[...].includes(x)`) is a call expression rather than a comparison node.
@@ -0,0 +1,60 @@
1
+ # Suppression (`esa-ignore`)
2
+
3
+ A comment directive for silencing a specific violation you've reviewed and decided to accept, without disabling the detector everywhere else. Also flags its own directives once they've gone stale.
4
+
5
+ ## Syntax
6
+
7
+ ```
8
+ // esa-ignore
9
+ // esa-ignore: nesting
10
+ // esa-ignore: nesting, complexity
11
+ // esa-ignore-file
12
+ // esa-ignore-file: coherence
13
+ ```
14
+
15
+ Works with either comment style (`//` or `#`) — the marker text is what matters, not the language's comment syntax.
16
+
17
+ - **Bare** `esa-ignore` suppresses every violation type on its line.
18
+ - **Typed** `esa-ignore: type1, type2` only suppresses the listed types (the same strings the CLI's JSON output uses: `nesting`, `complexity`, `cognitive`, `coherence`, `magic`, `parameters`, `inversion`, `primitive-obsession`, `match-opportunity`, `logical-control-flow`, `opaque-boolean`).
19
+ - **`esa-ignore-file`** (bare or typed) can appear anywhere in the file and suppresses that type for the whole file — the only way to suppress `coherence`, which is a file-scoped finding rather than a line-scoped one.
20
+
21
+ ## Placement
22
+
23
+ A directive suppresses violations on its own line. If it's the *only* thing on its line (nothing before the comment marker), it also covers the line directly below — so it can sit above a function signature or `if` header instead of getting crammed onto an already-long line:
24
+
25
+ ```python
26
+ # esa-ignore: complexity
27
+ def reconcile_ledger(a, b, c, d, e, f, g, h):
28
+ ...
29
+ ```
30
+
31
+ A directive sharing a line with real code only covers that line:
32
+
33
+ ```python
34
+ if very_deeply_nested_condition(): # esa-ignore: nesting
35
+ ...
36
+ ```
37
+
38
+ ## Staying honest
39
+
40
+ Two situations produce their own low-severity `suppression` finding instead of silently doing nothing:
41
+
42
+ - **Unused directive** — the violation it named doesn't exist (anymore). Usually means the underlying issue was already fixed and the comment is now dead weight.
43
+ - **Unknown type name** — a typo like `esa-ignore: nseting`. An unrecognized type never falls back to "suppress everything"; it matches nothing, which is what surfaces it as unused.
44
+
45
+ Both show up in the editor and in every CLI report format like any other finding, so a suppression can't quietly outlive the thing it was suppressing.
46
+
47
+ ## Combining with other tools' suppression comments
48
+
49
+ `esa-ignore` looks for its own `#`/`//` marker anywhere on the line, so it composes fine alongside `ruff`/`pyright`/`eslint` directives as long as it gets its own leading `#`/`//` — it doesn't need to be the only thing in the comment, and it won't interfere with another tool's directive parsing or vice versa:
50
+
51
+ ```python
52
+ x = eval(y) # noqa: S307 # esa-ignore: magic
53
+ foo: int = 1 # type: ignore[assignment] # esa-ignore: magic
54
+ ```
55
+
56
+ ```typescript
57
+ doThing(true); // eslint-disable-line no-restricted-syntax // esa-ignore: opaque-boolean
58
+ ```
59
+
60
+ Tacking `esa-ignore` directly onto another tool's directive *without* its own marker does not work — `# noqa: E501 esa-ignore: parameters` is not recognized, since "esa-ignore" isn't preceded by a `#`/`//` of its own. Give it a marker of its own.
@@ -0,0 +1,14 @@
1
+ # Energy and Entropy
2
+
3
+ The name is a deliberate analogy to thermodynamics, not just a metaphor for "bad code."
4
+
5
+ In physics, energy constrains which microstates a system can occupy, and entropy counts how many of those microstates are compatible with what we observe: `S(E) = k_B ln Ω(E)`. Adding energy usually increases entropy, because there are more ways to distribute it, but *how* it's distributed matters just as much as how much there is. A hot object next to a cold one has lower entropy than the same total energy spread evenly across both, which is why heat spontaneously flows from hot to cold: the system moves toward the macrostate with more compatible microstates.
6
+
7
+ Code behaves the same way. A function's "energy" here is its cyclomatic/cognitive complexity, nesting depth, parameter count, and so on: the raw amount of decision-making and structure packed into it. Its "entropy" is the number of ways a reader can misunderstand it, the number of code paths a change can silently break, and the number of mental states a maintainer has to hold at once to reason about it correctly. Just as in physics, higher energy tends to raise entropy: a function with more branches and deeper nesting generally has more ways to go wrong. But it's not purely amount, *how* that complexity is arranged matters too:
8
+
9
+ - A long function with 20 sequential, flat `if`s is high cyclomatic complexity but comparatively low entropy: each branch is independent and easy to reason about in isolation (the "evenly spread" case).
10
+ - The same 20 decision points nested five deep inside each other is high *cognitive* complexity: the reader must hold all five levels in mind simultaneously, which is a much higher-entropy (harder to predict, easier to break) arrangement of the same energy.
11
+
12
+ This is why the extension tracks cyclomatic and cognitive complexity as separate metrics rather than one score: they capture the *energy* and its *arrangement* respectively. Guard clauses, extracted functions, and early returns don't necessarily remove energy from a codebase; they redistribute it into a lower-entropy arrangement, the code equivalent of letting a hot and cold object equilibrate: same total energy, fewer surprising configurations, easier to hold a correct mental model of.
13
+
14
+ Entropy here also depends on the observer, not just the code. A function's energy is fixed by what's written, but its entropy, the number of arrangements consistent with what someone currently knows, can grow over time even if the code never changes: the original author forgets the reasoning, or a new developer inherits the file with no context. This detector only measures the static, code-side half of that (the energy and its arrangement); the knowledge-decay half is a reason to keep energy low in the first place, since low-entropy code is cheaper to relearn from scratch.
Binary file