codemods/analyzers/field.vocabulary.analyzer.ts

codemods/analyzers/field.vocabulary.analyzer.ts is a file in GovLab Extension Host. 102 lines of code and 26 definitions.

import { declaredTarget, innerTypes, keyOf, within } from "../selectors/field.selector.ts";
import { lineOf, programFor, toPosix } from "../selectors/program.selector.ts";
import type { FieldScope } from "../../types/field.types.ts";
import type { PlainClosedValue } from "../../types/analyzer.types.ts";
import { relative } from "node:path";
import ts from "typescript";

const isLiteralSet = function isLiteralSet(checker: ts.TypeChecker, type: ts.Type): boolean {
    const parts = innerTypes(checker, type);
    return parts.length > 0 && parts.every((part) => part.isStringLiteral());
};

const vocabularyOf = function vocabularyOf(
    checker: ts.TypeChecker,
    value: ts.Expression,
    typeRoots: readonly string[],
): string | null {
    const named = ts.isPropertyAccessExpression(value) ? value.name : value;
    const declaration = checker.getSymbolAtLocation(named)?.declarations?.[0];
    if (
        declaration === undefined ||
        !ts.isPropertySignature(declaration) ||
        declaration.type === undefined ||
        !within(declaration.getSourceFile().fileName, typeRoots)
    ) {
        return null;
    }
    return isLiteralSet(checker, checker.getTypeAtLocation(value)) ? declaration.type.getText() : null;
};

const isPlainString = function isPlainString(checker: ts.TypeChecker, type: ts.Type): boolean {
    return innerTypes(checker, type).some((part) => (part.flags & ts.TypeFlags.String) !== 0);
};

interface Target {
    readonly key: string;
    readonly type: ts.Type;
}

const targetOf = function targetOf(
    checker: ts.TypeChecker,
    node: ts.PropertyAssignment | ts.ShorthandPropertyAssignment,
): Target | null {
    const declared = ts.isObjectLiteralExpression(node.parent) ? declaredTarget(checker, node.parent) : undefined;
    const name = node.name.getText();
    for (const type of declared === undefined ? [] : innerTypes(checker, declared)) {
        const property = type.getProperty(name);
        const declaration = property?.declarations?.[0];
        const key = declaration === undefined ? null : keyOf(declaration);
        if (property !== undefined && key !== null) {
            return { key, type: checker.getTypeOfSymbol(property) };
        }
    }
    return null;
};

const assignedValue = function assignedValue(node: ts.Node): ts.Expression | null {
    if (ts.isPropertyAssignment(node)) {
        return node.initializer;
    }
    return ts.isShorthandPropertyAssignment(node) ? node.name : null;
};

const plainValueAt = function plainValueAt(
    checker: ts.TypeChecker,
    node: ts.Node,
    scope: FieldScope,
): PlainClosedValue | null {
    const value = assignedValue(node);
    if (value === null || !(ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node))) {
        return null;
    }
    const vocabulary = vocabularyOf(checker, value, scope.typeRoots);
    const target = vocabulary === null ? null : targetOf(checker, node);
    if (vocabulary === null || target === null || !isPlainString(checker, target.type)) {
        return null;
    }
    const source = node.getSourceFile();
    return {
        file: toPosix(relative(process.cwd(), source.fileName)),
        line: lineOf(source, node),
        target: target.key,
        vocabulary,
    };
};

const childrenOf = function childrenOf(node: ts.Node): readonly ts.Node[] {
    const children: ts.Node[] = [];
    ts.forEachChild(node, (child) => {
        children.push(child);
    });
    return children;
};

const valuesUnder = function valuesUnder(
    checker: ts.TypeChecker,
    node: ts.Node,
    scope: FieldScope,
): readonly PlainClosedValue[] {
    const here = plainValueAt(checker, node, scope);
    const below = childrenOf(node).flatMap((child) => valuesUnder(checker, child, scope));
    return here === null ? below : [here, ...below];
};

export const plainClosedValues = function plainClosedValues(scope: FieldScope): readonly PlainClosedValue[] {
    const program = programFor(scope.tsconfig);
    const checker = program.getTypeChecker();
    return program
        .getSourceFiles()
        .filter((source) => !source.isDeclarationFile && within(source.fileName, scope.readerRoots))
        .flatMap((source) => valuesUnder(checker, source, scope));
};