logicsrc/packages/openontology/src/package.ts

256 lines
8.1 KiB
TypeScript

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<LoadedPackage["schema"]>;
data?: Partial<LoadedPackage["data"]>;
}
/**
* 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<string, unknown>;
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 };