core/coordinators/validation.coordinator.ts

core/coordinators/validation.coordinator.ts is a file in GovLab Docs. 182 lines of code and 46 definitions.

import type { Categories, Finding, NameFinding, PerDocEntry } from "#types/finding.types";
import type { DocGraph, DocMeta, DocNode } from "#types/document.types";
import { REF_COLLECTIONS, createGovlabContext } from "@govlab/context";
import {
    analyzeSync,
    docMeta,
    docNodeFor,
    locationEntryOf,
    nameFindingFor,
    refScanOf,
} from "#core/analyzers/document.analyzer";
import { countFindings, sumCategories } from "#core/counters/finding.counter";
import {
    deadGovernsEdge,
    deadNameEdge,
    dependsOnCycle,
    docNameFinding,
    duplicateDocName,
} from "#configuration/strings/document.strings";
import { declaredSlotsOf, slotFamiliesOf, slotRefs } from "#core/parsers/binding.parser";
import { expectedText, summaryLine } from "#core/formatters/finding.formatter";
import { print, printErr } from "#core/reporters/base.reporter";
import { unboundSlot, unresolvedOntologyRef } from "#configuration/strings/reference.strings";
import type { ChartChecker } from "#types/diagram.types";
import type { DocEntry } from "#types/location.types";
import type { ValidateCtx } from "#types/environment.types";
import { buildDocGraph } from "#core/analyzers/graph.analyzer";
import { collidePaths } from "#core/analyzers/location.analyzer";
import { createMermaidChecker } from "#core/validators/diagram.validator";
import { emitFindings } from "#core/reporters/finding.reporter";
import { existsSync } from "node:fs";
import { isGeneratedDoc } from "#core/predicates/document.predicate";
import { join } from "node:path";
import { ontologyRefs } from "#core/parsers/ontology.parser";
import { pathCollision } from "#configuration/strings/finding.strings";
import { readTextSafe } from "#core/loaders/base.loader";
import { resolveRefs } from "#core/validators/reference.validator";

const POSIX_SEPARATOR = "/";
const CYCLE_JOIN = " → ";
const SOURCE_JOIN = ", ";

interface SlotTable {
    declared: ReadonlySet<string>;
    families: ReadonlySet<string>;
}

export class DocValidator {
    private readonly context: ValidateCtx;
    private readonly docNodes: DocNode[] = [];
    private readonly locationEntries: DocEntry[] = [];
    private readonly mermaid: ChartChecker;
    private readonly nameFindings: NameFinding[] = [];
    private resolveRef: ((ref: string) => boolean) | null = null;
    private slots: SlotTable | null = null;

    public constructor(context: ValidateCtx, mermaid: ChartChecker = createMermaidChecker()) {
        this.context = context;
        this.mermaid = mermaid;
    }

    public async run(docs: readonly string[]): Promise<number> {
        const entries = await Promise.all(docs.map(async (doc) => this.analyze(doc)));
        const perDoc = entries.filter((entry): entry is PerDocEntry => entry !== null);
        return this.report(docs.length, perDoc);
    }

    private async analyze(doc: string): Promise<PerDocEntry | null> {
        const source = readTextSafe(doc) ?? "";
        if (isGeneratedDoc(source)) {
            return null;
        }
        const meta = docMeta(this.context, doc, source);
        this.register(meta);
        const all: Categories = {
            ...analyzeSync(this.context, meta),
            ontologyRefs: this.ontologyFindings(meta),
            refsResolved: this.refFindings(meta),
            slotRefs: this.slotFindings(meta),
            syntax: await this.mermaid.check(source),
        };
        return countFindings(all) === 0 ? null : { all, relDoc: meta.relDoc };
    }

    private refFindings(meta: DocMeta): Finding[] {
        const [segment = ""] = meta.relDoc.split(POSIX_SEPARATOR);
        const { root } = this.context;
        const roots = segment === "" ? [root] : [root, join(root, segment)];
        return resolveRefs(refScanOf(this.context, meta).constructs, { roots, verbs: this.context.userReg.refVerbs });
    }

