Message Chains
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
message-chains.ts
import type { ClassDecl, Expr, Method } from "./program";import type { Tracer } from "../trace";import { CodePrinter } from "./code-printer";import type { SmellDetector } from "./detector";import { ProgramQuery } from "./program-query";/** One member access along a spine, root outward: `customer` in `order.customer.address`. */export interface ChainHop { readonly member: string; readonly kind: "field" | "call"; /** The hop reaches past the first object: its target is a call's result or a field of something other than `this`. */ readonly stranger: boolean; /** The printed expression the hop is applied to. */ readonly target: string;}/** A spine whose stranger hops reach the limit: Fowler's message chain, the "train wreck" `a.b().c().d()`. */export interface MessageChain { /** The statement the chain sits in. */ readonly nodeId: string; /** The printed spine. */ readonly text: string; /** The printed root: `this`, a name, a `new`, or a literal. */ readonly root: string; readonly hops: readonly ChainHop[]; /** The stranger hops: how far past the first object the spine reaches. */ readonly length: number;}export interface ChainReport { readonly method: string; readonly chains: readonly MessageChain[]; /** The most stranger hops any spine in the method has; 0 when none reaches past one object. */ readonly longest: number; /** At least one chain. */ readonly smelly: boolean;}/** A spine while it is read: the root and the hops from it outward, with the statement it belongs to. */interface Spine { readonly nodeId: string; readonly root: Expr; readonly hops: readonly SpineHop[]; readonly expr: Expr;}interface SpineHop { readonly member: string; readonly kind: "field" | "call"; readonly target: Expr; /** A call's arguments; empty for a field. */ readonly args: readonly Expr[];}interface Tally { readonly chains: MessageChain[]; longest: number;}/** * Fowler's Message Chains smell, read purely from syntax. A spine is a * maximal nest of field accesses and calls down to a root (`this`, a * name, a `new`, a literal); its hops run from the root outward. A hop * is a stranger hop when it reaches past the first object: its target is * a call's result, or a field of something other than `this`. So * `order.items.length` has one stranger hop, `this.a.b().c` has one, and * `order.customer.address.zip` has two: the "train wreck" that couples * the caller to the whole route between the objects. A spine whose * stranger hops reach `limit` is a chain; Hide Delegate shortens it one * hop at a time. Call arguments along a spine are spines of their own. * * Time: O(n) over the method's expression nodes. Space: O(depth). */export class MessageChains implements SmellDetector<Method, ChainReport> { private readonly printer = new CodePrinter(); constructor( readonly limit = 2, private readonly tracer?: Tracer, ) {} analyze(method: Method): ChainReport { return this.run(method, 0, 1); } /** Every method of the class in declaration order, so one trace walks the whole class. */ analyzeClass(cls: ClassDecl): readonly ChainReport[] { return cls.methods.map((method, index) => this.run(method, index, cls.methods.length)); } private run(method: Method, index: number, count: number): ChainReport { const tally: Tally = { chains: [], longest: 0 }; // ▸ a method begins: no chains, no strangers yet this.tracer?.emit("method", { name: method.name, index, count }); ProgramQuery.walk(method.body, (stmt) => { for (const expr of ProgramQuery.exprs(stmt)) this.spines(expr, stmt.id, method.name, tally); }); const smelly = tally.chains.length > 0; // ▸ the method's chains and its longest spine against the limit this.tracer?.emit("verdict", { name: method.name, chains: tally.chains.length, longest: tally.longest, limit: this.limit, smelly }); return { method: method.name, chains: tally.chains, longest: tally.longest, smelly }; } /** Every spine under an expression, outermost first, each read once; the parts beside a spine are searched for spines of their own. */ private spines(expr: Expr, nodeId: string, method: string, tally: Tally): void { switch (expr.kind) { case "field": case "call": { const spine = this.read(expr, nodeId); this.measure(spine, method, tally); for (const hop of spine.hops) for (const arg of hop.args) this.spines(arg, nodeId, method, tally); this.beside(spine.root, nodeId, method, tally); return; } default: this.beside(expr, nodeId, method, tally); } } /** The spines inside a root or a non-member expression: a `new`'s arguments, an operator's operands. */ private beside(expr: Expr, nodeId: string, method: string, tally: Tally): void { switch (expr.kind) { case "new": for (const arg of expr.args) this.spines(arg, nodeId, method, tally); return; case "unary": this.spines(expr.operand, nodeId, method, tally); return; case "binary": this.spines(expr.left, nodeId, method, tally); this.spines(expr.right, nodeId, method, tally); return; default: return; } } /** Follows the targets down to the root and lists the hops back up, root outward. */ private read(expr: Expr, nodeId: string): Spine { const hops: SpineHop[] = []; let current = expr; while (current.kind === "field" || current.kind === "call") { hops.unshift(current.kind === "field" ? { member: current.name, kind: "field", target: current.target, args: [] } : { member: current.method, kind: "call", target: current.target, args: current.args }); current = current.target; } return { nodeId, root: current, hops, expr }; } /** Counts the stranger hops of a spine; one-hop spines stay silent, every longer spine is traced hop by hop. */ private measure(spine: Spine, method: string, tally: Tally): void { if (spine.hops.length < 2) return; const hops: ChainHop[] = []; let strangers = 0; spine.hops.forEach((hop, index) => { const stranger = hop.target.kind === "call" || (hop.target.kind === "field" && hop.target.target.kind !== "this"); if (stranger) strangers++; const target = this.printer.expr(hop.target); hops.push({ member: hop.member, kind: hop.kind, stranger, target }); // ▸ one hop along a spine, root outward; a stranger when it reaches past the first object this.tracer?.emit("hop", { nodeId: spine.nodeId, method, index, member: hop.member, kind: hop.kind, target, stranger, strangers }); }); tally.longest = Math.max(tally.longest, strangers); if (strangers < this.limit) return; const chain: MessageChain = { nodeId: spine.nodeId, text: this.printer.expr(spine.expr), root: this.printer.expr(spine.root), hops, length: strangers }; tally.chains.push(chain); // ▸ the spine's strangers reach the limit: a message chain this.tracer?.emit("chain", { nodeId: spine.nodeId, method, text: chain.text, root: chain.root, hops: hops.map((h) => h.member), strangers, count: tally.chains.length }); }}The class: 2 chains; the longest spine 2 strangers. Still message chains.