codemods/analyzers/identifier.analyzer.ts

codemods/analyzers/identifier.analyzer.ts is a file in GovLab Extension Host. 149 lines of code and 36 definitions.

import type { IdentifierFinding, RenameLocation } from "../../types/analyzer.types.ts";
import { inRepo, lineOf, relPath, toPosix } from "../selectors/program.selector.ts";
import ts from "typescript";

const UPPER_A = 65;
const UPPER_Z = 90;
const DIGIT_0 = 48;
const DIGIT_9 = 57;
const UNDERSCORE = 95;

const isUpperAlpha = function isUpperAlpha(code: number): boolean {
    return code >= UPPER_A && code <= UPPER_Z;
};

const isConstantCase = function isConstantCase(name: string): boolean {
    for (let i = 0; i < name.length; i += 1) {
        const code = name.codePointAt(i) ?? 0;
        const allowed = isUpperAlpha(code) || code === UNDERSCORE || (code >= DIGIT_0 && code <= DIGIT_9);
        if (!allowed) {
            return false;
        }
    }
    return true;
};

const isPascalCase = function isPascalCase(name: string): boolean {
    if (name.length === 0 || !isUpperAlpha(name.codePointAt(0) ?? 0)) {
        return false;
    }
    return !isConstantCase(name);
};

const camelized = function camelized(name: string): string {
    return name.charAt(0).toLowerCase() + name.slice(1);
};

const isGenerated = function isGenerated(fileName: string): boolean {
    return toPosix(fileName).includes(".generated.");
};

const isConstDeclaration = function isConstDeclaration(node: ts.VariableDeclaration): boolean {
    const list = node.parent;
    if (!ts.isVariableDeclarationList(list)) {
        return false;
    }
    return (list.flags & ts.NodeFlags.Const) !== 0;
};

const declarationsOf = function declarationsOf(sourceFile: ts.SourceFile): ts.VariableDeclaration[] {
    const out: ts.VariableDeclaration[] = [];
    const visit = function visit(node: ts.Node): void {
        if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && isConstDeclaration(node)) {
            out.push(node);
        }
        ts.forEachChild(node, visit);
    };
    visit(sourceFile);
    return out;
};

const unaliased = function unaliased(checker: ts.TypeChecker, symbol: ts.Symbol | undefined): ts.Symbol | undefined {
    if (!symbol) {
        return undefined;
    }
    return (symbol.flags & ts.SymbolFlags.Alias) === 0 ? symbol : checker.getAliasedSymbol(symbol);
};

const targetSymbol = function targetSymbol(checker: ts.TypeChecker, node: ts.Identifier): ts.Symbol | undefined {
    const { parent } = node;
    if (ts.isShorthandPropertyAssignment(parent)) {
        return unaliased(checker, checker.getShorthandAssignmentValueSymbol(parent));
    }
    return unaliased(checker, checker.getSymbolAtLocation(node));
};

const shorthandBlocked = function shorthandBlocked(node: ts.Identifier): boolean {
    const { parent } = node;
    return ts.isShorthandPropertyAssignment(parent) || ts.isBindingElement(parent);
};

const identifiersIn = function identifiersIn(sourceFile: ts.SourceFile): ts.Identifier[] {
    const out: ts.Identifier[] = [];
    const visit = function visit(node: ts.Node): void {
        if (ts.isIdentifier(node)) {
            out.push(node);
        }
        ts.forEachChild(node, visit);
    };
    visit(sourceFile);
    return out;
};

const collectReferences = function collectReferences(
    program: ts.Program,
    files: readonly ts.SourceFile[],
    declared: ts.Symbol,
): { blocked: boolean; locations: RenameLocation[] } {
    const checker = program.getTypeChecker();
    const referencing = files.flatMap((sourceFile) =>
        identifiersIn(sourceFile)
            .filter((node) => targetSymbol(checker, node) === declared)
            .map((node) => ({ node, sourceFile })),
    );
    const locations = referencing
        .filter((hit) => !shorthandBlocked(hit.node))
        .map((hit) => ({
            end: hit.node.getEnd(),
            fileName: hit.sourceFile.fileName,
            start: hit.node.getStart(hit.sourceFile),
        }));
    return { blocked: referencing.some((hit) => shorthandBlocked(hit.node)), locations };
};

const nameTakenInFile = function nameTakenInFile(sourceFile: ts.SourceFile, name: string): boolean {
    const locals: unknown = Reflect.get(sourceFile, "locals");
    return locals instanceof Map && locals.has(name);
};

const blockedReason = function blockedReason(blocked: boolean, collides: boolean, to: string): string | null {
    if (collides) {
        return `the file already declares '${to}', so renaming would shadow or collide with an existing binding`;
    }
    if (blocked) {
        return "the name is used in a shorthand property or binding pattern, where renaming would silently change the property key";
    }
    return null;
};

const findingsIn = function findingsIn(
    program: ts.Program,
    files: readonly ts.SourceFile[],
    sourceFile: ts.SourceFile,
): IdentifierFinding[] {
    const checker = program.getTypeChecker();
    const out: IdentifierFinding[] = [];
    const renameable = declarationsOf(sourceFile)
        .map((declaration) => declaration.name)
        .filter((name) => ts.isIdentifier(name) && isPascalCase(name.text));
    for (const name of renameable) {
        const declared = ts.isIdentifier(name) ? targetSymbol(checker, name) : undefined;
        if (declared === undefined || !ts.isIdentifier(name)) {
            continue;
        }
        const to = camelized(name.text);
        const { blocked, locations } = collectReferences(program, files, declared);
        out.push({
            file: relPath(sourceFile.fileName),
            fileName: sourceFile.fileName,
            from: name.text,
            line: lineOf(sourceFile, name),
            locations: locations.filter((location) => inRepo(location.fileName) && !isGenerated(location.fileName)),
            reason: blockedReason(blocked, nameTakenInFile(sourceFile, to), to),
            to,
        });
    }
    return out;
};

export const scanProgram = function scanProgram(
    program: ts.Program,
    files: readonly ts.SourceFile[],
): IdentifierFinding[] {
    return files
        .filter((sourceFile) => !isGenerated(sourceFile.fileName))
        .flatMap((sourceFile) => findingsIn(program, files, sourceFile));
};