    private ontologyFindings(meta: DocMeta): Finding[] {
        const refs = ontologyRefs(meta.source, REF_COLLECTIONS);
        if (refs.length === 0) {
            return [];
        }
        this.resolveRef ??= createGovlabContext().resolveRef;
        const resolves = this.resolveRef;
        return refs
            .filter((found) => !resolves(found.ref))
            .map((found) => ({ col: found.col, detail: unresolvedOntologyRef(found.ref), line: found.line }));
    }

    private slotTable(): SlotTable | null {
        const adapter = this.context.harnessAdapter;
        if (adapter === null) {
            return null;
        }
        if (this.slots === null) {
            const declared = declaredSlotsOf(readTextSafe(join(this.context.root, adapter)) ?? "");
            this.slots = { declared, families: slotFamiliesOf(declared) };
        }
        return this.slots;
    }

    private slotFindings(meta: DocMeta): Finding[] {
        const table = this.slotTable();
        if (table === null || meta.delegated || meta.relDoc === this.context.harnessAdapter) {
            return [];
        }
        return slotRefs(meta.source, table.families)
            .filter((found) => !table.declared.has(found.slot))
            .map((found) => ({ col: found.col, detail: unboundSlot(found.slot), line: found.line }));
    }

    private register(meta: DocMeta): void {
        const entry = locationEntryOf(this.context, meta);
        if (entry !== null) {
            this.locationEntries.push(entry);
        }
        const node = docNodeFor(this.context, meta);
        if (node !== null) {
            this.docNodes.push(node);
        }
        const names = nameFindingFor(this.context, meta);
        if (names !== null) {
            this.nameFindings.push(names);
        }
    }

    private report(scanned: number, perDoc: readonly PerDocEntry[]): number {
        for (const entry of perDoc) {
            emitFindings(entry.relDoc, entry.all);
        }
        const totals = {
            ...sumCategories(perDoc),
            collision: this.emitCollisions(),
            deadEdge: this.emitGraph(),
            name: this.emitNames(),
        };
        print(summaryLine(scanned, perDoc.length, totals));
        return Object.values(totals).reduce((sum, count) => sum + count, 0);
    }

    private emitCollisions(): number {
        const collisions = collidePaths(this.locationEntries, this.context.registries, this.context.locationOptions);
        for (const collision of collisions) {
            printErr(pathCollision(collision.path, collision.sources.length, collision.sources.join(SOURCE_JOIN)));
        }
        return collisions.length;
    }

    private emitGoverns(graph: DocGraph): number {
        const dead = graph.nodes.flatMap((node) =>
            node.governs
                .filter((target) => !existsSync(join(this.context.root, target)))
                .map((target) => ({ node, target })),
        );
        for (const { node, target } of dead) {
            printErr(deadGovernsEdge(node.relPath, target));
        }
        return dead.length;
    }

    private emitGraph(): number {
        const graph = buildDocGraph(this.docNodes);
        for (const edge of graph.deadEdges) {
            printErr(deadNameEdge(edge.relPath, edge.field, edge.target));
        }
        for (const cycle of graph.cycles) {
            printErr(dependsOnCycle(cycle.join(CYCLE_JOIN)));
        }
        for (const name of graph.duplicateNames) {
            printErr(duplicateDocName(name));
        }
        return this.emitGoverns(graph) + graph.deadEdges.length + graph.cycles.length + graph.duplicateNames.length;
    }

    private emitNames(): number {
        const flat = this.nameFindings.flatMap((finding) =>
            finding.names.map((hit) => ({ hit, relDoc: finding.relDoc })),
        );
        for (const { hit, relDoc } of flat) {
            printErr(docNameFinding(relDoc, hit.detail ?? "", expectedText(hit)));
        }
        return flat.length;
    }
}