mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 14:57:28 +00:00
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>
272 lines
9.3 KiB
TypeScript
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/);
|
|
});
|
|
});
|