logicsrc/packages/openontology/src/canonical.test.ts
Anthony Ettinger 58c942c67f
Some checks failed
CI / build (push) Has been cancelled
test / test (push) Has been cancelled
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>
2026-07-26 02:10:13 -07:00

99 lines
3.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { parse as parseYaml, stringify as toYaml } from "yaml";
import { canonicalize, canonicalObject, digest, packageDigest } from "./canonical.js";
import { idForm, isValidId, toIri, createIdFactory } from "./ids.js";
describe("canonical JSON", () => {
it("sorts object keys so key order never changes the bytes", () => {
expect(canonicalize({ b: 1, a: 2 })).toBe('{"a":2,"b":1}');
expect(canonicalize({ a: 2, b: 1 })).toBe(canonicalize({ b: 1, a: 2 }));
});
it("sorts nested keys and preserves array order", () => {
const value = { z: [{ y: 1, x: 2 }], a: { d: 4, c: 3 } };
expect(canonicalize(value)).toBe('{"a":{"c":3,"d":4},"z":[{"x":2,"y":1}]}');
});
it("drops undefined members but keeps explicit nulls", () => {
expect(canonicalize({ a: undefined, b: null })).toBe('{"b":null}');
});
it("normalizes -0 so it cannot produce a second digest", () => {
expect(digest({ n: -0 })).toBe(digest({ n: 0 }));
});
it("refuses non-finite numbers rather than emitting null", () => {
expect(() => canonicalize({ n: Number.NaN })).toThrow(/non-finite/);
});
it("produces the same digest for YAML- and JSON-authored input (R16/R17)", () => {
const object = { kind: "Entity", id: "x:person:a", aliases: ["a", "b"], nested: { k: 1 } };
const fromJson = JSON.parse(JSON.stringify(object)) as unknown;
const fromYaml = parseYaml(toYaml(object)) as unknown;
expect(digest(fromYaml)).toBe(digest(fromJson));
});
it("round-trips through canonicalObject", () => {
const object = { b: 1, a: { d: [3, 2], c: undefined } };
expect(canonicalize(canonicalObject(object))).toBe(canonicalize(object));
});
it("changes the package digest when any file digest changes", () => {
const manifest = { id: "p", version: "0.1.0" };
const a = packageDigest(manifest, [{ path: "data/claims.ndjson", digest: "sha256:aa" }]);
const b = packageDigest(manifest, [{ path: "data/claims.ndjson", digest: "sha256:bb" }]);
expect(a).not.toBe(b);
});
it("is insensitive to the order files are listed in", () => {
const manifest = { id: "p" };
const files = [
{ path: "b.ndjson", digest: "sha256:bb" },
{ path: "a.ndjson", digest: "sha256:aa" }
];
expect(packageDigest(manifest, files)).toBe(packageDigest(manifest, [...files].reverse()));
});
});
describe("identifier profile", () => {
it("recognizes the three accepted forms", () => {
expect(idForm("ethereum:person:alice")).toBe("compact");
expect(idForm("https://example.org/person/alice")).toBe("iri");
expect(idForm("urn:logicsrc:ethereum:person:alice")).toBe("urn");
expect(idForm("not an id")).toBeNull();
expect(isValidId("Person")).toBe(false);
});
it("canonicalizes compact ids against the package namespace", () => {
expect(
toIri("ethereum:person:alice", { defaultNamespace: "https://logicsrc.com/ontology/ethereum/" })
).toBe("https://logicsrc.com/ontology/ethereum/person/alice");
});
it("adds the trailing slash when a namespace omits it", () => {
expect(toIri("x:person:a", { defaultNamespace: "https://example.org/ns" })).toBe(
"https://example.org/ns/person/a"
);
});
it("leaves IRIs and URNs untouched", () => {
const iri = "https://example.org/person/alice";
expect(toIri(iri, { defaultNamespace: "https://other.example/" })).toBe(iri);
const urn = "urn:logicsrc:person:alice";
expect(toIri(urn, { defaultNamespace: "https://other.example/" })).toBe(urn);
});
it("honours per-prefix namespaces for imported packages", () => {
expect(
toIri("other:person:bob", {
defaultNamespace: "https://example.org/mine/",
namespaces: { other: "https://example.org/theirs/" }
})
).toBe("https://example.org/theirs/person/bob");
});
it("generates deterministic sequential ids", () => {
const next = createIdFactory("claim");
expect([next(), next()]).toEqual(["claim:000001", "claim:000002"]);
});
});