import { existsSync, readFileSync } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { parse as parseYaml } from "yaml"; import { canonicalObject, digest, packageDigest } from "./canonical.js"; import { OPENONTOLOGY_VERSION } from "./types.js"; import type { BuiltPackage, DataSection, LoadedPackage, Manifest, SchemaSection } from "./types.js"; const SCHEMA_SECTIONS: SchemaSection[] = [ "namespaces", "entityTypes", "properties", "relationships", "constraints", "queries", "actions" ]; const DATA_SECTIONS: DataSection[] = ["entities", "claims", "sources", "evidence"]; const MANIFEST_NAMES = ["openontology.yaml", "openontology.yml", "openontology.json"]; export class PackageLoadError extends Error { readonly code = "OO-L-LOAD"; constructor(message: string) { super(message); this.name = "PackageLoadError"; } } export interface LoadInput { /** Directory containing openontology.yaml, or the manifest file itself. */ dir?: string; /** Fully in-memory package (used by tests, imports, and hosted adapters). */ manifest?: Manifest; schema?: Partial; data?: Partial; } /** * Load a package from disk or memory. Authoring formats (YAML, JSON, NDJSON, * inline manifest arrays) all compile to the same canonical objects here, so * everything downstream — validation, digests, queries — sees one shape. */ export function loadOntologyPackage(input: string | LoadInput): LoadedPackage { if (typeof input === "string") return loadFromDir(input); if (input.dir) return loadFromDir(input.dir); if (!input.manifest) throw new PackageLoadError("Provide either a directory or a manifest"); const loaded: LoadedPackage = { manifest: canonicalObject(input.manifest), schema: emptySchema(), data: emptyData(), files: [] }; for (const section of SCHEMA_SECTIONS) { const items = input.schema?.[section]; if (items) (loaded.schema[section] as unknown[]) = canonicalObject(items as unknown[]); } for (const section of DATA_SECTIONS) { const items = input.data?.[section]; if (items) (loaded.data[section] as unknown[]) = canonicalObject(items as unknown[]); } return loaded; } function loadFromDir(dirOrFile: string): LoadedPackage { const base = resolve(dirOrFile); const manifestPath = MANIFEST_NAMES.some((name) => base.endsWith(name)) ? base : findManifest(base); const dir = dirname(manifestPath); const manifest = canonicalObject(parseFile(manifestPath) as Manifest); if (manifest?.kind !== "OntologyPackage") { throw new PackageLoadError( `${manifestPath} is not an OpenOntology manifest (kind: ${JSON.stringify(manifest?.kind ?? null)})` ); } const loaded: LoadedPackage = { manifest, dir, schema: emptySchema(), data: emptyData(), files: [] }; for (const section of SCHEMA_SECTIONS) { const declared = manifest.schema?.[section]; const { items, path } = resolveSection(dir, declared, `schema.${section}`); (loaded.schema[section] as unknown[]) = items; if (path) loaded.files.push({ path, digest: digest(items), count: items.length }); } for (const section of DATA_SECTIONS) { const declared = manifest.data?.[section]; const { items, path } = resolveSection(dir, declared, `data.${section}`); (loaded.data[section] as unknown[]) = items; if (path) loaded.files.push({ path, digest: digest(items), count: items.length }); } if (manifest.context) { const contextPath = join(dir, manifest.context); if (!existsSync(contextPath)) { throw new PackageLoadError(`Manifest declares context ${manifest.context} but the file is missing`); } loaded.context = parseFile(contextPath) as Record; loaded.files.push({ path: manifest.context, digest: digest(loaded.context), count: 1 }); } loaded.files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); return loaded; } function findManifest(dir: string): string { for (const name of MANIFEST_NAMES) { const candidate = join(dir, name); if (existsSync(candidate)) return candidate; } throw new PackageLoadError(`No openontology.yaml (or .yml/.json) found in ${dir}`); } function resolveSection( dir: string, declared: string | object[] | undefined, label: string ): { items: object[]; path?: string } { if (declared === undefined) return { items: [] }; if (Array.isArray(declared)) return { items: canonicalObject(declared) }; const path = declared; const full = isAbsolute(path) ? path : join(dir, path); if (!existsSync(full)) { throw new PackageLoadError(`Manifest declares ${label}: ${path} but the file is missing`); } const parsed = parseFile(full); const items = Array.isArray(parsed) ? parsed : [parsed]; return { items: canonicalObject(items as object[]), path }; } function parseFile(path: string): unknown { const raw = readFileSync(path, "utf8"); const lower = path.toLowerCase(); if (lower.endsWith(".ndjson") || lower.endsWith(".jsonl")) { return raw .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.startsWith("//")) .map((line, index) => { try { return JSON.parse(line) as unknown; } catch (error) { throw new PackageLoadError( `${path}:${index + 1} is not valid JSON — ${(error as Error).message}` ); } }); } if (lower.endsWith(".json") || lower.endsWith(".jsonld")) { try { return JSON.parse(raw) as unknown; } catch (error) { throw new PackageLoadError(`${path} is not valid JSON — ${(error as Error).message}`); } } try { return parseYaml(raw) as unknown; } catch (error) { throw new PackageLoadError(`${path} is not valid YAML — ${(error as Error).message}`); } } /** * Compile a loaded package into the deterministic build artifact: canonical * objects, a per-file digest table, and the package digest that signing, * diffing, and publishing all key off. */ export function buildOntologyPackage( loaded: LoadedPackage, options: { builtAt?: string } = {} ): BuiltPackage { const files = loaded.files.length > 0 ? [...loaded.files] : [ ...SCHEMA_SECTIONS.map((s) => ({ path: `schema.${s}`, digest: digest(loaded.schema[s]), count: loaded.schema[s].length })), ...DATA_SECTIONS.map((s) => ({ path: `data.${s}`, digest: digest(loaded.data[s]), count: loaded.data[s].length })) ].filter((f) => f.count > 0); files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); // The digest covers the manifest *without* any previously embedded digest, // so building twice is idempotent. const { digest: _ignored, signatures, ...manifestForDigest } = loaded.manifest; const computed = packageDigest(manifestForDigest, files); const built: BuiltPackage = { openontology: loaded.manifest.openontology ?? OPENONTOLOGY_VERSION, kind: "BuiltOntologyPackage", manifest: { ...loaded.manifest, digest: computed }, digest: computed, files, schema: loaded.schema, data: loaded.data }; if (options.builtAt) built.builtAt = options.builtAt; if (loaded.context) built.context = loaded.context; if (signatures) built.signatures = signatures; return built; } /** Recompute a built package's digest — used to detect tampering or drift. */ export function verifyPackageDigest(built: BuiltPackage): { ok: boolean; expected: string } { const { digest: _ignored, signatures: _sigs, ...manifestForDigest } = built.manifest; const expected = packageDigest(manifestForDigest, built.files); return { ok: expected === built.digest, expected }; } function emptySchema(): LoadedPackage["schema"] { return { namespaces: [], entityTypes: [], properties: [], relationships: [], constraints: [], queries: [], actions: [] }; } function emptyData(): LoadedPackage["data"] { return { entities: [], claims: [], sources: [], evidence: [] }; } export { SCHEMA_SECTIONS, DATA_SECTIONS };