codemods/analyzers/location.analyzer.ts

codemods/analyzers/location.analyzer.ts is a file in GovLab Extension Host. 181 lines of code and 43 definitions.

import {
    CODEMOD_TSCONFIGS,
    inRepo,
    lineOf,
    programFor,
    relPath,
    repoSourceFiles,
    toPosix,
} from "../selectors/program.selector.ts";
import type { LiteralSite, LocationFinding } from "../../types/analyzer.types.ts";
import { ROOT, paths } from "@ssot/paths";
import { existsSync } from "node:fs";
import path from "node:path";
import ts from "typescript";

const SSOT_MODULE = "@ssot/paths";
const SSOT_ACCESSOR = "relativePath";
const ROOT_POSIX = toPosix(ROOT);

const PATH_CALLEES = new Set([
    "existsSync",
    "join",
    "mkdirSync",
    "readFileSync",
    "readdirSync",
    "resolve",
    "rmSync",
    "statSync",
    "writeFileSync",
]);

const PATH_NAME_SUFFIXES = ["DIR", "DIRS", "FILE", "FILES", "GLOB", "PATH", "PATHS", "ROOT"];

const keyIndex = function keyIndex(): [string, string][] {
    const flat: [string, string][] = [];
    const walk = function walk(node: unknown, trail: string[]): void {
        if (typeof node === "string") {
            const posix = toPosix(node);
            if (posix.startsWith(`${ROOT_POSIX}/`)) {
                flat.push([trail.join("."), posix.slice(ROOT_POSIX.length + 1)]);
            }
            return;
        }
        if (typeof node !== "object" || node === null) {
            return;
        }
        for (const [key, value] of Object.entries(node)) {
            walk(value, [...trail, key]);
        }
    };
    walk(paths, []);
    return flat.sort((a, b) => b[1].length - a[1].length);
};

const KEYS = keyIndex();

const matchOf = function matchOf(value: string): { key: string; rest: string } | null {
    const posix = toPosix(value);
    for (const [key, token] of KEYS) {
        if (posix === token) {
            return { key, rest: "" };
        }
        if (posix.startsWith(`${token}/`)) {
            return { key, rest: posix.slice(token.length) };
        }
    }
    return null;
};

const isPathName = function isPathName(name: string): boolean {
    const upper = name.toUpperCase();
    return PATH_NAME_SUFFIXES.some((suffix) => upper.endsWith(suffix));
};

const calleeOf = function calleeOf(call: ts.CallExpression): string {
    const callee = call.expression;
    return ts.isPropertyAccessExpression(callee) ? callee.name.text : (ts.isIdentifier(callee) ? callee.text : "");
};

const usedAsPath = function usedAsPath(chain: readonly ts.Node[]): boolean {
    for (let index = chain.length - 1; index >= 0; index -= 1) {
        const node = chain[index];
        if (node === undefined) {
            return false;
        }
        if (ts.isCallExpression(node)) {
            return PATH_CALLEES.has(calleeOf(node));
        }
        if (ts.isVariableDeclaration(node)) {
            return ts.isIdentifier(node.name) && isPathName(node.name.text);
        }
        if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name)) {
            return isPathName(node.name.text);
        }
        if (!ts.isArrayLiteralExpression(node) && !ts.isParenthesizedExpression(node)) {
            return false;
        }
    }
    return false;
};

const isSsotImport = function isSsotImport(statement: ts.Statement): statement is ts.ImportDeclaration {
    if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
        return false;
    }
    return statement.moduleSpecifier.text === SSOT_MODULE;
};

const ssotLocalName = function ssotLocalName(source: ts.SourceFile): string | null {
    for (const statement of source.statements.filter(isSsotImport)) {
        const bindings = statement.importClause?.namedBindings;
        if (bindings === undefined || !ts.isNamedImports(bindings)) {
            continue;
        }
        for (const element of bindings.elements) {
            if ((element.propertyName?.text ?? element.name.text) === SSOT_ACCESSOR) {
                return element.name.text;
            }
        }
    }
    return null;
};

const SSOT_CALLEES = new Set([SSOT_ACCESSOR, "absolutePath"]);

const viaSsot = function viaSsot(chain: readonly ts.Node[]): boolean {
    const parent = chain.at(-1);
    return parent !== undefined && ts.isCallExpression(parent) && SSOT_CALLEES.has(calleeOf(parent));
};

const findingFor = function findingFor(source: ts.SourceFile, site: LiteralSite): LocationFinding | null {
    const literal = site.node;
    const match = matchOf(literal.text);
    if (match === null || viaSsot(site.chain)) {
        return null;
    }
    const local = ssotLocalName(source);
    const accessor = local ?? SSOT_ACCESSOR;
    const resolves = existsSync(path.join(ROOT, literal.text));
    const positionReason = usedAsPath(site.chain)
        ? null
        : `'${literal.text}' spells a workspace location but is not in a path-forming position — confirm whether it is a path, a declaration key, or a fixture before rewriting`;
    const blocked = resolves
        ? positionReason
        : `'${literal.text}' does not exist on disk — it may have been renamed and moved at once, so the intended target must be confirmed by hand`;
    const call = `${accessor}("${match.key}")`;
    return {
        end: literal.getEnd(),
        file: relPath(source.fileName),
        from: literal.text,
        line: lineOf(source, literal),
        localName: accessor,
        needsImport: local === null,
        reason: blocked,
        start: literal.getStart(source),
        to: match.rest.length > 0 ? `\`\${${call}}${match.rest}\`` : call,
    };
};

const stringLiterals = function stringLiterals(source: ts.SourceFile): LiteralSite[] {
    const out: LiteralSite[] = [];
    const visit = function visit(node: ts.Node, chain: ts.Node[]): void {
        const parent = chain.at(-1);
        const isSpecifier = parent !== undefined && (ts.isImportDeclaration(parent) || ts.isExportDeclaration(parent));
        if (ts.isStringLiteral(node) && !isSpecifier) {
            out.push({ chain, node });
        }
        ts.forEachChild(node, (child) => {
            visit(child, [...chain, node]);
        });
    };
    visit(source, []);
    return out;
};

const findingsInSource = function findingsInSource(source: ts.SourceFile): LocationFinding[] {
    return stringLiterals(source)
        .map((site) => findingFor(source, site))
        .filter((finding): finding is LocationFinding => finding !== null);
};

const findingsInProgram = function findingsInProgram(tsconfig: string): LocationFinding[] {
    return repoSourceFiles(programFor(tsconfig))
        .filter((source) => inRepo(source.fileName) && !source.isDeclarationFile)
        .flatMap(findingsInSource);
};

export const collectLocationFindings = function collectLocationFindings(): LocationFinding[] {
    const seen = new Set<string>();
    const isFirstAtSite = function isFirstAtSite(finding: LocationFinding): boolean {
        const key = `${finding.file}:${String(finding.start)}`;
        if (seen.has(key)) {
            return false;
        }
        seen.add(key);
        return true;
    };
    return CODEMOD_TSCONFIGS.flatMap(findingsInProgram).filter(isFirstAtSite);
};