core/analyzers/site.analyzer.ts

core/analyzers/site.analyzer.ts is a file in GovLab Stats. 102 lines of code and 21 definitions.

import type { AppStats, PageStats, Tally } from "#types/site.types";
import { PAGE_MARKER, STYLE_EXTENSION } from "#configuration/constants/site.constants";
import { existsSync, readFileSync, statSync } from "node:fs";
import { extensionOf, posixOf } from "#core/selectors/source.selector";
import { isAuthoredRole, roleOf } from "#core/classifiers/source.classifier";
import { readdirSafe, walkFiles } from "#core/loaders/folder.loader";
import type { PathExclusion } from "@govlab/quality/config";
import { countLines } from "#core/analyzers/text.analyzer";
import { isGeneratedPath } from "#core/predicates/source.predicate";
import path from "node:path";
import { relativePath } from "@ssot/paths";

const EMPTY: AppStats = {
    assetBytes: 0,
    assetFiles: 0,
    pages: [],
    present: false,
    sourceFiles: 0,
    sourceLines: 0,
    styles: 0,
    subsystems: [],
};

const descendantDirs = function descendantDirs(
    absDir: string,
    exclude: readonly string[],
    ignore: PathExclusion,
): number {
    return readdirSafe(absDir)
        .filter((entry) => entry.isDirectory())
        .map((entry) => path.join(absDir, entry.name))
        .filter((abs) => !ignore(abs) && !exclude.includes(abs))
        .reduce((count, abs) => count + 1 + descendantDirs(abs, exclude, ignore), 0);
};

const isAuthoredSource = function isAuthoredSource(abs: string): boolean {
    const role = roleOf(extensionOf(path.basename(abs)));
    return isAuthoredRole(role) && !isGeneratedPath(abs, abs);
};

const codeLinesOf = function codeLinesOf(abs: string): number {
    return countLines(readFileSync(abs, "utf8")).code;
};

const sourceLinesOf = function sourceLinesOf(files: readonly string[]): { files: number; lines: number } {
    const counted = files.filter(isAuthoredSource).map(codeLinesOf);
    return { files: counted.length, lines: counted.reduce((sum, lines) => sum + lines, 0) };
};

const pagesUnder = function pagesUnder(memberAbs: string, memberRel: string, ignore: PathExclusion): PageStats[] {
    const markers = walkFiles(memberAbs, ignore).filter((abs) => path.basename(abs) === PAGE_MARKER);
    const depths = markers.map((abs) => path.relative(memberAbs, abs).split(path.sep).length);
    const shallowest = Math.min(...depths, Number.MAX_SAFE_INTEGER);
    const pageDirs = markers.map((abs) => path.dirname(abs));
    return markers
        .map((abs, index) => {
            const dir = path.dirname(abs);
            const nested = pageDirs.filter((other) => other.startsWith(`${dir}${path.sep}`));
            const own = walkFiles(dir, ignore).filter(
                (file) => !nested.some((inner) => file.startsWith(`${inner}${path.sep}`)),
            );
            const counted = sourceLinesOf(own);
            return {
                files: counted.files,
                isRoot: depths.at(index) === shallowest,
                lines: counted.lines,
                name: path.basename(dir),
                rel: `${memberRel}/${posixOf(path.relative(memberAbs, dir))}`,
                systems: descendantDirs(dir, nested, ignore),
            };
        })
        .toSorted((a, b) => Number(a.isRoot) - Number(b.isRoot) || b.lines - a.lines);
};

const tallyOne = function tallyOne(tally: Tally, abs: string): void {
    if (extensionOf(path.basename(abs)) === STYLE_EXTENSION) {
        tally.styles += 1;
    }
    if (isAuthoredSource(abs)) {
        tally.sourceFiles += 1;
        tally.sourceLines += codeLinesOf(abs);
        return;
    }
    tally.assetBytes += statSync(abs).size;
    tally.assetFiles += 1;
};

export const collectApp = function collectApp(
    root: string,
    containerRoots: readonly string[],
    ignore: PathExclusion,
): AppStats {
    const memberRel = relativePath("app.root");
    const memberAbs = path.join(root, memberRel);
    if (!existsSync(memberAbs)) {
        return EMPTY;
    }
    const tally: Tally = { assetBytes: 0, assetFiles: 0, sourceFiles: 0, sourceLines: 0, styles: 0 };
    for (const abs of walkFiles(memberAbs, ignore)) {
        tallyOne(tally, abs);
    }
    const subsystems = containerRoots
        .map((container) => ({
            container,
            folders: readdirSafe(path.join(root, container)).filter((entry) => entry.isDirectory()).length,
        }))
        .filter((entry) => entry.folders > 0)
        .toSorted((a, b) => b.folders - a.folders);
    return { ...tally, pages: pagesUnder(memberAbs, memberRel, ignore), present: true, subsystems };
};