Spaghetti Code
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.
hidden-flow.ts
import { DeadCode } from "./dead-code";import type { ClassDecl, Method } from "./program";import { ProgramQuery } from "./program-query";import type { Tracer } from "../trace";import type { SmellDetector } from "./detector";/** One hidden hand-off: `from` assigns the field, `to` reads it, and `via` is the method that calls both, when one does. */export interface HiddenEdge { readonly field: string; readonly from: string; readonly to: string; /** The first other method whose own calls include both ends; null when nothing in the class sequences them. */ readonly via: string | null; /** The field is a boolean tested in an `if` or a `while`: hidden control flow. */ readonly flag: boolean;}export interface HiddenFlowReport { readonly className: string; /** Every writer, field, reader triple across two methods, writers in declaration order. */ readonly edges: readonly HiddenEdge[]; /** The distinct flag fields that take part in a hand-off, in edge order. */ readonly flags: readonly string[]; /** The channels already in the open: parameters plus non-void returns over the class's methods. */ readonly explicit: number; /** The edges with a via: values passed between steps of one routine through a field. */ readonly passed: number; readonly smelly: boolean;}/** What one method does with the class's fields through `this`. */interface MethodFlow { readonly method: Method; readonly writes: readonly string[]; readonly reads: readonly string[]; readonly calls: readonly string[];}/** * Spaghetti code, in the form Brown et al. name in AntiPatterns (1998): * a routine chopped into methods that take nothing and return nothing, * handing their results to each other through the object's fields and * flags. The class looks cohesive, since every method touches the shared * fields and LCOM is low, but no reader can follow the flow: a value * written in one method is read in another, and only the method that * calls both in order knows that the first must run before the second. * * The detector reads the flow off the class. Per method it lists the * fields assigned through `this` and the fields read through `this` * (an assignment's own target is the one access that is a write, as * `DeadCode.scan` reports it). Every writer, field, reader triple across * two different methods is a hidden edge. The tell is `via`: the first * other method whose own calls include both the writer and the reader. * With a via, the field is a message between two steps of one routine, * which a return value and a parameter should carry instead; the edge is * a hand-off and counts toward `passed`. Without one (an account's * `deposit` and `available` share `balance`, but nothing calls both) the * field is the object's state, and the edge is what an object is for. A * `flag` is a boolean field tested in an `if` or a `while` anywhere in * the class: the classic hidden control flow, where one step decides and * a later step obeys. `explicit` counts the channels already in the open, * parameters and non-void returns, so the cure shows as hidden edges * turning into explicit ones. * * Time: O(s + m^2 * f) over s statements, m methods, and f fields of the * class. Space: O(m * f) for the per-method field lists. */export class HiddenFlow implements SmellDetector<ClassDecl, HiddenFlowReport> { constructor(private readonly tracer?: Tracer) {} analyze(cls: ClassDecl): HiddenFlowReport { const flows = cls.methods.map((method) => HiddenFlow.flowOf(method)); const flagFields = HiddenFlow.flagFields(cls); flows.forEach((flow, index) => // ▸ a method: the fields it writes and reads, what it takes and returns this.tracer?.emit("method", { className: cls.name, name: flow.method.name, params: flow.method.params.length, returns: flow.method.returns, writes: [...flow.writes], reads: [...flow.reads], index, count: flows.length, }), ); const edges: HiddenEdge[] = []; for (const writer of flows) { for (const field of writer.writes) { for (const reader of flows) { if (reader === writer || !reader.reads.includes(field)) continue; const via = flows.find((other) => other !== writer && other !== reader && other.calls.includes(writer.method.name) && other.calls.includes(reader.method.name)); const edge: HiddenEdge = { field, from: writer.method.name, to: reader.method.name, via: via ? via.method.name : null, flag: flagFields.includes(field) }; edges.push(edge); // ▸ one method writes a field another reads; a via makes it a hand-off this.tracer?.emit("edge", { field: edge.field, from: edge.from, to: edge.to, via: edge.via ?? "", flag: edge.flag, count: edges.length }); } } } const passed = edges.filter((edge) => edge.via !== null).length; const flags = [...new Set(edges.filter((edge) => edge.via !== null && edge.flag).map((edge) => edge.field))]; const explicit = cls.methods.reduce((n, method) => n + method.params.length + (method.returns === "void" ? 0 : 1), 0); const smelly = passed > 0; // ▸ hidden hand-offs, flags, and channels in the open this.tracer?.emit("verdict", { className: cls.name, methods: cls.methods.length, edges: edges.length, passed, flags: [...flags], explicit, smelly }); return { className: cls.name, edges, flags, explicit, passed, smelly }; } /** The fields a method assigns and reads through `this`, each in first-use order, and the methods it calls on `this`. */ private static flowOf(method: Method): MethodFlow { const writes: string[] = []; const reads: string[] = []; ProgramQuery.walk(method.body, (stmt) => DeadCode.scan( stmt, () => undefined, (field, written) => { if (field.target.kind !== "this") return; const list = written ? writes : reads; if (!list.includes(field.name)) list.push(field.name); }, ), ); return { method, writes, reads, calls: ProgramQuery.selfCalls(method.body) }; } /** The boolean fields of the class that some `if` or `while` test reads through `this`, anywhere in the class. */ private static flagFields(cls: ClassDecl): readonly string[] { const booleans = new Set(cls.fields.filter((field) => field.type === "boolean").map((field) => field.name)); const flags: string[] = []; for (const method of cls.methods) { ProgramQuery.walk(method.body, (stmt) => { if (stmt.kind !== "if" && stmt.kind !== "while") return; ProgramQuery.visitExpr(stmt.test, (e) => { if (e.kind === "field" && e.target.kind === "this" && booleans.has(e.name) && !flags.includes(e.name)) flags.push(e.name); }); }); } return flags; }}Payroll: 3 hidden hand-offs; 0 in the open. Still spaghetti.