Long Method
in progressThis lesson is still being built - content, demos, and checkpoints may change before release.
in progressThis lesson is still being built - content, demos, and checkpoints may change before release.
cyclomatic-complexity.ts
import type { Tracer } from "../trace";import { CodePrinter } from "./code-printer";import type { SmellDetector } from "./detector";import type { ClassDecl, Expr, Method, Stmt } from "./program";import { ProgramQuery } from "./program-query";/** What added a path: a branching statement, or an `&&` / `||` inside a test. */export type DecisionKind = "if" | "for-each" | "while" | "and" | "or";export interface DecisionPoint { /** The statement the decision belongs to. */ readonly nodeId: string; readonly kind: DecisionKind; readonly depth: number;}export interface ComplexityThresholds { /** Above this many paths the method is called long (McCabe suggested 10). */ readonly complexity: number; /** Deeper than this many nested blocks the method is called long. */ readonly depth: number;}export interface ComplexityReport { readonly method: string; /** Decision points plus one: the number of independent paths through the method. */ readonly complexity: number; /** The deepest nesting any statement sits at; 0 for a straight line. */ readonly maxDepth: number; /** Statements at every depth. */ readonly statements: number; readonly decisions: readonly DecisionPoint[]; /** Over either threshold. */ readonly smelly: boolean;}interface Counters { complexity: number; maxDepth: number; statements: number; readonly decisions: DecisionPoint[];}export const DEFAULT_THRESHOLDS: ComplexityThresholds = { complexity: 10, depth: 3 };/** * McCabe's cyclomatic complexity, with nesting depth beside it. The walk * visits every statement once, outermost first: a plain statement is a * step along the current path; an `if`, a loop, or an `&&` / `||` inside a * test opens one more independent path, so the count starts at one and * grows by one per decision. Depth is how many blocks enclose a * statement; an else-if ladder stays at its `if`'s depth, since the * reader does not step in. A method is long when either number is over * its threshold: many paths need many tests, and deep nesting needs the * reader to hold every enclosing condition in mind at once. * * Time: O(n) over n statements and expression nodes. Space: O(depth). */export class CyclomaticComplexity implements SmellDetector<Method, ComplexityReport> { constructor( readonly thresholds: ComplexityThresholds = DEFAULT_THRESHOLDS, private readonly tracer?: Tracer, ) {} analyze(method: Method): ComplexityReport { const state: Counters = { complexity: 1, maxDepth: 0, statements: 0, decisions: [] }; // ▸ a method begins: one path so far, depth zero this.tracer?.emit("method", { name: method.name, params: method.params.map((p) => p.name), statements: ProgramQuery.count(method.body) }); this.walk(method.body, 0, state); const smelly = state.complexity > this.thresholds.complexity || state.maxDepth > this.thresholds.depth; // ▸ the method's numbers against the limits this.tracer?.emit("verdict", { name: method.name, complexity: state.complexity, maxDepth: state.maxDepth, statements: state.statements, smelly, thresholdComplexity: this.thresholds.complexity, thresholdDepth: this.thresholds.depth }); return { method: method.name, complexity: state.complexity, maxDepth: state.maxDepth, statements: state.statements, decisions: state.decisions, smelly }; } /** Every method of the class in declaration order, each through `analyze`, so one trace walks the whole class. */ analyzeClass(cls: ClassDecl): readonly ComplexityReport[] { return cls.methods.map((method) => this.analyze(method)); } /** * The decision points in a body and everything nested in it, by the * same untraced walk `analyze` counts with: one per if and loop, one * per `&&` / `||` in a test. For one statement, pass it as a body of one. */ static decisionsIn(body: readonly Stmt[]): readonly DecisionPoint[] { const state: Counters = { complexity: 1, maxDepth: 0, statements: 0, decisions: [] }; new CyclomaticComplexity().walk(body, 0, state); return state.decisions; } private walk(body: readonly Stmt[], depth: number, state: Counters): void { for (const stmt of body) { state.statements++; state.maxDepth = Math.max(state.maxDepth, depth); // ▸ the walk reaches a statement at its depth this.tracer?.emit("enter", { nodeId: stmt.id, kind: stmt.kind, depth, complexity: state.complexity }); if (stmt.kind === "if" || stmt.kind === "while" || stmt.kind === "for-each") { this.decide(stmt.id, stmt.kind, depth, state); if (stmt.kind !== "for-each") for (const op of this.operators(stmt.test)) this.decide(stmt.id, op, depth, state); for (const block of this.blocks(stmt, depth)) this.walk(block.body, block.depth, state); // ▸ a block closes: back up one level this.tracer?.emit("leave", { nodeId: stmt.id, kind: stmt.kind, depth, maxDepth: state.maxDepth }); } } } private decide(nodeId: string, kind: DecisionKind, depth: number, state: Counters): void { state.complexity++; state.decisions.push({ nodeId, kind, depth }); // ▸ a decision: one more independent path through the method this.tracer?.emit("branch", { nodeId, kind, complexity: state.complexity, depth }); } /** The blocks a statement opens with the depth each is walked at: an else-if chain continues at the same depth. */ private blocks(stmt: Stmt, depth: number): readonly { body: readonly Stmt[]; depth: number }[] { if (stmt.kind === "if") { const chained = CodePrinter.chainedIf(stmt); const blocks = [{ body: stmt.then, depth: depth + 1 }]; if (stmt.else) blocks.push({ body: stmt.else, depth: chained ? depth : depth + 1 }); return blocks; } return ProgramQuery.children(stmt).map((body) => ({ body, depth: depth + 1 })); } /** The `&&` and `||` operators inside a test, left to right; each is a short-circuit decision. */ private operators(expr: Expr): readonly ("and" | "or")[] { const ops: ("and" | "or")[] = []; ProgramQuery.visitExpr(expr, (e) => { if (e.kind === "binary" && e.op === "&&") ops.push("and"); if (e.kind === "binary" && e.op === "||") ops.push("or"); }); return ops; }}process: 11 paths; nested 5 deep. Still a long method.