rdf-construct 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rdf_construct/__init__.py +12 -0
- rdf_construct/__main__.py +0 -0
- rdf_construct/cli.py +1762 -0
- rdf_construct/core/__init__.py +33 -0
- rdf_construct/core/config.py +116 -0
- rdf_construct/core/ordering.py +219 -0
- rdf_construct/core/predicate_order.py +212 -0
- rdf_construct/core/profile.py +157 -0
- rdf_construct/core/selector.py +64 -0
- rdf_construct/core/serialiser.py +232 -0
- rdf_construct/core/utils.py +89 -0
- rdf_construct/cq/__init__.py +77 -0
- rdf_construct/cq/expectations.py +365 -0
- rdf_construct/cq/formatters/__init__.py +45 -0
- rdf_construct/cq/formatters/json.py +104 -0
- rdf_construct/cq/formatters/junit.py +104 -0
- rdf_construct/cq/formatters/text.py +146 -0
- rdf_construct/cq/loader.py +300 -0
- rdf_construct/cq/runner.py +321 -0
- rdf_construct/diff/__init__.py +59 -0
- rdf_construct/diff/change_types.py +214 -0
- rdf_construct/diff/comparator.py +338 -0
- rdf_construct/diff/filters.py +133 -0
- rdf_construct/diff/formatters/__init__.py +71 -0
- rdf_construct/diff/formatters/json.py +192 -0
- rdf_construct/diff/formatters/markdown.py +210 -0
- rdf_construct/diff/formatters/text.py +195 -0
- rdf_construct/docs/__init__.py +60 -0
- rdf_construct/docs/config.py +238 -0
- rdf_construct/docs/extractors.py +603 -0
- rdf_construct/docs/generator.py +360 -0
- rdf_construct/docs/renderers/__init__.py +7 -0
- rdf_construct/docs/renderers/html.py +803 -0
- rdf_construct/docs/renderers/json.py +390 -0
- rdf_construct/docs/renderers/markdown.py +628 -0
- rdf_construct/docs/search.py +278 -0
- rdf_construct/docs/templates/html/base.html.jinja +44 -0
- rdf_construct/docs/templates/html/class.html.jinja +152 -0
- rdf_construct/docs/templates/html/hierarchy.html.jinja +28 -0
- rdf_construct/docs/templates/html/index.html.jinja +110 -0
- rdf_construct/docs/templates/html/instance.html.jinja +90 -0
- rdf_construct/docs/templates/html/namespaces.html.jinja +37 -0
- rdf_construct/docs/templates/html/property.html.jinja +124 -0
- rdf_construct/docs/templates/html/single_page.html.jinja +169 -0
- rdf_construct/lint/__init__.py +75 -0
- rdf_construct/lint/config.py +214 -0
- rdf_construct/lint/engine.py +396 -0
- rdf_construct/lint/formatters.py +327 -0
- rdf_construct/lint/rules.py +692 -0
- rdf_construct/main.py +6 -0
- rdf_construct/puml2rdf/__init__.py +103 -0
- rdf_construct/puml2rdf/config.py +230 -0
- rdf_construct/puml2rdf/converter.py +420 -0
- rdf_construct/puml2rdf/merger.py +200 -0
- rdf_construct/puml2rdf/model.py +202 -0
- rdf_construct/puml2rdf/parser.py +565 -0
- rdf_construct/puml2rdf/validators.py +451 -0
- rdf_construct/shacl/__init__.py +56 -0
- rdf_construct/shacl/config.py +166 -0
- rdf_construct/shacl/converters.py +520 -0
- rdf_construct/shacl/generator.py +364 -0
- rdf_construct/shacl/namespaces.py +93 -0
- rdf_construct/stats/__init__.py +29 -0
- rdf_construct/stats/collector.py +178 -0
- rdf_construct/stats/comparator.py +298 -0
- rdf_construct/stats/formatters/__init__.py +83 -0
- rdf_construct/stats/formatters/json.py +38 -0
- rdf_construct/stats/formatters/markdown.py +153 -0
- rdf_construct/stats/formatters/text.py +186 -0
- rdf_construct/stats/metrics/__init__.py +26 -0
- rdf_construct/stats/metrics/basic.py +147 -0
- rdf_construct/stats/metrics/complexity.py +137 -0
- rdf_construct/stats/metrics/connectivity.py +130 -0
- rdf_construct/stats/metrics/documentation.py +128 -0
- rdf_construct/stats/metrics/hierarchy.py +207 -0
- rdf_construct/stats/metrics/properties.py +88 -0
- rdf_construct/uml/__init__.py +22 -0
- rdf_construct/uml/context.py +194 -0
- rdf_construct/uml/mapper.py +371 -0
- rdf_construct/uml/odm_renderer.py +789 -0
- rdf_construct/uml/renderer.py +684 -0
- rdf_construct/uml/uml_layout.py +393 -0
- rdf_construct/uml/uml_style.py +613 -0
- rdf_construct-0.2.0.dist-info/METADATA +431 -0
- rdf_construct-0.2.0.dist-info/RECORD +88 -0
- rdf_construct-0.2.0.dist-info/WHEEL +4 -0
- rdf_construct-0.2.0.dist-info/entry_points.txt +3 -0
- rdf_construct-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
"""Lint rules for RDF ontology quality checking.
|
|
2
|
+
|
|
3
|
+
Each rule is a function that takes a graph and returns a list of LintIssue objects.
|
|
4
|
+
Rules are registered with the @lint_rule decorator.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from rdflib import Graph, Namespace, RDF, RDFS, URIRef
|
|
14
|
+
from rdflib.namespace import OWL
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Severity(Enum):
|
|
18
|
+
"""Severity levels for lint issues."""
|
|
19
|
+
|
|
20
|
+
ERROR = "error"
|
|
21
|
+
WARNING = "warning"
|
|
22
|
+
INFO = "info"
|
|
23
|
+
|
|
24
|
+
def __lt__(self, other: Severity) -> bool:
|
|
25
|
+
order = {Severity.INFO: 0, Severity.WARNING: 1, Severity.ERROR: 2}
|
|
26
|
+
return order[self] < order[other]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class LintIssue:
|
|
31
|
+
"""A single lint issue found in an ontology.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
rule_id: Identifier for the rule that found this issue.
|
|
35
|
+
severity: How serious the issue is (error/warning/info).
|
|
36
|
+
entity: The URI of the entity with the issue.
|
|
37
|
+
message: Human-readable description of the issue.
|
|
38
|
+
line: Approximate line number in source (if available).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
rule_id: str
|
|
42
|
+
severity: Severity
|
|
43
|
+
entity: URIRef | None
|
|
44
|
+
message: str
|
|
45
|
+
line: int | None = None
|
|
46
|
+
|
|
47
|
+
def __str__(self) -> str:
|
|
48
|
+
entity_str = f" '{self.entity}'" if self.entity else ""
|
|
49
|
+
line_str = f":{self.line}" if self.line else ""
|
|
50
|
+
return f"{line_str} {self.severity.value}[{self.rule_id}]:{entity_str} {self.message}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class RuleSpec:
|
|
55
|
+
"""Specification for a lint rule.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
rule_id: Unique identifier for this rule.
|
|
59
|
+
description: What this rule checks for.
|
|
60
|
+
category: Category grouping (structural/documentation/best-practice).
|
|
61
|
+
default_severity: Default severity level.
|
|
62
|
+
check_fn: Function that performs the check.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
rule_id: str
|
|
66
|
+
description: str
|
|
67
|
+
category: str
|
|
68
|
+
default_severity: Severity
|
|
69
|
+
check_fn: Callable[[Graph], list[LintIssue]]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Registry of all lint rules
|
|
73
|
+
_RULE_REGISTRY: dict[str, RuleSpec] = {}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def lint_rule(
|
|
77
|
+
rule_id: str,
|
|
78
|
+
description: str,
|
|
79
|
+
category: str,
|
|
80
|
+
default_severity: Severity,
|
|
81
|
+
) -> Callable:
|
|
82
|
+
"""Decorator to register a lint rule.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
rule_id: Unique identifier (e.g., 'orphan-class').
|
|
86
|
+
description: What this rule checks.
|
|
87
|
+
category: 'structural', 'documentation', or 'best-practice'.
|
|
88
|
+
default_severity: Default severity level.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Decorator function.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def decorator(fn: Callable[[Graph], list[LintIssue]]) -> Callable:
|
|
95
|
+
spec = RuleSpec(
|
|
96
|
+
rule_id=rule_id,
|
|
97
|
+
description=description,
|
|
98
|
+
category=category,
|
|
99
|
+
default_severity=default_severity,
|
|
100
|
+
check_fn=fn,
|
|
101
|
+
)
|
|
102
|
+
_RULE_REGISTRY[rule_id] = spec
|
|
103
|
+
return fn
|
|
104
|
+
|
|
105
|
+
return decorator
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_all_rules() -> dict[str, RuleSpec]:
|
|
109
|
+
"""Return all registered rules."""
|
|
110
|
+
return _RULE_REGISTRY.copy()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_rule(rule_id: str) -> RuleSpec | None:
|
|
114
|
+
"""Get a rule by ID."""
|
|
115
|
+
return _RULE_REGISTRY.get(rule_id)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def list_rules() -> list[str]:
|
|
119
|
+
"""List all rule IDs."""
|
|
120
|
+
return list(_RULE_REGISTRY.keys())
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# -----------------------------------------------------------------------------
|
|
124
|
+
# Helper functions for rules
|
|
125
|
+
# -----------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get_classes(graph: Graph) -> set[URIRef]:
|
|
129
|
+
"""Get all classes (owl:Class and rdfs:Class)."""
|
|
130
|
+
classes: set[URIRef] = set()
|
|
131
|
+
for cls in graph.subjects(RDF.type, OWL.Class):
|
|
132
|
+
if isinstance(cls, URIRef):
|
|
133
|
+
classes.add(cls)
|
|
134
|
+
for cls in graph.subjects(RDF.type, RDFS.Class):
|
|
135
|
+
if isinstance(cls, URIRef):
|
|
136
|
+
classes.add(cls)
|
|
137
|
+
return classes
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_properties(graph: Graph) -> set[URIRef]:
|
|
141
|
+
"""Get all properties (all property types)."""
|
|
142
|
+
props: set[URIRef] = set()
|
|
143
|
+
for prop_type in (
|
|
144
|
+
RDF.Property,
|
|
145
|
+
OWL.ObjectProperty,
|
|
146
|
+
OWL.DatatypeProperty,
|
|
147
|
+
OWL.AnnotationProperty,
|
|
148
|
+
):
|
|
149
|
+
for prop in graph.subjects(RDF.type, prop_type):
|
|
150
|
+
if isinstance(prop, URIRef):
|
|
151
|
+
props.add(prop)
|
|
152
|
+
return props
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_object_properties(graph: Graph) -> set[URIRef]:
|
|
156
|
+
"""Get owl:ObjectProperty entities."""
|
|
157
|
+
return {p for p in graph.subjects(RDF.type, OWL.ObjectProperty) if isinstance(p, URIRef)}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_datatype_properties(graph: Graph) -> set[URIRef]:
|
|
161
|
+
"""Get owl:DatatypeProperty entities."""
|
|
162
|
+
return {p for p in graph.subjects(RDF.type, OWL.DatatypeProperty) if isinstance(p, URIRef)}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_superclasses(graph: Graph, cls: URIRef) -> set[URIRef]:
|
|
166
|
+
"""Get direct superclasses of a class."""
|
|
167
|
+
return {o for o in graph.objects(cls, RDFS.subClassOf) if isinstance(o, URIRef)}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_superproperties(graph: Graph, prop: URIRef) -> set[URIRef]:
|
|
171
|
+
"""Get direct superproperties of a property."""
|
|
172
|
+
return {o for o in graph.objects(prop, RDFS.subPropertyOf) if isinstance(o, URIRef)}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def has_inherited_domain(graph: Graph, prop: URIRef, visited: set[URIRef] | None = None) -> bool:
|
|
176
|
+
"""Check if property has domain (directly or inherited from superproperty)."""
|
|
177
|
+
if visited is None:
|
|
178
|
+
visited = set()
|
|
179
|
+
if prop in visited:
|
|
180
|
+
return False
|
|
181
|
+
visited.add(prop)
|
|
182
|
+
|
|
183
|
+
if (prop, RDFS.domain, None) in graph:
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
for superprop in get_superproperties(graph, prop):
|
|
187
|
+
if has_inherited_domain(graph, superprop, visited):
|
|
188
|
+
return True
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def has_inherited_range(graph: Graph, prop: URIRef, visited: set[URIRef] | None = None) -> bool:
|
|
193
|
+
"""Check if property has range (directly or inherited from superproperty)."""
|
|
194
|
+
if visited is None:
|
|
195
|
+
visited = set()
|
|
196
|
+
if prop in visited:
|
|
197
|
+
return False
|
|
198
|
+
visited.add(prop)
|
|
199
|
+
|
|
200
|
+
if (prop, RDFS.range, None) in graph:
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
for superprop in get_superproperties(graph, prop):
|
|
204
|
+
if has_inherited_range(graph, superprop, visited):
|
|
205
|
+
return True
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def get_all_referenced_uris(graph: Graph) -> set[URIRef]:
|
|
210
|
+
"""Get all URIs referenced in the graph (subjects, predicates, objects)."""
|
|
211
|
+
uris: set[URIRef] = set()
|
|
212
|
+
for s, p, o in graph:
|
|
213
|
+
if isinstance(s, URIRef):
|
|
214
|
+
uris.add(s)
|
|
215
|
+
if isinstance(p, URIRef):
|
|
216
|
+
uris.add(p)
|
|
217
|
+
if isinstance(o, URIRef):
|
|
218
|
+
uris.add(o)
|
|
219
|
+
return uris
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_defined_entities(graph: Graph) -> set[URIRef]:
|
|
223
|
+
"""Get all entities that are defined as subjects with rdf:type."""
|
|
224
|
+
return {s for s in graph.subjects(RDF.type, None) if isinstance(s, URIRef)}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def is_builtin(uri: URIRef) -> bool:
|
|
228
|
+
"""Check if a URI is from a built-in namespace (RDF, RDFS, OWL, XSD)."""
|
|
229
|
+
builtin_namespaces = [
|
|
230
|
+
"http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
|
231
|
+
"http://www.w3.org/2000/01/rdf-schema#",
|
|
232
|
+
"http://www.w3.org/2002/07/owl#",
|
|
233
|
+
"http://www.w3.org/2001/XMLSchema#",
|
|
234
|
+
]
|
|
235
|
+
uri_str = str(uri)
|
|
236
|
+
return any(uri_str.startswith(ns) for ns in builtin_namespaces)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def get_namespace(uri: URIRef) -> str:
|
|
240
|
+
"""Extract namespace from a URI (everything before the local name)."""
|
|
241
|
+
uri_str = str(uri)
|
|
242
|
+
if "#" in uri_str:
|
|
243
|
+
return uri_str.rsplit("#", 1)[0] + "#"
|
|
244
|
+
elif "/" in uri_str:
|
|
245
|
+
return uri_str.rsplit("/", 1)[0] + "/"
|
|
246
|
+
return uri_str
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# -----------------------------------------------------------------------------
|
|
250
|
+
# Structural Rules (default: ERROR)
|
|
251
|
+
# -----------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@lint_rule(
|
|
255
|
+
rule_id="orphan-class",
|
|
256
|
+
description="Class has no rdfs:subClassOf declaration and isn't owl:Thing or rdfs:Resource",
|
|
257
|
+
category="structural",
|
|
258
|
+
default_severity=Severity.ERROR,
|
|
259
|
+
)
|
|
260
|
+
def check_orphan_class(graph: Graph) -> list[LintIssue]:
|
|
261
|
+
"""Check for classes with no superclass."""
|
|
262
|
+
issues = []
|
|
263
|
+
classes = get_classes(graph)
|
|
264
|
+
|
|
265
|
+
# Exempt top-level classes
|
|
266
|
+
top_classes = {OWL.Thing, RDFS.Resource, RDFS.Class, OWL.Class}
|
|
267
|
+
|
|
268
|
+
for cls in classes:
|
|
269
|
+
if cls in top_classes or is_builtin(cls):
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
superclasses = get_superclasses(graph, cls)
|
|
273
|
+
if not superclasses:
|
|
274
|
+
issues.append(
|
|
275
|
+
LintIssue(
|
|
276
|
+
rule_id="orphan-class",
|
|
277
|
+
severity=Severity.ERROR,
|
|
278
|
+
entity=cls,
|
|
279
|
+
message="Class has no rdfs:subClassOf declaration",
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return issues
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@lint_rule(
|
|
287
|
+
rule_id="dangling-reference",
|
|
288
|
+
description="Reference to an entity that is not defined in the ontology",
|
|
289
|
+
category="structural",
|
|
290
|
+
default_severity=Severity.ERROR,
|
|
291
|
+
)
|
|
292
|
+
def check_dangling_reference(graph: Graph) -> list[LintIssue]:
|
|
293
|
+
"""Check for references to undefined entities."""
|
|
294
|
+
issues = []
|
|
295
|
+
defined = get_defined_entities(graph)
|
|
296
|
+
referenced = get_all_referenced_uris(graph)
|
|
297
|
+
|
|
298
|
+
# Get namespaces of all defined entities
|
|
299
|
+
defined_namespaces = {get_namespace(d) for d in defined}
|
|
300
|
+
|
|
301
|
+
for uri in referenced:
|
|
302
|
+
if is_builtin(uri):
|
|
303
|
+
continue
|
|
304
|
+
if uri not in defined:
|
|
305
|
+
# Only report if it's in a namespace we define things in
|
|
306
|
+
uri_namespace = get_namespace(uri)
|
|
307
|
+
if uri_namespace in defined_namespaces:
|
|
308
|
+
issues.append(
|
|
309
|
+
LintIssue(
|
|
310
|
+
rule_id="dangling-reference",
|
|
311
|
+
severity=Severity.ERROR,
|
|
312
|
+
entity=uri,
|
|
313
|
+
message="Referenced entity is not defined in this ontology",
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
return issues
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@lint_rule(
|
|
321
|
+
rule_id="circular-subclass",
|
|
322
|
+
description="Class is a subclass of itself (directly or transitively)",
|
|
323
|
+
category="structural",
|
|
324
|
+
default_severity=Severity.ERROR,
|
|
325
|
+
)
|
|
326
|
+
def check_circular_subclass(graph: Graph) -> list[LintIssue]:
|
|
327
|
+
"""Check for circular subclass relationships."""
|
|
328
|
+
issues = []
|
|
329
|
+
classes = get_classes(graph)
|
|
330
|
+
|
|
331
|
+
for cls in classes:
|
|
332
|
+
# BFS to find if cls is reachable from itself via subClassOf
|
|
333
|
+
visited: set[URIRef] = set()
|
|
334
|
+
queue = list(get_superclasses(graph, cls))
|
|
335
|
+
|
|
336
|
+
while queue:
|
|
337
|
+
current = queue.pop(0)
|
|
338
|
+
if current == cls:
|
|
339
|
+
issues.append(
|
|
340
|
+
LintIssue(
|
|
341
|
+
rule_id="circular-subclass",
|
|
342
|
+
severity=Severity.ERROR,
|
|
343
|
+
entity=cls,
|
|
344
|
+
message="Class is a subclass of itself (circular hierarchy)",
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
break
|
|
348
|
+
|
|
349
|
+
if current not in visited and isinstance(current, URIRef):
|
|
350
|
+
visited.add(current)
|
|
351
|
+
queue.extend(get_superclasses(graph, current))
|
|
352
|
+
|
|
353
|
+
return issues
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@lint_rule(
|
|
357
|
+
rule_id="property-no-type",
|
|
358
|
+
description="Property lacks explicit rdf:type declaration",
|
|
359
|
+
category="structural",
|
|
360
|
+
default_severity=Severity.ERROR,
|
|
361
|
+
)
|
|
362
|
+
def check_property_no_type(graph: Graph) -> list[LintIssue]:
|
|
363
|
+
"""Check for properties without explicit type.
|
|
364
|
+
|
|
365
|
+
This catches subjects that have domain/range but no property type.
|
|
366
|
+
"""
|
|
367
|
+
issues = []
|
|
368
|
+
|
|
369
|
+
# Find all subjects that have domain or range but no property type
|
|
370
|
+
property_types = {
|
|
371
|
+
RDF.Property,
|
|
372
|
+
OWL.ObjectProperty,
|
|
373
|
+
OWL.DatatypeProperty,
|
|
374
|
+
OWL.AnnotationProperty,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
for subj in graph.subjects(RDFS.domain, None):
|
|
378
|
+
if isinstance(subj, URIRef) and not is_builtin(subj):
|
|
379
|
+
types = set(graph.objects(subj, RDF.type))
|
|
380
|
+
if not types.intersection(property_types):
|
|
381
|
+
issues.append(
|
|
382
|
+
LintIssue(
|
|
383
|
+
rule_id="property-no-type",
|
|
384
|
+
severity=Severity.ERROR,
|
|
385
|
+
entity=subj,
|
|
386
|
+
message="Has rdfs:domain but no property type declaration",
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
for subj in graph.subjects(RDFS.range, None):
|
|
391
|
+
if isinstance(subj, URIRef) and not is_builtin(subj):
|
|
392
|
+
types = set(graph.objects(subj, RDF.type))
|
|
393
|
+
if not types.intersection(property_types):
|
|
394
|
+
# Avoid duplicates if already reported
|
|
395
|
+
existing = [i for i in issues if i.entity == subj]
|
|
396
|
+
if not existing:
|
|
397
|
+
issues.append(
|
|
398
|
+
LintIssue(
|
|
399
|
+
rule_id="property-no-type",
|
|
400
|
+
severity=Severity.ERROR,
|
|
401
|
+
entity=subj,
|
|
402
|
+
message="Has rdfs:range but no property type declaration",
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
return issues
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@lint_rule(
|
|
410
|
+
rule_id="empty-ontology",
|
|
411
|
+
description="owl:Ontology declaration has no metadata (label, version, etc.)",
|
|
412
|
+
category="structural",
|
|
413
|
+
default_severity=Severity.ERROR,
|
|
414
|
+
)
|
|
415
|
+
def check_empty_ontology(graph: Graph) -> list[LintIssue]:
|
|
416
|
+
"""Check for owl:Ontology with no metadata."""
|
|
417
|
+
issues = []
|
|
418
|
+
|
|
419
|
+
# Common ontology metadata predicates
|
|
420
|
+
metadata_predicates = {
|
|
421
|
+
RDFS.label,
|
|
422
|
+
RDFS.comment,
|
|
423
|
+
OWL.versionInfo,
|
|
424
|
+
OWL.versionIRI,
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
for ont in graph.subjects(RDF.type, OWL.Ontology):
|
|
428
|
+
if isinstance(ont, URIRef):
|
|
429
|
+
has_metadata = False
|
|
430
|
+
for pred in metadata_predicates:
|
|
431
|
+
if (ont, pred, None) in graph:
|
|
432
|
+
has_metadata = True
|
|
433
|
+
break
|
|
434
|
+
|
|
435
|
+
if not has_metadata:
|
|
436
|
+
issues.append(
|
|
437
|
+
LintIssue(
|
|
438
|
+
rule_id="empty-ontology",
|
|
439
|
+
severity=Severity.ERROR,
|
|
440
|
+
entity=ont,
|
|
441
|
+
message="owl:Ontology has no metadata (label, comment, or version)",
|
|
442
|
+
)
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
return issues
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# -----------------------------------------------------------------------------
|
|
449
|
+
# Documentation Rules (default: WARNING)
|
|
450
|
+
# -----------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@lint_rule(
|
|
454
|
+
rule_id="missing-label",
|
|
455
|
+
description="Entity lacks rdfs:label annotation",
|
|
456
|
+
category="documentation",
|
|
457
|
+
default_severity=Severity.WARNING,
|
|
458
|
+
)
|
|
459
|
+
def check_missing_label(graph: Graph) -> list[LintIssue]:
|
|
460
|
+
"""Check for classes and properties without labels."""
|
|
461
|
+
issues = []
|
|
462
|
+
|
|
463
|
+
# Check classes
|
|
464
|
+
for cls in get_classes(graph):
|
|
465
|
+
if is_builtin(cls):
|
|
466
|
+
continue
|
|
467
|
+
if (cls, RDFS.label, None) not in graph:
|
|
468
|
+
issues.append(
|
|
469
|
+
LintIssue(
|
|
470
|
+
rule_id="missing-label",
|
|
471
|
+
severity=Severity.WARNING,
|
|
472
|
+
entity=cls,
|
|
473
|
+
message="Class lacks rdfs:label",
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
# Check properties
|
|
478
|
+
for prop in get_properties(graph):
|
|
479
|
+
if is_builtin(prop):
|
|
480
|
+
continue
|
|
481
|
+
if (prop, RDFS.label, None) not in graph:
|
|
482
|
+
issues.append(
|
|
483
|
+
LintIssue(
|
|
484
|
+
rule_id="missing-label",
|
|
485
|
+
severity=Severity.WARNING,
|
|
486
|
+
entity=prop,
|
|
487
|
+
message="Property lacks rdfs:label",
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
return issues
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
@lint_rule(
|
|
495
|
+
rule_id="missing-comment",
|
|
496
|
+
description="Class or property lacks rdfs:comment annotation",
|
|
497
|
+
category="documentation",
|
|
498
|
+
default_severity=Severity.WARNING,
|
|
499
|
+
)
|
|
500
|
+
def check_missing_comment(graph: Graph) -> list[LintIssue]:
|
|
501
|
+
"""Check for classes and properties without comments."""
|
|
502
|
+
issues = []
|
|
503
|
+
|
|
504
|
+
# Check classes
|
|
505
|
+
for cls in get_classes(graph):
|
|
506
|
+
if is_builtin(cls):
|
|
507
|
+
continue
|
|
508
|
+
if (cls, RDFS.comment, None) not in graph:
|
|
509
|
+
issues.append(
|
|
510
|
+
LintIssue(
|
|
511
|
+
rule_id="missing-comment",
|
|
512
|
+
severity=Severity.WARNING,
|
|
513
|
+
entity=cls,
|
|
514
|
+
message="Class lacks rdfs:comment",
|
|
515
|
+
)
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
# Check properties
|
|
519
|
+
for prop in get_properties(graph):
|
|
520
|
+
if is_builtin(prop):
|
|
521
|
+
continue
|
|
522
|
+
if (prop, RDFS.comment, None) not in graph:
|
|
523
|
+
issues.append(
|
|
524
|
+
LintIssue(
|
|
525
|
+
rule_id="missing-comment",
|
|
526
|
+
severity=Severity.WARNING,
|
|
527
|
+
entity=prop,
|
|
528
|
+
message="Property lacks rdfs:comment",
|
|
529
|
+
)
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
return issues
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# -----------------------------------------------------------------------------
|
|
536
|
+
# Best Practice Rules (default: INFO)
|
|
537
|
+
# -----------------------------------------------------------------------------
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
@lint_rule(
|
|
541
|
+
rule_id="redundant-subclass",
|
|
542
|
+
description="Class has redundant subclass declaration (A → B → C, but also A → C)",
|
|
543
|
+
category="best-practice",
|
|
544
|
+
default_severity=Severity.INFO,
|
|
545
|
+
)
|
|
546
|
+
def check_redundant_subclass(graph: Graph) -> list[LintIssue]:
|
|
547
|
+
"""Check for redundant subclass relationships."""
|
|
548
|
+
issues = []
|
|
549
|
+
classes = get_classes(graph)
|
|
550
|
+
|
|
551
|
+
for cls in classes:
|
|
552
|
+
superclasses = get_superclasses(graph, cls)
|
|
553
|
+
|
|
554
|
+
# For each pair of direct superclasses, check if one is an ancestor of the other
|
|
555
|
+
superclass_list = list(superclasses)
|
|
556
|
+
for i, sup1 in enumerate(superclass_list):
|
|
557
|
+
for sup2 in superclass_list[i + 1:]:
|
|
558
|
+
# Check if sup1 is an ancestor of sup2 (or vice versa)
|
|
559
|
+
if _is_ancestor(graph, sup1, sup2):
|
|
560
|
+
issues.append(
|
|
561
|
+
LintIssue(
|
|
562
|
+
rule_id="redundant-subclass",
|
|
563
|
+
severity=Severity.INFO,
|
|
564
|
+
entity=cls,
|
|
565
|
+
message=f"Redundant subclass: inherits from both {sup1} and {sup2} "
|
|
566
|
+
f"(but {sup2} already inherits from {sup1})",
|
|
567
|
+
)
|
|
568
|
+
)
|
|
569
|
+
elif _is_ancestor(graph, sup2, sup1):
|
|
570
|
+
issues.append(
|
|
571
|
+
LintIssue(
|
|
572
|
+
rule_id="redundant-subclass",
|
|
573
|
+
severity=Severity.INFO,
|
|
574
|
+
entity=cls,
|
|
575
|
+
message=f"Redundant subclass: inherits from both {sup1} and {sup2} "
|
|
576
|
+
f"(but {sup1} already inherits from {sup2})",
|
|
577
|
+
)
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
return issues
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _is_ancestor(graph: Graph, potential_ancestor: URIRef, cls: URIRef) -> bool:
|
|
584
|
+
"""Check if potential_ancestor is an ancestor of cls via subClassOf."""
|
|
585
|
+
visited: set[URIRef] = set()
|
|
586
|
+
queue = list(get_superclasses(graph, cls))
|
|
587
|
+
|
|
588
|
+
while queue:
|
|
589
|
+
current = queue.pop(0)
|
|
590
|
+
if current == potential_ancestor:
|
|
591
|
+
return True
|
|
592
|
+
if current not in visited and isinstance(current, URIRef):
|
|
593
|
+
visited.add(current)
|
|
594
|
+
queue.extend(get_superclasses(graph, current))
|
|
595
|
+
|
|
596
|
+
return False
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
@lint_rule(
|
|
600
|
+
rule_id="property-no-domain",
|
|
601
|
+
description="Object property has no rdfs:domain declaration (direct or inherited)",
|
|
602
|
+
category="best-practice",
|
|
603
|
+
default_severity=Severity.INFO,
|
|
604
|
+
)
|
|
605
|
+
def check_property_no_domain(graph: Graph) -> list[LintIssue]:
|
|
606
|
+
"""Check for object properties without domain (including inherited)."""
|
|
607
|
+
issues = []
|
|
608
|
+
|
|
609
|
+
for prop in get_object_properties(graph):
|
|
610
|
+
if is_builtin(prop):
|
|
611
|
+
continue
|
|
612
|
+
if not has_inherited_domain(graph, prop):
|
|
613
|
+
issues.append(
|
|
614
|
+
LintIssue(
|
|
615
|
+
rule_id="property-no-domain",
|
|
616
|
+
severity=Severity.INFO,
|
|
617
|
+
entity=prop,
|
|
618
|
+
message="Object property has no rdfs:domain (direct or inherited)",
|
|
619
|
+
)
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
return issues
|
|
623
|
+
|
|
624
|
+
@lint_rule(
|
|
625
|
+
rule_id="property-no-range",
|
|
626
|
+
description="Object property has no rdfs:range declaration (direct or inherited)",
|
|
627
|
+
category="best-practice",
|
|
628
|
+
default_severity=Severity.INFO,
|
|
629
|
+
)
|
|
630
|
+
def check_property_no_range(graph: Graph) -> list[LintIssue]:
|
|
631
|
+
"""Check for object properties without range (including inherited)."""
|
|
632
|
+
issues = []
|
|
633
|
+
|
|
634
|
+
for prop in get_object_properties(graph):
|
|
635
|
+
if is_builtin(prop):
|
|
636
|
+
continue
|
|
637
|
+
if not has_inherited_range(graph, prop):
|
|
638
|
+
issues.append(
|
|
639
|
+
LintIssue(
|
|
640
|
+
rule_id="property-no-range",
|
|
641
|
+
severity=Severity.INFO,
|
|
642
|
+
entity=prop,
|
|
643
|
+
message="Object property has no rdfs:range (direct or inherited)",
|
|
644
|
+
)
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
return issues
|
|
648
|
+
|
|
649
|
+
@lint_rule(
|
|
650
|
+
rule_id="inconsistent-naming",
|
|
651
|
+
description="Entity names don't follow consistent convention (CamelCase vs snake_case)",
|
|
652
|
+
category="best-practice",
|
|
653
|
+
default_severity=Severity.INFO,
|
|
654
|
+
)
|
|
655
|
+
def check_inconsistent_naming(graph: Graph) -> list[LintIssue]:
|
|
656
|
+
"""Check for inconsistent naming conventions.
|
|
657
|
+
|
|
658
|
+
OWL convention: Classes use UpperCamelCase, properties use lowerCamelCase.
|
|
659
|
+
"""
|
|
660
|
+
issues = []
|
|
661
|
+
|
|
662
|
+
# Check classes - should be UpperCamelCase
|
|
663
|
+
for cls in get_classes(graph):
|
|
664
|
+
if is_builtin(cls):
|
|
665
|
+
continue
|
|
666
|
+
local_name = str(cls).split("#")[-1].split("/")[-1]
|
|
667
|
+
if local_name and not local_name[0].isupper():
|
|
668
|
+
issues.append(
|
|
669
|
+
LintIssue(
|
|
670
|
+
rule_id="inconsistent-naming",
|
|
671
|
+
severity=Severity.INFO,
|
|
672
|
+
entity=cls,
|
|
673
|
+
message=f"Class name '{local_name}' should start with uppercase (UpperCamelCase)",
|
|
674
|
+
)
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
# Check properties - should be lowerCamelCase
|
|
678
|
+
for prop in get_properties(graph):
|
|
679
|
+
if is_builtin(prop):
|
|
680
|
+
continue
|
|
681
|
+
local_name = str(prop).split("#")[-1].split("/")[-1]
|
|
682
|
+
if local_name and local_name[0].isupper():
|
|
683
|
+
issues.append(
|
|
684
|
+
LintIssue(
|
|
685
|
+
rule_id="inconsistent-naming",
|
|
686
|
+
severity=Severity.INFO,
|
|
687
|
+
entity=prop,
|
|
688
|
+
message=f"Property name '{local_name}' should start with lowercase (lowerCamelCase)",
|
|
689
|
+
)
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
return issues
|