UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

261 lines (260 loc) • 12.3 kB
/** * Middle-man / delegate-only class detection (#325, split from #305). * * ast-grep can match *existence* ("this class has a delegate method") but not * *universal quantification* ("EVERY method of this class is a pure forward to * one held field") — that whole-class judgment is a structural pass over the * already-extracted outline, not a pattern rule (see #325 for the full * rationale). This module computes, per class, a "delegation ratio" — the * share of real methods whose ENTIRE body is a single pure-forwarding call to * one held field — and flags the class only when that ratio is near 100% AND * the class isn't a named facade/adapter/proxy/wrapper/decorator, and doesn't * structurally implement an interface (a legitimate reason for near-total * forwarding). * * Deliberately precision-first: every ambiguous case (destructured params, * multi-statement bodies, mixed delegate fields, too few methods to judge) * resolves to "not flagged" rather than guessing. False negatives are cheap; * flooding legitimate forwarding layers (the risk #325 repeatedly calls out) * is not. * * Scope (v1): languages with a simple, deterministic "self" token and a `.` * (or PHP's `->`) member-access operator — typescript/tsx/javascript, java, * kotlin, csharp, swift, dart, python, ruby, rust, php. Go/C++ are skipped — * Go has no `this`-equivalent token in the method text (the receiver name is * arbitrary) and C++ mixes `.`/`->` depending on whether the held field is a * pointer, both of which need real AST access to resolve soundly; a future * slice can add them once middle-man analysis has an AST-node entry point * rather than this text-based one. */ /** Self-reference token + member-access separator, per languageId. Both the * self→field and field→method hops use the SAME separator in every language * covered here (true even for PHP's `$this->field->method()`). */ const SELF_TOKEN = { typescript: { token: "this", sep: "." }, tsx: { token: "this", sep: "." }, javascript: { token: "this", sep: "." }, java: { token: "this", sep: "." }, kotlin: { token: "this", sep: "." }, csharp: { token: "this", sep: "." }, swift: { token: "this", sep: "." }, dart: { token: "this", sep: "." }, rust: { token: "self", sep: "." }, python: { token: "self", sep: "." }, ruby: { token: "self", sep: "." }, php: { token: "$this", sep: "->" }, }; /** Guard: a class named after an intentional forwarding pattern is never the * Fowler "Middle Man" smell — that's its whole job. Substring match, so * `LegacyApiAdapter`/`ConfigFacade`/`LoggingProxy`/`HttpWrapper` all guard out. */ const INTENTIONAL_FORWARDER_NAME = /adapter|facade|proxy|wrapper|decorator/i; /** Names that are constructors/destructors (never delegation candidates) in at * least one covered language. */ const CONSTRUCTOR_NAMES = new Set(["constructor", "__init__", "__new__", "initialize"]); /** Below this many real (non-accessor, non-constructor) methods, a delegation * ratio isn't a meaningful judgment — a one-method utility class forwarding * that one call is not "the class only ever delegates", it's just a small * class (#325 FP concern: don't flood on tiny legitimate wrappers). */ const MIN_CANDIDATE_METHODS = 2; /** How close to "every method forwards" counts as the smell. Near-100%, not * "mostly" — a class with a couple of forwarding convenience methods among * real logic must NOT flag (the exact distinction #325 asks for). */ const DELEGATION_RATIO_THRESHOLD = 0.9; function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function buildForwardRegex(token, sep) { const t = escapeRegExp(token); const s = escapeRegExp(sep); // `(?:return\s+)?(?:await\s+)?` — value-returning and void forwards both // count (issue explicitly includes the no-`return` void-method form). return new RegExp(`^(?:return\\s+)?(?:await\\s+)?${t}${s}(\\w+)${s}(\\w+)\\(([^()]*)\\)\\s*;?$`); } /** Splits a parameter/argument list on top-level commas only (naive but * sufficient — a nested paren pair would already have failed the outer * `[^()]*` capture in `buildForwardRegex`, so args here never contain `(`). */ function splitArgs(s) { const trimmed = s.trim(); if (!trimmed) return []; return trimmed.split(",").map((a) => a.trim()); } /** Parameter names from a method's captured `(a: T, b = 1)`-shaped signature * text. Returns `undefined` (ineligible — don't guess) for destructured * params, since arg-identity comparison can't be done textually for those. */ function paramNamesFromSignature(signature, languageId) { const inner = (signature ?? "").trim().replace(/^\(/, "").replace(/\)$/, ""); if (inner.trim() === "") return []; if (inner.includes("{") || inner.includes("[")) return undefined; const names = splitArgs(inner) .filter((p) => p.length > 0) .map((raw) => { let name = raw; // Rest/spread params forward as themselves textually (`...args`). const isRest = /^\.\.\./.test(name); name = name.replace(/^\.\.\./, ""); name = name.split("=")[0].trim(); // strip default value name = name.split(":")[0].trim(); // strip type annotation name = name.replace(/\?$/, ""); // strip optional marker return isRest ? `...${name}` : name; }); if (languageId === "python" && names[0] === "self") return names.slice(1); if (languageId === "python" && names[0] === "cls") return names.slice(1); return names; } /** Extracts the statement(s) inside a method body as a list of non-blank, * comment-stripped, trimmed lines. Brace languages: content between the * FIRST `{` and LAST `}` in the member's source range (sound as long as the * body itself contains no nested `{…}` — true by construction for the * single-statement forward pattern we're checking for; anything with nested * braces has >1 meaningful line anyway and correctly fails the single- * statement check below). Python: everything after the `def …:` line, * dedented is unnecessary since we only care about non-blank line count. */ function bodyStatementLines(lines, entry, isPython) { const raw = lines.slice(entry.startLine - 1, entry.endLine).join("\n"); let body; if (isPython) { const nlIdx = raw.indexOf("\n"); if (nlIdx === -1) return []; body = raw.slice(nlIdx + 1); } else { const braceIdx = raw.indexOf("{"); const lastBrace = raw.lastIndexOf("}"); if (braceIdx === -1 || lastBrace === -1 || lastBrace <= braceIdx) return []; body = raw.slice(braceIdx + 1, lastBrace); } return body .replace(/\/\*[\s\S]*?\*\//g, "") .split("\n") .map((l) => l.replace(/\/\/.*$/, "")) .map((l) => (isPython ? l.replace(/#.*$/, "") : l)) .map((l) => l.trim()) .filter((l) => l.length > 0) // A lone triple-quoted docstring line in python isn't executable, but // distinguishing it textually is unreliable — treat any leading `"""`/`'''` // line as noise so a documented one-liner forward still qualifies. .filter((l) => !/^("""|''')/.test(l)); } /** Detects an accessor (getter/setter) declaration from its raw source line — * accessors are a DIFFERENT smell (anemic class) and excluded from both the * numerator and denominator here so property-heavy adapters/DTOs don't skew * the ratio either way. */ function isAccessorLine(rawLine) { return /^\s*(?:public\s+|private\s+|protected\s+|internal\s+|static\s+|override\s+|readonly\s+)*(?:get|set)\s+\w+\s*[/(]/.test(rawLine); } /** * Computes the middle-man signal for a single class entry, or `undefined` * when there isn't enough signal to judge (too few candidate methods, no * language support, mixed delegate fields, etc). Pure function over already- * extracted data — no re-parsing, no file I/O beyond the lines already read * by the caller. */ export function analyzeMiddleMan(classEntry, lines, languageId) { if (!languageId) return undefined; const selfSpec = SELF_TOKEN[languageId]; if (!selfSpec) return undefined; if (!classEntry.members || classEntry.members.length === 0) return undefined; const isPython = languageId === "python"; const forwardRe = buildForwardRegex(selfSpec.token, selfSpec.sep); const className = classEntry.name; const candidates = classEntry.members.filter((m) => { if (m.kind !== "method") return false; if (CONSTRUCTOR_NAMES.has(m.name)) return false; if (m.name === className || m.name === `~${className}`) return false; // C++/C# ctor/dtor-by-name const rawLine = lines[m.startLine - 1] ?? ""; if (isAccessorLine(rawLine)) return false; return true; }); if (candidates.length < MIN_CANDIDATE_METHODS) return undefined; let forwardingCount = 0; const fieldCounts = new Map(); for (const member of candidates) { const bodyLines = bodyStatementLines(lines, member, isPython); if (bodyLines.length !== 1) continue; const match = forwardRe.exec(bodyLines[0]); if (!match) continue; const [, field, calledMethod, argsText] = match; const paramNames = paramNamesFromSignature(member.signature, languageId); if (paramNames === undefined) continue; // destructured params — ineligible const args = splitArgs(argsText); if (args.length !== paramNames.length) continue; if (!args.every((a, i) => a === paramNames[i])) continue; void calledMethod; forwardingCount += 1; fieldCounts.set(field, (fieldCounts.get(field) ?? 0) + 1); } if (forwardingCount === 0) return undefined; // "One held field" (#325) — require every forward to target the SAME // field. Two-or-more distinct delegate fields is a different shape // (multi-target wrapper) and out of scope for this flag; be conservative. if (fieldCounts.size !== 1) return undefined; const [delegateField] = [...fieldCounts.keys()]; return { delegationRatio: forwardingCount / candidates.length, candidateCount: candidates.length, forwardingCount, delegateField, }; } /** True when the class structurally looks like an intentional interface- * forwarding shape — `implements X` on the declaration line(s), which is * a legitimate, common reason for a class to be "all delegation" (a typed * adapter satisfying an interface). Scans the declaration line plus one * continuation line to tolerate simple wraps. */ function implementsInterface(lines, classEntry) { const declText = lines .slice(classEntry.startLine - 1, Math.min(classEntry.startLine + 1, lines.length)) .join(" "); return /\bimplements\b/.test(declText); } /** * Mutates `entries` in place (same convention as the rest of module-report's * flag computation): every class-kind entry that clears the delegation-ratio * threshold, isn't a named facade/adapter/proxy/wrapper/decorator, and isn't * a structural interface-forwarder gets `flags: [..., "middle man"]` and a * `delegationRatio` field. Operates on the FLAT entries list (post-nesting, * so `members` is populated) — nested/inner classes are analyzed the same as * top-level ones. */ export function annotateMiddleMan(entries, content, languageId) { if (!languageId || !SELF_TOKEN[languageId]) return; const lines = content.split(/\r?\n/); for (const entry of entries) { if (entry.kind !== "class") continue; if (INTENTIONAL_FORWARDER_NAME.test(entry.name)) continue; if (implementsInterface(lines, entry)) continue; const signal = analyzeMiddleMan(entry, lines, languageId); if (!signal) continue; if (signal.delegationRatio < DELEGATION_RATIO_THRESHOLD) continue; entry.delegationRatio = signal.delegationRatio; entry.flags = entry.flags ? [...entry.flags, "middle man"] : ["middle man"]; } }