mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 05:07:10 +00:00
256 lines
8.1 KiB
TypeScript
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 };
|