Skip to content

Commit e94d2b8

Browse files
authored
Add linter rules for slot_group and in_subset referential integrity
1 parent 591eefa commit e94d2b8

6 files changed

Lines changed: 391 additions & 2 deletions

File tree

packages/linkml/src/linkml/linter/config/datamodel/config.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Auto generated from config.yaml by pythongen.py version: 0.0.1
2-
# Generation date: 2025-09-30T13:24:19
2+
# Generation date: 2026-06-26T12:49:40
33
# Schema: linter-config
44
#
55
# id: https://w3id.org/linkml/linter/config
@@ -25,7 +25,7 @@
2525
from linkml_runtime.linkml_model.types import Boolean, String
2626
from linkml_runtime.utils.metamodelcore import Bool
2727

28-
metamodel_version = "1.7.0"
28+
metamodel_version = "1.11.0"
2929
version = None
3030

3131
# Namespaces
@@ -81,8 +81,10 @@ class Rules(YAMLRoot):
8181

8282
canonical_prefixes: Optional[Union[dict, "CanonicalPrefixesConfig"]] = None
8383
no_empty_title: Optional[Union[dict, "NoEmptyTitleConfig"]] = None
84+
no_invalid_slot_group: Optional[Union[dict, "RuleConfig"]] = None
8485
no_invalid_slot_usage: Optional[Union[dict, "RuleConfig"]] = None
8586
no_undeclared_slots: Optional[Union[dict, "RuleConfig"]] = None
87+
no_undeclared_subsets: Optional[Union[dict, "RuleConfig"]] = None
8688
no_undeclared_ranges: Optional[Union[dict, "RuleConfig"]] = None
8789
no_xsd_int_type: Optional[Union[dict, "RuleConfig"]] = None
8890
one_identifier_per_class: Optional[Union[dict, "RuleConfig"]] = None
@@ -100,12 +102,18 @@ def __post_init__(self, *_: str, **kwargs: Any):
100102
if self.no_empty_title is not None and not isinstance(self.no_empty_title, NoEmptyTitleConfig):
101103
self.no_empty_title = NoEmptyTitleConfig(**as_dict(self.no_empty_title))
102104

105+
if self.no_invalid_slot_group is not None and not isinstance(self.no_invalid_slot_group, RuleConfig):
106+
self.no_invalid_slot_group = RuleConfig(**as_dict(self.no_invalid_slot_group))
107+
103108
if self.no_invalid_slot_usage is not None and not isinstance(self.no_invalid_slot_usage, RuleConfig):
104109
self.no_invalid_slot_usage = RuleConfig(**as_dict(self.no_invalid_slot_usage))
105110

106111
if self.no_undeclared_slots is not None and not isinstance(self.no_undeclared_slots, RuleConfig):
107112
self.no_undeclared_slots = RuleConfig(**as_dict(self.no_undeclared_slots))
108113

114+
if self.no_undeclared_subsets is not None and not isinstance(self.no_undeclared_subsets, RuleConfig):
115+
self.no_undeclared_subsets = RuleConfig(**as_dict(self.no_undeclared_subsets))
116+
109117
if self.no_undeclared_ranges is not None and not isinstance(self.no_undeclared_ranges, RuleConfig):
110118
self.no_undeclared_ranges = RuleConfig(**as_dict(self.no_undeclared_ranges))
111119

@@ -439,6 +447,15 @@ class slots:
439447
range=Optional[Union[dict, NoEmptyTitleConfig]],
440448
)
441449

450+
slots.rules__no_invalid_slot_group = Slot(
451+
uri=LINTCFG.no_invalid_slot_group,
452+
name="rules__no_invalid_slot_group",
453+
curie=LINTCFG.curie("no_invalid_slot_group"),
454+
model_uri=LINTCFG.rules__no_invalid_slot_group,
455+
domain=None,
456+
range=Optional[Union[dict, RuleConfig]],
457+
)
458+
442459
slots.rules__no_invalid_slot_usage = Slot(
443460
uri=LINTCFG.no_invalid_slot_usage,
444461
name="rules__no_invalid_slot_usage",
@@ -457,6 +474,15 @@ class slots:
457474
range=Optional[Union[dict, RuleConfig]],
458475
)
459476

