forked from ChelseaKR/ctdl-validate
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdomain_range.py
More file actions
349 lines (317 loc) · 14.8 KB
/
Copy pathdomain_range.py
File metadata and controls
349 lines (317 loc) · 14.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
"""Check 4: domain and range, from the schema's own declarations.
A property used on a class outside its schema:domainIncludes (including
subclasses) is an ERROR, as is a reference resolving to an in-payload entity
whose class is outside schema:rangeIncludes. Terms in the ceterms/ceasn
namespaces that the vendored schema snapshot does not declare are WARNINGs
(they may be typos or newer than the snapshot). This check also carries the
generic form of the wrong-framework-identifier bug: a Competency whose
isPartOf matches no CompetencyFramework in its own payload even though the
payload contains one.
Two range disagreements are dispositions rather than errors, because the
published sources contradict each other rather than the document contradicting
a source: ``RANGE_DOCS_CONFLICT`` for ceasn:isChildOf, and
``CONCEPT_RANGE_CONFLICT`` for the properties CTDL ranges on skos:Concept
while ranging the same kind of value on ceterms:CredentialAlignmentObject
elsewhere. Both are INFO and neither gates the exit code.
"""
from __future__ import annotations
from dataclasses import dataclass
from .. import rules
from ..findings import Finding, Severity
from ..graph import NestedRef, Node
from ..schema import ALIGNMENT_RANGE_TERM, SchemaIndex, is_checked_term, vocab_prefix
from ..session import Session
#: Documented conflicts between the schema encoding and Credential Engine's
#: own usage guidance. See rules.ISCHILDOF_RANGE_CONFLICT.
DOCUMENTED_RANGE_CONFLICTS = frozenset({("ceasn:isChildOf", "ceasn:CompetencyFramework")})
#: Classes that satisfy a declared skos:Concept range in practice even though
#: the encoding gives them no path to it. See rules.concept_range_conflict_rule.
ALIGNMENT_RANGE = frozenset({ALIGNMENT_RANGE_TERM})
#: CTDL's three version properties: each relates a resource to another version
#: of the same resource, and each declares a range that is a strict subset of
#: its own domain. Named here rather than derived because the disposition
#: rests on what a *version* is, which no part of the encoding states; the
#: classes it applies to are derived (SchemaIndex.domain_only_classes).
#: See rules.version_range_conflict_rule.
VERSION_PROPERTIES = frozenset(
{
"ceterms:latestVersion",
"ceterms:nextVersion",
"ceterms:previousVersion",
}
)
def _unknown_type_findings(node: Node, schema: SchemaIndex) -> list[Finding]:
findings: list[Finding] = []
for node_type in node.types:
if is_checked_term(node_type) and node_type not in schema.classes:
findings.append(
Finding(
code="UNKNOWN_CLASS",
severity=Severity.WARNING,
entity=node.label,
prop="@type",
value=node_type,
message="Class is not declared in the vendored schema snapshot.",
rule=rules.unknown_term_rule("class", vocab_prefix(node_type)),
)
)
return findings
@dataclass(frozen=True)
class _Target:
"""The entity a reference names, once the run has found it and typed it."""
label: str
types: tuple[str, ...]
#: Empty for an in-payload target; names the supplied file otherwise, so
#: every judgement says which document it rests on.
origin: str
#: What to print as the finding's value: the reference as written where
#: there is one, the target's own label for a nested node.
text: str
def _resolve_target(value: object, session: Session) -> _Target | None:
"""The reference's target and declared classes, or None if it cannot be judged.
Returns None for a value that is not a reference, for one the run cannot
see (check 3 reports that as UNVERIFIABLE, and it is not this check's job),
and for one whose target declares no class the snapshot knows -- an
undeclared or untyped target is not evidence of anything.
"""
if not isinstance(value, (str, NestedRef)):
return None
target = session.graph.resolve(value)
# A reference the payload does not contain is judged against the documents
# supplied with --resolve, and only against those.
external = session.supplied.get(value) if target is None else None
if target is not None:
label, declared, origin = target.label, target.types, ""
elif external is not None:
label, declared = external.node_id, external.types
origin = f" That entity was read from {external.source}, supplied with --resolve."
else:
return None
types = session.schema.known_types(declared)
if not types:
return None
return _Target(
label=label,
types=types,
origin=origin,
text=value if isinstance(value, str) else label,
)
def _concept_conflict(node: Node, prop: str, hit: _Target, schema: SchemaIndex) -> Finding | None:
"""A scheme-bound concept reference encoded the way the Registry encodes it.
CTDL ranges a reference to a term from one of its own concept schemes on
skos:Concept for some properties and on CredentialAlignmentObject for
others, with nothing about the value to tell the families apart, and its
published documents use CredentialAlignmentObject for both. An ERROR here
would report Credential Engine's dominant encoding as a defect.
"""
prop_def = schema.properties[prop]
if not prop_def.is_scheme_bound_concept:
return None
if not schema.class_matches(hit.types, ALIGNMENT_RANGE):
return None
return Finding(
code="CONCEPT_RANGE_CONFLICT",
severity=Severity.INFO,
entity=node.label,
prop=prop,
value=hit.text,
message=(
f"{prop} declares its range as skos:Concept, and this value is a "
f"{ALIGNMENT_RANGE_TERM}. That is how the Registry's published documents "
"encode it, and how CTDL declares the range of other properties drawing on "
f"the same concept scheme ({', '.join(sorted(prop_def.target_scheme))}), so "
"this is very likely correct as written. Nothing to fix unless you meant to "
"reference a skos:Concept directly." + hit.origin
),
rule=rules.concept_range_conflict_rule(
prop, prop_def.target_scheme, schema.alignment_ranged_siblings(prop)
),
)
def _version_conflict(node: Node, prop: str, hit: _Target, schema: SchemaIndex) -> Finding | None:
"""A version link the encoding admits as a subject and refuses as an object.
CTDL's version properties declare a range that is a strict subset of their
own domain. Where a document versions an entity with another entity of the
same class, and that class is one the encoding dropped from the range, the
two declarations contradict each other and the document satisfies the one
that says this class may be versioned at all. Narrowed to a link between
two entities of the same class, because that is the only reading under
which the range omission is certainly the mistake: a version of a thing is
a thing of the same kind.
"""
if prop not in VERSION_PROPERTIES:
return None
dropped = schema.domain_only_classes(prop)
asymmetric = frozenset(schema.known_types(node.types)) & frozenset(hit.types) & dropped
if not asymmetric:
return None
cls = sorted(asymmetric)[0]
return Finding(
code="VERSION_RANGE_CONFLICT",
severity=Severity.INFO,
entity=node.label,
prop=prop,
value=hit.text,
message=(
f"{prop} declares {cls} in its domain and omits it from its range, so CTDL "
f"says a {cls} may have a version while saying that version may not itself "
f"be a {cls}. One of those two declarations is wrong, and this tool cannot "
f"tell you which. It does not gate on it, because every class the range "
f"does admit would make a {cls}'s version something other than a {cls}, and "
"there is no third option to point you at." + hit.origin
),
rule=rules.version_range_conflict_rule(prop, cls, dropped),
)
def _docs_conflict(node: Node, prop: str, hit: _Target) -> Finding | None:
"""A range the encoding excludes and Credential Engine's own examples use."""
conflict = next((t for t in hit.types if (prop, t) in DOCUMENTED_RANGE_CONFLICTS), None)
if conflict is None:
return None
return Finding(
code="RANGE_DOCS_CONFLICT",
severity=Severity.INFO,
entity=node.label,
prop=prop,
value=hit.text,
message=(
f"Referenced entity is a {conflict}, which the declared range of {prop} does "
"not include, but Credential Engine's own guidance and examples use exactly "
"this pattern." + hit.origin
),
rule=rules.ISCHILDOF_RANGE_CONFLICT,
)
def _range_violation(node: Node, prop: str, hit: _Target, schema: SchemaIndex) -> Finding:
"""No published source excuses this one: the reference is out of range."""
return Finding(
code="RANGE_VIOLATION",
severity=Severity.ERROR,
entity=node.label,
prop=prop,
value=hit.text,
message=(
f"Referenced entity {hit.label} is typed [{', '.join(hit.types)}], which is "
f"outside the declared range of {prop}." + hit.origin
),
rule=rules.range_rule(prop, schema.properties[prop].range),
)
def _range_findings(
node: Node, prop: str, values: tuple[object, ...], session: Session
) -> list[Finding]:
schema = session.schema
prop_def = schema.properties[prop]
if not prop_def.range_has_entities:
return []
# A range of rdfs:Resource admits every entity there is, so nothing can
# fall outside it. Checking a target's classes against it would invert the
# declaration and reject everything, because no CTDL class reaches
# rdfs:Resource by rdfs:subClassOf. See schema.UNIVERSAL_RANGE_TERMS.
if prop_def.range_is_universal:
return []
findings: list[Finding] = []
for value in values:
hit = _resolve_target(value, session)
if hit is None or schema.class_matches(hit.types, prop_def.range):
continue
# Three published sources excuse a range the encoding excludes; where
# none of them applies, the reference is an ERROR and gates the exit
# code. Order is immaterial: no two can match the same reference.
findings.append(
_concept_conflict(node, prop, hit, schema)
or _version_conflict(node, prop, hit, schema)
or _docs_conflict(node, prop, hit)
or _range_violation(node, prop, hit, schema)
)
return findings
def _ispartof_framework_findings(node: Node, session: Session) -> list[Finding]:
"""The generic wrong-framework-identifier bug (competency extracts)."""
graph, schema, supplied = session.graph, session.schema, session.supplied
if not schema.class_matches(schema.known_types(node.types), frozenset({"ceasn:Competency"})):
return []
values = node.props.get("ceasn:isPartOf", ())
if not values:
return []
framework = frozenset({"ceasn:CompetencyFramework"})
in_payload = {
n.node_id
for n in graph.nodes
if n.node_id is not None and schema.class_matches(schema.known_types(n.types), framework)
}
# A framework handed to the run with --resolve is as much a candidate as
# one in the payload: the question this check asks is whether the
# identifier names a framework the run can see, not where it came from.
from_supplied = {
entity.node_id
for entity in supplied.entities.values()
if schema.class_matches(schema.known_types(entity.types), framework)
}
framework_ids = in_payload | from_supplied
if not framework_ids:
return [] # no framework anywhere in reach: nothing to compare against
where = "this payload" if not from_supplied else "this payload or the documents supplied"
findings: list[Finding] = []
for value in values:
if not isinstance(value, str):
continue
if value in framework_ids:
continue
if graph.resolve(value) is not None or supplied.get(value) is not None:
continue # resolves to a non-framework: RANGE_VIOLATION covers it
findings.append(
Finding(
code="ISPARTOF_FRAMEWORK_MISMATCH",
severity=Severity.WARNING,
entity=node.label,
prop="ceasn:isPartOf",
value=value,
message=(
"This competency's isPartOf identifier matches no "
f"CompetencyFramework in {where}, although this run can see "
f"one ({', '.join(sorted(framework_ids))}). If this competency "
"belongs to that framework, this identifier is the wrong one."
),
rule=rules.SAME_GRAPH_FRAMEWORK,
)
)
return findings
def check(session: Session) -> list[Finding]:
graph, schema = session.graph, session.schema
findings: list[Finding] = []
for node in graph.nodes:
findings.extend(_unknown_type_findings(node, schema))
node_types = schema.known_types(node.types)
for prop, values in sorted(node.props.items()):
prop_def = schema.properties.get(prop)
if prop_def is None:
if is_checked_term(prop):
findings.append(
Finding(
code="UNKNOWN_PROPERTY",
severity=Severity.WARNING,
entity=node.label,
prop=prop,
value="-",
message=("Property is not declared in the vendored schema snapshot."),
rule=rules.unknown_term_rule("property", vocab_prefix(prop)),
)
)
continue
if (
node_types
and prop_def.domain
and not schema.class_matches(node_types, prop_def.domain)
):
findings.append(
Finding(
code="DOMAIN_VIOLATION",
severity=Severity.ERROR,
entity=node.label,
prop=prop,
value=f"@type=[{', '.join(node_types)}]",
message=(
f"{prop} is not declared for class(es) [{', '.join(node_types)}]."
),
rule=rules.domain_rule(prop, prop_def.domain),
)
)
findings.extend(_range_findings(node, prop, values, session))
findings.extend(_ispartof_framework_findings(node, session))
return findings