rdf-construct 0.3.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.
Files changed (110) hide show
  1. rdf_construct/__init__.py +12 -0
  2. rdf_construct/__main__.py +0 -0
  3. rdf_construct/cli.py +3429 -0
  4. rdf_construct/core/__init__.py +33 -0
  5. rdf_construct/core/config.py +116 -0
  6. rdf_construct/core/ordering.py +219 -0
  7. rdf_construct/core/predicate_order.py +212 -0
  8. rdf_construct/core/profile.py +157 -0
  9. rdf_construct/core/selector.py +64 -0
  10. rdf_construct/core/serialiser.py +232 -0
  11. rdf_construct/core/utils.py +89 -0
  12. rdf_construct/cq/__init__.py +77 -0
  13. rdf_construct/cq/expectations.py +365 -0
  14. rdf_construct/cq/formatters/__init__.py +45 -0
  15. rdf_construct/cq/formatters/json.py +104 -0
  16. rdf_construct/cq/formatters/junit.py +104 -0
  17. rdf_construct/cq/formatters/text.py +146 -0
  18. rdf_construct/cq/loader.py +300 -0
  19. rdf_construct/cq/runner.py +321 -0
  20. rdf_construct/diff/__init__.py +59 -0
  21. rdf_construct/diff/change_types.py +214 -0
  22. rdf_construct/diff/comparator.py +338 -0
  23. rdf_construct/diff/filters.py +133 -0
  24. rdf_construct/diff/formatters/__init__.py +71 -0
  25. rdf_construct/diff/formatters/json.py +192 -0
  26. rdf_construct/diff/formatters/markdown.py +210 -0
  27. rdf_construct/diff/formatters/text.py +195 -0
  28. rdf_construct/docs/__init__.py +60 -0
  29. rdf_construct/docs/config.py +238 -0
  30. rdf_construct/docs/extractors.py +603 -0
  31. rdf_construct/docs/generator.py +360 -0
  32. rdf_construct/docs/renderers/__init__.py +7 -0
  33. rdf_construct/docs/renderers/html.py +803 -0
  34. rdf_construct/docs/renderers/json.py +390 -0
  35. rdf_construct/docs/renderers/markdown.py +628 -0
  36. rdf_construct/docs/search.py +278 -0
  37. rdf_construct/docs/templates/html/base.html.jinja +44 -0
  38. rdf_construct/docs/templates/html/class.html.jinja +152 -0
  39. rdf_construct/docs/templates/html/hierarchy.html.jinja +28 -0
  40. rdf_construct/docs/templates/html/index.html.jinja +110 -0
  41. rdf_construct/docs/templates/html/instance.html.jinja +90 -0
  42. rdf_construct/docs/templates/html/namespaces.html.jinja +37 -0
  43. rdf_construct/docs/templates/html/property.html.jinja +124 -0
  44. rdf_construct/docs/templates/html/single_page.html.jinja +169 -0
  45. rdf_construct/lint/__init__.py +75 -0
  46. rdf_construct/lint/config.py +214 -0
  47. rdf_construct/lint/engine.py +396 -0
  48. rdf_construct/lint/formatters.py +327 -0
  49. rdf_construct/lint/rules.py +692 -0
  50. rdf_construct/localise/__init__.py +114 -0
  51. rdf_construct/localise/config.py +508 -0
  52. rdf_construct/localise/extractor.py +427 -0
  53. rdf_construct/localise/formatters/__init__.py +36 -0
  54. rdf_construct/localise/formatters/markdown.py +229 -0
  55. rdf_construct/localise/formatters/text.py +224 -0
  56. rdf_construct/localise/merger.py +346 -0
  57. rdf_construct/localise/reporter.py +356 -0
  58. rdf_construct/main.py +6 -0
  59. rdf_construct/merge/__init__.py +165 -0
  60. rdf_construct/merge/config.py +354 -0
  61. rdf_construct/merge/conflicts.py +281 -0
  62. rdf_construct/merge/formatters.py +426 -0
  63. rdf_construct/merge/merger.py +425 -0
  64. rdf_construct/merge/migrator.py +339 -0
  65. rdf_construct/merge/rules.py +377 -0
  66. rdf_construct/merge/splitter.py +1102 -0
  67. rdf_construct/puml2rdf/__init__.py +103 -0
  68. rdf_construct/puml2rdf/config.py +230 -0
  69. rdf_construct/puml2rdf/converter.py +420 -0
  70. rdf_construct/puml2rdf/merger.py +200 -0
  71. rdf_construct/puml2rdf/model.py +202 -0
  72. rdf_construct/puml2rdf/parser.py +565 -0
  73. rdf_construct/puml2rdf/validators.py +451 -0
  74. rdf_construct/refactor/__init__.py +72 -0
  75. rdf_construct/refactor/config.py +362 -0
  76. rdf_construct/refactor/deprecator.py +328 -0
  77. rdf_construct/refactor/formatters/__init__.py +8 -0
  78. rdf_construct/refactor/formatters/text.py +311 -0
  79. rdf_construct/refactor/renamer.py +294 -0
  80. rdf_construct/shacl/__init__.py +56 -0
  81. rdf_construct/shacl/config.py +166 -0
  82. rdf_construct/shacl/converters.py +520 -0
  83. rdf_construct/shacl/generator.py +364 -0
  84. rdf_construct/shacl/namespaces.py +93 -0
  85. rdf_construct/stats/__init__.py +29 -0
  86. rdf_construct/stats/collector.py +178 -0
  87. rdf_construct/stats/comparator.py +298 -0
  88. rdf_construct/stats/formatters/__init__.py +83 -0
  89. rdf_construct/stats/formatters/json.py +38 -0
  90. rdf_construct/stats/formatters/markdown.py +153 -0
  91. rdf_construct/stats/formatters/text.py +186 -0
  92. rdf_construct/stats/metrics/__init__.py +26 -0
  93. rdf_construct/stats/metrics/basic.py +147 -0
  94. rdf_construct/stats/metrics/complexity.py +137 -0
  95. rdf_construct/stats/metrics/connectivity.py +130 -0
  96. rdf_construct/stats/metrics/documentation.py +128 -0
  97. rdf_construct/stats/metrics/hierarchy.py +207 -0
  98. rdf_construct/stats/metrics/properties.py +88 -0
  99. rdf_construct/uml/__init__.py +22 -0
  100. rdf_construct/uml/context.py +194 -0
  101. rdf_construct/uml/mapper.py +371 -0
  102. rdf_construct/uml/odm_renderer.py +789 -0
  103. rdf_construct/uml/renderer.py +684 -0
  104. rdf_construct/uml/uml_layout.py +393 -0
  105. rdf_construct/uml/uml_style.py +613 -0
  106. rdf_construct-0.3.0.dist-info/METADATA +496 -0
  107. rdf_construct-0.3.0.dist-info/RECORD +110 -0
  108. rdf_construct-0.3.0.dist-info/WHEEL +4 -0
  109. rdf_construct-0.3.0.dist-info/entry_points.txt +3 -0
  110. rdf_construct-0.3.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