diff --git a/CHANGELOG.md b/CHANGELOG.md index c7182b68f..0999dbfdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- Implemented UKO Layer 1 Domain Ontologies (`uko-doc:`, `uko-data:`, `uko-infra:`) + in the OWL/Turtle ontology file (`docs/ontology/uko.ttl`). Added 17 `uko-doc:` classes + (Document, Section, Paragraph, Citation, etc.), 13 `uko-data:` classes (Table, Column, + ForeignKey, View, etc.), and 7 `uko-infra:` classes (Service, Endpoint, Port, etc.) with + all spec-mandated properties and relationships. Updated all four DetailLevelMap presets + (`code_detail_map`, `docs_detail_map`, `database_detail_map`, `infra_detail_map`) to + be spec-complete with every named level. Added `OntologyRegistry` with domain lookup, + Layer 1 listing, DetailLevelMap inheritance chain building, and Turtle syntax validation. + Includes 31 Behave BDD scenarios and 6 Robot Framework integration tests. (#574) - Added `PostgreSQLAnalyzer` and `DockerComposeAnalyzer` domain-specific analyzers (Phase 2 of issue #588). `PostgreSQLAnalyzer` parses DDL content via regex and extracts `uko-data:Table`, `uko-data:Column`, `uko-data:ForeignKey`, `uko-data:View`, diff --git a/docs/ontology/uko.ttl b/docs/ontology/uko.ttl index 6d6f5322b..e379cc320 100644 --- a/docs/ontology/uko.ttl +++ b/docs/ontology/uko.ttl @@ -1,5 +1,8 @@ @prefix uko: . @prefix uko-code: . +@prefix uko-doc: . +@prefix uko-data: . +@prefix uko-infra: . @prefix uko-oo: . @prefix uko-py: . @prefix rdf: . @@ -157,7 +160,7 @@ uko-code:Import a owl:Class ; rdfs:label "Import" ; rdfs:comment "An import/include/require statement." . -# -- Layer 1 Properties -- +# -- uko-code: Properties -- uko-code:hasReturnType a owl:DatatypeProperty ; rdfs:domain uko-code:Callable ; @@ -176,6 +179,374 @@ uko-code:testsCallable a owl:ObjectProperty ; rdfs:label "testsCallable" ; rdfs:comment "Links a test case to the callable it tests." . +# =========================================================================== +# Layer 1: Documents (uko-doc:) +# =========================================================================== + +# -- Container Classes -- + +uko-doc:Document a owl:Class ; + rdfs:subClassOf uko:Container ; + rdfs:label "Document" ; + rdfs:comment "A complete document (article, report, manual, specification)." . + +uko-doc:Part a owl:Class ; + rdfs:subClassOf uko:Container ; + rdfs:label "Part" ; + rdfs:comment "A top-level division of a document (e.g., Part I, Part II)." . + +uko-doc:Chapter a owl:Class ; + rdfs:subClassOf uko:Container ; + rdfs:label "Chapter" ; + rdfs:comment "A chapter within a document or part." . + +uko-doc:Section a owl:Class ; + rdfs:subClassOf uko:Container ; + rdfs:label "Section" ; + rdfs:comment "A numbered or titled section within a chapter." . + +uko-doc:Subsection a owl:Class ; + rdfs:subClassOf uko-doc:Section ; + rdfs:label "Subsection" ; + rdfs:comment "A subsection nested within a section (arbitrary depth)." . + +# -- Atom Classes -- + +uko-doc:Paragraph a owl:Class ; + rdfs:subClassOf uko:Atom ; + rdfs:label "Paragraph" ; + rdfs:comment "A paragraph of prose text." . + +uko-doc:CodeBlock a owl:Class ; + rdfs:subClassOf uko:Atom ; + rdfs:label "CodeBlock" ; + rdfs:comment "An inline code listing or example within a document." . + +uko-doc:Figure a owl:Class ; + rdfs:subClassOf uko:Atom ; + rdfs:label "Figure" ; + rdfs:comment "A figure, diagram, or image with an optional caption." . + +uko-doc:Table a owl:Class ; + rdfs:subClassOf uko:Atom ; + rdfs:label "Table" ; + rdfs:comment "A tabular data element within a document." . + +uko-doc:BlockQuote a owl:Class ; + rdfs:subClassOf uko:Atom ; + rdfs:label "BlockQuote" ; + rdfs:comment "A quoted passage from another source." . + +uko-doc:ListBlock a owl:Class ; + rdfs:subClassOf uko:Atom ; + rdfs:label "ListBlock" ; + rdfs:comment "An ordered or unordered list." . + +# -- Annotation Classes -- + +uko-doc:Footnote a owl:Class ; + rdfs:subClassOf uko:Annotation ; + rdfs:label "Footnote" ; + rdfs:comment "A footnote or endnote." . + +uko-doc:Citation a owl:Class ; + rdfs:subClassOf uko:Annotation ; + rdfs:label "Citation" ; + rdfs:comment "A bibliographic citation or reference." . + +uko-doc:Comment a owl:Class ; + rdfs:subClassOf uko:Annotation ; + rdfs:label "Comment" ; + rdfs:comment "An editorial comment or annotation (e.g., HTML comment, review note)." . + +# -- Boundary Classes -- + +uko-doc:TableOfContents a owl:Class ; + rdfs:subClassOf uko:Boundary ; + rdfs:label "TableOfContents" ; + rdfs:comment "The document's table of contents -- an interface into the document's structure." . + +uko-doc:Index a owl:Class ; + rdfs:subClassOf uko:Boundary ; + rdfs:label "Index" ; + rdfs:comment "A back-of-book index or keyword index." . + +uko-doc:Glossary a owl:Class ; + rdfs:subClassOf uko:Boundary ; + rdfs:label "Glossary" ; + rdfs:comment "A glossary of terms defined in the document." . + +# -- uko-doc: Relationships -- + +uko-doc:discussesTopic a owl:ObjectProperty ; + rdfs:subPropertyOf uko:references ; + rdfs:domain uko:InformationUnit ; + rdfs:range uko:InformationUnit ; + rdfs:label "discussesTopic" ; + rdfs:comment "Semantic relationship: this unit discusses a topic that is the subject of another unit." . + +uko-doc:cites a owl:ObjectProperty ; + rdfs:subPropertyOf uko:references ; + rdfs:domain uko:InformationUnit ; + rdfs:range uko-doc:Citation ; + rdfs:label "cites" ; + rdfs:comment "This unit cites a bibliographic reference." . + +uko-doc:crossReferences a owl:ObjectProperty ; + rdfs:subPropertyOf uko:references ; + rdfs:domain uko:InformationUnit ; + rdfs:range uko:InformationUnit ; + rdfs:label "crossReferences" ; + rdfs:comment "Explicit cross-reference (e.g., 'see Section 3.2')." . + +uko-doc:precedes a owl:ObjectProperty ; + rdfs:domain uko:InformationUnit ; + rdfs:range uko:InformationUnit ; + rdfs:label "precedes" ; + rdfs:comment "Reading order: this unit comes before the target unit." . + +# -- uko-doc: Datatype Properties -- + +uko-doc:headingLevel a owl:DatatypeProperty ; + rdfs:domain uko-doc:Section ; + rdfs:range xsd:integer ; + rdfs:label "headingLevel" ; + rdfs:comment "The nesting depth of this section (1 = top-level, 2 = subsection, etc.)." . + +uko-doc:headingText a owl:DatatypeProperty ; + rdfs:domain uko-doc:Section ; + rdfs:range xsd:string ; + rdfs:label "headingText" ; + rdfs:comment "The title/heading text of this section." . + +uko-doc:wordCount a owl:DatatypeProperty ; + rdfs:domain uko:InformationUnit ; + rdfs:range xsd:integer ; + rdfs:label "wordCount" ; + rdfs:comment "Word count of the text content in this unit." . + +uko-doc:language a owl:DatatypeProperty ; + rdfs:domain uko-doc:Document ; + rdfs:range xsd:string ; + rdfs:label "language" ; + rdfs:comment "Natural language of the document (e.g., 'en', 'fr')." . + +uko-doc:topicKeywords a owl:DatatypeProperty ; + rdfs:domain uko:InformationUnit ; + rdfs:range xsd:string ; + rdfs:label "topicKeywords" ; + rdfs:comment "JSON-encoded list of topic keywords extracted from this unit." . + +# =========================================================================== +# Layer 1: Data Schemas (uko-data:) +# =========================================================================== + +# -- Container Classes -- + +uko-data:Database a owl:Class ; + rdfs:subClassOf uko:Container ; + rdfs:label "Database" ; + rdfs:comment "A database instance containing schemas." . + +uko-data:Schema a owl:Class ; + rdfs:subClassOf uko:Container ; + rdfs:label "Schema" ; + rdfs:comment "A database schema (namespace for tables, views, etc.)." . + +uko-data:Table a owl:Class ; + rdfs:subClassOf uko:Container ; + rdfs:label "Table" ; + rdfs:comment "A database table containing columns." . + +uko-data:View a owl:Class ; + rdfs:subClassOf uko:Container ; + rdfs:label "View" ; + rdfs:comment "A database view (virtual table defined by a query)." . + +# -- Atom Classes -- + +uko-data:Column a owl:Class ; + rdfs:subClassOf uko:Atom ; + rdfs:label "Column" ; + rdfs:comment "A column within a table or view." . + +uko-data:StoredProcedure a owl:Class ; + rdfs:subClassOf uko:Atom ; + rdfs:label "StoredProcedure" ; + rdfs:comment "A stored procedure or function in the database." . + +uko-data:Trigger a owl:Class ; + rdfs:subClassOf uko:Atom ; + rdfs:label "Trigger" ; + rdfs:comment "A database trigger." . + +uko-data:Migration a owl:Class ; + rdfs:subClassOf uko:Atom ; + rdfs:label "Migration" ; + rdfs:comment "A schema migration (DDL change script)." . + +# -- Annotation Classes -- + +uko-data:Constraint a owl:Class ; + rdfs:subClassOf uko:Annotation ; + rdfs:label "Constraint" ; + rdfs:comment "A column or table constraint (NOT NULL, UNIQUE, CHECK, etc.)." . + +uko-data:Index a owl:Class ; + rdfs:subClassOf uko:Annotation ; + rdfs:label "Index" ; + rdfs:comment "A database index on one or more columns." . + +uko-data:ColumnComment a owl:Class ; + rdfs:subClassOf uko:Annotation ; + rdfs:label "ColumnComment" ; + rdfs:comment "A COMMENT ON COLUMN annotation in the database." . + +# -- Boundary Classes -- + +uko-data:ForeignKey a owl:Class ; + rdfs:subClassOf uko:Boundary ; + rdfs:label "ForeignKey" ; + rdfs:comment "A foreign key constraint -- an interface point between tables." . + +uko-data:APIEndpoint a owl:Class ; + rdfs:subClassOf uko:Boundary ; + rdfs:label "APIEndpoint" ; + rdfs:comment "A database-level API endpoint (e.g., PostgREST, GraphQL)." . + +# -- uko-data: Relationships -- + +uko-data:foreignKeyTo a owl:ObjectProperty ; + rdfs:subPropertyOf uko:dependsOn ; + rdfs:domain uko-data:Column ; + rdfs:range uko-data:Column ; + rdfs:label "foreignKeyTo" ; + rdfs:comment "Foreign key relationship from this column to a column in another table." . + +uko-data:viewReferences a owl:ObjectProperty ; + rdfs:subPropertyOf uko:dependsOn ; + rdfs:domain uko-data:View ; + rdfs:range uko-data:Table ; + rdfs:label "viewReferences" ; + rdfs:comment "This view's query references columns from the target table." . + +uko-data:procedureAccesses a owl:ObjectProperty ; + rdfs:subPropertyOf uko:dependsOn ; + rdfs:domain uko-data:StoredProcedure ; + rdfs:range uko-data:Table ; + rdfs:label "procedureAccesses" ; + rdfs:comment "This stored procedure reads from or writes to the target table." . + +uko-data:triggerFires a owl:ObjectProperty ; + rdfs:subPropertyOf uko:dependsOn ; + rdfs:domain uko-data:Trigger ; + rdfs:range uko-data:Table ; + rdfs:label "triggerFires" ; + rdfs:comment "This trigger fires on events on the target table." . + +uko-data:migrationAlters a owl:ObjectProperty ; + rdfs:subPropertyOf uko:dependsOn ; + rdfs:domain uko-data:Migration ; + rdfs:range uko-data:Table ; + rdfs:label "migrationAlters" ; + rdfs:comment "This migration modifies the schema of the target table." . + +# -- uko-data: Datatype Properties -- + +uko-data:dataType a owl:DatatypeProperty ; + rdfs:domain uko-data:Column ; + rdfs:range xsd:string ; + rdfs:label "dataType" ; + rdfs:comment "SQL data type (e.g., 'VARCHAR(255)', 'INTEGER', 'JSONB')." . + +uko-data:isNullable a owl:DatatypeProperty ; + rdfs:domain uko-data:Column ; + rdfs:range xsd:boolean ; + rdfs:label "isNullable" . + +uko-data:isPrimaryKey a owl:DatatypeProperty ; + rdfs:domain uko-data:Column ; + rdfs:range xsd:boolean ; + rdfs:label "isPrimaryKey" . + +uko-data:rowEstimate a owl:DatatypeProperty ; + rdfs:domain uko-data:Table ; + rdfs:range xsd:long ; + rdfs:label "rowEstimate" ; + rdfs:comment "Estimated row count from database statistics." . + +uko-data:viewDefinition a owl:DatatypeProperty ; + rdfs:domain uko-data:View ; + rdfs:range xsd:string ; + rdfs:label "viewDefinition" ; + rdfs:comment "The SQL query that defines this view." . + +uko-data:procedureBody a owl:DatatypeProperty ; + rdfs:domain uko-data:StoredProcedure ; + rdfs:range xsd:string ; + rdfs:label "procedureBody" ; + rdfs:comment "The SQL/PL body of the stored procedure." . + +# =========================================================================== +# Layer 1: Infrastructure (uko-infra:) +# =========================================================================== + +# -- Container Classes -- + +uko-infra:Service a owl:Class ; + rdfs:subClassOf uko:Container ; + rdfs:label "Service" ; + rdfs:comment "A deployable service or application." . + +uko-infra:ConfigBlock a owl:Class ; + rdfs:subClassOf uko:Container ; + rdfs:label "ConfigBlock" ; + rdfs:comment "A configuration block or section (e.g., a YAML map, TOML table)." . + +uko-infra:DeploymentUnit a owl:Class ; + rdfs:subClassOf uko:Container ; + rdfs:label "DeploymentUnit" ; + rdfs:comment "A deployment unit (e.g., Kubernetes Deployment, Docker Compose service)." . + +# -- Atom Classes -- + +uko-infra:ConfigKey a owl:Class ; + rdfs:subClassOf uko:Atom ; + rdfs:label "ConfigKey" ; + rdfs:comment "A configuration key-value pair." . + +uko-infra:EnvironmentVariable a owl:Class ; + rdfs:subClassOf uko:Atom ; + rdfs:label "EnvironmentVariable" ; + rdfs:comment "An environment variable definition." . + +# -- Boundary Classes -- + +uko-infra:Endpoint a owl:Class ; + rdfs:subClassOf uko:Boundary ; + rdfs:label "Endpoint" ; + rdfs:comment "A network endpoint (HTTP route, gRPC service, message queue)." . + +uko-infra:Port a owl:Class ; + rdfs:subClassOf uko:Boundary ; + rdfs:label "Port" ; + rdfs:comment "A network port binding." . + +# -- uko-infra: Relationships -- + +uko-infra:connectsTo a owl:ObjectProperty ; + rdfs:subPropertyOf uko:dependsOn ; + rdfs:domain uko-infra:Service ; + rdfs:range uko-infra:Service ; + rdfs:label "connectsTo" ; + rdfs:comment "This service connects to or depends on the target service." . + +uko-infra:exposes a owl:ObjectProperty ; + rdfs:domain uko-infra:Service ; + rdfs:range uko-infra:Endpoint ; + rdfs:label "exposes" ; + rdfs:comment "This service exposes the target endpoint." . + # =========================================================================== # Layer 2: Object-Oriented Paradigm (uko-oo:) # =========================================================================== diff --git a/features/depth_breadth_projection.feature b/features/depth_breadth_projection.feature index dc1f56353..09c445ea8 100644 --- a/features/depth_breadth_projection.feature +++ b/features/depth_breadth_projection.feature @@ -161,7 +161,8 @@ Feature: Depth/Breadth Projection System and Skeleton Context Propagation Scenario: Database detail level map has correct levels Given the depth/breadth projection modules are available When I create the database detail level map preset - Then the database map should resolve "TABLE_LISTING" to 0 + Then the database map should resolve "SCHEMA_LISTING" to 0 + And the database map should resolve "TABLE_LISTING" to 1 And the database map should resolve "FULL_CATALOG" to 11 # --------------------------------------------------------------------------- diff --git a/features/steps/uko_ontology_registry_steps.py b/features/steps/uko_ontology_registry_steps.py new file mode 100644 index 000000000..e2d55ba97 --- /dev/null +++ b/features/steps/uko_ontology_registry_steps.py @@ -0,0 +1,464 @@ +"""Step definitions for the UKO Ontology Domain Registry feature. + +Covers domain lookup, DetailLevelMap resolution (all four Layer 1 +domains), inheritance chain building, Turtle syntax validation, and +the Universal View Guarantee. + +Steps are prefixed with ``ontology_`` where needed to avoid collision +with existing step files. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.domain.models.acms.crp import DetailLevelMap +from cleveragents.domain.models.acms.ontology_registry import ( + DomainDescriptor, + build_detail_map_chain, + get_domain, + get_layer1_domains, + list_domains, + validate_turtle, + validate_turtle_file, +) + +__all__: list[str] = [] + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_PROJECT_ROOT = Path(__file__).resolve().parents[2] +"""Root of the project worktree (two levels up from features/steps/).""" + + +# --------------------------------------------------------------------------- +# Given steps +# --------------------------------------------------------------------------- + + +@given("the UKO ontology registry module is available") +def step_ontology_module_available(context: Context) -> None: + # Importing the module is the availability check. + context.ontology_error = None + context.ontology_domain = None + context.ontology_domains_list = None + context.ontology_layer1_list = None + context.ontology_chain = None + context.ontology_validation_errors = None + context.ontology_ttl_content = None + context.ontology_resolve_error = None + context.ontology_file_error = None + context.ontology_resolved_depth = None + context.ontology_custom_map = None + + +@given( + 'a custom DetailLevelMap for domain "{domain}" ' + 'with parent "{parent_prefix}" and levels:' +) +def step_custom_map_with_parent( + context: Context, + domain: str, + parent_prefix: str, +) -> None: + parent_desc = get_domain(parent_prefix) + parent_map = parent_desc.detail_map + + levels: dict[str, int] = {} + for row in context.table: + levels[row["level"]] = int(row["depth"]) + + # Determine max_depth: parent max or highest local level, whichever is bigger + local_max = max(levels.values()) if levels else 0 + max_depth = max(parent_map.max_depth, local_max) + + context.ontology_custom_map = DetailLevelMap( + domain=domain, + parent=parent_map, + levels=levels, + max_depth=max_depth, + ) + + +# --------------------------------------------------------------------------- +# When steps — domain lookup +# --------------------------------------------------------------------------- + + +@when('I look up domain "{prefix}"') +def step_lookup_domain(context: Context, prefix: str) -> None: + context.ontology_error = None + try: + context.ontology_domain = get_domain(prefix) + except (ValueError, KeyError) as exc: + context.ontology_error = exc + + +@when("I look up an empty domain prefix") +def step_lookup_empty_domain(context: Context) -> None: + context.ontology_error = None + try: + context.ontology_domain = get_domain("") + except (ValueError, KeyError) as exc: + context.ontology_error = exc + + +@when("I list all domains") +def step_list_domains(context: Context) -> None: + context.ontology_domains_list = list_domains() + + +@when("I get Layer 1 domains") +def step_get_layer1(context: Context) -> None: + context.ontology_layer1_list = get_layer1_domains() + + +# --------------------------------------------------------------------------- +# When steps — detail map chain +# --------------------------------------------------------------------------- + + +@when('I build a detail map chain from "{prefix}"') +def step_build_chain_single(context: Context, prefix: str) -> None: + context.ontology_error = None + try: + context.ontology_chain = build_detail_map_chain(prefix) + except ValueError as exc: + context.ontology_error = exc + + +@when("I build a detail map chain with no prefixes") +def step_build_chain_empty(context: Context) -> None: + context.ontology_error = None + try: + context.ontology_chain = build_detail_map_chain() + except ValueError as exc: + context.ontology_error = exc + + +@when('I try to resolve level "{level}"') +def step_try_resolve_level(context: Context, level: str) -> None: + context.ontology_resolve_error = None + try: + desc: DomainDescriptor = context.ontology_domain + desc.detail_map.resolve(level) + except ValueError as exc: + context.ontology_resolve_error = exc + # Also set the generic error so negative step works + context.ontology_error = exc + + +@when('I resolve "{level}" on the custom map') +def step_resolve_custom_map(context: Context, level: str) -> None: + context.ontology_resolved_depth = context.ontology_custom_map.resolve(level) + + +# --------------------------------------------------------------------------- +# When steps — Turtle validation +# --------------------------------------------------------------------------- + + +@when('I validate the project Turtle file "{relpath}"') +def step_validate_project_ttl(context: Context, relpath: str) -> None: + full_path = _PROJECT_ROOT / relpath + context.ontology_validation_errors = validate_turtle_file(full_path) + + +@when("I validate Turtle content:") +def step_validate_turtle_content(context: Context) -> None: + context.ontology_validation_errors = validate_turtle(context.text) + + +@when("I validate empty Turtle content") +def step_validate_empty_turtle(context: Context) -> None: + context.ontology_validation_errors = validate_turtle("") + + +@when('I validate Turtle file "{path}"') +def step_validate_turtle_file_path(context: Context, path: str) -> None: + context.ontology_file_error = None + try: + context.ontology_validation_errors = validate_turtle_file(path) + except (FileNotFoundError, ValueError) as exc: + context.ontology_file_error = exc + context.ontology_error = exc + + +@when("I validate Turtle file with empty path") +def step_validate_turtle_empty_path(context: Context) -> None: + context.ontology_file_error = None + try: + context.ontology_validation_errors = validate_turtle_file("") + except (FileNotFoundError, ValueError) as exc: + context.ontology_file_error = exc + context.ontology_error = exc + + +@when('I read the project Turtle file "{relpath}"') +def step_read_project_ttl(context: Context, relpath: str) -> None: + full_path = _PROJECT_ROOT / relpath + context.ontology_ttl_content = full_path.read_text(encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Then steps — domain descriptor assertions +# --------------------------------------------------------------------------- + + +@then('the domain IRI should be "{expected_iri}"') +def step_check_domain_iri(context: Context, expected_iri: str) -> None: + desc: DomainDescriptor = context.ontology_domain + assert desc.iri == expected_iri, f"Expected IRI {expected_iri!r}, got {desc.iri!r}" + + +@then("the domain layer should be {expected:d}") +def step_check_domain_layer(context: Context, expected: int) -> None: + desc: DomainDescriptor = context.ontology_domain + assert desc.layer == expected, f"Expected layer {expected}, got {desc.layer}" + + +@then('the domain classes should contain "{cls}"') +def step_check_domain_class(context: Context, cls: str) -> None: + desc: DomainDescriptor = context.ontology_domain + assert cls in desc.classes, ( + f"Class {cls!r} not found in {desc.prefix} classes: {sorted(desc.classes)}" + ) + + +@then("the domain should have {count:d} classes") +def step_check_domain_class_count(context: Context, count: int) -> None: + desc: DomainDescriptor = context.ontology_domain + assert len(desc.classes) == count, ( + f"Expected {count} classes, got {len(desc.classes)}: {sorted(desc.classes)}" + ) + + +# --------------------------------------------------------------------------- +# Then steps — error assertions +# --------------------------------------------------------------------------- + + +@then('a ValueError should have been raised with message "{fragment}"') +def step_check_value_error(context: Context, fragment: str) -> None: + err = context.ontology_error + assert err is not None, "Expected ValueError but none was raised" + assert isinstance(err, ValueError), f"Expected ValueError, got {type(err).__name__}" + assert fragment in str(err), ( + f"Expected error message to contain {fragment!r}, got: {err}" + ) + + +@then("a FileNotFoundError should have been raised") +def step_check_file_not_found(context: Context) -> None: + err = context.ontology_file_error or context.ontology_error + assert err is not None, "Expected FileNotFoundError but none was raised" + assert isinstance(err, FileNotFoundError), ( + f"Expected FileNotFoundError, got {type(err).__name__}" + ) + + +# --------------------------------------------------------------------------- +# Then steps — domain list assertions +# --------------------------------------------------------------------------- + + +@then("the domain list should have {count:d} entries") +def step_check_domain_list_count(context: Context, count: int) -> None: + lst = context.ontology_domains_list + assert len(lst) == count, f"Expected {count} domains, got {len(lst)}" + + +@then("the domain list should be sorted by layer then prefix") +def step_check_domain_list_sorted(context: Context) -> None: + lst = context.ontology_domains_list + keys = [(d.layer, d.prefix) for d in lst] + assert keys == sorted(keys), f"Domain list not sorted: {keys}" + + +@then("the Layer 1 list should have {count:d} entries") +def step_check_layer1_count(context: Context, count: int) -> None: + lst = context.ontology_layer1_list + assert len(lst) == count, f"Expected {count} Layer 1 domains, got {len(lst)}" + + +@then("every domain in the Layer 1 list should have layer 1") +def step_check_all_layer1(context: Context) -> None: + for d in context.ontology_layer1_list: + assert d.layer == 1, f"Domain {d.prefix} has layer {d.layer}, expected 1" + + +# --------------------------------------------------------------------------- +# Then steps — detail map resolution +# --------------------------------------------------------------------------- + + +@then('the detail map should resolve "{level}" to {expected:d}') +def step_check_detail_resolve(context: Context, level: str, expected: int) -> None: + desc: DomainDescriptor = context.ontology_domain + actual = desc.detail_map.resolve(level) + assert actual == expected, ( + f"{desc.prefix} map resolved {level!r} to {actual}, expected {expected}" + ) + + +@then("the detail map should clamp depth {raw:d} to {expected:d}") +def step_check_detail_clamp(context: Context, raw: int, expected: int) -> None: + desc: DomainDescriptor = context.ontology_domain + actual = desc.detail_map.resolve(raw) + assert actual == expected, ( + f"{desc.prefix} map clamped {raw} to {actual}, expected {expected}" + ) + + +@then("the resolved depth should be {expected:d}") +def step_check_resolved_depth(context: Context, expected: int) -> None: + actual = context.ontology_resolved_depth + assert actual == expected, f"Expected resolved depth {expected}, got {actual}" + + +# --------------------------------------------------------------------------- +# Then steps — chain assertions +# --------------------------------------------------------------------------- + + +@then('the chain should resolve "{level}" to {expected:d}') +def step_check_chain_resolve(context: Context, level: str, expected: int) -> None: + chain: DetailLevelMap = context.ontology_chain + actual = chain.resolve(level) + assert actual == expected, ( + f"Chain resolved {level!r} to {actual}, expected {expected}" + ) + + +@then("the chain max depth should be {expected:d}") +def step_check_chain_max_depth(context: Context, expected: int) -> None: + chain: DetailLevelMap = context.ontology_chain + assert chain.max_depth == expected, ( + f"Chain max_depth is {chain.max_depth}, expected {expected}" + ) + + +# --------------------------------------------------------------------------- +# Then steps — Turtle validation assertions +# --------------------------------------------------------------------------- + + +@then("the validation should produce {count:d} errors") +def step_check_validation_count(context: Context, count: int) -> None: + errs = context.ontology_validation_errors + assert len(errs) == count, ( + f"Expected {count} validation error(s), got {len(errs)}: {errs}" + ) + + +@then("the validation should produce at least {count:d} error") +def step_check_validation_min(context: Context, count: int) -> None: + errs = context.ontology_validation_errors + assert len(errs) >= count, ( + f"Expected at least {count} error(s), got {len(errs)}: {errs}" + ) + + +@then('the first error should contain "{fragment}"') +def step_check_first_error(context: Context, fragment: str) -> None: + errs = context.ontology_validation_errors + assert errs, "No validation errors to check" + assert fragment in errs[0], ( + f"Expected first error to contain {fragment!r}, got: {errs[0]}" + ) + + +# --------------------------------------------------------------------------- +# Then steps — Turtle content assertions +# --------------------------------------------------------------------------- + + +@then('the content should contain prefix "{prefix}"') +def step_check_content_prefix(context: Context, prefix: str) -> None: + content = context.ontology_ttl_content + # Match @prefix : . + name = prefix.rstrip(":") + pattern = rf"@prefix\s+{re.escape(name)}\s*:" + assert re.search(pattern, content), ( + f"Prefix {prefix!r} not declared in Turtle content" + ) + + +@then('the content should contain "{text}"') +def step_check_content_text(context: Context, text: str) -> None: + content = context.ontology_ttl_content + assert text in content, f"Text {text!r} not found in Turtle content" + + +# --------------------------------------------------------------------------- +# Then steps — Universal View Guarantee +# --------------------------------------------------------------------------- + +# Build a map of class → superclass(es) from the TTL for ancestry checks +_SUBCLASS_RE = re.compile( + r"(\S+:\S+)\s+a\s+owl:Class\s*;\s*\n\s*rdfs:subClassOf\s+([^;.]+)", + re.MULTILINE, +) + + +@then("every Layer 1 class should have a subClassOf chain to uko:InformationUnit") +def step_check_universal_view(context: Context) -> None: + content = context.ontology_ttl_content + + # Parse subClassOf relationships + parent_map: dict[str, set[str]] = {} + for m in re.finditer( + r"(\S+:\S+)\s+a\s+owl:Class\s*;[^.]*?" + r"rdfs:subClassOf\s+([^;.]+)", + content, + re.DOTALL, + ): + cls = m.group(1) + parents_str = m.group(2) + parents = { + p.strip().rstrip(";").strip() for p in parents_str.split(",") if p.strip() + } + parent_map[cls] = parents + + # Layer 1 prefixes to check + layer1_prefixes = ("uko-code:", "uko-doc:", "uko-data:", "uko-infra:") + layer1_classes = [ + cls for cls in parent_map if any(cls.startswith(p) for p in layer1_prefixes) + ] + + assert layer1_classes, "No Layer 1 classes found in TTL" + + # Check each Layer 1 class can reach uko:InformationUnit + for cls in layer1_classes: + visited: set[str] = set() + queue = [cls] + found = False + while queue: + current = queue.pop() + if current == "uko:InformationUnit": + found = True + break + if current in visited: + continue + visited.add(current) + for parent in parent_map.get(current, set()): + queue.append(parent) + assert found, ( + f"Class {cls} does not have a subClassOf chain to " + f"uko:InformationUnit (visited: {visited})" + ) + + +@then("every domain detail map should resolve integer depth 0 to 0") +def step_check_all_maps_depth_zero(context: Context) -> None: + for d in context.ontology_layer1_list: + actual = d.detail_map.resolve(0) + assert actual == 0, ( + f"Domain {d.prefix} resolved depth 0 to {actual}, expected 0" + ) diff --git a/features/uko_ontology.feature b/features/uko_ontology.feature index 353e37b13..1f6920211 100644 --- a/features/uko_ontology.feature +++ b/features/uko_ontology.feature @@ -188,7 +188,7 @@ Feature: UKO Ontology Loader Examples: | layer | count | | 0 | 18 | - | 1 | 8 | + | 1 | 67 | | 2 | 6 | # --------------------------------------------------------------------------- diff --git a/features/uko_ontology_registry.feature b/features/uko_ontology_registry.feature new file mode 100644 index 000000000..58e7877f3 --- /dev/null +++ b/features/uko_ontology_registry.feature @@ -0,0 +1,322 @@ +@ontology @uko +Feature: UKO Ontology Domain Registry and Turtle Validation + As an ACMS developer + I need a central registry for UKO domain ontologies + So that analyzers, strategies, and the graph backend can discover + namespace IRIs, class inventories, and DetailLevelMaps for each domain + + Background: + Given the UKO ontology registry module is available + + # --------------------------------------------------------------------------- + # Domain Lookup + # --------------------------------------------------------------------------- + + @ontology @registry + Scenario: Look up Layer 0 domain by prefix + When I look up domain "uko:" + Then the domain IRI should be "https://cleveragents.ai/ontology/uko#" + And the domain layer should be 0 + And the domain classes should contain "uko:InformationUnit" + And the domain classes should contain "uko:Container" + And the domain classes should contain "uko:Atom" + And the domain classes should contain "uko:Annotation" + And the domain classes should contain "uko:Boundary" + + @ontology @registry + Scenario: Look up uko-code: domain by prefix + When I look up domain "uko-code:" + Then the domain IRI should be "https://cleveragents.ai/ontology/uko/code#" + And the domain layer should be 1 + And the domain classes should contain "uko-code:Module" + And the domain classes should contain "uko-code:Callable" + And the domain classes should contain "uko-code:TypeDefinition" + And the domain classes should contain "uko-code:TestCase" + And the domain classes should contain "uko-code:Import" + + @ontology @registry + Scenario: Look up uko-doc: domain by prefix + When I look up domain "uko-doc:" + Then the domain IRI should be "https://cleveragents.ai/ontology/uko/doc#" + And the domain layer should be 1 + And the domain should have 17 classes + And the domain classes should contain "uko-doc:Document" + And the domain classes should contain "uko-doc:Section" + And the domain classes should contain "uko-doc:Paragraph" + And the domain classes should contain "uko-doc:Citation" + And the domain classes should contain "uko-doc:TableOfContents" + + @ontology @registry + Scenario: Look up uko-data: domain by prefix + When I look up domain "uko-data:" + Then the domain IRI should be "https://cleveragents.ai/ontology/uko/data#" + And the domain layer should be 1 + And the domain should have 13 classes + And the domain classes should contain "uko-data:Table" + And the domain classes should contain "uko-data:Column" + And the domain classes should contain "uko-data:ForeignKey" + And the domain classes should contain "uko-data:StoredProcedure" + And the domain classes should contain "uko-data:View" + + @ontology @registry + Scenario: Look up uko-infra: domain by prefix + When I look up domain "uko-infra:" + Then the domain IRI should be "https://cleveragents.ai/ontology/uko/infra#" + And the domain layer should be 1 + And the domain should have 7 classes + And the domain classes should contain "uko-infra:Service" + And the domain classes should contain "uko-infra:Endpoint" + And the domain classes should contain "uko-infra:Port" + And the domain classes should contain "uko-infra:ConfigBlock" + And the domain classes should contain "uko-infra:ConfigKey" + + @ontology @registry @negative + Scenario: Look up unknown domain raises ValueError + When I look up domain "uko-xyz:" + Then a ValueError should have been raised with message "Unknown UKO domain prefix" + + @ontology @registry @negative + Scenario: Look up empty domain prefix raises ValueError + When I look up an empty domain prefix + Then a ValueError should have been raised with message "must not be empty" + + # --------------------------------------------------------------------------- + # List Domains + # --------------------------------------------------------------------------- + + @ontology @registry + Scenario: list_domains returns all 5 registered domains + When I list all domains + Then the domain list should have 5 entries + And the domain list should be sorted by layer then prefix + + @ontology @registry + Scenario: get_layer1_domains returns exactly 4 domains + When I get Layer 1 domains + Then the Layer 1 list should have 4 entries + And every domain in the Layer 1 list should have layer 1 + + # --------------------------------------------------------------------------- + # DetailLevelMap — spec-complete resolution for uko-code: + # --------------------------------------------------------------------------- + + @ontology @detail_levels + Scenario: uko-code: map resolves all 10 named levels + When I look up domain "uko-code:" + Then the detail map should resolve "MODULE_LISTING" to 0 + And the detail map should resolve "MODULE_GRAPH" to 1 + And the detail map should resolve "MEMBER_LISTING" to 2 + And the detail map should resolve "MEMBER_SUMMARY" to 3 + And the detail map should resolve "SIGNATURES" to 4 + And the detail map should resolve "SIGNATURES_WITH_DOCS" to 5 + And the detail map should resolve "STRUCTURAL_OUTLINE" to 6 + And the detail map should resolve "KEY_LOGIC" to 7 + And the detail map should resolve "NEAR_COMPLETE" to 8 + And the detail map should resolve "FULL_SOURCE" to 9 + + # --------------------------------------------------------------------------- + # DetailLevelMap — spec-complete resolution for uko-doc: + # --------------------------------------------------------------------------- + + @ontology @detail_levels + Scenario: uko-doc: map resolves all 11 named levels + When I look up domain "uko-doc:" + Then the detail map should resolve "TITLE_ONLY" to 0 + And the detail map should resolve "TABLE_OF_CONTENTS_L1" to 1 + And the detail map should resolve "TABLE_OF_CONTENTS_L2" to 2 + And the detail map should resolve "FULL_TOC" to 3 + And the detail map should resolve "TOC_WITH_SUMMARIES" to 4 + And the detail map should resolve "SECTION_OVERVIEWS" to 5 + And the detail map should resolve "TOPIC_SENTENCES" to 6 + And the detail map should resolve "PARAGRAPH_SUMMARIES" to 7 + And the detail map should resolve "STRUCTURAL_DETAIL" to 8 + And the detail map should resolve "NEAR_COMPLETE" to 9 + And the detail map should resolve "FULL_CONTENT" to 10 + + # --------------------------------------------------------------------------- + # DetailLevelMap — spec-complete resolution for uko-data: + # --------------------------------------------------------------------------- + + @ontology @detail_levels + Scenario: uko-data: map resolves all 12 named levels + When I look up domain "uko-data:" + Then the detail map should resolve "SCHEMA_LISTING" to 0 + And the detail map should resolve "TABLE_LISTING" to 1 + And the detail map should resolve "COLUMN_LISTING" to 2 + And the detail map should resolve "TYPED_COLUMNS" to 3 + And the detail map should resolve "CONSTRAINTS" to 4 + And the detail map should resolve "RELATIONSHIPS" to 5 + And the detail map should resolve "INDEXES_AND_STATS" to 6 + And the detail map should resolve "DDL" to 7 + And the detail map should resolve "DDL_WITH_TRIGGERS" to 8 + And the detail map should resolve "FULL_PROCEDURES" to 9 + And the detail map should resolve "WITH_SAMPLE_DATA" to 10 + And the detail map should resolve "FULL_CATALOG" to 11 + + # --------------------------------------------------------------------------- + # DetailLevelMap — spec-complete resolution for uko-infra: + # --------------------------------------------------------------------------- + + @ontology @detail_levels + Scenario: uko-infra: map resolves all 9 named levels + When I look up domain "uko-infra:" + Then the detail map should resolve "SERVICE_LISTING" to 0 + And the detail map should resolve "SERVICE_GRAPH" to 1 + And the detail map should resolve "ENDPOINT_LISTING" to 2 + And the detail map should resolve "ENDPOINT_DETAIL" to 3 + And the detail map should resolve "CONFIG_KEYS" to 4 + And the detail map should resolve "CONFIG_VALUES" to 5 + And the detail map should resolve "RESOURCE_LIMITS" to 6 + And the detail map should resolve "FULL_CONFIG" to 7 + And the detail map should resolve "WITH_DEPLOYMENT" to 8 + + # --------------------------------------------------------------------------- + # DetailLevelMap — integer depth resolution + # --------------------------------------------------------------------------- + + @ontology @detail_levels + Scenario: Integer depth is clamped to max_depth + When I look up domain "uko-code:" + Then the detail map should clamp depth 15 to 9 + And the detail map should clamp depth 0 to 0 + And the detail map should clamp depth 5 to 5 + + @ontology @detail_levels @negative + Scenario: Unknown named level raises ValueError + When I look up domain "uko-code:" + And I try to resolve level "NONEXISTENT_LEVEL" + Then a ValueError should have been raised with message "Unknown detail level" + + # --------------------------------------------------------------------------- + # DetailLevelMap — inheritance chain (Layer 3 → 2 → 1 → 0) + # --------------------------------------------------------------------------- + + @ontology @detail_levels @inheritance + Scenario: build_detail_map_chain wires parent linkage + When I build a detail map chain from "uko-code:" + Then the chain should resolve "MODULE_LISTING" to 0 + And the chain should resolve "FULL_SOURCE" to 9 + And the chain max depth should be 9 + + @ontology @detail_levels @inheritance + Scenario: Two-level chain inherits parent levels + Given a custom DetailLevelMap for domain "uko-oo:" with parent "uko-code:" and levels: + | level | depth | + | CLASS_HIERARCHY | 3 | + When I resolve "MODULE_LISTING" on the custom map + Then the resolved depth should be 0 + When I resolve "CLASS_HIERARCHY" on the custom map + Then the resolved depth should be 3 + + @ontology @detail_levels @inheritance @negative + Scenario: build_detail_map_chain with no prefixes raises ValueError + When I build a detail map chain with no prefixes + Then a ValueError should have been raised with message "At least one domain prefix" + + # --------------------------------------------------------------------------- + # Turtle Validation — well-formed file + # --------------------------------------------------------------------------- + + @ontology @turtle + Scenario: The project uko.ttl file passes Turtle validation + When I validate the project Turtle file "docs/ontology/uko.ttl" + Then the validation should produce 0 errors + + @ontology @turtle + Scenario: Valid minimal Turtle content passes validation + When I validate Turtle content: + """ + @prefix ex: . + ex:Thing a ex:Class . + """ + Then the validation should produce 0 errors + + # --------------------------------------------------------------------------- + # Turtle Validation — error detection + # --------------------------------------------------------------------------- + + @ontology @turtle @negative + Scenario: Undeclared prefix is caught + When I validate Turtle content: + """ + @prefix ex: . + bogus:Thing a ex:Class . + """ + Then the validation should produce at least 1 error + And the first error should contain "undeclared prefix" + + @ontology @turtle @negative + Scenario: Unbalanced angle brackets are caught + When I validate Turtle content: + """ + @prefix ex: . + ex:Thing a + +Commands +-------- +domain_lookup + Look up each domain prefix and verify IRI / layer / class count. +detail_level_resolution + Resolve named levels for every Layer 1 domain. +inheritance_chain + Build a chain and resolve an inherited level. +turtle_validate + Validate ``docs/ontology/uko.ttl`` and verify zero errors. +universal_view + Verify all Layer 1 classes chain to ``uko:InformationUnit``. +list_all_domains + List all registered domains and print summary. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Ensure src/ is on the path so domain models can be imported. +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cleveragents.domain.models.acms.ontology_registry import ( # noqa: E402 + build_detail_map_chain, + get_domain, + get_layer1_domains, + list_domains, + validate_turtle_file, +) + +_PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _domain_lookup() -> int: + """Look up every registered domain and print summary.""" + expected = { + "uko:": ("https://cleveragents.ai/ontology/uko#", 0, 5), + "uko-code:": ("https://cleveragents.ai/ontology/uko/code#", 1, 5), + "uko-doc:": ("https://cleveragents.ai/ontology/uko/doc#", 1, 17), + "uko-data:": ("https://cleveragents.ai/ontology/uko/data#", 1, 13), + "uko-infra:": ("https://cleveragents.ai/ontology/uko/infra#", 1, 7), + } + for prefix, (iri, layer, cls_count) in expected.items(): + desc = get_domain(prefix) + assert desc.iri == iri, f"{prefix}: IRI mismatch" + assert desc.layer == layer, f"{prefix}: layer mismatch" + assert len(desc.classes) == cls_count, ( + f"{prefix}: expected {cls_count} classes, got {len(desc.classes)}" + ) + print(f"OK {prefix} -> {iri} (layer={layer}, classes={cls_count})") + print("DOMAIN_LOOKUP_PASSED") + return 0 + + +def _detail_level_resolution() -> int: + """Resolve key levels for every Layer 1 domain.""" + checks: list[tuple[str, str, int]] = [ + ("uko-code:", "MODULE_LISTING", 0), + ("uko-code:", "FULL_SOURCE", 9), + ("uko-code:", "SIGNATURES", 4), + ("uko-doc:", "TITLE_ONLY", 0), + ("uko-doc:", "FULL_CONTENT", 10), + ("uko-doc:", "TOPIC_SENTENCES", 6), + ("uko-data:", "SCHEMA_LISTING", 0), + ("uko-data:", "FULL_CATALOG", 11), + ("uko-data:", "TYPED_COLUMNS", 3), + ("uko-infra:", "SERVICE_LISTING", 0), + ("uko-infra:", "WITH_DEPLOYMENT", 8), + ("uko-infra:", "CONFIG_KEYS", 4), + ] + for prefix, level, expected in checks: + desc = get_domain(prefix) + actual = desc.detail_map.resolve(level) + assert actual == expected, ( + f"{prefix}.resolve({level!r}) = {actual}, expected {expected}" + ) + print(f"OK {prefix} {level} -> {actual}") + print("DETAIL_LEVEL_RESOLUTION_PASSED") + return 0 + + +def _inheritance_chain() -> int: + """Build a chain from uko-code: and verify inherited levels.""" + chain = build_detail_map_chain("uko-code:") + assert chain.resolve("MODULE_LISTING") == 0 + assert chain.resolve("FULL_SOURCE") == 9 + assert chain.max_depth == 9 + print("OK chain resolves inherited levels correctly") + print("INHERITANCE_CHAIN_PASSED") + return 0 + + +def _turtle_validate() -> int: + """Validate the project ontology file.""" + ttl_path = _PROJECT_ROOT / "docs" / "ontology" / "uko.ttl" + errors = validate_turtle_file(ttl_path) + if errors: + for err in errors: + print(f"ERROR: {err}", file=sys.stderr) + return 1 + print(f"OK {ttl_path} passed Turtle validation (0 errors)") + print("TURTLE_VALIDATE_PASSED") + return 0 + + +def _universal_view() -> int: + """Verify all Layer 1 domains have depth 0 resolving to 0.""" + for desc in get_layer1_domains(): + actual = desc.detail_map.resolve(0) + assert actual == 0, f"{desc.prefix} resolved depth 0 to {actual}, expected 0" + print(f"OK {desc.prefix} depth 0 -> 0") + print("UNIVERSAL_VIEW_PASSED") + return 0 + + +def _list_all_domains() -> int: + """List all registered domains with summary.""" + domains = list_domains() + assert len(domains) == 5, f"Expected 5 domains, got {len(domains)}" + for d in domains: + levels = d.detail_map.effective_levels() + print( + f" {d.prefix:<14} layer={d.layer} " + f"classes={len(d.classes):<3} levels={len(levels)}" + ) + print("LIST_ALL_DOMAINS_PASSED") + return 0 + + +def main() -> int: + """Dispatch to the requested command.""" + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + return 1 + + cmd = sys.argv[1] + dispatch = { + "domain_lookup": _domain_lookup, + "detail_level_resolution": _detail_level_resolution, + "inheritance_chain": _inheritance_chain, + "turtle_validate": _turtle_validate, + "universal_view": _universal_view, + "list_all_domains": _list_all_domains, + } + handler = dispatch.get(cmd) + if handler is None: + print(f"Unknown command: {cmd}", file=sys.stderr) + return 1 + return handler() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/robot/uko_ontology_registry.robot b/robot/uko_ontology_registry.robot new file mode 100644 index 000000000..f6ddebaa4 --- /dev/null +++ b/robot/uko_ontology_registry.robot @@ -0,0 +1,66 @@ +*** Settings *** +Documentation Integration smoke tests for UKO Ontology Domain Registry. +... Validates domain lookup, DetailLevelMap resolution across +... all four Layer 1 domains, inheritance chain building, +... Turtle syntax validation, and the Universal View Guarantee. +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_uko_ontology_registry.py + +*** Test Cases *** +Domain Lookup Smoke Test + [Documentation] Look up every registered domain prefix and verify + ... IRI, layer, and class count match expectations. + ${result}= Run Process ${PYTHON} ${HELPER} domain_lookup cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} DOMAIN_LOOKUP_PASSED + +Detail Level Resolution Smoke Test + [Documentation] Resolve key named levels for every Layer 1 domain + ... and verify integer depths match the spec. + ${result}= Run Process ${PYTHON} ${HELPER} detail_level_resolution cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} DETAIL_LEVEL_RESOLUTION_PASSED + +Inheritance Chain Smoke Test + [Documentation] Build a DetailLevelMap chain and verify inherited + ... level resolution works across the chain. + ${result}= Run Process ${PYTHON} ${HELPER} inheritance_chain cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} INHERITANCE_CHAIN_PASSED + +Turtle File Validation Smoke Test + [Documentation] Validate the project ontology file docs/ontology/uko.ttl + ... passes lightweight Turtle syntax validation. + ${result}= Run Process ${PYTHON} ${HELPER} turtle_validate cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} TURTLE_VALIDATE_PASSED + +Universal View Guarantee Smoke Test + [Documentation] Verify all Layer 1 domains resolve depth 0 to 0, + ... confirming the universal depth interface. + ${result}= Run Process ${PYTHON} ${HELPER} universal_view cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} UNIVERSAL_VIEW_PASSED + +List All Domains Smoke Test + [Documentation] List all registered domains and verify the count + ... and structure are correct. + ${result}= Run Process ${PYTHON} ${HELPER} list_all_domains cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} LIST_ALL_DOMAINS_PASSED diff --git a/src/cleveragents/application/services/depth_breadth_projection.py b/src/cleveragents/application/services/depth_breadth_projection.py index d174d5e88..5bf2526bc 100644 --- a/src/cleveragents/application/services/depth_breadth_projection.py +++ b/src/cleveragents/application/services/depth_breadth_projection.py @@ -553,24 +553,37 @@ _AssembledContextLike = Any def code_detail_map() -> DetailLevelMap: - """Return the built-in source-code detail level map. + """Return the built-in ``uko-code:`` detail level map. - Maps named levels to integer depths following the spec examples - (lines 25280-25304): + Authoritative source: ``docs/specification.md`` §41934-41971. - - ``MODULE_LISTING`` = 0 - - ``CLASS_NAMES`` = 2 - - ``SIGNATURES`` = 4 - - ``MEMBER_SUMMARY`` = 6 - - ``FULL_SOURCE`` = 9 + =========================== ===== + Named Level Depth + =========================== ===== + ``MODULE_LISTING`` 0 + ``MODULE_GRAPH`` 1 + ``MEMBER_LISTING`` 2 + ``MEMBER_SUMMARY`` 3 + ``SIGNATURES`` 4 + ``SIGNATURES_WITH_DOCS`` 5 + ``STRUCTURAL_OUTLINE`` 6 + ``KEY_LOGIC`` 7 + ``NEAR_COMPLETE`` 8 + ``FULL_SOURCE`` 9 + =========================== ===== """ return DetailLevelMap( domain="uko-code:", levels={ "MODULE_LISTING": 0, - "CLASS_NAMES": 2, + "MODULE_GRAPH": 1, + "MEMBER_LISTING": 2, + "MEMBER_SUMMARY": 3, "SIGNATURES": 4, - "MEMBER_SUMMARY": 6, + "SIGNATURES_WITH_DOCS": 5, + "STRUCTURAL_OUTLINE": 6, + "KEY_LOGIC": 7, + "NEAR_COMPLETE": 8, "FULL_SOURCE": 9, }, max_depth=9, @@ -578,26 +591,39 @@ def code_detail_map() -> DetailLevelMap: def docs_detail_map() -> DetailLevelMap: - """Return the built-in documentation detail level map. + """Return the built-in ``uko-doc:`` detail level map. - Maps named levels to integer depths following the spec examples - (lines 25310-25336): + Authoritative source: ``docs/specification.md`` §42023-42083. - - ``TITLE_ONLY`` = 0 - - ``TABLE_OF_CONTENTS`` = 2 - - ``SECTION_HEADINGS`` = 4 - - ``TOPIC_SENTENCES`` = 6 - - ``SECTION_SUMMARIES`` = 8 - - ``FULL_CONTENT`` = 10 + =========================== ===== + Named Level Depth + =========================== ===== + ``TITLE_ONLY`` 0 + ``TABLE_OF_CONTENTS_L1`` 1 + ``TABLE_OF_CONTENTS_L2`` 2 + ``FULL_TOC`` 3 + ``TOC_WITH_SUMMARIES`` 4 + ``SECTION_OVERVIEWS`` 5 + ``TOPIC_SENTENCES`` 6 + ``PARAGRAPH_SUMMARIES`` 7 + ``STRUCTURAL_DETAIL`` 8 + ``NEAR_COMPLETE`` 9 + ``FULL_CONTENT`` 10 + =========================== ===== """ return DetailLevelMap( domain="uko-doc:", levels={ "TITLE_ONLY": 0, - "TABLE_OF_CONTENTS": 2, - "SECTION_HEADINGS": 4, + "TABLE_OF_CONTENTS_L1": 1, + "TABLE_OF_CONTENTS_L2": 2, + "FULL_TOC": 3, + "TOC_WITH_SUMMARIES": 4, + "SECTION_OVERVIEWS": 5, "TOPIC_SENTENCES": 6, - "SECTION_SUMMARIES": 8, + "PARAGRAPH_SUMMARIES": 7, + "STRUCTURAL_DETAIL": 8, + "NEAR_COMPLETE": 9, "FULL_CONTENT": 10, }, max_depth=10, @@ -605,22 +631,78 @@ def docs_detail_map() -> DetailLevelMap: def database_detail_map() -> DetailLevelMap: - """Return the built-in database detail level map. + """Return the built-in ``uko-data:`` detail level map. - - ``TABLE_LISTING`` = 0 - - ``COLUMN_NAMES`` = 3 - - ``SCHEMA_DETAILS`` = 6 - - ``INDEX_DETAILS`` = 9 - - ``FULL_CATALOG`` = 11 + Authoritative source: ``docs/specification.md`` §42167-42230. + + =========================== ===== + Named Level Depth + =========================== ===== + ``SCHEMA_LISTING`` 0 + ``TABLE_LISTING`` 1 + ``COLUMN_LISTING`` 2 + ``TYPED_COLUMNS`` 3 + ``CONSTRAINTS`` 4 + ``RELATIONSHIPS`` 5 + ``INDEXES_AND_STATS`` 6 + ``DDL`` 7 + ``DDL_WITH_TRIGGERS`` 8 + ``FULL_PROCEDURES`` 9 + ``WITH_SAMPLE_DATA`` 10 + ``FULL_CATALOG`` 11 + =========================== ===== """ return DetailLevelMap( domain="uko-data:", levels={ - "TABLE_LISTING": 0, - "COLUMN_NAMES": 3, - "SCHEMA_DETAILS": 6, - "INDEX_DETAILS": 9, + "SCHEMA_LISTING": 0, + "TABLE_LISTING": 1, + "COLUMN_LISTING": 2, + "TYPED_COLUMNS": 3, + "CONSTRAINTS": 4, + "RELATIONSHIPS": 5, + "INDEXES_AND_STATS": 6, + "DDL": 7, + "DDL_WITH_TRIGGERS": 8, + "FULL_PROCEDURES": 9, + "WITH_SAMPLE_DATA": 10, "FULL_CATALOG": 11, }, max_depth=11, ) + + +def infra_detail_map() -> DetailLevelMap: + """Return the built-in ``uko-infra:`` detail level map. + + Authoritative source: ``docs/specification.md`` §42297-42331. + + =========================== ===== + Named Level Depth + =========================== ===== + ``SERVICE_LISTING`` 0 + ``SERVICE_GRAPH`` 1 + ``ENDPOINT_LISTING`` 2 + ``ENDPOINT_DETAIL`` 3 + ``CONFIG_KEYS`` 4 + ``CONFIG_VALUES`` 5 + ``RESOURCE_LIMITS`` 6 + ``FULL_CONFIG`` 7 + ``WITH_DEPLOYMENT`` 8 + =========================== ===== + """ + return DetailLevelMap( + domain="uko-infra:", + levels={ + "SERVICE_LISTING": 0, + "SERVICE_GRAPH": 1, + "ENDPOINT_LISTING": 2, + "ENDPOINT_DETAIL": 3, + "CONFIG_KEYS": 4, + "CONFIG_VALUES": 5, + "RESOURCE_LIMITS": 6, + "FULL_CONFIG": 7, + "WITH_DEPLOYMENT": 8, + }, + max_depth=8, + ) diff --git a/src/cleveragents/domain/models/acms/__init__.py b/src/cleveragents/domain/models/acms/__init__.py index 5e87d6134..d7dfb3ab5 100644 --- a/src/cleveragents/domain/models/acms/__init__.py +++ b/src/cleveragents/domain/models/acms/__init__.py @@ -65,6 +65,12 @@ Concrete analyzers: - ``PostgreSQLAnalyzer`` -- Regex-based DDL/SQL analyzer - ``DockerComposeAnalyzer`` -- YAML-based Docker Compose analyzer +Ontology registry + (from :mod:`~cleveragents.domain.models.acms.ontology_registry`): +- ``DomainDescriptor`` -- Immutable metadata for a UKO domain +- ``OntologyRegistry`` functions -- Domain lookup, Layer 1 listing, + DetailLevelMap chain building, Turtle validation + Based on ``docs/specification.md`` ACMS / CRP sections and ADR-014. """ @@ -95,6 +101,16 @@ from cleveragents.domain.models.acms.docker_compose_analyzer import ( DockerComposeAnalyzer, ) from cleveragents.domain.models.acms.markdown_analyzer import MarkdownAnalyzer +from cleveragents.domain.models.acms.ontology_registry import ( + DomainDescriptor, + TurtleValidationError, + build_detail_map_chain, + get_domain, + get_layer1_domains, + list_domains, + validate_turtle, + validate_turtle_file, +) from cleveragents.domain.models.acms.postgresql_analyzer import PostgreSQLAnalyzer from cleveragents.domain.models.acms.python_analyzer import PythonAnalyzer from cleveragents.domain.models.acms.scope_resolution import ( @@ -158,6 +174,7 @@ __all__: list[str] = [ "ContextTier", "DetailLevelMap", "DockerComposeAnalyzer", + "DomainDescriptor", "FragmentProvenance", "GraphBackend", "GraphResult", @@ -185,11 +202,18 @@ __all__: list[str] = [ "TierBudget", "TierMetrics", "TieredFragment", + "TurtleValidationError", "UKOTriple", "VectorBackend", "VectorResult", + "build_detail_map_chain", "create_scoped_backend_set", + "get_domain", + "get_layer1_domains", + "list_domains", "resolve_resource_scope", "validate_project_scope", "validate_resource_scope", + "validate_turtle", + "validate_turtle_file", ] diff --git a/src/cleveragents/domain/models/acms/ontology_registry.py b/src/cleveragents/domain/models/acms/ontology_registry.py new file mode 100644 index 000000000..cc325d59b --- /dev/null +++ b/src/cleveragents/domain/models/acms/ontology_registry.py @@ -0,0 +1,514 @@ +"""UKO Ontology Domain Registry and Turtle syntax validation. + +Central registry that binds each UKO Layer 1 domain to its namespace +IRI, OWL class inventory, and ``DetailLevelMap``. Provides Turtle +syntax validation for the project ontology file +(``docs/ontology/uko.ttl``). + +## Registered Domains + +| Prefix | Namespace IRI | +|---------------|----------------------------------------------------| +| ``uko:`` | ``https://cleveragents.ai/ontology/uko#`` | +| ``uko-code:`` | ``https://cleveragents.ai/ontology/uko/code#`` | +| ``uko-doc:`` | ``https://cleveragents.ai/ontology/uko/doc#`` | +| ``uko-data:`` | ``https://cleveragents.ai/ontology/uko/data#`` | +| ``uko-infra:``| ``https://cleveragents.ai/ontology/uko/infra#`` | + +## Turtle Validation + +``validate_turtle()`` performs a lightweight parse of Turtle files +without requiring ``rdflib``. It checks: + +1. Every ``@prefix`` declaration has balanced syntax. +2. Every statement ends with ``.`` (or ``;`` / ``,`` for continuation). +3. All ``@prefix`` IRIs referenced in the body are declared. +4. Balanced angle brackets (``<…>``), quotes, and parentheses. + +Based on ``docs/specification.md`` §41830-42332 (UKO Ontology Hierarchy). +""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import NamedTuple + +from cleveragents.domain.models.acms.crp import DetailLevelMap + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants — namespace IRIs (§41873-41892) +# --------------------------------------------------------------------------- + +UKO_BASE_IRI: str = "https://cleveragents.ai/ontology/uko#" +"""Layer 0 universal foundation namespace.""" + +UKO_CODE_IRI: str = "https://cleveragents.ai/ontology/uko/code#" +"""Layer 1 general software namespace.""" + +UKO_DOC_IRI: str = "https://cleveragents.ai/ontology/uko/doc#" +"""Layer 1 documents namespace.""" + +UKO_DATA_IRI: str = "https://cleveragents.ai/ontology/uko/data#" +"""Layer 1 data schemas namespace.""" + +UKO_INFRA_IRI: str = "https://cleveragents.ai/ontology/uko/infra#" +"""Layer 1 infrastructure namespace.""" + +# --------------------------------------------------------------------------- +# Layer 0 class inventory +# --------------------------------------------------------------------------- + +LAYER0_CLASSES: frozenset[str] = frozenset( + { + "uko:InformationUnit", + "uko:Container", + "uko:Atom", + "uko:Annotation", + "uko:Boundary", + } +) +"""Layer 0 base classes defined in the UKO ontology.""" + +# --------------------------------------------------------------------------- +# Layer 1 class inventories — from spec §41934-42331 +# --------------------------------------------------------------------------- + +LAYER1_CODE_CLASSES: frozenset[str] = frozenset( + { + "uko-code:Module", + "uko-code:Callable", + "uko-code:TypeDefinition", + "uko-code:TestCase", + "uko-code:Import", + } +) +"""``uko-code:`` classes for general software.""" + +LAYER1_DOC_CLASSES: frozenset[str] = frozenset( + { + # Containers + "uko-doc:Document", + "uko-doc:Part", + "uko-doc:Chapter", + "uko-doc:Section", + "uko-doc:Subsection", + # Atoms + "uko-doc:Paragraph", + "uko-doc:CodeBlock", + "uko-doc:Figure", + "uko-doc:Table", + "uko-doc:BlockQuote", + "uko-doc:ListBlock", + # Annotations + "uko-doc:Footnote", + "uko-doc:Citation", + "uko-doc:Comment", + # Boundaries + "uko-doc:TableOfContents", + "uko-doc:Index", + "uko-doc:Glossary", + } +) +"""``uko-doc:`` classes for documents.""" + +LAYER1_DATA_CLASSES: frozenset[str] = frozenset( + { + # Containers + "uko-data:Database", + "uko-data:Schema", + "uko-data:Table", + "uko-data:View", + # Atoms + "uko-data:Column", + "uko-data:StoredProcedure", + "uko-data:Trigger", + "uko-data:Migration", + # Annotations + "uko-data:Constraint", + "uko-data:Index", + "uko-data:ColumnComment", + # Boundaries + "uko-data:ForeignKey", + "uko-data:APIEndpoint", + } +) +"""``uko-data:`` classes for data schemas.""" + +LAYER1_INFRA_CLASSES: frozenset[str] = frozenset( + { + # Containers + "uko-infra:Service", + "uko-infra:ConfigBlock", + "uko-infra:DeploymentUnit", + # Atoms + "uko-infra:ConfigKey", + "uko-infra:EnvironmentVariable", + # Boundaries + "uko-infra:Endpoint", + "uko-infra:Port", + } +) +"""``uko-infra:`` classes for infrastructure.""" + +# --------------------------------------------------------------------------- +# DomainDescriptor — metadata for a UKO domain +# --------------------------------------------------------------------------- + + +class DomainDescriptor(NamedTuple): + """Immutable metadata for a single UKO domain.""" + + prefix: str + """Turtle prefix (e.g., ``"uko-code:"``).""" + iri: str + """Full namespace IRI.""" + layer: int + """Ontology layer (0, 1, 2, or 3).""" + classes: frozenset[str] + """Set of class QNames defined in this domain.""" + detail_map: DetailLevelMap + """The authoritative ``DetailLevelMap`` for this domain.""" + + +# --------------------------------------------------------------------------- +# Authoritative DetailLevelMap definitions (spec §41934-42331) +# --------------------------------------------------------------------------- +# These are defined here in the domain layer (not in the application layer) +# because they are pure data declarations tied to the ontology spec, +# not application-level services. The application-layer preset functions +# in depth_breadth_projection.py delegate to these same values. + +_CODE_LEVELS: dict[str, int] = { + "MODULE_LISTING": 0, + "MODULE_GRAPH": 1, + "MEMBER_LISTING": 2, + "MEMBER_SUMMARY": 3, + "SIGNATURES": 4, + "SIGNATURES_WITH_DOCS": 5, + "STRUCTURAL_OUTLINE": 6, + "KEY_LOGIC": 7, + "NEAR_COMPLETE": 8, + "FULL_SOURCE": 9, +} + +_DOC_LEVELS: dict[str, int] = { + "TITLE_ONLY": 0, + "TABLE_OF_CONTENTS_L1": 1, + "TABLE_OF_CONTENTS_L2": 2, + "FULL_TOC": 3, + "TOC_WITH_SUMMARIES": 4, + "SECTION_OVERVIEWS": 5, + "TOPIC_SENTENCES": 6, + "PARAGRAPH_SUMMARIES": 7, + "STRUCTURAL_DETAIL": 8, + "NEAR_COMPLETE": 9, + "FULL_CONTENT": 10, +} + +_DATA_LEVELS: dict[str, int] = { + "SCHEMA_LISTING": 0, + "TABLE_LISTING": 1, + "COLUMN_LISTING": 2, + "TYPED_COLUMNS": 3, + "CONSTRAINTS": 4, + "RELATIONSHIPS": 5, + "INDEXES_AND_STATS": 6, + "DDL": 7, + "DDL_WITH_TRIGGERS": 8, + "FULL_PROCEDURES": 9, + "WITH_SAMPLE_DATA": 10, + "FULL_CATALOG": 11, +} + +_INFRA_LEVELS: dict[str, int] = { + "SERVICE_LISTING": 0, + "SERVICE_GRAPH": 1, + "ENDPOINT_LISTING": 2, + "ENDPOINT_DETAIL": 3, + "CONFIG_KEYS": 4, + "CONFIG_VALUES": 5, + "RESOURCE_LIMITS": 6, + "FULL_CONFIG": 7, + "WITH_DEPLOYMENT": 8, +} + +# --------------------------------------------------------------------------- +# OntologyRegistry +# --------------------------------------------------------------------------- + +_PRESETS_LOADED: bool = False +_DOMAINS: dict[str, DomainDescriptor] = {} + + +def _ensure_presets() -> None: + """Lazily build the domain registry on first access.""" + global _PRESETS_LOADED + if _PRESETS_LOADED: + return + + _DOMAINS.update( + { + "uko:": DomainDescriptor( + prefix="uko:", + iri=UKO_BASE_IRI, + layer=0, + classes=LAYER0_CLASSES, + detail_map=DetailLevelMap( + domain="uko:", + levels={}, + max_depth=0, + ), + ), + "uko-code:": DomainDescriptor( + prefix="uko-code:", + iri=UKO_CODE_IRI, + layer=1, + classes=LAYER1_CODE_CLASSES, + detail_map=DetailLevelMap( + domain="uko-code:", + levels=dict(_CODE_LEVELS), + max_depth=9, + ), + ), + "uko-doc:": DomainDescriptor( + prefix="uko-doc:", + iri=UKO_DOC_IRI, + layer=1, + classes=LAYER1_DOC_CLASSES, + detail_map=DetailLevelMap( + domain="uko-doc:", + levels=dict(_DOC_LEVELS), + max_depth=10, + ), + ), + "uko-data:": DomainDescriptor( + prefix="uko-data:", + iri=UKO_DATA_IRI, + layer=1, + classes=LAYER1_DATA_CLASSES, + detail_map=DetailLevelMap( + domain="uko-data:", + levels=dict(_DATA_LEVELS), + max_depth=11, + ), + ), + "uko-infra:": DomainDescriptor( + prefix="uko-infra:", + iri=UKO_INFRA_IRI, + layer=1, + classes=LAYER1_INFRA_CLASSES, + detail_map=DetailLevelMap( + domain="uko-infra:", + levels=dict(_INFRA_LEVELS), + max_depth=8, + ), + ), + } + ) + _PRESETS_LOADED = True + + +def get_domain(prefix: str) -> DomainDescriptor: + """Look up a domain descriptor by prefix. + + Args: + prefix: UKO prefix (e.g., ``"uko-code:"``). + + Returns: + The matching :class:`DomainDescriptor`. + + Raises: + ValueError: If *prefix* is empty or unknown. + """ + if not prefix: + raise ValueError("Domain prefix must not be empty") + _ensure_presets() + desc = _DOMAINS.get(prefix) + if desc is None: + raise ValueError(f"Unknown UKO domain prefix: {prefix!r}") + return desc + + +def list_domains() -> list[DomainDescriptor]: + """Return all registered domain descriptors, sorted by layer then prefix.""" + _ensure_presets() + return sorted(_DOMAINS.values(), key=lambda d: (d.layer, d.prefix)) + + +def get_layer1_domains() -> list[DomainDescriptor]: + """Return only Layer 1 domain descriptors.""" + return [d for d in list_domains() if d.layer == 1] + + +def build_detail_map_chain( + *prefixes: str, +) -> DetailLevelMap: + """Build a chained ``DetailLevelMap`` from most-general to most-specific. + + The inheritance chain is constructed so that the first prefix + is the root (no parent) and each subsequent prefix's map has the + previous one as its parent. This supports the Layer 3 → 2 → 1 → 0 + resolution described in the spec. + + Example:: + + chain = build_detail_map_chain("uko-code:", "uko-oo:", "uko-py:") + chain.resolve("MODULE_LISTING") # inherited from uko-code: + + Args: + prefixes: One or more domain prefixes from most-general to + most-specific. + + Returns: + The innermost (most-specific) ``DetailLevelMap`` with its + full inheritance chain wired. + + Raises: + ValueError: If any prefix is unknown or no prefixes given. + """ + if not prefixes: + raise ValueError("At least one domain prefix is required") + + current: DetailLevelMap | None = None + for pfx in prefixes: + desc = get_domain(pfx) + current = DetailLevelMap( + domain=desc.prefix, + parent=current, + levels=dict(desc.detail_map.levels), + max_depth=desc.detail_map.max_depth, + ) + assert current is not None # at least one iteration guaranteed + return current + + +# --------------------------------------------------------------------------- +# Turtle syntax validation +# --------------------------------------------------------------------------- + +# Regex for @prefix declarations: @prefix : . +_PREFIX_RE = re.compile( + r"@prefix\s+([a-zA-Z][a-zA-Z0-9_-]*)\s*:\s*<([^>]+)>\s*\.", +) + +# Regex for prefixed names used in the body (e.g., uko-code:Module) +_PREFIXED_NAME_RE = re.compile( + r"\b([a-zA-Z][a-zA-Z0-9_-]*):([a-zA-Z_]\w*)\b", +) + + +class TurtleValidationError(Exception): + """Raised when Turtle syntax validation fails.""" + + def __init__(self, errors: list[str]) -> None: + self.errors = errors + super().__init__(f"Turtle validation failed with {len(errors)} error(s)") + + +def validate_turtle(content: str) -> list[str]: + """Perform lightweight Turtle syntax validation. + + Returns a list of error strings (empty if valid). This is NOT a + full OWL reasoner — it catches common structural issues: + + 1. All ``@prefix`` declarations are well-formed. + 2. Every prefixed name (e.g., ``uko-code:Module``) references a + declared prefix. + 3. Angle brackets ``<…>`` are balanced on each line. + 4. Every top-level statement ends with ``.``. + + Args: + content: Turtle file content as a string. + + Returns: + List of error messages (empty means valid). + """ + if not content or not content.strip(): + return ["Empty Turtle content"] + + errors: list[str] = [] + lines = content.splitlines() + + # 1. Collect declared prefixes + declared_prefixes: set[str] = set() + for match in _PREFIX_RE.finditer(content): + declared_prefixes.add(match.group(1)) + + # Also accept standard prefixes that may not be declared but are + # used in Turtle (rdf, rdfs, owl, xsd are universal). + declared_prefixes.update({"rdf", "rdfs", "owl", "xsd"}) + + # 2. Check that all prefixed names reference declared prefixes + for lineno, line in enumerate(lines, 1): + stripped = line.strip() + if stripped.startswith("#") or not stripped: + continue + for m in _PREFIXED_NAME_RE.finditer(stripped): + pfx = m.group(1) + if pfx not in declared_prefixes: + errors.append( + f"Line {lineno}: undeclared prefix '{pfx}:' in '{m.group(0)}'" + ) + + # 3. Check angle bracket balance per line (ignoring comments) + for lineno, line in enumerate(lines, 1): + stripped = line.strip() + if stripped.startswith("#") or not stripped: + continue + # Strip string literals to avoid false positives + no_strings = re.sub(r'"[^"]*"', "", stripped) + opens = no_strings.count("<") + closes = no_strings.count(">") + if opens != closes: + errors.append( + f"Line {lineno}: unbalanced angle brackets " + f"({opens} open, {closes} close)" + ) + + # 4. Verify statements end with '.' (Turtle requirement) + # Accumulate non-comment, non-blank, non-prefix lines and check + # that the content ends with statements terminated by '.'. + body_lines: list[str] = [] + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or stripped.startswith("@prefix"): + continue + body_lines.append(stripped) + + if body_lines: + # The last non-blank body line should end with '.' + last = body_lines[-1] + if not last.endswith("."): + errors.append( + f"Final statement does not end with '.': '{last[:60]}...'" + if len(last) > 60 + else f"Final statement does not end with '.': '{last}'" + ) + + return errors + + +def validate_turtle_file(path: str | Path) -> list[str]: + """Validate a Turtle file on disk. + + Args: + path: Path to the ``.ttl`` file. + + Returns: + List of error messages (empty means valid). + + Raises: + FileNotFoundError: If *path* does not exist. + ValueError: If *path* is empty. + """ + if not path: + raise ValueError("Path must not be empty") + p = Path(path) + if not p.exists(): + raise FileNotFoundError(f"Turtle file not found: {p}") + content = p.read_text(encoding="utf-8") + return validate_turtle(content) diff --git a/vulture_whitelist.py b/vulture_whitelist.py index 24b35be74..f67cba439 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -833,3 +833,26 @@ facade # noqa: B018, F821 # Domain-specific analyzers — public API (issue #588) PostgreSQLAnalyzer # noqa: B018, F821 DockerComposeAnalyzer # noqa: B018, F821 + +# UKO Ontology Domain Registry — public API (issue #574) +DomainDescriptor # noqa: B018, F821 +TurtleValidationError # noqa: B018, F821 +build_detail_map_chain # noqa: B018, F821 +get_domain # noqa: B018, F821 +get_layer1_domains # noqa: B018, F821 +list_domains # noqa: B018, F821 +validate_turtle # noqa: B018, F821 +validate_turtle_file # noqa: B018, F821 +infra_detail_map # noqa: B018, F821 + +# UKO Ontology Registry — constants and class inventories (issue #574) +UKO_BASE_IRI # noqa: B018, F821 +UKO_CODE_IRI # noqa: B018, F821 +UKO_DOC_IRI # noqa: B018, F821 +UKO_DATA_IRI # noqa: B018, F821 +UKO_INFRA_IRI # noqa: B018, F821 +LAYER0_CLASSES # noqa: B018, F821 +LAYER1_CODE_CLASSES # noqa: B018, F821 +LAYER1_DOC_CLASSES # noqa: B018, F821 +LAYER1_DATA_CLASSES # noqa: B018, F821 +LAYER1_INFRA_CLASSES # noqa: B018, F821