mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 21:27:07 +00:00
feat(openontology): implement OpenOntology Phase 0 + local engine and CLI (#99)
Implements OpenPRD 0001 through Phase 0 (specification, schemas, example, docs surface) and Phase 1 (local engine, CLI, conformance tests). Schemas (17 contracts, JSON Schema Draft 2020-12, additionalProperties:false) manifest, namespace, entity-type, property, relationship-type, constraint, query, action, entity, claim, source, evidence, changeset, review, approval, event, package — registered in @logicsrc/validators and exported from @logicsrc/schemas under https://logicsrc.com/schemas/openontology/. @logicsrc/openontology - canonical JSON + sha256 package digests; YAML, JSON, NDJSON, and inline authoring all compile to the same bytes, so digests are authoring-agnostic - id profile: compact / IRI / urn with one canonicalization rule, prefix bound by a Namespace object so IRIs reverse unambiguously - validation: schema, graph (domain/range, datatypes, dangling refs), provenance (source-or-firstParty, agent runId, derivation inputs), policy (excerpt limits, licensing, visibility, staleness) and declared constraints; four severities, stable codes, text/json/yaml/markdown - portable triple-pattern query AST: multi-hop, 14 operators, asOf and recordedAsOf, per-status filtering, distinct/order/limit, explanation mode, and enforced depth/binding/row limits - append-only store: claims are immutable; dispute/retract/supersede append status transitions and the effective status is the latest one - change sets: 9 operations, atomic pre-flight, conflict detection on stale base revisions, semantic diff with duplicate-identity warnings and affected-query deltas, per-operation reviewer decisions - policy: agents propose but can never apply — the denial keys on actor type, so every scope plus high confidence plus --yolo still cannot apply; merges need approval, bulk retractions need two, undeclared action side effects are denied - JSON-LD 1.1 export/import with PROV-O aliases and lossy-field reporting - pluggable signature envelope with a jws-ed25519 reference profile and a fail-closed trust policy CLI: logicsrc ontology init|validate|lint|build|inspect, entity, claim, query, changeset, import, export, audit. Reads take --format, writes default to a proposal, exit codes are stable for CI. Example: examples/openontology/ethereum-ecosystem — 12 entity types, 17 relationship types, 63 entities, 169 claims, 25 sources, 31 evidence records, 5 saved queries, every claim lifecycle state, and a pending merge proposal. All data is fictional; the directory is removable without affecting any core test. Docs: docs/openontology{,-governance,-interoperability}.md, a real /openontology route, homepage + nav + sitemap entries, and a root README section. Verification: 112 new tests; full monorepo build and every workspace test pass; conformance bundle (18 valid + 13 invalid fixtures) runs against the published schemas alone; Node.js 25 and Bun 1.3 produce byte-identical digests, revisions, event trails, and query results. Not included (later PRD phases): MCP resources, REST/SSE, Turso adapter, TUI and PWA surfaces, RDF/SHACL mappings, source adapters, governed actions. Refs: prd/0001-add-logicsrc-openontology-spec.md Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0d9dab0447
commit
58c942c67f
101 changed files with 11934 additions and 10 deletions
256
packages/openontology/src/package.ts
Normal file
256
packages/openontology/src/package.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { 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 = manifestPath.slice(0, manifestPath.lastIndexOf("/")) || ".";
|
||||
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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue