mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 14:57: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
804
packages/openontology/src/validate.ts
Normal file
804
packages/openontology/src/validate.ts
Normal file
|
|
@ -0,0 +1,804 @@
|
|||
import { validate as validateSchema, type SchemaKind } from "@logicsrc/validators";
|
||||
import { isValidId } from "./ids.js";
|
||||
import { evaluateQuery, type KnowledgeView } from "./query.js";
|
||||
import type {
|
||||
Claim,
|
||||
Finding,
|
||||
LoadedPackage,
|
||||
Severity,
|
||||
ValidationReport,
|
||||
ValueType
|
||||
} from "./types.js";
|
||||
|
||||
export interface ValidateOptions {
|
||||
/**
|
||||
* Strict mode fails on unknown entity types, unknown predicates, unresolved
|
||||
* references, and unnamespaced extension keys. Non-strict downgrades the
|
||||
* "unknown" family to warnings so a package can be authored incrementally.
|
||||
*/
|
||||
strict?: boolean;
|
||||
/** Excerpts longer than this raise a policy finding. */
|
||||
maxExcerptLength?: number;
|
||||
/** Verify the manifest digest against a freshly computed one. */
|
||||
expectedDigest?: string;
|
||||
}
|
||||
|
||||
const SCHEMA_KIND: Record<string, SchemaKind> = {
|
||||
Namespace: "openontology-namespace",
|
||||
EntityType: "openontology-entity-type",
|
||||
Property: "openontology-property",
|
||||
RelationshipType: "openontology-relationship-type",
|
||||
Constraint: "openontology-constraint",
|
||||
SavedQuery: "openontology-query",
|
||||
Action: "openontology-action",
|
||||
Entity: "openontology-entity",
|
||||
Claim: "openontology-claim",
|
||||
Source: "openontology-source",
|
||||
Evidence: "openontology-evidence"
|
||||
};
|
||||
|
||||
export function validateOntologyPackage(
|
||||
pkg: LoadedPackage,
|
||||
options: ValidateOptions = {}
|
||||
): ValidationReport {
|
||||
const findings: Finding[] = [];
|
||||
const strict = options.strict ?? false;
|
||||
const unknownSeverity: Severity = strict ? "error" : "warning";
|
||||
const maxExcerpt = options.maxExcerptLength ?? 500;
|
||||
|
||||
const add = (finding: Finding) => findings.push(finding);
|
||||
|
||||
/* ── 1. Schema structure ───────────────────────────────────────────── */
|
||||
|
||||
const manifestResult = validateSchema("openontology-manifest", pkg.manifest);
|
||||
if (!manifestResult.ok) {
|
||||
for (const error of manifestResult.errors) {
|
||||
add({
|
||||
code: "OO-S-MANIFEST",
|
||||
severity: "error",
|
||||
objectId: pkg.manifest?.id,
|
||||
file: "openontology.yaml",
|
||||
path: error.instancePath || "/",
|
||||
message: `Manifest ${error.instancePath || "/"} ${error.message ?? "failed validation"}`,
|
||||
hint: "See https://logicsrc.com/schemas/openontology/manifest.schema.json"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sections: Array<[string, unknown[]]> = [
|
||||
["namespaces", pkg.schema.namespaces],
|
||||
["entityTypes", pkg.schema.entityTypes],
|
||||
["properties", pkg.schema.properties],
|
||||
["relationships", pkg.schema.relationships],
|
||||
["constraints", pkg.schema.constraints],
|
||||
["queries", pkg.schema.queries],
|
||||
["actions", pkg.schema.actions],
|
||||
["entities", pkg.data.entities],
|
||||
["claims", pkg.data.claims],
|
||||
["sources", pkg.data.sources],
|
||||
["evidence", pkg.data.evidence]
|
||||
];
|
||||
|
||||
for (const [section, items] of sections) {
|
||||
items.forEach((item, index) => {
|
||||
const kind = (item as { kind?: string }).kind;
|
||||
const schemaKind = kind ? SCHEMA_KIND[kind] : undefined;
|
||||
if (!schemaKind) {
|
||||
add({
|
||||
code: "OO-S-KIND",
|
||||
severity: "error",
|
||||
file: section,
|
||||
path: `/${index}`,
|
||||
message: `Object in ${section}[${index}] has unknown kind ${JSON.stringify(kind ?? null)}`,
|
||||
hint: `Expected one of: ${Object.keys(SCHEMA_KIND).join(", ")}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = validateSchema(schemaKind, item);
|
||||
if (result.ok) return;
|
||||
for (const error of result.errors) {
|
||||
add({
|
||||
code: "OO-S-OBJECT",
|
||||
severity: "error",
|
||||
objectId: (item as { id?: string }).id,
|
||||
file: section,
|
||||
path: `/${index}${error.instancePath}`,
|
||||
message: `${kind} ${(item as { id?: string }).id ?? index}: ${error.instancePath || "/"} ${
|
||||
error.message ?? "failed validation"
|
||||
}`
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 2. Identity and uniqueness ────────────────────────────────────── */
|
||||
|
||||
const seen = new Map<string, string>();
|
||||
const checkUnique = (id: string | undefined, section: string) => {
|
||||
if (!id) return;
|
||||
const key = `${section}:${id}`;
|
||||
if (seen.has(key)) {
|
||||
add({
|
||||
code: "OO-G-DUPLICATE-ID",
|
||||
severity: "error",
|
||||
objectId: id,
|
||||
file: section,
|
||||
message: `Duplicate id ${id} in ${section}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
seen.set(key, section);
|
||||
};
|
||||
|
||||
for (const t of pkg.schema.entityTypes) checkUnique(t.id, "entityTypes");
|
||||
for (const r of pkg.schema.relationships) checkUnique(r.id, "relationships");
|
||||
for (const p of pkg.schema.properties) checkUnique(p.id, "properties");
|
||||
for (const c of pkg.schema.constraints) checkUnique(c.id, "constraints");
|
||||
for (const q of pkg.schema.queries) checkUnique(q.id, "queries");
|
||||
for (const e of pkg.data.entities) checkUnique(e.id, "entities");
|
||||
for (const c of pkg.data.claims) checkUnique(c.id, "claims");
|
||||
for (const s of pkg.data.sources) checkUnique(s.id, "sources");
|
||||
for (const ev of pkg.data.evidence) checkUnique(ev.id, "evidence");
|
||||
|
||||
for (const entity of pkg.data.entities) {
|
||||
if (!isValidId(entity.id)) {
|
||||
add({
|
||||
code: "OO-G-ID-FORM",
|
||||
severity: "error",
|
||||
objectId: entity.id,
|
||||
file: "entities",
|
||||
message: `Entity id ${JSON.stringify(entity.id)} is not a compact, IRI, or urn: identifier`,
|
||||
hint: "Use prefix:type:slug, an https:// IRI, or urn:logicsrc:..."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 3. Type system wiring ─────────────────────────────────────────── */
|
||||
|
||||
const entityTypes = new Map(pkg.schema.entityTypes.map((t) => [t.id, t]));
|
||||
const relationships = new Map(pkg.schema.relationships.map((r) => [r.id, r]));
|
||||
const properties = new Map(pkg.schema.properties.map((p) => [p.id, p]));
|
||||
const entities = new Map(pkg.data.entities.map((e) => [e.id, e]));
|
||||
const sources = new Set(pkg.data.sources.map((s) => s.id));
|
||||
const evidenceIds = new Set(pkg.data.evidence.map((e) => e.id));
|
||||
const claims = new Map(pkg.data.claims.map((c) => [c.id, c]));
|
||||
|
||||
for (const entity of pkg.data.entities) {
|
||||
if (!entityTypes.has(entity.type)) {
|
||||
add({
|
||||
code: "OO-G-UNKNOWN-ENTITY-TYPE",
|
||||
severity: unknownSeverity,
|
||||
objectId: entity.id,
|
||||
file: "entities",
|
||||
message: `Entity ${entity.id} has undeclared type ${entity.type}`,
|
||||
hint: `Declared types: ${[...entityTypes.keys()].join(", ") || "(none)"}`
|
||||
});
|
||||
}
|
||||
if (entity.supersededBy && !entities.has(entity.supersededBy)) {
|
||||
add({
|
||||
code: "OO-G-DANGLING-REF",
|
||||
severity: "error",
|
||||
objectId: entity.id,
|
||||
file: "entities",
|
||||
message: `Entity ${entity.id} is supersededBy unknown entity ${entity.supersededBy}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const type of pkg.schema.entityTypes) {
|
||||
for (const parent of type.extends ?? []) {
|
||||
if (!entityTypes.has(parent)) {
|
||||
add({
|
||||
code: "OO-G-UNKNOWN-ENTITY-TYPE",
|
||||
severity: unknownSeverity,
|
||||
objectId: type.id,
|
||||
file: "entityTypes",
|
||||
message: `Entity type ${type.id} extends undeclared type ${parent}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const rel of pkg.schema.relationships) {
|
||||
for (const [side, list] of [
|
||||
["from", rel.from],
|
||||
["to", rel.to]
|
||||
] as const) {
|
||||
for (const typeId of list) {
|
||||
if (!entityTypes.has(typeId)) {
|
||||
add({
|
||||
code: "OO-G-UNKNOWN-ENTITY-TYPE",
|
||||
severity: unknownSeverity,
|
||||
objectId: rel.id,
|
||||
file: "relationships",
|
||||
message: `Relationship ${rel.id} declares undeclared ${side} type ${typeId}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rel.inverse && !relationships.has(rel.inverse)) {
|
||||
add({
|
||||
code: "OO-G-UNKNOWN-PREDICATE",
|
||||
severity: unknownSeverity,
|
||||
objectId: rel.id,
|
||||
file: "relationships",
|
||||
message: `Relationship ${rel.id} declares undeclared inverse ${rel.inverse}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 4. Claims: predicates, domain/range, datatypes, provenance ────── */
|
||||
|
||||
for (const claim of pkg.data.claims) {
|
||||
const isRelationship = "entity" in claim.object;
|
||||
const rel = relationships.get(claim.predicate);
|
||||
const prop = properties.get(claim.predicate);
|
||||
const subjectEntity = entities.get(claim.subject);
|
||||
|
||||
if (!subjectEntity) {
|
||||
add({
|
||||
code: "OO-G-DANGLING-REF",
|
||||
severity: "error",
|
||||
objectId: claim.id,
|
||||
file: "claims",
|
||||
message: `Claim ${claim.id} references unknown subject entity ${claim.subject}`
|
||||
});
|
||||
}
|
||||
|
||||
if (!rel && !prop) {
|
||||
add({
|
||||
code: "OO-G-UNKNOWN-PREDICATE",
|
||||
severity: unknownSeverity,
|
||||
objectId: claim.id,
|
||||
file: "claims",
|
||||
message: `Claim ${claim.id} uses undeclared predicate ${claim.predicate}`,
|
||||
hint: "Declare it as a relationship type (entity object) or property (value object)"
|
||||
});
|
||||
}
|
||||
|
||||
if (isRelationship && !rel) {
|
||||
if (prop && prop.type !== "entity-reference") {
|
||||
add({
|
||||
code: "OO-G-OBJECT-KIND",
|
||||
severity: "error",
|
||||
objectId: claim.id,
|
||||
file: "claims",
|
||||
message: `Claim ${claim.id} has an entity object but ${claim.predicate} is a ${prop.type} property`
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!isRelationship && rel) {
|
||||
add({
|
||||
code: "OO-G-OBJECT-KIND",
|
||||
severity: "error",
|
||||
objectId: claim.id,
|
||||
file: "claims",
|
||||
message: `Claim ${claim.id} has a value object but ${claim.predicate} is a relationship type`,
|
||||
hint: "Relationship claims must use { entity: <id> }"
|
||||
});
|
||||
}
|
||||
|
||||
if (isRelationship && rel) {
|
||||
const objectId = (claim.object as { entity: string }).entity;
|
||||
const objectEntity = entities.get(objectId);
|
||||
if (!objectEntity) {
|
||||
add({
|
||||
code: "OO-G-DANGLING-REF",
|
||||
severity: "error",
|
||||
objectId: claim.id,
|
||||
file: "claims",
|
||||
message: `Claim ${claim.id} references unknown object entity ${objectId}`
|
||||
});
|
||||
}
|
||||
if (subjectEntity && !rel.from.includes(subjectEntity.type)) {
|
||||
add({
|
||||
code: "OO-G-DOMAIN",
|
||||
severity: "error",
|
||||
objectId: claim.id,
|
||||
file: "claims",
|
||||
message: `Claim ${claim.id}: ${rel.id} does not accept subject type ${subjectEntity.type}`,
|
||||
hint: `Allowed: ${rel.from.join(", ")}`
|
||||
});
|
||||
}
|
||||
if (objectEntity && !rel.to.includes(objectEntity.type)) {
|
||||
add({
|
||||
code: "OO-G-RANGE",
|
||||
severity: "error",
|
||||
objectId: claim.id,
|
||||
file: "claims",
|
||||
message: `Claim ${claim.id}: ${rel.id} does not accept object type ${objectEntity.type}`,
|
||||
hint: `Allowed: ${rel.to.join(", ")}`
|
||||
});
|
||||
}
|
||||
if (rel.temporal && !claim.validTime?.from && !claim.observedAt) {
|
||||
add({
|
||||
code: "OO-G-TEMPORAL-MISSING",
|
||||
severity: "warning",
|
||||
objectId: claim.id,
|
||||
file: "claims",
|
||||
message: `Claim ${claim.id} uses temporal relationship ${rel.id} without validTime.from or observedAt`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!isRelationship && prop) {
|
||||
const value = (claim.object as { value: unknown; datatype?: ValueType }).value;
|
||||
const declared = (claim.object as { datatype?: ValueType }).datatype ?? prop.type;
|
||||
if (!datatypeMatches(value, declared)) {
|
||||
add({
|
||||
code: "OO-G-DATATYPE",
|
||||
severity: "error",
|
||||
objectId: claim.id,
|
||||
file: "claims",
|
||||
message: `Claim ${claim.id}: value ${JSON.stringify(value)} is not a valid ${declared}`
|
||||
});
|
||||
}
|
||||
if (prop.enum && !prop.enum.some((v) => JSON.stringify(v) === JSON.stringify(value))) {
|
||||
add({
|
||||
code: "OO-G-ENUM",
|
||||
severity: "error",
|
||||
objectId: claim.id,
|
||||
file: "claims",
|
||||
message: `Claim ${claim.id}: ${JSON.stringify(value)} is not an allowed value for ${prop.id}`,
|
||||
hint: `Allowed: ${prop.enum.map((v) => JSON.stringify(v)).join(", ")}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Provenance: R54 — a source, or an explicit first-party declaration.
|
||||
if ((claim.sources?.length ?? 0) === 0 && !claim.firstParty && claim.status !== "derived") {
|
||||
add({
|
||||
code: "OO-P-NO-SOURCE",
|
||||
severity: "error",
|
||||
objectId: claim.id,
|
||||
file: "claims",
|
||||
message: `Claim ${claim.id} has no source and is not marked firstParty`,
|
||||
hint: "Add sources: [<source-id>] or firstParty: true"
|
||||
});
|
||||
}
|
||||
for (const sourceId of claim.sources ?? []) {
|
||||
if (!sources.has(sourceId)) {
|
||||
add({
|
||||
code: "OO-G-DANGLING-REF",
|
||||
severity: "error",
|
||||
objectId: claim.id,
|
||||
file: "claims",
|
||||
message: `Claim ${claim.id} references unknown source ${sourceId}`
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const evId of claim.evidence ?? []) {
|
||||
if (!evidenceIds.has(evId)) {
|
||||
add({
|
||||
code: "OO-G-DANGLING-REF",
|
||||
severity: "error",
|
||||
objectId: claim.id,
|
||||
file: "claims",
|
||||
message: `Claim ${claim.id} references unknown evidence ${evId}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// R55 — agent-authored claims must be traceable to a run.
|
||||
if (claim.assertedBy.startsWith("agent:") && !claim.runId) {
|
||||
add({
|
||||
code: "OO-P-NO-RUN",
|
||||
severity: "error",
|
||||
objectId: claim.id,
|
||||
file: "claims",
|
||||
message: `Claim ${claim.id} was asserted by ${claim.assertedBy} but carries no runId`
|
||||
});
|
||||
}
|
||||
|
||||
// R56 — derived claims must say what produced them.
|
||||
if (claim.status === "derived" && !claim.derivedFrom) {
|
||||
add({
|
||||
code: "OO-P-NO-DERIVATION",
|
||||
severity: "error",
|
||||
objectId: claim.id,
|
||||
file: "claims",
|
||||
message: `Derived claim ${claim.id} does not declare derivedFrom`
|
||||
});
|
||||
}
|
||||
|
||||
for (const [field, target] of [
|
||||
["supersedes", claim.supersedes],
|
||||
["supersededBy", claim.supersededBy],
|
||||
["disputes", claim.disputes]
|
||||
] as const) {
|
||||
if (target && !claims.has(target)) {
|
||||
add({
|
||||
code: "OO-G-DANGLING-REF",
|
||||
severity: "error",
|
||||
objectId: claim.id,
|
||||
file: "claims",
|
||||
message: `Claim ${claim.id}.${field} points at unknown claim ${target}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (claim.validTime?.from && claim.validTime.to && claim.validTime.to < claim.validTime.from) {
|
||||
add({
|
||||
code: "OO-G-TEMPORAL-ORDER",
|
||||
severity: "error",
|
||||
objectId: claim.id,
|
||||
file: "claims",
|
||||
message: `Claim ${claim.id} has validTime.to before validTime.from`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 5. Evidence and source policy ─────────────────────────────────── */
|
||||
|
||||
const sourceById = new Map(pkg.data.sources.map((s) => [s.id, s]));
|
||||
for (const ev of pkg.data.evidence) {
|
||||
const source = sourceById.get(ev.source);
|
||||
if (!source) {
|
||||
add({
|
||||
code: "OO-G-DANGLING-REF",
|
||||
severity: "error",
|
||||
objectId: ev.id,
|
||||
file: "evidence",
|
||||
message: `Evidence ${ev.id} references unknown source ${ev.source}`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (ev.excerpt && ev.excerpt.length > maxExcerpt) {
|
||||
add({
|
||||
code: "OO-P-EXCERPT-LENGTH",
|
||||
severity: "policy",
|
||||
objectId: ev.id,
|
||||
file: "evidence",
|
||||
message: `Evidence ${ev.id} excerpt is ${ev.excerpt.length} chars, above the ${maxExcerpt} limit`,
|
||||
hint: "Shorten the excerpt or drop it and keep the selector"
|
||||
});
|
||||
}
|
||||
if (ev.excerpt && source.license === "unknown") {
|
||||
add({
|
||||
code: "OO-P-EXCERPT-LICENSE",
|
||||
severity: "policy",
|
||||
objectId: ev.id,
|
||||
file: "evidence",
|
||||
message: `Evidence ${ev.id} carries an excerpt from ${source.id}, whose license is unknown`,
|
||||
hint: "Record a license on the source, or keep only the selector"
|
||||
});
|
||||
}
|
||||
if (source.visibility === "private" && (ev.visibility ?? "public") === "public") {
|
||||
add({
|
||||
code: "OO-P-VISIBILITY",
|
||||
severity: "error",
|
||||
objectId: ev.id,
|
||||
file: "evidence",
|
||||
message: `Evidence ${ev.id} is public but its source ${source.id} is private`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const source of pkg.data.sources) {
|
||||
if (!source.stale) continue;
|
||||
const affected = pkg.data.claims.filter((claim) => claim.sources?.includes(source.id)).length;
|
||||
add({
|
||||
code: "OO-P-STALE-SOURCE",
|
||||
severity: "warning",
|
||||
objectId: source.id,
|
||||
file: "sources",
|
||||
message: `Source ${source.id} is marked stale; ${affected} claim(s) resting on it need re-verification`,
|
||||
hint: source.lastCheckedAt ? `Last checked ${source.lastCheckedAt}` : undefined
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 6. Declared constraints ───────────────────────────────────────── */
|
||||
|
||||
const view: KnowledgeView = {
|
||||
entities,
|
||||
claims: pkg.data.claims,
|
||||
relationships,
|
||||
properties
|
||||
};
|
||||
|
||||
for (const constraint of pkg.schema.constraints) {
|
||||
for (const finding of evaluateConstraint(constraint, view, pkg)) add(finding);
|
||||
}
|
||||
|
||||
/* ── 7. Extension namespacing (strict) ─────────────────────────────── */
|
||||
|
||||
if (strict) {
|
||||
const objectsWithExtensions: Array<{ id?: string; extensions?: Record<string, unknown> }> = [
|
||||
...pkg.schema.entityTypes,
|
||||
...pkg.schema.relationships,
|
||||
...pkg.data.entities,
|
||||
...pkg.data.claims
|
||||
];
|
||||
for (const obj of objectsWithExtensions) {
|
||||
for (const key of Object.keys(obj.extensions ?? {})) {
|
||||
if (!key.includes(":") && !key.includes(".")) {
|
||||
add({
|
||||
code: "OO-S-EXTENSION-NS",
|
||||
severity: "error",
|
||||
objectId: obj.id,
|
||||
message: `Extension key ${JSON.stringify(key)} is not namespaced`,
|
||||
hint: "Use vendor:key or vendor.key to prevent future collisions"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.expectedDigest && pkg.manifest.digest && options.expectedDigest !== pkg.manifest.digest) {
|
||||
add({
|
||||
code: "OO-S-DIGEST",
|
||||
severity: "error",
|
||||
objectId: pkg.manifest.id,
|
||||
message: `Manifest digest ${pkg.manifest.digest} does not match computed ${options.expectedDigest}`,
|
||||
hint: "Re-run `logicsrc ontology build` after editing package files"
|
||||
});
|
||||
}
|
||||
|
||||
const counts: Record<Severity, number> = { error: 0, warning: 0, info: 0, policy: 0 };
|
||||
for (const finding of findings) counts[finding.severity] += 1;
|
||||
|
||||
return {
|
||||
ok: counts.error === 0,
|
||||
findings,
|
||||
counts,
|
||||
checked: {
|
||||
entityTypes: pkg.schema.entityTypes.length,
|
||||
relationshipTypes: pkg.schema.relationships.length,
|
||||
entities: pkg.data.entities.length,
|
||||
claims: pkg.data.claims.length,
|
||||
sources: pkg.data.sources.length,
|
||||
evidence: pkg.data.evidence.length,
|
||||
constraints: pkg.schema.constraints.length,
|
||||
queries: pkg.schema.queries.length
|
||||
},
|
||||
digest: pkg.manifest.digest
|
||||
};
|
||||
}
|
||||
|
||||
function evaluateConstraint(
|
||||
constraint: LoadedPackage["schema"]["constraints"][number],
|
||||
view: KnowledgeView,
|
||||
pkg: LoadedPackage
|
||||
): Finding[] {
|
||||
const severity = constraint.severity ?? "error";
|
||||
const code = constraint.code ?? `OO-C-${constraint.rule.type.toUpperCase()}`;
|
||||
const out: Finding[] = [];
|
||||
const live = (claim: Claim) => claim.status === "asserted" || claim.status === "derived";
|
||||
|
||||
const fail = (message: string, objectId?: string) => {
|
||||
out.push({
|
||||
code,
|
||||
severity,
|
||||
objectId,
|
||||
file: "constraints",
|
||||
message: `${constraint.id}: ${message}`,
|
||||
hint: constraint.remediation
|
||||
});
|
||||
};
|
||||
|
||||
switch (constraint.rule.type) {
|
||||
case "required-predicate": {
|
||||
const { entityType, predicate } = constraint.rule;
|
||||
for (const entity of pkg.data.entities) {
|
||||
if (entity.type !== entityType) continue;
|
||||
if (entity.status && entity.status !== "active") continue;
|
||||
const has = pkg.data.claims.some(
|
||||
(c) => live(c) && c.subject === entity.id && c.predicate === predicate
|
||||
);
|
||||
if (!has) fail(`entity ${entity.id} is missing required predicate ${predicate}`, entity.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "cardinality": {
|
||||
const { predicate, entityType, min, max } = constraint.rule;
|
||||
const bySubject = new Map<string, number>();
|
||||
for (const claim of pkg.data.claims) {
|
||||
if (!live(claim) || claim.predicate !== predicate) continue;
|
||||
bySubject.set(claim.subject, (bySubject.get(claim.subject) ?? 0) + 1);
|
||||
}
|
||||
for (const entity of pkg.data.entities) {
|
||||
if (entityType && entity.type !== entityType) continue;
|
||||
const count = bySubject.get(entity.id) ?? 0;
|
||||
if (min !== undefined && count < min) {
|
||||
fail(`entity ${entity.id} has ${count} ${predicate} claims, below the minimum of ${min}`, entity.id);
|
||||
}
|
||||
if (max !== undefined && count > max) {
|
||||
fail(`entity ${entity.id} has ${count} ${predicate} claims, above the maximum of ${max}`, entity.id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "unique": {
|
||||
const { predicate } = constraint.rule;
|
||||
const byValue = new Map<string, string[]>();
|
||||
for (const claim of pkg.data.claims) {
|
||||
if (!live(claim) || claim.predicate !== predicate) continue;
|
||||
const key = "entity" in claim.object ? claim.object.entity : JSON.stringify(claim.object.value);
|
||||
byValue.set(key, [...(byValue.get(key) ?? []), claim.subject]);
|
||||
}
|
||||
for (const [key, subjects] of byValue) {
|
||||
if (subjects.length > 1) {
|
||||
fail(`${predicate} value ${key} is shared by ${subjects.length} entities: ${subjects.join(", ")}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "allowed-values": {
|
||||
const { predicate, values } = constraint.rule;
|
||||
const allowed = values.map((v) => JSON.stringify(v));
|
||||
for (const claim of pkg.data.claims) {
|
||||
if (!live(claim) || claim.predicate !== predicate) continue;
|
||||
if ("entity" in claim.object) continue;
|
||||
if (!allowed.includes(JSON.stringify(claim.object.value))) {
|
||||
fail(`claim ${claim.id} has disallowed value ${JSON.stringify(claim.object.value)}`, claim.id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "domain-range": {
|
||||
const { predicate, from, to } = constraint.rule;
|
||||
for (const claim of pkg.data.claims) {
|
||||
if (!live(claim) || claim.predicate !== predicate) continue;
|
||||
const subject = view.entities.get(claim.subject);
|
||||
if (from && subject && !from.includes(subject.type)) {
|
||||
fail(`claim ${claim.id} subject type ${subject.type} is outside the declared domain`, claim.id);
|
||||
}
|
||||
if (to && "entity" in claim.object) {
|
||||
const object = view.entities.get(claim.object.entity);
|
||||
if (object && !to.includes(object.type)) {
|
||||
fail(`claim ${claim.id} object type ${object.type} is outside the declared range`, claim.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "temporal-bounds": {
|
||||
const { predicate, notBefore, notAfter, requireValidFrom } = constraint.rule;
|
||||
for (const claim of pkg.data.claims) {
|
||||
if (!live(claim) || claim.predicate !== predicate) continue;
|
||||
const from = claim.validTime?.from ?? undefined;
|
||||
if (requireValidFrom && !from) {
|
||||
fail(`claim ${claim.id} is missing validTime.from`, claim.id);
|
||||
}
|
||||
if (from && notBefore && from < notBefore) {
|
||||
fail(`claim ${claim.id} starts ${from}, before the allowed ${notBefore}`, claim.id);
|
||||
}
|
||||
const to = claim.validTime?.to ?? undefined;
|
||||
if (to && notAfter && to > notAfter) {
|
||||
fail(`claim ${claim.id} ends ${to}, after the allowed ${notAfter}`, claim.id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "query": {
|
||||
const rule = constraint.rule;
|
||||
const saved = pkg.schema.queries.find((q) => q.id === rule.query);
|
||||
if (!saved) {
|
||||
out.push({
|
||||
code: "OO-C-UNKNOWN-QUERY",
|
||||
severity: "error",
|
||||
objectId: constraint.id,
|
||||
file: "constraints",
|
||||
message: `${constraint.id}: references unknown saved query ${rule.query}`
|
||||
});
|
||||
break;
|
||||
}
|
||||
const expect = rule.expect ?? "empty";
|
||||
const result = evaluateQuery(view, saved.query);
|
||||
if (expect === "empty" && result.rows.length > 0) {
|
||||
fail(`query ${saved.id} returned ${result.rows.length} violating row(s)`);
|
||||
}
|
||||
if (expect === "non-empty" && result.rows.length === 0) {
|
||||
fail(`query ${saved.id} returned no rows but was expected to`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function datatypeMatches(value: unknown, type: ValueType): boolean {
|
||||
switch (type) {
|
||||
case "string":
|
||||
return typeof value === "string";
|
||||
case "number":
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
case "integer":
|
||||
return typeof value === "number" && Number.isInteger(value);
|
||||
case "boolean":
|
||||
return typeof value === "boolean";
|
||||
case "date":
|
||||
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||
case "date-time":
|
||||
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
||||
case "duration":
|
||||
return typeof value === "string" && /^P/.test(value);
|
||||
case "url":
|
||||
return typeof value === "string" && /^[a-z][a-z0-9+.-]*:/i.test(value);
|
||||
case "email":
|
||||
return typeof value === "string" && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value);
|
||||
case "enum":
|
||||
return value !== undefined && value !== null;
|
||||
case "object":
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
case "array":
|
||||
return Array.isArray(value);
|
||||
case "binary-reference":
|
||||
return typeof value === "string";
|
||||
case "entity-reference":
|
||||
return typeof value === "string";
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Report rendering ──────────────────────────────────────────────────── */
|
||||
|
||||
export type ReportFormat = "text" | "json" | "yaml" | "markdown";
|
||||
|
||||
export function renderReport(report: ValidationReport, format: ReportFormat = "text"): string {
|
||||
if (format === "json") return JSON.stringify(report, null, 2);
|
||||
|
||||
if (format === "yaml") {
|
||||
const lines = [
|
||||
`ok: ${report.ok}`,
|
||||
"counts:",
|
||||
...Object.entries(report.counts).map(([k, v]) => ` ${k}: ${v}`),
|
||||
"findings:"
|
||||
];
|
||||
for (const f of report.findings) {
|
||||
lines.push(` - code: ${f.code}`);
|
||||
lines.push(` severity: ${f.severity}`);
|
||||
lines.push(` message: ${JSON.stringify(f.message)}`);
|
||||
if (f.objectId) lines.push(` objectId: ${f.objectId}`);
|
||||
if (f.file) lines.push(` file: ${f.file}`);
|
||||
if (f.path) lines.push(` path: ${f.path}`);
|
||||
if (f.hint) lines.push(` hint: ${JSON.stringify(f.hint)}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
if (format === "markdown") {
|
||||
const lines = [
|
||||
`# Validation ${report.ok ? "passed" : "failed"}`,
|
||||
"",
|
||||
`- errors: ${report.counts.error}`,
|
||||
`- warnings: ${report.counts.warning}`,
|
||||
`- policy: ${report.counts.policy}`,
|
||||
`- info: ${report.counts.info}`,
|
||||
""
|
||||
];
|
||||
if (report.findings.length > 0) {
|
||||
lines.push("| severity | code | object | message |", "| --- | --- | --- | --- |");
|
||||
for (const f of report.findings) {
|
||||
lines.push(`| ${f.severity} | ${f.code} | ${f.objectId ?? ""} | ${f.message.replace(/\|/g, "\\|")} |`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const tick = (label: string, n: number) => ` ✓ ${n} ${label}`;
|
||||
lines.push(tick("entity types", report.checked.entityTypes));
|
||||
lines.push(tick("relationship types", report.checked.relationshipTypes));
|
||||
lines.push(tick("entities", report.checked.entities));
|
||||
lines.push(tick("claims", report.checked.claims));
|
||||
lines.push(tick("sources", report.checked.sources));
|
||||
lines.push(tick("evidence records", report.checked.evidence));
|
||||
lines.push(tick("constraints", report.checked.constraints));
|
||||
|
||||
for (const f of report.findings) {
|
||||
const mark = f.severity === "error" ? "✗" : f.severity === "warning" ? "!" : "·";
|
||||
lines.push(` ${mark} [${f.severity}] ${f.code} ${f.message}${f.hint ? `\n hint: ${f.hint}` : ""}`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
report.ok
|
||||
? "OpenOntology package is valid."
|
||||
: `OpenOntology package is INVALID (${report.counts.error} error(s), ${report.counts.warning} warning(s)).`
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue