Skip to content

Commit 9e06ff1

Browse files
committed
fix(complexity): score else-if chains at parent nesting level
Else-if alternates no longer inherit consequent cognitive nesting; golden pins labyrinth cognitive_complexity at 27 on minimal fixture.
1 parent 0aa9948 commit 9e06ff1

9 files changed

Lines changed: 65 additions & 20 deletions

File tree

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ All base tables use `STRICT` mode; **`source_fts`** is an FTS5 virtual table (no
283283
| parent_name | TEXT | Nearest **named** enclosing scope (class, function, method, arrow-assigned-to-const). Walks past anonymous arrows / IIFEs / callbacks (e.g. `forEach(() => …)` inside `foo``parent_name='foo'`). NULL when no named owner exists — true module-scope OR inside a top-level anonymous IIFE. **For a strict "module-scope only" filter use `scope_local_id = 0`** (the canonical answer), not `parent_name IS NULL` |
284284
| visibility | TEXT | JSDoc visibility tag derived from `doc_comment` at parse time: `public` / `private` / `internal` / `alpha` / `beta`. NULL when no tag present. Tag must start its own line (after the JSDoc `*` prefix); first match in document order wins. Powers the `visibility-tags` recipe and `WHERE visibility = ?` queries via the partial index `idx_symbols_visibility` |
285285
| complexity | REAL | Cyclomatic complexity (McCabe; `1 + decision points`) for function-shaped symbols (top-level `function`, named arrow/const, class methods). NULL for non-functions. Decision points: `if`, `while`, `do…while`, `for`, `for…in`, `for…of`, `case X:` arms (not `default:`), short-circuit logical/nullish operators, ternary `?:`, and `catch` clauses. Powers `high-complexity-untested` (cyclomatic gate) |
286-
| cognitive_complexity | INTEGER | SonarSource cognitive complexity for the same function-shaped symbols as `complexity` (NULL otherwise). Penalizes nesting; computed in the same oxc walk as cyclomatic (`src/extractors/complexity.ts`). Powers `high-cognitive-complexity`; also exposed as a column on `high-complexity-untested` rows |
286+
| cognitive_complexity | INTEGER | Sonar-inspired cognitive complexity for the same function-shaped symbols as `complexity` (NULL otherwise). Penalizes nesting over flat else-if chains; same oxc walk as cyclomatic. Powers `high-cognitive-complexity`; also exposed as a column on `high-complexity-untested` rows |
287287
| name_column_start | INTEGER | 0-based column of the symbol-name token on `line_start` (per [R.6]) |
288288
| name_column_end | INTEGER | One-past-last column of the symbol-name token |
289289
| scope_local_id | INTEGER | Enclosing scope where the symbol's NAME is declared (joins `scopes.local_id`). Default `0` (module) |

docs/glossary.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ Per-function decision-point count (REAL column on `symbols`). Computed by the pa
141141

142142
### `symbols.cognitive_complexity` / cognitive complexity
143143

144-
SonarSource cognitive complexity (INTEGER on `symbols`) for the same function-shaped symbols as cyclomatic `complexity`. Penalizes nested control flow; same oxc walk as McCabe (`src/extractors/complexity.ts`). Recipes: `high-cognitive-complexity` (`min_score` default 15); `high-complexity-untested` includes the column while filtering on cyclomatic `complexity`.
144+
SonarSource-inspired cognitive complexity (INTEGER on `symbols`) for the same function-shaped symbols as cyclomatic `complexity`. Penalizes nested control flow; computed in the same parser walk as McCabe. Recipes: `high-cognitive-complexity` (`min_score` default 15, Sonar rule threshold); `high-complexity-untested` includes the column while filtering on cyclomatic `complexity`.
145145

146146
### `source_fts` (FTS5 virtual table) / `--with-fts` / opt-in full-text
147147

fixtures/golden/minimal/high-cognitive-complexity.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"file_path": "src/lib/complexity-fixture.ts",
66
"line_start": 22,
77
"line_end": 83,
8-
"cognitive_complexity": 40,
8+
"cognitive_complexity": 27,
99
"complexity": 19,
1010
"nesting_depth": 5
1111
}

fixtures/golden/minimal/high-complexity-untested.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"line_start": 22,
77
"line_end": 83,
88
"complexity": 19,
9-
"cognitive_complexity": 40,
9+
"cognitive_complexity": 27,
1010
"coverage_pct": 0
1111
}
1212
]

src/extractors/complexity.test.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,42 @@ describe("cognitive complexity", () => {
2323
expect(sym?.cognitive_complexity).toBeGreaterThan(sym!.complexity!);
2424
});
2525

26-
it("flat if-else-if chain: cognitive ≈ cyclomatic", () => {
27-
const src = `export function flat(n: number): number {
26+
it("flat if-else-if chain: cognitive ≈ cyclomatic, below nested peer", () => {
27+
const flatSrc = `export function flat(n: number): number {
2828
if (n % 2 === 0) return 1;
2929
else if (n % 3 === 0) return 2;
3030
else if (n % 5 === 0) return 3;
3131
return 0;
3232
}
3333
`;
34-
const d = extractFileData("/proj/x.ts", src, "x.ts");
35-
const sym = d.symbols.find((s) => s.name === "flat");
36-
expect(sym?.complexity).toBe(4);
37-
expect(sym?.cognitive_complexity).toBeGreaterThanOrEqual(3);
38-
expect(sym?.cognitive_complexity).toBeLessThanOrEqual(sym!.complexity! + 2);
34+
const nestedSrc = `export function nested(level: number): number {
35+
if (level > 0) {
36+
if (level > 1) {
37+
if (level > 2) {
38+
return level;
39+
}
40+
return 2;
41+
}
42+
return 1;
43+
}
44+
return 0;
45+
}
46+
`;
47+
const flat = extractFileData("/proj/x.ts", flatSrc, "x.ts").symbols.find(
48+
(s) => s.name === "flat",
49+
);
50+
const nested = extractFileData(
51+
"/proj/x.ts",
52+
nestedSrc,
53+
"x.ts",
54+
).symbols.find((s) => s.name === "nested");
55+
expect(flat?.complexity).toBe(4);
56+
expect(nested?.complexity).toBe(4);
57+
expect(flat?.cognitive_complexity).toBe(3);
58+
expect(nested?.cognitive_complexity).toBe(6);
59+
expect(flat!.cognitive_complexity!).toBeLessThan(
60+
nested!.cognitive_complexity!,
61+
);
3962
});
4063

4164
it("class method: both complexity and cognitive_complexity populated", () => {

src/extractors/complexity.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,13 @@ export function createComplexityTracker(
6666
getArrowSymbol(node) {
6767
return arrowMap.get(node);
6868
},
69-
cognitiveStructural() {
69+
cognitiveStructural(opts?: { elseIf?: boolean }) {
7070
const top = stack[stack.length - 1];
7171
if (!top) return;
72-
top.cognitive += 1 + top.cognitiveNest;
72+
const nestLevel = opts?.elseIf
73+
? Math.max(0, top.cognitiveNest - 1)
74+
: top.cognitiveNest;
75+
top.cognitive += 1 + nestLevel;
7376
},
7477
cognitiveFlat() {
7578
const top = stack[stack.length - 1];
@@ -91,8 +94,12 @@ export const complexityExtractor: TierExtractor = {
9194
tierId: "complexity",
9295
register(visitor, ctx) {
9396
const { complexity } = ctx;
97+
// Else-if chains reuse the parent `if`'s cognitive nesting level (Sonar
98+
// spec) — mark alternate `IfStatement` nodes on the parent enter.
99+
const elseIfNodes = new WeakSet<object>();
100+
94101
const nest = () => {
95-
complexity.cognitiveStructural();
102+
complexity.cognitiveStructural({});
96103
complexity.enterCognitiveNest();
97104
complexity.enterNest();
98105
complexity.increment();
@@ -101,6 +108,21 @@ export const complexityExtractor: TierExtractor = {
101108
complexity.exitCognitiveNest();
102109
complexity.exitNest();
103110
};
111+
const nestIf = (node: any) => {
112+
if (node.alternate?.type === "IfStatement") {
113+
elseIfNodes.add(node.alternate);
114+
}
115+
const isElseIf = elseIfNodes.has(node);
116+
complexity.cognitiveStructural({ elseIf: isElseIf });
117+
if (!isElseIf) complexity.enterCognitiveNest();
118+
complexity.enterNest();
119+
complexity.increment();
120+
};
121+
const unnestIf = (node: any) => {
122+
if (!elseIfNodes.has(node)) complexity.exitCognitiveNest();
123+
elseIfNodes.delete(node);
124+
complexity.exitNest();
125+
};
104126

105127
Object.assign(visitor, {
106128
// `symbolsExtractor`'s VariableDeclaration populates the arrow
@@ -122,8 +144,8 @@ export const complexityExtractor: TierExtractor = {
122144
// forms (if/for/while/try/ternary) ALSO increment nesting_depth
123145
// on enter and decrement on exit. Cognitive uses Sonar structural
124146
// increments (+1 + nesting) on the same shapes.
125-
IfStatement: nest,
126-
"IfStatement:exit": unnest,
147+
IfStatement: nestIf,
148+
"IfStatement:exit": unnestIf,
127149
WhileStatement: nest,
128150
"WhileStatement:exit": unnest,
129151
DoWhileStatement: nest,

src/extractors/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export interface ComplexityTracker {
7171
markArrowSymbol(node: object, symbolIndex: number): void;
7272
getArrowSymbol(node: object): number | undefined;
7373
/** Sonar structural break (+1 + current cognitive nesting). */
74-
cognitiveStructural(): void;
74+
cognitiveStructural(opts?: { elseIf?: boolean }): void;
7575
/** Flat +1 (switch case, boolean operator) — no nesting penalty. */
7676
cognitiveFlat(): void;
7777
enterCognitiveNest(): void;

templates/recipes/high-cognitive-complexity.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ params:
44
type: number
55
required: false
66
default: 15
7-
description: Minimum SonarSource cognitive complexity score (default matches Sonar rule threshold)
7+
description: Minimum cognitive complexity score (default 15 matches Sonar rule threshold)
88
actions:
99
- type: review-cognitive-complexity
1010
auto_fixable: false
@@ -13,7 +13,7 @@ actions:
1313

1414
# high-cognitive-complexity
1515

16-
Functions with **cognitive complexity**`min_score` (default **15**, Sonar-aligned). Uses the same function-shaped symbol coverage as cyclomatic `symbols.complexity` (top-level functions, named arrow/const, class methods).
16+
Functions with **cognitive complexity**`min_score` (default **15**, Sonar rule threshold). Uses the same function-shaped symbol coverage as cyclomatic `symbols.complexity` (top-level functions, named arrow/const, class methods).
1717

1818
Distinct from `high-complexity-untested` (cyclomatic gate + coverage) and `deeply-nested-functions` (`nesting_depth` only).
1919

templates/recipes/high-complexity-untested.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ McCabe formula: `1 + (decision points)`. Branching nodes counted by Codemap's pa
1616
- `&&` / `||` / `??` short-circuit operators (`?` / `:` ternary too)
1717
- `catch` clauses
1818

19-
**Computed for function-shaped symbols** — top-level `function` declarations, named arrow/const bindings, and class methods (`MethodDefinition` bodies). Non-function kinds (interfaces, types, enums, plain consts) get `complexity = NULL` and are excluded by `WHERE s.complexity IS NOT NULL`.
19+
**Computed for function-shaped symbols** — top-level `function` declarations, named arrow/const bindings, and class methods. Non-function kinds (interfaces, types, enums, plain consts) get `complexity = NULL` and are excluded by `WHERE s.complexity IS NOT NULL`.
2020

2121
## Cognitive complexity column (`symbols.cognitive_complexity`)
2222

0 commit comments

Comments
 (0)