477+
slots.rules__no_undeclared_subsets = Slot(
478+
uri=LINTCFG.no_undeclared_subsets,
479+
name="rules__no_undeclared_subsets",
480+
curie=LINTCFG.curie("no_undeclared_subsets"),
481+
model_uri=LINTCFG.rules__no_undeclared_subsets,
482+
domain=None,
483+
range=Optional[Union[dict, RuleConfig]],
484+
)
485+
460486
slots.rules__no_undeclared_ranges = Slot(
461487
uri=LINTCFG.no_undeclared_ranges,
462488
name="rules__no_undeclared_ranges",

packages/linkml/src/linkml/linter/config/datamodel/config.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ classes:
4848
description: >-
4949
Disallow empty titles on schema elements. Autofix will transform the element's
5050
name into a title.
51+
no_invalid_slot_group:
52+
range: RuleConfig
53+
description: >-
54+
Disallow `slot_group` references that do not resolve to a slot marked
55+
`is_grouping_slot: true`. Not auto-fixable.
5156
no_invalid_slot_usage:
5257
range: RuleConfig
5358
description: >-
@@ -58,6 +63,11 @@ classes:
5863
description: >-
5964
Disallow the use of slots in class specifications if the name of the slot
6065
does not refer to an existing slot. Not auto-fixable.
66+
no_undeclared_subsets:
67+
range: RuleConfig
68+
description: >-
69+
Disallow `in_subset` references to subsets that are not declared in the
70+
schema's `subsets` section. Not auto-fixable.
6171
no_undeclared_ranges:
6272
range: RuleConfig
6373
description: >-

packages/linkml/src/linkml/linter/config/default.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,14 @@ rules:
1616
exclude_type: []
1717
no_xsd_int_type:
1818
level: disabled
19+
no_invalid_slot_group:
20+
level: disabled
1921
no_invalid_slot_usage:
2022
level: disabled
2123
no_undeclared_slots:
2224
level: disabled
25+
no_undeclared_subsets:
26+
level: disabled
2327
no_undeclared_ranges:
2428
level: disabled
2529
one_identifier_per_class:

packages/linkml/src/linkml/linter/rules.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
EnumDefinition,
2828
EnumDefinitionName,
2929
SlotDefinition,
30+
SlotDefinitionName,
3031
TypeDefinition,
3132
TypeDefinitionName,
3233
)
@@ -455,3 +456,94 @@ def check(self, schema_view: SchemaView, fix: bool = False) -> Iterable[LinterPr
455456
f"'{prefix.prefix_reference}' instead of using prefix "
456457
f"'{namespace_to_prefix[prefix.prefix_reference]}'"
457458
)
459+
460+
461+
class NoInvalidSlotGroupRule(LinterRule):
462+
"""Disallow `slot_group` references that do not resolve to a grouping slot.
463+
464+
A slot may declare `slot_group: B` only if B is a defined slot and B is marked
465+
`is_grouping_slot: true`. The LinkML metamodel gives `slot_group` the range
466+
`slot_definition` but does not enforce that the referenced name resolves to a
467+
declared slot, nor that the target is a grouping slot, so dangling or
468+
non-grouping `slot_group` references pass silently. This rule is the
469+
`slot_group` analogue of `no_undeclared_ranges`. Not auto-fixable.
470+
"""
471+
472+
id = "no_invalid_slot_group"
473+
474+
def check(self, schema_view: SchemaView, fix: bool = False) -> Iterable[LinterProblem]:
475+
# Lookup table for resolving slot_group targets. all_slots() is acceptable here
476+
# because we only need to know whether a grouping slot of a given name exists.
477+
all_slots: dict[SlotDefinitionName, SlotDefinition] = schema_view.all_slots()
478+
479+
def check_slot(slot: SlotDefinition, descriptor: str) -> Iterable[LinterProblem]:
480+
group = slot.slot_group
481+
if not group:
482+
return
483+
if group not in all_slots:
484+
yield LinterProblem(f"{descriptor} has slot_group '{group}' which is not a defined slot.")
485+
elif not all_slots[group].is_grouping_slot:
486+
yield LinterProblem(
487+
f"{descriptor} has slot_group '{group}' which is not marked 'is_grouping_slot: true'."
488+
)
489+
490+
# Iterate global slots, then each class's attributes and slot_usage separately.
491+
# all_slots() de-duplicates attributes by name, so iterating it would miss
492+
# slot_group on attributes that share a name across classes or override a global
493+
# slot. attributes=False yields only the global slots; attributes are checked
494+
# per class below, each in its own context.
495+
for slot_name, slot_def in schema_view.all_slots(attributes=False).items():
496+
yield from check_slot(slot_def, f"Slot '{slot_name}'")
497+
498+
for class_name, class_def in schema_view.all_classes().items():
499+
for attr_name, attr in (class_def.attributes or {}).items():
500+
yield from check_slot(attr, f"Class '{class_name}' attribute '{attr_name}'")
501+
for usage_name, usage in (class_def.slot_usage or {}).items():
502+
yield from check_slot(usage, f"Class '{class_name}' slot_usage '{usage_name}'")
503+
504+
505+
class NoUndeclaredSubsetsRule(LinterRule):
506+
"""Disallow `in_subset` references to subsets not declared in the `subsets` section.
507+
508+
An element may declare `in_subset: S` only if S is a declared `SubsetDefinition`.
509+
The LinkML metamodel gives `in_subset` the range `subset_definition` but does not
510+
enforce that the referenced name resolves to a declared subset, so dangling
511+
`in_subset` references pass silently. This rule is the `in_subset` analogue of
512+
`no_undeclared_ranges`. Not auto-fixable.
513+
"""
514+
515+
id = "no_undeclared_subsets"
516+
517+
def check(self, schema_view: SchemaView, fix: bool = False) -> Iterable[LinterProblem]:
518+
declared_subsets: set[str] = set(schema_view.all_subsets())
519+
520+
def check_element(element: Element, descriptor: str) -> Iterable[LinterProblem]:
521+
for subset_name in getattr(element, "in_subset", None) or []:
522+
if subset_name not in declared_subsets:
523+
yield LinterProblem(f"{descriptor} asserts membership in undeclared subset '{subset_name}'.")
524+
525+
# Iterate global slots, then each class's attributes and slot_usage separately.
526+
# all_slots() de-duplicates attributes by name, so iterating it would miss
527+
# in_subset on attributes that share a name across classes or override a global
528+
# slot. attributes=False yields only the global slots; attributes are checked
529+
# per class below, each in its own context.
530+
for slot_name, slot_def in schema_view.all_slots(attributes=False).items():
531+
yield from check_element(slot_def, f"Slot '{slot_name}'")
532+
533+
# classes, plus their attributes and slot_usage overrides
534+
for class_name, class_def in schema_view.all_classes().items():
535+
yield from check_element(class_def, f"Class '{class_name}'")
536+
for attr_name, attr in (class_def.attributes or {}).items():
537+
yield from check_element(attr, f"Class '{class_name}' attribute '{attr_name}'")
538+
for usage_name, usage in (class_def.slot_usage or {}).items():
539+
yield from check_element(usage, f"Class '{class_name}' slot_usage '{usage_name}'")
540+
541+
# enums and their permissible values
542+
for enum_name, enum_def in schema_view.all_enums().items():
543+
yield from check_element(enum_def, f"Enum '{enum_name}'")
544+
for pv_name, pv in (enum_def.permissible_values or {}).items():
545+
yield from check_element(pv, f"Enum '{enum_name}' permissible value '{pv_name}'")
546+
547+
# types
548+
for type_name, type_def in schema_view.all_types().items():
549+
yield from check_element(type_def, f"Type '{type_name}'")
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Tests of the linter's slot_group integrity rule."""
2+
3+
from linkml.linter.config.datamodel.config import RuleConfig, RuleLevel
4+
from linkml.linter.linter import LinterProblem
5+
from linkml.linter.rules import NoInvalidSlotGroupRule
6+
from linkml_runtime.utils.schemaview import SchemaView
7+
8+
9+
def check_schema(test_schema: str) -> list[LinterProblem]:
10+
"""Check a schema using the NoInvalidSlotGroupRule linter.
11+
12+
:param test_schema: schema to test, as a string
13+
:type test_schema: str
14+
:return: list of linting problems discovered
15+
:rtype: list[LinterProblem]
16+
"""
17+
schema_view = SchemaView(test_schema)
18+
config = RuleConfig(level=RuleLevel.error.text)
19+
20+
rule = NoInvalidSlotGroupRule(config)
21+
return list(rule.check(schema_view, fix=False))
22+
23+
24+
def test_valid_slot_group() -> None:
25+
"""A slot_group that names a slot marked is_grouping_slot: true is valid."""
26+
test_schema = """id: http://example.org/test_slot_group
27+
name: test_slot_group
28+
slots:
29+
section_header:
30+
is_grouping_slot: true
31+
member_slot:
32+
slot_group: section_header
33+
"""
34+
assert not check_schema(test_schema)
35+
36+
37+
def test_no_slot_group() -> None:
38+
"""A slot with no slot_group produces no problems."""
39+
test_schema = """id: http://example.org/test_slot_group
40+
name: test_slot_group
41+
slots:
42+
plain_slot:
43+
"""
44+
assert not check_schema(test_schema)
45+
46+
47+
def test_undefined_slot_group() -> None:
48+
"""A slot_group naming something that is not a defined slot is an error."""
49+
test_schema = """id: http://example.org/test_slot_group
50+
name: test_slot_group
51+
slots:
52+
member_slot:
53+
slot_group: Not A Slot
54+
"""
55+
problems = check_schema(test_schema)
56+
assert len(problems) == 1
57+
assert problems[0].message == "Slot 'member_slot' has slot_group 'Not A Slot' which is not a defined slot."
58+
59+
60+
def test_non_grouping_slot_group() -> None:
61+
"""A slot_group naming a defined slot that is not a grouping slot is an error."""
62+
test_schema = """id: http://example.org/test_slot_group
63+
name: test_slot_group
64+
slots:
65+
ordinary_slot:
66+
member_slot:
67+
slot_group: ordinary_slot
68+
"""
69+
problems = check_schema(test_schema)
70+
assert len(problems) == 1
71+
assert (
72+
problems[0].message
73+
== "Slot 'member_slot' has slot_group 'ordinary_slot' which is not marked 'is_grouping_slot: true'."
74+
)
75+
76+
77+
def test_slot_group_in_slot_usage() -> None:
78+
"""slot_group asserted in a class's slot_usage is also checked."""
79+
test_schema = """id: http://example.org/test_slot_group
80+
name: test_slot_group
81+
slots:
82+
member_slot:
83+
classes:
84+
SomeClass:
85+
slots:
86+
- member_slot
87+
slot_usage:
88+
member_slot:
89+
slot_group: Not A Slot
90+
"""
91+
problems = check_schema(test_schema)
92+
assert len(problems) == 1
93+
assert (
94+
problems[0].message
95+
== "Class 'SomeClass' slot_usage 'member_slot' has slot_group 'Not A Slot' which is not a defined slot."
96+
)
97+
98+
99+
def test_slot_group_on_class_attribute() -> None:
100+
"""slot_group on an inline attribute is checked, including one overriding a global slot name."""
101+
test_schema = """id: http://example.org/test_slot_group
102+
name: test_slot_group
103+
slots:
104+
member_slot:
105+
classes:
106+
A:
107+
attributes:
108+
member_slot:
109+
slot_group: Not A Slot
110+
"""
111+
problems = check_schema(test_schema)
112+
assert len(problems) == 1
113+
assert (
114+
problems[0].message
115+
== "Class 'A' attribute 'member_slot' has slot_group 'Not A Slot' which is not a defined slot."
116+
)

0 commit comments

Comments
 (0)