logicsrc/packages/openontology/src/adapters.test.ts
Anthony Ettinger da5f6f8381
Some checks are pending
CI / build (push) Waiting to run
test / test (push) Waiting to run
feat(openontology): Phase 2 + Phase 3 — storage, REST/SSE, MCP, RDF/SHACL, adapters, TUI, explorer (#101)
Everything the two shipped PRD phases deferred, minus what is called out below.

Storage (Phase 2)
  @logicsrc/openontology gains a SQLite/Turso adapter. It hydrates the read
  model at open, serves reads synchronously — a query evaluator that awaits per
  triple pattern is unusable — and buffers mutations as SQL that flush() writes
  in one transaction. Versioned idempotent migrations; indexes over subject,
  predicate, entity-valued object, status, both time axes, aliases, and external
  ids; FTS5 for label/alias search. The append-only status log is replayed on
  open, so retractions, supersessions, and merge redirects survive a reopen.

REST + SSE + OpenAPI (Phase 2)
  16 paths under /api/ontologies in logicsrc-web, described at
  /api/ontologies/openapi and referencing the published JSON Schemas rather
  than restating them. No token is read-only; a curator token can apply; an
  agent token can propose and cannot apply. Idempotency-Key on mutations,
  revision ETags, 409 on a stale base revision, and an SSE stream that emits
  the same event objects as the JSON endpoint.

MCP (Phase 2)
  OpenOntology and OpenPRD surfaces on the standards server: spec/manifest/
  schema/queries and PRD spec/index as resources, 11 ontology tools and 6 PRD
  tools, 7 prompts. Read-only by default; OPENONTOLOGY_MCP_WRITABLE=1 buys
  proposals, never applies — the denial is the shared policy layer, not a
  second rule that could drift.

Interoperability (Phase 3)
  RDF/Turtle export and import of the reified profile, plus the plain triple
  for asserted relationships so a consumer wanting only the accepted graph gets
  one. SHACL for 5 of 7 constraint kinds; `unique` and `query` are reported as
  unmapped in both the return value and the generated Turtle, because a shape
  that quietly means something narrower is worse than no shape.

Source adapters (Phase 3)
  CSV, JSON, YAML, NDJSON, Markdown, generic JSON HTTP, and GitHub. All produce
  PROPOSED change-set operations with source, evidence selector, run id, and
  confidence attached; fetch is injected so ingestion is offline and testable.
  Each declares its capabilities, so "nothing was deleted upstream" is never
  confused with "this adapter cannot see deletions" — none of the seven can.

TUI + explorer
  Keyboard-first panels (types, entities, claims, sources, queries, change
  sets, validation, audit) as plain strings that survive SSH and 60 columns;
  status is a glyph and a word, never colour alone; the key bar wraps rather
  than truncating. Wired as `logicsrc ontology tui`. A read-only web explorer
  at /openontology/explore with entity and claim views showing status, both
  clocks, confidence, sources, evidence, and append-only history — plus an
  /openprd page for the companion standard.

Bugs found and fixed while testing
  - the API built a new engine per request, so `explain` could never find a
    resultId from a prior request; engines are now cached per role
  - the TUI status bar called engine.validateOntologyPackage(), appending a
    package.validated event on every repaint; it now uses the pure validator

Verification: 76 new tests (527 total across the monorepo, all passing); full
build green; the libSQL adapter is exercised against real files, the API
through its route handlers, and MCP over an in-memory transport.

Not included: PWA review/approval write flows (they need an auth story this
deployment does not have), OWL/RDFS mappings, SPARQL/Cypher/Datalog query
adapters, and Phase 4 governed actions. The compatibility matrix marks those
"planned", not "supported".

Refs: prd/0001-add-logicsrc-openontology-spec.md

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 05:10:33 -07:00

272 lines
9.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
csvAdapter,
githubAdapter,
httpApiAdapter,
jsonAdapter,
listAdapters,
markdownAdapter,
ndjsonAdapter,
parseCsv,
yamlAdapter,
type FetchLike,
type IngestContext
} from "./adapters.js";
import { createOntologyEngine } from "./engine.js";
import { loadPrdFixturePackage } from "./test-helpers.js";
import { proposerActor } from "./policy.js";
const ctx: IngestContext = {
prefix: "test",
actor: "agent:importer",
runId: "run_import_1",
now: "2026-07-26T00:00:00Z",
confidence: 0.6,
license: "CC-BY-4.0"
};
const MAPPING = {
entityType: "Person",
idField: "handle",
nameField: "name",
idSegment: "person",
aliasField: "aliases",
externalIds: { github: "github" },
properties: { role: "role" },
relationships: { worksOn: { field: "projects", targetSegment: "project" } }
};
describe("adapter capabilities", () => {
it("declares what every adapter can and cannot do (R118)", () => {
const adapters = listAdapters();
expect(adapters.map((a) => a.id).sort()).toEqual([
"csv",
"github",
"http-api",
"json",
"markdown",
"ndjson",
"yaml"
]);
for (const adapter of adapters) {
expect(adapter.capabilities).toHaveProperty("publicData");
expect(adapter.capabilities).toHaveProperty("deletions");
}
// None of the shipped adapters can see upstream deletions — say so.
expect(adapters.every((a) => a.capabilities.deletions === false)).toBe(true);
});
});
describe("CSV", () => {
const csv = `handle,name,role,github,projects
alice,Alice Reyes,Protocol engineer,areyes,"zk-prover,ledger-indexer"
bob,Bob Nakamura,Indexer lead,bnak,ledger-indexer
`;
it("parses quoted fields and embedded separators", () => {
const rows = parseCsv(csv);
expect(rows).toHaveLength(2);
expect(rows[0]!.projects).toBe("zk-prover,ledger-indexer");
});
it("handles escaped quotes", () => {
const rows = parseCsv('a,b\n"say ""hi""",2\n');
expect(rows[0]!.a).toBe('say "hi"');
});
it("maps rows to entities and claims", () => {
const result = csvAdapter.ingest({ uri: "https://example.org/people.csv", content: csv, mapping: MAPPING }, ctx);
expect(result.entities.map((e) => e.id)).toEqual(["test:person:alice", "test:person:bob"]);
expect(result.entities[0]!.externalIds).toEqual({ github: "areyes" });
const worksOn = result.claims.filter((c) => c.predicate === "worksOn");
expect(worksOn).toHaveLength(3);
expect(worksOn[0]!.object).toEqual({ entity: "test:project:zk-prover" });
});
it("proposes rather than asserts, and records the run (R113/R114)", () => {
const result = csvAdapter.ingest({ uri: "https://example.org/people.csv", content: csv, mapping: MAPPING }, ctx);
expect(result.claims.every((c) => c.status === "proposed")).toBe(true);
expect(result.claims.every((c) => c.runId === "run_import_1")).toBe(true);
expect(result.claims.every((c) => c.confidence === 0.6)).toBe(true);
});
it("attaches source and evidence with a line selector", () => {
const result = csvAdapter.ingest({ uri: "https://example.org/people.csv", content: csv, mapping: MAPPING }, ctx);
expect(result.sources[0]!.contentHash).toMatch(/^sha256:/);
expect(result.sources[0]!.license).toBe("CC-BY-4.0");
expect(result.evidence[0]!.selector).toEqual({ type: "line-range", start: 2, end: 2 });
expect(result.claims[0]!.sources).toEqual([result.sources[0]!.id]);
});
it("skips a row with no id and says so", () => {
const result = csvAdapter.ingest(
{ uri: "x.csv", content: "handle,name\n,Nobody\nalice,Alice\n", mapping: MAPPING },
ctx
);
expect(result.entities).toHaveLength(1);
expect(result.warnings[0]).toMatch(/no handle/);
});
});
describe("JSON, YAML, NDJSON", () => {
const records = [{ handle: "alice", name: "Alice Reyes" }];
it("reads a JSON array", () => {
const result = jsonAdapter.ingest(
{ uri: "x.json", content: JSON.stringify(records), mapping: MAPPING },
ctx
);
expect(result.entities[0]!.canonicalName).toBe("Alice Reyes");
});
it("finds the array inside a wrapper object", () => {
const result = jsonAdapter.ingest(
{ uri: "x.json", content: JSON.stringify({ data: records }), mapping: MAPPING },
ctx
);
expect(result.entities).toHaveLength(1);
});
it("reads a YAML sequence", () => {
const result = yamlAdapter.ingest(
{ uri: "x.yaml", content: "- handle: alice\n name: Alice Reyes\n", mapping: MAPPING },
ctx
);
expect(result.entities[0]!.id).toBe("test:person:alice");
});
it("reports a bad NDJSON line instead of aborting the file", () => {
const result = ndjsonAdapter.ingest(
{ uri: "x.ndjson", content: `${JSON.stringify(records[0])}\nnot json\n`, mapping: MAPPING },
ctx
);
expect(result.entities).toHaveLength(1);
expect(result.warnings[0]).toMatch(/line 2 is not valid JSON/);
});
});
describe("Markdown", () => {
const md = `# Title
## ZK Prover
See [the repo](https://example.org/zk) and [docs](https://example.org/docs).
## Ledger Indexer
No links here.
`;
it("turns headings into entities and links into claims", () => {
const result = markdownAdapter.ingest({ uri: "x.md", content: md, entityType: "Project" }, ctx);
expect(result.entities.map((e) => e.canonicalName)).toEqual(["ZK Prover", "Ledger Indexer"]);
expect(result.claims).toHaveLength(2);
expect(result.claims[0]!.object).toEqual({ value: "https://example.org/zk" });
});
it("warns when the document has no headings at the requested level", () => {
const result = markdownAdapter.ingest({ uri: "x.md", content: "just prose\n", entityType: "Project" }, ctx);
expect(result.warnings[0]).toMatch(/no level-2 headings/);
});
});
describe("HTTP and GitHub", () => {
const fetchOk = (bodies: Record<string, unknown>): FetchLike =>
async (url) => {
const key = Object.keys(bodies).find((k) => url.includes(k));
if (!key) return { ok: false, status: 404, text: async () => "{}" };
return { ok: true, status: 200, text: async () => JSON.stringify(bodies[key]) };
};
it("reads a JSON endpoint through an injected fetch", async () => {
const result = await httpApiAdapter.ingest(
{
url: "https://example.org/api/people",
mapping: MAPPING,
path: "items",
fetch: fetchOk({ "/api/people": { items: [{ handle: "alice", name: "Alice Reyes" }] } })
},
ctx
);
expect(result.entities[0]!.id).toBe("test:person:alice");
expect(result.evidence[0]!.selector).toMatchObject({ type: "api-field" });
});
it("throws on a non-OK response rather than proposing nothing silently", async () => {
const fetchFail: FetchLike = async () => ({ ok: false, status: 503, text: async () => "" });
await expect(
httpApiAdapter.ingest({ url: "https://example.org/x", mapping: MAPPING, fetch: fetchFail }, ctx)
).rejects.toThrow(/HTTP 503/);
});
it("maps a GitHub repo and its contributors", async () => {
const result = await githubAdapter.ingest(
{
repo: "example/zk-prover",
fetch: fetchOk({
"/repos/example/zk-prover/contributors": [{ login: "areyes", contributions: 42 }],
"/repos/example/zk-prover": {
full_name: "example/zk-prover",
name: "zk-prover",
language: "Rust",
license: { spdx_id: "Apache-2.0" },
html_url: "https://example.org/zk-prover"
}
})
},
ctx
);
expect(result.entities.map((e) => e.type)).toEqual(["Codebase", "Person"]);
expect(result.sources[0]!.license).toBe("Apache-2.0");
expect(result.claims.find((c) => c.predicate === "language")?.object).toEqual({ value: "Rust" });
expect(result.claims.find((c) => c.predicate === "contributesTo")?.object).toEqual({
entity: "test:code:zk-prover"
});
});
it("warns when a repository declares no licence", async () => {
const result = await githubAdapter.ingest(
{
repo: "example/unlicensed",
fetch: fetchOk({
"/repos/example/unlicensed/contributors": [],
"/repos/example/unlicensed": { name: "unlicensed", full_name: "example/unlicensed" }
})
},
ctx
);
expect(result.warnings.some((w) => /no SPDX licence/.test(w))).toBe(true);
expect(result.sources[0]!.license).toBe("CC-BY-4.0");
});
});
describe("ingestion feeds the governed path", () => {
it("produces operations a proposer agent can turn into a change set", () => {
const engine = createOntologyEngine({
package: loadPrdFixturePackage(),
actor: proposerActor("agent:importer"),
clock: () => ctx.now
});
const result = csvAdapter.ingest(
{
uri: "https://example.org/people.csv",
content: "handle,name\ndave,Dave Okonkwo\n",
mapping: { entityType: "Person", idField: "handle", nameField: "name", idSegment: "person" }
},
ctx
);
const changeSet = engine.createOntologyChangeSet({
title: "Import people.csv",
operations: result.operations,
runId: ctx.runId
});
expect(changeSet.status).toBe("proposed");
// An imported entity does not exist until a human applies the change set.
expect(() => engine.getEntity("test:person:dave")).toThrow(/Unknown entity/);
});
});