mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 06:47:28 +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
253
packages/openontology/src/validate.test.ts
Normal file
253
packages/openontology/src/validate.test.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { buildOntologyPackage, loadOntologyPackage, verifyPackageDigest } from "./package.js";
|
||||
import { initOntologyPackage } from "./scaffold.js";
|
||||
import { renderReport, validateOntologyPackage } from "./validate.js";
|
||||
import { OPENONTOLOGY_VERSION } from "./types.js";
|
||||
import type { Claim, LoadedPackage } from "./types.js";
|
||||
|
||||
const NOW = "2026-07-26T00:00:00Z";
|
||||
const dirs: string[] = [];
|
||||
|
||||
function scaffold(): { dir: string; pkg: LoadedPackage } {
|
||||
const dir = mkdtempSync(join(tmpdir(), "openontology-"));
|
||||
dirs.push(dir);
|
||||
initOntologyPackage(dir, { id: "test-ecosystem", now: NOW });
|
||||
return { dir, pkg: loadOntologyPackage(dir) };
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Clone the scaffold and mutate one claim, to isolate a single failure mode. */
|
||||
function withClaims(pkg: LoadedPackage, mutate: (claims: Claim[]) => void): LoadedPackage {
|
||||
const copy = structuredClone(pkg);
|
||||
mutate(copy.data.claims);
|
||||
return copy;
|
||||
}
|
||||
|
||||
describe("init + validate", () => {
|
||||
it("scaffolds a package that passes strict validation with no edits (R139)", () => {
|
||||
const { pkg } = scaffold();
|
||||
const report = validateOntologyPackage(pkg, { strict: true });
|
||||
const errors = report.findings.filter((f) => f.severity === "error");
|
||||
expect(errors).toEqual([]);
|
||||
expect(report.ok).toBe(true);
|
||||
expect(report.checked.entities).toBe(8);
|
||||
expect(report.checked.claims).toBe(14);
|
||||
});
|
||||
|
||||
it("loads YAML manifest, YAML schema files, and NDJSON data together", () => {
|
||||
const { pkg } = scaffold();
|
||||
expect(pkg.manifest.kind).toBe("OntologyPackage");
|
||||
expect(pkg.schema.entityTypes.map((t) => t.id)).toEqual(["Person", "Organization", "Project"]);
|
||||
expect(pkg.data.sources).toHaveLength(2);
|
||||
expect(pkg.files.map((f) => f.path)).toContain("data/claims.ndjson");
|
||||
});
|
||||
|
||||
it("builds a deterministic digest that survives a rebuild", () => {
|
||||
const { pkg } = scaffold();
|
||||
const first = buildOntologyPackage(pkg);
|
||||
const second = buildOntologyPackage(loadOntologyPackage(structuredClone(pkg)));
|
||||
expect(first.digest).toBe(second.digest);
|
||||
expect(verifyPackageDigest(first).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("detects a tampered built package", () => {
|
||||
const { pkg } = scaffold();
|
||||
const built = buildOntologyPackage(pkg);
|
||||
built.files[0] = { ...built.files[0], digest: `sha256:${"0".repeat(64)}` };
|
||||
expect(verifyPackageDigest(built).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a missing declared file instead of loading a partial package", () => {
|
||||
const { dir } = scaffold();
|
||||
rmSync(join(dir, "data/claims.ndjson"));
|
||||
expect(() => loadOntologyPackage(dir)).toThrow(/data.claims.*missing/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("graph validation", () => {
|
||||
it("rejects a claim whose subject type is outside the relationship domain", () => {
|
||||
const { pkg } = scaffold();
|
||||
const broken = withClaims(pkg, (claims) => {
|
||||
const claim = claims.find((c) => c.predicate === "worksOn") as Claim;
|
||||
claim.subject = "test:org:northwind"; // an Organization cannot worksOn
|
||||
});
|
||||
const report = validateOntologyPackage(broken);
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.findings.map((f) => f.code)).toContain("OO-G-DOMAIN");
|
||||
});
|
||||
|
||||
it("rejects a claim whose object type is outside the relationship range", () => {
|
||||
const { pkg } = scaffold();
|
||||
const broken = withClaims(pkg, (claims) => {
|
||||
const claim = claims.find((c) => c.predicate === "worksOn") as Claim;
|
||||
claim.object = { entity: "test:org:northwind" };
|
||||
});
|
||||
expect(validateOntologyPackage(broken).findings.map((f) => f.code)).toContain("OO-G-RANGE");
|
||||
});
|
||||
|
||||
it("rejects a value object on a relationship predicate", () => {
|
||||
const { pkg } = scaffold();
|
||||
const broken = withClaims(pkg, (claims) => {
|
||||
const claim = claims.find((c) => c.predicate === "worksOn") as Claim;
|
||||
claim.object = { value: "ZK Prover" };
|
||||
});
|
||||
expect(validateOntologyPackage(broken).findings.map((f) => f.code)).toContain("OO-G-OBJECT-KIND");
|
||||
});
|
||||
|
||||
it("rejects a value that does not match its declared datatype", () => {
|
||||
const { pkg } = scaffold();
|
||||
const broken = withClaims(pkg, (claims) => {
|
||||
const claim = claims.find((c) => c.predicate === "homepage") as Claim;
|
||||
claim.object = { value: 42 };
|
||||
});
|
||||
expect(validateOntologyPackage(broken).findings.map((f) => f.code)).toContain("OO-G-DATATYPE");
|
||||
});
|
||||
|
||||
it("rejects dangling entity, source, and claim references", () => {
|
||||
const { pkg } = scaffold();
|
||||
const broken = withClaims(pkg, (claims) => {
|
||||
(claims[0] as Claim).subject = "test:person:nobody";
|
||||
(claims[1] as Claim).sources = ["test:source:missing"];
|
||||
(claims[2] as Claim).supersedes = "test:claim:9999";
|
||||
});
|
||||
const codes = validateOntologyPackage(broken).findings.map((f) => f.code);
|
||||
expect(codes.filter((c) => c === "OO-G-DANGLING-REF").length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("rejects a claim with no source and no firstParty declaration (R54)", () => {
|
||||
const { pkg } = scaffold();
|
||||
const broken = withClaims(pkg, (claims) => {
|
||||
const claim = claims[0] as Claim;
|
||||
delete claim.sources;
|
||||
delete claim.firstParty;
|
||||
});
|
||||
expect(validateOntologyPackage(broken).findings.map((f) => f.code)).toContain("OO-P-NO-SOURCE");
|
||||
});
|
||||
|
||||
it("requires a runId on agent-authored claims (R55)", () => {
|
||||
const { pkg } = scaffold();
|
||||
const broken = withClaims(pkg, (claims) => {
|
||||
(claims[0] as Claim).assertedBy = "agent:research-mapper";
|
||||
});
|
||||
expect(validateOntologyPackage(broken).findings.map((f) => f.code)).toContain("OO-P-NO-RUN");
|
||||
});
|
||||
|
||||
it("requires derivedFrom on derived claims (R56)", () => {
|
||||
const { pkg } = scaffold();
|
||||
const broken = withClaims(pkg, (claims) => {
|
||||
(claims[0] as Claim).status = "derived";
|
||||
});
|
||||
expect(validateOntologyPackage(broken).findings.map((f) => f.code)).toContain("OO-P-NO-DERIVATION");
|
||||
});
|
||||
|
||||
it("rejects validTime.to before validTime.from", () => {
|
||||
const { pkg } = scaffold();
|
||||
const broken = withClaims(pkg, (claims) => {
|
||||
(claims[0] as Claim).validTime = { from: "2026-05-01T00:00:00Z", to: "2026-01-01T00:00:00Z" };
|
||||
});
|
||||
expect(validateOntologyPackage(broken).findings.map((f) => f.code)).toContain("OO-G-TEMPORAL-ORDER");
|
||||
});
|
||||
|
||||
it("catches duplicate ids", () => {
|
||||
const { pkg } = scaffold();
|
||||
const broken = structuredClone(pkg);
|
||||
broken.data.entities.push(structuredClone(broken.data.entities[0]));
|
||||
expect(validateOntologyPackage(broken).findings.map((f) => f.code)).toContain("OO-G-DUPLICATE-ID");
|
||||
});
|
||||
|
||||
it("treats unknown predicates as warnings by default and errors in strict mode (R75)", () => {
|
||||
const { pkg } = scaffold();
|
||||
const broken = withClaims(pkg, (claims) => {
|
||||
(claims[0] as Claim).predicate = "notDeclared";
|
||||
});
|
||||
const lenient = validateOntologyPackage(broken);
|
||||
const strict = validateOntologyPackage(broken, { strict: true });
|
||||
expect(lenient.findings.find((f) => f.code === "OO-G-UNKNOWN-PREDICATE")?.severity).toBe("warning");
|
||||
expect(strict.findings.find((f) => f.code === "OO-G-UNKNOWN-PREDICATE")?.severity).toBe("error");
|
||||
expect(lenient.ok).toBe(true);
|
||||
expect(strict.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("flags a schema-invalid object with its path", () => {
|
||||
const { pkg } = scaffold();
|
||||
const broken = structuredClone(pkg);
|
||||
delete (broken.data.entities[0] as Partial<{ canonicalName: string }>).canonicalName;
|
||||
const report = validateOntologyPackage(broken);
|
||||
const finding = report.findings.find((f) => f.code === "OO-S-OBJECT");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.path).toMatch(/^\/0/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provenance policy", () => {
|
||||
it("raises a policy finding for an over-long excerpt", () => {
|
||||
const { pkg } = scaffold();
|
||||
const withEvidence = structuredClone(pkg);
|
||||
withEvidence.data.evidence.push({
|
||||
openontology: OPENONTOLOGY_VERSION,
|
||||
kind: "Evidence",
|
||||
id: "test:evidence:1",
|
||||
source: "test:source:team-page",
|
||||
selector: { type: "line-range", start: 1, end: 2 },
|
||||
excerpt: "x".repeat(600)
|
||||
});
|
||||
const report = validateOntologyPackage(withEvidence, { maxExcerptLength: 500 });
|
||||
const finding = report.findings.find((f) => f.code === "OO-P-EXCERPT-LENGTH");
|
||||
expect(finding?.severity).toBe("policy");
|
||||
// A policy finding is not an error: the package still validates.
|
||||
expect(report.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects public evidence drawn from a private source", () => {
|
||||
const { pkg } = scaffold();
|
||||
const copy = structuredClone(pkg);
|
||||
copy.data.sources[0].visibility = "private";
|
||||
copy.data.evidence.push({
|
||||
openontology: OPENONTOLOGY_VERSION,
|
||||
kind: "Evidence",
|
||||
id: "test:evidence:2",
|
||||
source: copy.data.sources[0].id,
|
||||
selector: { type: "whole-document" },
|
||||
visibility: "public"
|
||||
});
|
||||
expect(validateOntologyPackage(copy).findings.map((f) => f.code)).toContain("OO-P-VISIBILITY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("constraints", () => {
|
||||
it("reports a violation of a declared required-predicate constraint", () => {
|
||||
const { pkg } = scaffold();
|
||||
const broken = structuredClone(pkg);
|
||||
broken.data.claims = broken.data.claims.filter((c) => c.predicate !== "homepage");
|
||||
const report = validateOntologyPackage(broken);
|
||||
const violations = report.findings.filter((f) => f.code === "OO-C-REQUIRED-PREDICATE");
|
||||
expect(violations).toHaveLength(3);
|
||||
expect(violations[0].severity).toBe("warning");
|
||||
});
|
||||
});
|
||||
|
||||
describe("report rendering", () => {
|
||||
it("renders text, json, yaml, and markdown (R71)", () => {
|
||||
const { pkg } = scaffold();
|
||||
const report = validateOntologyPackage(pkg);
|
||||
expect(renderReport(report, "text")).toContain("OpenOntology package is valid.");
|
||||
expect(JSON.parse(renderReport(report, "json")).ok).toBe(true);
|
||||
expect(renderReport(report, "yaml")).toContain("ok: true");
|
||||
expect(renderReport(report, "markdown")).toContain("# Validation passed");
|
||||
});
|
||||
|
||||
it("gives every finding a stable code and a remediation hint where known", () => {
|
||||
const { pkg } = scaffold();
|
||||
const broken = withClaims(pkg, (claims) => {
|
||||
delete (claims[0] as Claim).sources;
|
||||
});
|
||||
const finding = validateOntologyPackage(broken).findings.find((f) => f.code === "OO-P-NO-SOURCE");
|
||||
expect(finding?.hint).toMatch(/firstParty/);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue