Feature Envy
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.
feature-envy.ts
import type { ClassDecl, Expr, MemberRef, Method, Program } from "./program";import type { Tracer } from "../trace";import type { SmellDetector } from "./detector";import { ProgramQuery } from "./program-query";export interface EnvyThresholds { /** Lanza and Marinescu's FEW: more foreign fields than this is envy, and more providers than this is not envy but a broad dependency. */ readonly few: number; /** Their ONE THIRD: a method whose own share of the data it touches is under this is envious. */ readonly ownShare: number;}export const DEFAULT_ENVY_THRESHOLDS: EnvyThresholds = { few: 3, ownShare: 1 / 3 };export interface DataAccess { /** The statement the field expression sits in. */ readonly nodeId: string; /** The class that declares the field: the method's own class, or the class the target types to. */ readonly owner: string; readonly field: string; /** Whether the owner is another class than the method's. */ readonly foreign: boolean;}export interface EnvyReport { readonly method: string; readonly className: string; /** Every field access in statement order, inner before outer, repeats included. */ readonly accesses: readonly DataAccess[]; /** ATFD: distinct foreign fields, counted as `Owner.field`. */ readonly atfd: number; /** Distinct own fields. */ readonly ownData: number; /** LAA: own / (own + foreign); 1 when the method touches no data. */ readonly laa: number; /** FDP: distinct classes the foreign fields belong to. */ readonly fdp: number; /** Each foreign class with how many of its fields the method reads. */ readonly providers: Readonly<Record<string, number>>; /** The provider with the most fields read; null when there is none. */ readonly envied: string | null; readonly smelly: boolean;}interface Tally { readonly accesses: DataAccess[]; readonly own: Set<string>; readonly foreign: Set<string>; readonly providers: Record<string, number>;}/** * Feature envy, Fowler's smell of a method that is more interested in * another class's data than in its own, measured with the detection * strategy of Lanza and Marinescu: ATFD (access to foreign data) counts * the distinct fields of other classes the method reaches, LAA (locality * of attribute accesses) is the share of the touched fields that are the * method's own, and FDP (foreign data providers) counts the classes * those foreign fields belong to. The method is envious when ATFD is * over FEW, LAA is under ONE THIRD, and FDP is at most FEW: it reads a * lot of someone else's data, little of its own, and that someone is * one or two classes, so the method has a home to move to. * * A data access is a `field` expression whose target is `this`, or * whose target types (through `ProgramQuery.typeOf`) to a class of the * program: a parameter, a local, a loop variable, a field, or a call * returning that class. A target of a type outside the program (`string`, * `Item[]`) is ignored, so `this.items.length` is not an access to * `Item`. Accessor calls are calls, not field expressions, and are not * counted; this toy form sees data only when it is named as a field. * * Time: O(n · d) over n expression nodes and the depth d a target's * type is resolved through. Space: O(n). */export class FeatureEnvy implements SmellDetector<MemberRef, EnvyReport> { constructor( readonly program: Program, readonly thresholds: EnvyThresholds = DEFAULT_ENVY_THRESHOLDS, private readonly tracer?: Tracer, ) {} analyze(target: MemberRef): EnvyReport { const query = new ProgramQuery(this.program); const cls = query.class(target.className); const method = query.method(target); return this.analyzeMethod(query, cls, method, cls.methods.indexOf(method), cls.methods.length); } /** Every method of the class, in declaration order, as one trace. */ analyzeClass(cls: ClassDecl): readonly EnvyReport[] { const query = new ProgramQuery(this.program); return cls.methods.map((method, index) => this.analyzeMethod(query, cls, method, index, cls.methods.length)); } private analyzeMethod(query: ProgramQuery, cls: ClassDecl, method: Method, index: number, count: number): EnvyReport { // ▸ a method: its tally of own and foreign data opens this.tracer?.emit("method", { className: cls.name, count, index, name: method.name }); const locals = ProgramQuery.locals(method); const tally: Tally = { accesses: [], own: new Set(), foreign: new Set(), providers: {} }; ProgramQuery.walk(method.body, (stmt) => { for (const root of ProgramQuery.exprs(stmt)) { this.eachField(root, (expr) => { const owner = this.ownerOf(query, expr, cls, locals); if (owner !== null) this.record(tally, stmt.id, owner, expr.name, cls, method); }); } }); const atfd = tally.foreign.size; const ownData = tally.own.size; const laa = atfd + ownData === 0 ? 1 : ownData / (ownData + atfd); const fdp = Object.keys(tally.providers).length; // ▸ own fields, foreign fields, and the classes they belong to this.tracer?.emit("tally", { atfd, fdp, laa, method: method.name, ownData, providers: { ...tally.providers } }); let envied: string | null = null; for (const [owner, fields] of Object.entries(tally.providers)) { if (envied === null || fields > tally.providers[envied]!) envied = owner; } const { few, ownShare } = this.thresholds; const smelly = atfd > few && laa < ownShare && fdp <= few; // ▸ the three numbers against few and a third this.tracer?.emit("verdict", { atfd, className: cls.name, envied, fdp, few, laa, method: method.name, ownShare, smelly }); return { method: method.name, className: cls.name, accesses: tally.accesses, atfd, ownData, laa, fdp, providers: tally.providers, envied, smelly }; } /** One access: own fields by name, foreign fields by `Owner.field`, each distinct one credited to its provider. */ private record(tally: Tally, nodeId: string, owner: string, field: string, cls: ClassDecl, method: Method): void { const foreign = owner !== cls.name; if (foreign) { const key = `${owner}.${field}`; if (!tally.foreign.has(key)) { tally.foreign.add(key); tally.providers[owner] = (tally.providers[owner] ?? 0) + 1; } } else { tally.own.add(field); } tally.accesses.push({ nodeId, owner, field, foreign }); // ▸ one field read, credited to the class that declares it this.tracer?.emit("access", { atfd: tally.foreign.size, field, foreign, method: method.name, nodeId, owner, ownData: tally.own.size }); } /** The class that owns the field expression's data: the method's own class for `this.x`, else the target's type when it is a class of the program, else null. */ private ownerOf(query: ProgramQuery, expr: Expr & { kind: "field" }, cls: ClassDecl, locals: ReadonlyMap<string, string>): string | null { if (expr.target.kind === "this") return cls.name; const type = query.typeOf(expr.target, cls, locals); return type !== null && query.classOrNull(type) !== null ? type : null; } /** Post-order over an expression tree, so `this.parcel` is seen before `this.parcel.weight`. */ private eachField(expr: Expr, visit: (field: Expr & { kind: "field" }) => void): void { switch (expr.kind) { case "field": this.eachField(expr.target, visit); visit(expr); break; case "call": this.eachField(expr.target, visit); for (const arg of expr.args) this.eachField(arg, visit); break; case "new": for (const arg of expr.args) this.eachField(arg, visit); break; case "unary": this.eachField(expr.operand, visit); break; case "binary": this.eachField(expr.left, visit); this.eachField(expr.right, visit); break; default: break; } }}Invoice.header: 4 foreign, 0 own, locality 0%. Still envies Customer.