Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions eslint-effects/ast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type { Rule } from "eslint";

/**
Read an AST field after checking the container.
@param value - Possible AST node.
@param key - Field to read.
@returns The field, or undefined.
*/
export function field(value: unknown, key: string): unknown {
return typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined;
}

/**
Read a string field without a type assertion.
@param value - Possible AST node.
@param key - Field to read.
@returns The string, or an empty string.
*/
export function text(value: unknown, key: string): string {
const result = field(value, key);

return typeof result === "string" ? result : "";
}

/**
Read a node's type.
@param value - Possible AST node.
@returns The type, or an empty string.
*/
export function kind(value: unknown): string {
return text(value, "type");
}

/**
Resolve a lexical binding at an expression.
@param context - Active rule context.
@param node - Expression whose scope is needed.
@param name - Identifier to resolve.
@returns The nearest variable, or undefined for an undeclared global.
*/
export function binding(context: Rule.RuleContext, node: Rule.Node, name: string) {
let scope = context.sourceCode.getScope(node);

for (;;) {
const variable = scope.set.get(name);

if (variable) {
return variable;
}

if (!scope.upper) {
return;
}

scope = scope.upper;
}
}

/**
Check whether a node lies inside another node's source range.
@param node - Node to locate.
@param container - Possible enclosing node.
@returns Whether both ranges exist and the node is enclosed.
*/
export function isWithin(node: unknown, container: unknown): boolean {
const range = field(node, "range");
const bounds = field(container, "range");

return (
Array.isArray(range) &&
Array.isArray(bounds) &&
typeof range[0] === "number" &&
typeof range[1] === "number" &&
typeof bounds[0] === "number" &&
typeof bounds[1] === "number" &&
range[0] >= bounds[0] &&
range[1] <= bounds[1]
);
}

/**
Read an explicit function name or its direct binding/property name.
@param node - Function to inspect.
@returns The name, or an empty string for anonymous callbacks and computed keys.
*/
export function functionName(node: unknown): string {
const own = text(field(node, "id"), "name");
const parent = field(node, "parent");

if (own || kind(parent) === "VariableDeclarator") {
return own || text(field(parent, "id"), "name");
}

return ["MethodDefinition", "PropertyDefinition"].includes(kind(parent)) && !field(parent, "computed")
? text(field(parent, "key"), "name")
: "";
}

/**
Split conventional camelCase, PascalCase, acronym, and snake_case names into words.
@param name - Function name to split.
@returns Lowercase words with no substring matching.
*/
export function words(name: string): string[] {
return name
.replaceAll(/([A-Z]+)([A-Z][a-z])/gu, "$1 $2")
.replaceAll(/([a-z\d])([A-Z])/gu, "$1 $2")
.toLowerCase()
.split(/[^a-z\d]+/u)
.filter(Boolean);
}
71 changes: 71 additions & 0 deletions eslint-effects/computation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { binding, field, isWithin, kind, text } from "./ast.ts";
import type { Rule } from "eslint";

/**
Check a small expression grammar for a direct calculation over parameters and literals.
@param node - Expression to inspect.
@param parameters - Plain parameter names, excluding destructuring and defaults.
@returns Whether the expression fits the grammar. This is a refactoring candidate, not a purity proof.
*/
function isCalculationPart(node: unknown, parameters: Set<string>): boolean {
if (kind(node) === "Literal") {
return typeof field(node, "value") === "number" || typeof field(node, "value") === "string";
}

return kind(node) === "Identifier"
? parameters.has(text(node, "name"))
: kind(node) === "BinaryExpression" &&
["+", "-", "*", "/", "%", "**"].includes(text(node, "operator")) &&
isCalculationPart(field(node, "left"), parameters) &&
isCalculationPart(field(node, "right"), parameters);
}

/**
Count operators and input references in the accepted arithmetic grammar.
@param node - Expression to inspect.
@param names - Parameter names resolved at the return statement.
@returns Operator and input counts for the candidate.
*/
function calculationSize(node: unknown, names: Set<string>): { operators: number; inputs: number } {
if (kind(node) === "BinaryExpression") {
const left = calculationSize(field(node, "left"), names);
const right = calculationSize(field(node, "right"), names);

return { operators: 1 + left.operators + right.operators, inputs: left.inputs + right.inputs };
}

return { operators: 0, inputs: kind(node) === "Identifier" && names.has(text(node, "name")) ? 1 : 0 };
}

