169 lines
8.7 KiB
TypeScript
169 lines
8.7 KiB
TypeScript
import type { ErrorCode } from "./error";
|
|
|
|
export const LIBRARY_NEGATIVE_CASE_SCHEMA = 1 as const;
|
|
|
|
export interface LibraryNegativeLibraryIR {
|
|
libraryId: string;
|
|
dependencyIds: string[];
|
|
}
|
|
|
|
export interface LibraryNegativeDataBlockIR {
|
|
dataBlockId: string;
|
|
sourceLibraryId: string;
|
|
}
|
|
|
|
export interface LibraryNegativeCrossReferenceIR {
|
|
fromLibraryId: string;
|
|
toLibraryId: string;
|
|
}
|
|
|
|
export interface LibraryNegativeReloadIR {
|
|
sourceLibraryId: string;
|
|
generation: number;
|
|
}
|
|
|
|
export interface LibraryNegativeInputIR {
|
|
schemaVersion: typeof LIBRARY_NEGATIVE_CASE_SCHEMA;
|
|
libraries: LibraryNegativeLibraryIR[];
|
|
dataBlocks: LibraryNegativeDataBlockIR[];
|
|
crossReferences: LibraryNegativeCrossReferenceIR[];
|
|
reloads: LibraryNegativeReloadIR[];
|
|
}
|
|
|
|
export interface LibraryNegativeValidationIR {
|
|
status: "VALID";
|
|
}
|
|
|
|
export class LibraryNegativeValidationError extends Error {
|
|
readonly code: ErrorCode;
|
|
readonly path?: string;
|
|
|
|
constructor(code: ErrorCode, message: string, path?: string) {
|
|
super(`${code}: ${message}`);
|
|
this.name = "LibraryNegativeValidationError";
|
|
this.code = code;
|
|
this.path = path;
|
|
}
|
|
}
|
|
|
|
const LIBRARY_ID = /^library:[a-f0-9]{64}$/;
|
|
const DATA_BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9:._/ -]{0,255}$/;
|
|
const MAX_ENTRIES = 10_000;
|
|
|
|
function record(value: unknown, path: string): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path} must be an object`, path);
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
|
|
const actual = Object.keys(value).sort();
|
|
const allowed = [...expected].sort();
|
|
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path} contains undeclared fields`, path);
|
|
}
|
|
|
|
function libraryId(value: unknown, path: string): string {
|
|
if (typeof value !== "string" || !LIBRARY_ID.test(value)) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path} must be a library identity`, path);
|
|
return value;
|
|
}
|
|
|
|
function dataBlockId(value: unknown, path: string): string {
|
|
if (typeof value !== "string" || !DATA_BLOCK_ID.test(value)) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path} is invalid`, path);
|
|
return value;
|
|
}
|
|
|
|
function integer(value: unknown, path: string): number {
|
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path} must be a safe integer >= 0`, path);
|
|
return value;
|
|
}
|
|
|
|
function parseLibrary(value: unknown, path: string): LibraryNegativeLibraryIR {
|
|
const item = record(value, path);
|
|
exactKeys(item, ["libraryId", "dependencyIds"], path);
|
|
if (!Array.isArray(item.dependencyIds) || item.dependencyIds.length > MAX_ENTRIES) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path}.dependencyIds exceeds its bound`, path);
|
|
return { libraryId: libraryId(item.libraryId, `${path}.libraryId`), dependencyIds: item.dependencyIds.map((dependency, index) => libraryId(dependency, `${path}.dependencyIds[${index}]`)) };
|
|
}
|
|
|
|
function parseDataBlock(value: unknown, path: string): LibraryNegativeDataBlockIR {
|
|
const item = record(value, path);
|
|
exactKeys(item, ["dataBlockId", "sourceLibraryId"], path);
|
|
return { dataBlockId: dataBlockId(item.dataBlockId, `${path}.dataBlockId`), sourceLibraryId: libraryId(item.sourceLibraryId, `${path}.sourceLibraryId`) };
|
|
}
|
|
|
|
function parseCrossReference(value: unknown, path: string): LibraryNegativeCrossReferenceIR {
|
|
const item = record(value, path);
|
|
exactKeys(item, ["fromLibraryId", "toLibraryId"], path);
|
|
return { fromLibraryId: libraryId(item.fromLibraryId, `${path}.fromLibraryId`), toLibraryId: libraryId(item.toLibraryId, `${path}.toLibraryId`) };
|
|
}
|
|
|
|
function parseReload(value: unknown, path: string): LibraryNegativeReloadIR {
|
|
const item = record(value, path);
|
|
exactKeys(item, ["sourceLibraryId", "generation"], path);
|
|
return { sourceLibraryId: libraryId(item.sourceLibraryId, `${path}.sourceLibraryId`), generation: integer(item.generation, `${path}.generation`) };
|
|
}
|
|
|
|
export function parseLibraryNegativeInput(value: unknown): LibraryNegativeInputIR {
|
|
const input = record(value, "input");
|
|
exactKeys(input, ["schemaVersion", "libraries", "dataBlocks", "crossReferences", "reloads"], "input");
|
|
if (input.schemaVersion !== LIBRARY_NEGATIVE_CASE_SCHEMA) throw new LibraryNegativeValidationError("PROTOCOL_MISMATCH", "Unsupported library negative-case schema", "schemaVersion");
|
|
const libraries = input.libraries;
|
|
const dataBlocks = input.dataBlocks;
|
|
const crossReferences = input.crossReferences;
|
|
const reloads = input.reloads;
|
|
if (!Array.isArray(libraries) || libraries.length > MAX_ENTRIES) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", "input.libraries exceeds its bound", "input.libraries");
|
|
if (!Array.isArray(dataBlocks) || dataBlocks.length > MAX_ENTRIES) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", "input.dataBlocks exceeds its bound", "input.dataBlocks");
|
|
if (!Array.isArray(crossReferences) || crossReferences.length > MAX_ENTRIES) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", "input.crossReferences exceeds its bound", "input.crossReferences");
|
|
if (!Array.isArray(reloads) || reloads.length > MAX_ENTRIES) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", "input.reloads exceeds its bound", "input.reloads");
|
|
return {
|
|
schemaVersion: LIBRARY_NEGATIVE_CASE_SCHEMA,
|
|
libraries: libraries.map((item, index) => parseLibrary(item, `libraries[${index}]`)),
|
|
dataBlocks: dataBlocks.map((item, index) => parseDataBlock(item, `dataBlocks[${index}]`)),
|
|
crossReferences: crossReferences.map((item, index) => parseCrossReference(item, `crossReferences[${index}]`)),
|
|
reloads: reloads.map((item, index) => parseReload(item, `reloads[${index}]`)),
|
|
};
|
|
}
|
|
|
|
function assertNoCycle(nodes: Set<string>, edges: Map<string, string[]>, label: string): void {
|
|
const active = new Set<string>();
|
|
const complete = new Set<string>();
|
|
const visit = (node: string): void => {
|
|
if (active.has(node)) throw new LibraryNegativeValidationError("LIBRARY_DEPENDENCY_CYCLE", `${label} contains a cycle at ${node}`, label);
|
|
if (complete.has(node)) return;
|
|
active.add(node);
|
|
for (const dependency of edges.get(node) ?? []) {
|
|
if (!nodes.has(dependency)) throw new LibraryNegativeValidationError("ASSET_MANIFEST_INVALID", `${label} references a missing library ${dependency}`, label);
|
|
visit(dependency);
|
|
}
|
|
active.delete(node);
|
|
complete.add(node);
|
|
};
|
|
for (const node of nodes) visit(node);
|
|
}
|
|
|
|
export function validateLibraryNegativeInput(value: unknown): LibraryNegativeValidationIR {
|
|
const input = parseLibraryNegativeInput(value);
|
|
const libraries = new Set(input.libraries.map((item) => item.libraryId));
|
|
if (libraries.size !== input.libraries.length) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", "duplicate library ID", "libraries");
|
|
const libraryEdges = new Map(input.libraries.map((item) => [item.libraryId, item.dependencyIds]));
|
|
assertNoCycle(libraries, libraryEdges, "library dependencies");
|
|
const crossEdges = new Map<string, string[]>();
|
|
for (const item of input.crossReferences) {
|
|
if (!libraries.has(item.fromLibraryId) || !libraries.has(item.toLibraryId)) throw new LibraryNegativeValidationError("ASSET_MANIFEST_INVALID", "cross-library reference names a missing library", "crossReferences");
|
|
crossEdges.set(item.fromLibraryId, [...(crossEdges.get(item.fromLibraryId) ?? []), item.toLibraryId]);
|
|
}
|
|
assertNoCycle(libraries, crossEdges, "cross-library references");
|
|
const dataBlocks = new Set<string>();
|
|
for (const item of input.dataBlocks) {
|
|
if (!libraries.has(item.sourceLibraryId)) throw new LibraryNegativeValidationError("ASSET_SOURCE_HASH_MISMATCH", "data-block source library is missing", "dataBlocks");
|
|
if (dataBlocks.has(item.dataBlockId)) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `duplicate data-block ID ${item.dataBlockId}`, "dataBlocks");
|
|
dataBlocks.add(item.dataBlockId);
|
|
}
|
|
const reloads = new Set<string>();
|
|
for (const item of input.reloads) {
|
|
if (!libraries.has(item.sourceLibraryId)) throw new LibraryNegativeValidationError("ASSET_SOURCE_HASH_MISMATCH", "reload source library is missing", "reloads");
|
|
const identity = `${item.sourceLibraryId}:${item.generation}`;
|
|
if (reloads.has(identity)) throw new LibraryNegativeValidationError("REVISION_CONFLICT", `duplicate reload ${identity}`, "reloads");
|
|
reloads.add(identity);
|
|
}
|
|
return { status: "VALID" };
|
|
}
|