/**
Identify a returned arithmetic expression over plain parameters.
@param argument - Returned expression.
@param owner - Function that owns the return.
@param context - Rule context used to resolve shadowed parameters.
@param statement - Return statement with the relevant scope.
@returns Whether extraction of a calculation can be considered.
*/
export function isReturnedCalculation(
argument: unknown,
owner: unknown,
context: Rule.RuleContext,
statement: Rule.Node
): boolean {
const params = field(owner, "params");
const candidates = new Set(
Array.isArray(params)
? params.filter((item: unknown) => kind(item) === "Identifier").map((item: unknown) => text(item, "name"))
: []
);

const names = new Set(
[...candidates].filter((name) => {
const definition = binding(context, statement, name)?.defs[0];

return definition?.type === "Parameter" && isWithin(definition.name, owner);
})
);
const size = calculationSize(argument, names);

return size.operators >= 2 && size.inputs > 0 && isCalculationPart(argument, names);
}
141 changes: 141 additions & 0 deletions eslint-effects/detection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { binding, field, isWithin, kind, text } from "./ast.ts";
import { filesystemCalls, globalEffects } from "./vocabulary.ts";
import type { Rule } from "eslint";

export interface Effect {
category: string;
evidence: string;
}

/**
Read a static dotted call path. Computed properties deliberately remain unknown.
@param node - Callee to inspect.
@returns The path segments, or an empty array.
*/
function callPath(node: unknown): string[] {
if (kind(node) === "Identifier") {
return [text(node, "name")];
}

if (kind(node) === "MemberExpression" && !field(node, "computed")) {
const base = callPath(field(node, "object"));

return base.length > 0 ? [...base, text(field(node, "property"), "name")] : [];
}

return [];
}

/**
Resolve supported ES imports to their module and exported path.
@param definition - Import binding definition.
@param suffix - Member path after the local binding.
@returns The canonical module and exported member path.
*/
function importedCall(definition: unknown, suffix: string[]) {
const specifier = field(definition, "node");
const moduleName = text(field(field(definition, "parent"), "source"), "value");
const imported = field(specifier, "imported");
const member = kind(specifier) === "ImportSpecifier" ? text(imported, "name") || text(imported, "value") : "";
const path = member ? [member, ...suffix] : suffix;

return { moduleName: moduleName.replace(/^node:/u, ""), member: path.join(".") };
}

/**
Identify explicitly supported filesystem and process imports.
@param moduleName - Canonical module name.
@param member - Exported call path.
@returns The effect category when supported.
*/
function importedCategory(moduleName: string, member: string): string | undefined {
const fsMember = member.replace(/^promises\./u, "");

if (["fs", "fs/promises"].includes(moduleName) && filesystemCalls.has(fsMember)) {
return "filesystem";
}

return moduleName === "child_process" &&
["spawn", "spawnSync", "exec", "execSync", "execFile", "execFileSync", "fork"].includes(member)
? "process"
: undefined;
}

/**
Recognize direct ambient calls and lexical Node imports, never arbitrary method names.
@param context - Active rule context.
@param node - Call expression.
@param customCalls - Explicit project-owned global call paths mapped to categories.
@returns High-confidence evidence, or undefined.
*/
export function callEffect(
context: Rule.RuleContext,
node: Rule.Node,
customCalls: Record<string, string>
): Effect | undefined {
const path = callPath(field(node, "callee"));
const root = path[0];
const variable = root ? binding(context, node, root) : undefined;
const definition = variable?.defs[0];
const direct = path.join(".");

if (definition?.type === "ImportBinding") {
const imported = importedCall(definition, path.slice(1));
const importedEffect = importedCategory(imported.moduleName, imported.member);

return importedEffect
? { category: importedEffect, evidence: `${imported.moduleName}.${imported.member}()` }
: undefined;
}

if (!variable || variable.defs.length === 0) {
const configured = Object.hasOwn(customCalls, direct) ? customCalls[direct] : undefined;
const standard = Object.hasOwn(globalEffects, direct) ? globalEffects[direct] : undefined;
const category = configured ?? standard;

return category ? { category, evidence: `${direct}()` } : undefined;
}

return undefined;
}

/**
Find the root of a property-write target.
@param node - Assignment, update, or delete target.
@returns Its root identifier or this expression.
*/
function writeRoot(node: unknown): unknown {
return kind(node) === "MemberExpression" ? writeRoot(field(node, "object")) : node;
}

/**
Recognize writes through inputs, this, or module bindings. Captured locals and aliases remain unknown.
@param context - Active rule context.
@param node - Write expression.
@param owner - Nearest enclosing function.
@returns State evidence, or undefined.
*/
export function writeEffect(context: Rule.RuleContext, node: Rule.Node, owner: Rule.Node): Effect | undefined {
const target = field(node, kind(node) === "AssignmentExpression" ? "left" : "argument");
const root = writeRoot(target);
const name = text(root, "name");
const variable = name ? binding(context, node, name) : undefined;
const definition = variable?.defs[0];
const isMember = kind(target) === "MemberExpression";

if (isMember && kind(root) === "ThisExpression") {
return { category: "state", evidence: "write through this" };
}

if (!definition) {
return undefined;
}

if (definition.type === "Parameter" && isWithin(definition.name, owner)) {
return isMember ? { category: "state", evidence: `write through input ${name}` } : undefined;
}

return !isWithin(definition.name, owner) && ["module", "global"].includes(variable.scope.variableScope.type)
? { category: "state", evidence: `write to module binding ${name}` }
: undefined;
}
Loading
Loading