ontolint 0.1.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 (62) hide show
  1. ontolint/__init__.py +7 -0
  2. ontolint/__main__.py +8 -0
  3. ontolint/_version.py +15 -0
  4. ontolint/adapters/__init__.py +1 -0
  5. ontolint/adapters/robot.py +72 -0
  6. ontolint/adapters/shacl.py +147 -0
  7. ontolint/catalogue.py +77 -0
  8. ontolint/cli.py +302 -0
  9. ontolint/context.py +127 -0
  10. ontolint/data/rule_catalogue.csv +308 -0
  11. ontolint/engine.py +157 -0
  12. ontolint/github.py +27 -0
  13. ontolint/graph/__init__.py +5 -0
  14. ontolint/graph/algorithms.py +198 -0
  15. ontolint/linter.py +96 -0
  16. ontolint/loader.py +385 -0
  17. ontolint/models.py +115 -0
  18. ontolint/ontolint.py +34 -0
  19. ontolint/ownership.py +159 -0
  20. ontolint/paths.py +89 -0
  21. ontolint/policy.py +202 -0
  22. ontolint/profiles.py +68 -0
  23. ontolint/py.typed +1 -0
  24. ontolint/reporting.py +306 -0
  25. ontolint/rule_info.py +139 -0
  26. ontolint/rules/__init__.py +5 -0
  27. ontolint/rules/alignment.py +428 -0
  28. ontolint/rules/builtin.py +156 -0
  29. ontolint/rules/class_heuristics.py +195 -0
  30. ontolint/rules/classes.py +297 -0
  31. ontolint/rules/external.py +58 -0
  32. ontolint/rules/governance.py +137 -0
  33. ontolint/rules/governance_metadata.py +300 -0
  34. ontolint/rules/hierarchy_quality.py +370 -0
  35. ontolint/rules/identifiers.py +89 -0
  36. ontolint/rules/iri_lexical_extras.py +157 -0
  37. ontolint/rules/iri_quality.py +193 -0
  38. ontolint/rules/lexical.py +279 -0
  39. ontolint/rules/lexical_quality.py +291 -0
  40. ontolint/rules/metadata.py +111 -0
  41. ontolint/rules/metrics.py +396 -0
  42. ontolint/rules/obo.py +423 -0
  43. ontolint/rules/owl.py +415 -0
  44. ontolint/rules/owl_axioms.py +478 -0
  45. ontolint/rules/owl_expressions.py +491 -0
  46. ontolint/rules/registry.py +69 -0
  47. ontolint/rules/runtime.py +95 -0
  48. ontolint/rules/safe_loader.py +56 -0
  49. ontolint/rules/safety.py +253 -0
  50. ontolint/rules/shacl_authoring.py +393 -0
  51. ontolint/rules/shacl_quality.py +273 -0
  52. ontolint/rules/shapes.py +151 -0
  53. ontolint/rules/skos.py +158 -0
  54. ontolint/rules/skos_integrity.py +388 -0
  55. ontolint/rules/specs.py +17 -0
  56. ontolint/rules/support.py +136 -0
  57. ontolint/selection.py +94 -0
  58. ontolint-0.1.0.dist-info/METADATA +456 -0
  59. ontolint-0.1.0.dist-info/RECORD +62 -0
  60. ontolint-0.1.0.dist-info/WHEEL +4 -0
  61. ontolint-0.1.0.dist-info/entry_points.txt +3 -0
  62. ontolint-0.1.0.dist-info/licenses/LICENSE +202 -0
ontolint/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Public Python API for ontology-quality linting."""
2
+
3
+ from .cli import main
4
+ from .linter import Linter
5
+ from .models import Finding
6
+
7
+ __all__ = ["Finding", "Linter", "main"]
ontolint/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m ontolint``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
ontolint/_version.py ADDED
@@ -0,0 +1,15 @@
1
+ """Single fallback for the package version (a test pins it to ``pyproject.toml``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import metadata
6
+
7
+ FALLBACK_VERSION = "0.1.0"
8
+
9
+
10
+ def package_version() -> str:
11
+ """Return the installed distribution version, or the packaged fallback when not installed."""
12
+ try:
13
+ return metadata.version("ontolint")
14
+ except metadata.PackageNotFoundError:
15
+ return FALLBACK_VERSION
@@ -0,0 +1 @@
1
+ """Optional validation backends (pySHACL, ROBOT) that translate results into findings."""
@@ -0,0 +1,72 @@
1
+ """ROBOT adapter: OWL profile validation in an isolated Docker container."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from ontolint.models import Finding
10
+ from ontolint.rules.specs import FIXES, SEVERITY
11
+
12
+
13
+ def robot_profile_issues(ontology: Path, profile: str, image: str, timeout: int) -> list[Finding]:
14
+ """Run ROBOT profile validation in an isolated Docker container.
15
+
16
+ The ontology's parent directory is mounted read-only and the container has no
17
+ network access. Use a digest-pinned ``image`` in CI for reproducibility.
18
+ """
19
+ source = ontology.resolve()
20
+ if not source.is_file():
21
+ raise FileNotFoundError(source)
22
+ with tempfile.TemporaryDirectory(prefix="ontolint-robot-") as output_dir:
23
+ output = Path(output_dir)
24
+ report = output / "profile.txt"
25
+ command = [
26
+ "docker",
27
+ "run",
28
+ "--rm",
29
+ "--network",
30
+ "none",
31
+ "--memory",
32
+ "2g",
33
+ "--pids-limit",
34
+ "256",
35
+ "--cpus",
36
+ "2",
37
+ "--cap-drop",
38
+ "ALL",
39
+ "--security-opt",
40
+ "no-new-privileges",
41
+ "-v",
42
+ f"{source.parent}:/input:ro",
43
+ "-v",
44
+ f"{output}:/output",
45
+ "-w",
46
+ "/output",
47
+ image,
48
+ "validate-profile",
49
+ "--profile",
50
+ profile,
51
+ "--input",
52
+ f"/input/{source.name}",
53
+ "--output",
54
+ "/output/profile.txt",
55
+ ]
56
+ completed = subprocess.run( # noqa: S603 -- fixed Docker executable and constructed argument list.
57
+ command, capture_output=True, text=True, timeout=timeout, check=False
58
+ )
59
+ if completed.returncode == 0:
60
+ return []
61
+ details = report.read_text(encoding="utf-8", errors="replace").strip() if report.exists() else ""
62
+ details = details or completed.stderr.strip() or completed.stdout.strip() or "ROBOT did not produce a report."
63
+ return [
64
+ Finding(
65
+ "SEM001",
66
+ SEVERITY["SEM001"],
67
+ str(source),
68
+ f"ROBOT OWL {profile} profile validation failed: {details[:4000]}",
69
+ FIXES["SEM001"],
70
+ "certain",
71
+ )
72
+ ]
@@ -0,0 +1,147 @@
1
+ """pySHACL adapter: SHACL validation results become SHACL001 findings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from dataclasses import dataclass, field
7
+
8
+ from rdflib import Graph, URIRef
9
+ from rdflib.namespace import RDF, SH
10
+ from rdflib.term import Node
11
+
12
+ from ontolint.models import Finding
13
+ from ontolint.rules.specs import FIXES
14
+
15
+ MAX_LIST_LENGTH = 400
16
+ """Longest rdf:list the SHACL backend is asked to validate (pySHACL meta-validation is quadratic in it)."""
17
+ MAX_LIST_COST = 160_000
18
+ """Bound on the sum over lists of ``length ** 2`` (a few seconds of pySHACL work)."""
19
+
20
+ _BAD_IRI_CHARS = frozenset('<>" {}|\\^`')
21
+ """The characters ``rdflib`` refuses to serialise in an IRI (``rdflib.term._is_valid_uri``)."""
22
+
23
+
24
+ class ShapesRejected(ValueError):
25
+ """An input graph would make pySHACL crash or run for minutes; it is refused before validation."""
26
+
27
+
28
+ def _first_unserialisable_iri(graph: Graph) -> str | None:
29
+ seen: set[str] = set()
30
+ for triple in graph:
31
+ for term in triple:
32
+ if isinstance(term, URIRef) and term not in seen:
33
+ seen.add(term)
34
+ if not _BAD_IRI_CHARS.isdisjoint(term):
35
+ return str(term)
36
+ return None
37
+
38
+
39
+ def _check_iris(graph: Graph, name: str) -> None:
40
+ bad = _first_unserialisable_iri(graph)
41
+ if bad is not None:
42
+ raise ShapesRejected(f"{name} contains an IRI that cannot be serialised: {bad[:80]!r}")
43
+
44
+
45
+ @dataclass
46
+ class _Lists:
47
+ """The rdf:list structure of one graph and the cells the checks have walked so far."""
48
+
49
+ name: str
50
+ rest: dict[Node, Node]
51
+ cells: set[Node]
52
+ reached: set[Node] = field(default_factory=set[Node])
53
+
54
+
55
+ def _walk_list(head: Node, lists: _Lists, *, strict: bool) -> int:
56
+ """Walk one list from ``head`` and return its length; reject cycles, and in strict mode sharing and length."""
57
+ length, cell = 0, head
58
+ walk: set[Node] = set()
59
+ while cell != RDF.nil and cell in lists.cells:
60
+ if cell in walk:
61
+ raise ShapesRejected(f"{lists.name} contains a cyclic rdf:list (rdf:rest loops back)")
62
+ if cell in lists.reached:
63
+ if strict:
64
+ raise ShapesRejected(f"{lists.name} contains an rdf:list cell shared by several lists")
65
+ break # a shared tail is legal RDF; the cells beyond it were already walked
66
+ walk.add(cell)
67
+ lists.reached.add(cell)
68
+ length += 1
69
+ if strict and length > MAX_LIST_LENGTH:
70
+ raise ShapesRejected(f"{lists.name} contains an rdf:list longer than {MAX_LIST_LENGTH} items")
71
+ cell = lists.rest.get(cell, RDF.nil)
72
+ return length
73
+
74
+
75
+ def _check_lists(graph: Graph, name: str, *, strict: bool) -> None:
76
+ rest: dict[Node, Node] = {}
77
+ indegree: Counter[Node] = Counter()
78
+ for cell, _, tail in graph.triples((None, RDF.rest, None)):
79
+ if cell in rest and rest[cell] != tail:
80
+ raise ShapesRejected(f"{name} contains an rdf:list cell with several rdf:rest values")
81
+ rest[cell] = tail
82
+ indegree[tail] += 1
83
+ lists = _Lists(name, rest, set(rest) | {s for s, _, _ in graph.triples((None, RDF.first, None))})
84
+ cost = 0
85
+ for head in (c for c in lists.cells if indegree[c] == 0):
86
+ length = _walk_list(head, lists, strict=strict)
87
+ cost += length * length
88
+ if strict and cost > MAX_LIST_COST:
89
+ raise ShapesRejected(f"{name} contains too many long rdf:lists to validate in reasonable time")
90
+ if lists.reached != lists.cells - {RDF.nil}:
91
+ raise ShapesRejected(f"{name} contains a cyclic rdf:list (rdf:rest loops back)")
92
+
93
+
94
+ def check_graph(graph: Graph, name: str, *, strict: bool = True) -> None:
95
+ """Raise :class:`ShapesRejected` unless pySHACL can process ``graph`` safely (linear time, no recursion).
96
+
97
+ Every check looks for IRIs rdflib cannot serialise and for cyclic lists. ``strict`` (the shapes graph)
98
+ additionally rejects shared, overlong or numerous long lists, because pySHACL's meta-validation of the
99
+ shapes walks each list once per internal result, which is quadratic in the list length.
100
+ """
101
+ _check_iris(graph, name)
102
+ _check_lists(graph, name, strict=strict)
103
+
104
+
105
+ def shacl_issues(g: Graph, sg: Graph) -> list[Finding]:
106
+ """Validate ``g`` against ``sg`` with pySHACL (imported lazily; required only with shapes)."""
107
+ try:
108
+ from pyshacl import validate # noqa: PLC0415 -- optional dependency, required only with --shapes.
109
+ except ImportError:
110
+ raise RuntimeError("pySHACL is needed for --shapes: pip install pyshacl") from None
111
+ check_graph(sg, "shapes graph")
112
+ try:
113
+ conforms, report, _ = validate(
114
+ g, shacl_graph=sg, inference="none", advanced=True, meta_shacl=True, abort_on_first=False
115
+ )
116
+ except ShapesRejected:
117
+ raise
118
+ except Exception as exc:
119
+ # The data graph may hold large enumerations, odd IRIs (IRI001 reports those) or malformed lists, and
120
+ # pySHACL fails on them only when a violation message has to describe such a node: diagnose it then.
121
+ try:
122
+ check_graph(g, "data graph", strict=False)
123
+ except ShapesRejected as problem:
124
+ raise ShapesRejected(f"{problem}; pySHACL failed on it: {type(exc).__name__}: {exc}") from exc
125
+ raise
126
+ if conforms:
127
+ return []
128
+ if not isinstance(report, Graph):
129
+ raise RuntimeError(f"pySHACL returned an unexpected report type: {type(report).__name__}")
130
+ results: list[Finding] = []
131
+ for result in report.subjects(RDF.type, SH.ValidationResult):
132
+ focus = str(next(report.objects(result, SH.focusNode), "(unknown)"))
133
+ message = "; ".join(map(str, report.objects(result, SH.resultMessage))) or "SHACL constraint violation"
134
+ src = str(next(report.objects(result, SH.sourceShape), "(unknown)"))
135
+ results.append(Finding("SHACL001", "error", focus, f"{message} [shape: {src}]", FIXES["SHACL001"], "certain"))
136
+ if not results:
137
+ results.append(
138
+ Finding(
139
+ "SHACL001",
140
+ "error",
141
+ "(shapes)",
142
+ "SHACL returned nonconformant without individual result nodes.",
143
+ FIXES["SHACL001"],
144
+ "certain",
145
+ )
146
+ )
147
+ return results
ontolint/catalogue.py ADDED
@@ -0,0 +1,77 @@
1
+ """Typed view of the planning catalogue (implemented and proposed rules).
2
+
3
+ The catalogue records what is *planned* (:class:`CatalogueStatus`); runtime outcomes
4
+ (:class:`ExecutionStatus`) are a different concept and live on :class:`RuleExecution`.
5
+ The canonical file is ``data/rule_catalogue.csv`` in the repository; an identical copy
6
+ ships inside the package so the loader works from an installed wheel (a test enforces
7
+ that the two never drift).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import csv
13
+ from dataclasses import dataclass
14
+ from functools import cache
15
+ from importlib import resources
16
+
17
+ from .models import CatalogueStatus, RuleBackend
18
+
19
+ # Backends of implemented rules; planned rules use free-text backends and map to ``None``.
20
+ # Graph-algorithm rules (hierarchy metrics) and shapes-graph authoring rules run in-process on
21
+ # RDFLib graphs, so they map to the RDFLIB backend even though the catalogue prose is more specific.
22
+ # Mixed backends of still-proposed rows (for example "RDFLib graph or OWL profile backend") stay
23
+ # unmapped until a row is implemented and its actual backend is known.
24
+ BACKEND_IDS: dict[str, RuleBackend] = {
25
+ "RDFLib graph": RuleBackend.RDFLIB,
26
+ "RDFLib graph algorithms": RuleBackend.RDFLIB,
27
+ "pySHACL and RDFLib graph": RuleBackend.RDFLIB,
28
+ "pySHACL": RuleBackend.PYSHACL,
29
+ "ROBOT Docker backend": RuleBackend.ROBOT_DOCKER,
30
+ "CLI": RuleBackend.CLI,
31
+ }
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class CatalogueRow:
36
+ """One catalogue entry."""
37
+
38
+ rule_id: str
39
+ family: str
40
+ title: str
41
+ status: CatalogueStatus
42
+ category: str
43
+ backend: str
44
+ test_requirement: str
45
+
46
+ @property
47
+ def backend_id(self) -> RuleBackend | None:
48
+ """The stable backend identifier, or ``None`` for a planned, unmapped backend."""
49
+ return BACKEND_IDS.get(self.backend)
50
+
51
+
52
+ @cache
53
+ def load_catalogue() -> tuple[CatalogueRow, ...]:
54
+ """Load every catalogue row in file order from the packaged CSV."""
55
+ text = resources.files("ontolint").joinpath("data/rule_catalogue.csv").read_text(encoding="utf-8")
56
+ return tuple(
57
+ CatalogueRow(
58
+ rule_id=row["rule_id"],
59
+ family=row["family"],
60
+ title=row["title"],
61
+ status=CatalogueStatus(row["status"]),
62
+ category=row["category"],
63
+ backend=row["backend"],
64
+ test_requirement=row["test_requirement"],
65
+ )
66
+ for row in csv.DictReader(text.splitlines())
67
+ )
68
+
69
+
70
+ def implemented_rows() -> tuple[CatalogueRow, ...]:
71
+ """Rows whose rules ship in the built-in registry."""
72
+ return tuple(row for row in load_catalogue() if row.status is CatalogueStatus.IMPLEMENTED)
73
+
74
+
75
+ def planned_rows() -> tuple[CatalogueRow, ...]:
76
+ """Rows that are proposed but not yet implemented."""
77
+ return tuple(row for row in load_catalogue() if row.status is not CatalogueStatus.IMPLEMENTED)
ontolint/cli.py ADDED
@@ -0,0 +1,302 @@
1
+ """Command-line interface: argument parsing, orchestration, and report output.
2
+
3
+ Exit codes: ``0`` clean, ``1`` findings reach ``--fail-on``, ``2`` tool failure. A tool failure is
4
+ bad input/configuration/selectors, or a requested rule that failed or is unsupported (for example
5
+ ``--owl-profile`` without Docker): the report is still written, but the run cannot pass.
6
+ Rules skipped for an optional input that was not supplied, or by an ignore, never affect the code.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+ from collections.abc import Sequence
15
+ from contextvars import ContextVar
16
+ from dataclasses import dataclass, replace
17
+ from pathlib import Path
18
+
19
+ from rdflib import Graph
20
+
21
+ from . import engine
22
+ from ._version import package_version
23
+ from .catalogue import planned_rows
24
+ from .loader import InputRejected, ParseFailure, load_graph
25
+ from .models import ExecutionStatus, Finding, RuleBackend, RuleExecution
26
+ from .paths import InputFile, resolve_inputs
27
+ from .policy import Policy
28
+ from .profiles import PROFILES, Profile
29
+ from .reporting import FileReport, RunReport, fingerprint, render_report, run_exit_code
30
+ from .rule_info import UnknownRuleError, explain_rule, render_explanation, render_rule_list, rule_rows
31
+ from .rules.registry import RuleRegistry, UnknownSelectorError, default_registry
32
+ from .selection import Selection, SelectorSyntaxError, resolve_selection, split_selectors, unknown_selector_help
33
+
34
+ FORMATS = ["text", "json", "sarif"]
35
+
36
+
37
+ def _positive_int(text: str) -> int:
38
+ """argparse type for the loader limits: an integer of at least 1."""
39
+ try:
40
+ value = int(text)
41
+ except ValueError:
42
+ raise argparse.ArgumentTypeError(f"must be a positive integer, got {text!r}") from None
43
+ if value < 1:
44
+ raise argparse.ArgumentTypeError(f"must be a positive integer (at least 1), got {value}")
45
+ return value
46
+
47
+
48
+ def build_parser() -> argparse.ArgumentParser:
49
+ """Create the ``ontolint`` argument parser."""
50
+ ap = argparse.ArgumentParser(description="Ontology anti-pattern linter. No automatic network/import access.")
51
+ ap.add_argument(
52
+ "ontology",
53
+ nargs="*",
54
+ help="Ontology RDF file(s); each is linted independently. Expand globs in your shell; directories are rejected",
55
+ )
56
+ ap.add_argument("--shapes", help="Optional SHACL shapes graph (requires pyshacl)")
57
+ ap.add_argument("--base", help="Only lint declared terms in this namespace")
58
+ ap.add_argument("--config", help="Optional JSON policy with ignored_rules, severity_overrides and thresholds")
59
+ ap.add_argument("--baseline", help="JSON of fingerprints to exclude from displayed and gating findings")
60
+ ap.add_argument("--write-baseline", help="Write full set of fingerprints for future differential linting")
61
+ ap.add_argument("--language", action="append", default=[], help="Required label language; repeatable")
62
+ ap.add_argument("--depth-limit", type=int)
63
+ ap.add_argument("--inheritance-limit", type=int)
64
+ ap.add_argument("--soup-limit", type=int)
65
+ ap.add_argument(
66
+ "--max-input-bytes", type=_positive_int, help="Refuse input files larger than this (default 64 MiB)"
67
+ )
68
+ ap.add_argument(
69
+ "--max-triples", type=_positive_int, help="Refuse inputs with more triples than this (default 5000000)"
70
+ )
71
+ ap.add_argument("--owl-profile", choices=("EL", "QL", "RL", "DL", "Full"))
72
+ ap.add_argument("--robot-image", default="ontolint-robot:1.9.11")
73
+ ap.add_argument("--robot-timeout", type=int, default=120)
74
+ ap.add_argument("--format", choices=FORMATS, default="text")
75
+ ap.add_argument("--output", help="Output file (default stdout)")
76
+ ap.add_argument("--fail-on", choices=["error", "warning", "info", "never"], default=None)
77
+ _add_selection_arguments(ap)
78
+ return ap
79
+
80
+
81
+ def _add_selection_arguments(ap: argparse.ArgumentParser) -> None:
82
+ ap.add_argument("--profile", choices=sorted(PROFILES), help="Named rule profile; see the README for each profile")
83
+ ap.add_argument(
84
+ "--select", action="append", default=[], help="Rule IDs or families to run; comma-separated, repeatable"
85
+ )
86
+ ap.add_argument(
87
+ "--ignore", action="append", default=[], help="Rule IDs or families to skip; comma-separated, repeatable"
88
+ )
89
+ ap.add_argument("--list-rules", action="store_true", help="List implemented and proposed rules, then exit")
90
+ ap.add_argument("--explain", metavar="RULE", help="Explain one rule, then exit")
91
+ ap.add_argument("--version", action="store_true", help="Print the version, then exit")
92
+
93
+
94
+ def _policy(args: argparse.Namespace, config: Policy, ignored: frozenset[str]) -> Policy:
95
+ """Merge CLI options over the policy file; explicit CLI options take precedence."""
96
+ return replace(
97
+ config,
98
+ base=args.base if args.base is not None else config.base,
99
+ languages=tuple(x.lower() for x in args.language) if args.language else config.languages,
100
+ depth_limit=args.depth_limit if args.depth_limit is not None else config.depth_limit,
101
+ inheritance_limit=args.inheritance_limit if args.inheritance_limit is not None else config.inheritance_limit,
102
+ soup_limit=args.soup_limit if args.soup_limit is not None else config.soup_limit,
103
+ max_input_bytes=args.max_input_bytes if args.max_input_bytes is not None else config.max_input_bytes,
104
+ max_triples=args.max_triples if args.max_triples is not None else config.max_triples,
105
+ ignored_rules=ignored,
106
+ )
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class RunPlan:
111
+ """Everything shared by every file of one invocation."""
112
+
113
+ policy: Policy
114
+ selection: Selection
115
+ overrides: dict[str, str]
116
+ shapes: Graph | None
117
+ registry: RuleRegistry
118
+
119
+
120
+ def _lint_file(item: InputFile, graph: Graph, plan: RunPlan, args: argparse.Namespace) -> FileReport:
121
+ """Lint one parsed file and record an execution outcome for every selected rule."""
122
+ ctx = engine.build_context(graph, plan.shapes, plan.policy)
123
+ if ctx.context.ownership.base_selected_nothing:
124
+ print(
125
+ f"TOOL002: warning: --base {plan.policy.base!r} selects none of the terms declared in {item.display}; "
126
+ "term-level rules have nothing to check (is the base a prefix of the term IRIs?)",
127
+ file=sys.stderr,
128
+ )
129
+ robot = (
130
+ engine.RobotRequest(item.path, args.owl_profile, args.robot_image, args.robot_timeout)
131
+ if args.owl_profile
132
+ else None
133
+ )
134
+ executions: list[RuleExecution] = []
135
+ findings: tuple[Finding, ...] = ()
136
+ run_ids = [s.rule_id for s in plan.selection.run]
137
+ if run_ids:
138
+ result = engine.execute(ctx, run_ids, registry=plan.registry, robot=robot)
139
+ findings, executions = result.findings, list(result.executions)
140
+ executions += [
141
+ RuleExecution(s.rule_id, ExecutionStatus.SKIPPED, "ignored by --ignore or profile")
142
+ for s in plan.selection.ignored
143
+ ]
144
+ findings = tuple(replace(f, severity=plan.overrides.get(f.rule, f.severity)) for f in _legacy_order(findings, plan))
145
+ return FileReport(item.display, item.uri, findings, tuple(sorted(executions, key=lambda x: x.rule_id)))
146
+
147
+
148
+ def _legacy_order(findings: Sequence[Finding], plan: RunPlan) -> list[Finding]:
149
+ """Graph rules first (already sorted), then SHACL, then ROBOT: the order reports have always used."""
150
+ rank = {RuleBackend.RDFLIB: 0, RuleBackend.PYSHACL: 1, RuleBackend.ROBOT_DOCKER: 2}
151
+ backends = {s.rule_id: s.backend for s in plan.registry.all()}
152
+ return sorted(findings, key=lambda f: rank.get(backends.get(f.rule, RuleBackend.RDFLIB), 0))
153
+
154
+
155
+ def _file_fingerprints(report: FileReport, multi: bool) -> set[str]:
156
+ return {fingerprint(f, report.path if multi else None) for f in report.findings}
157
+
158
+
159
+ def _apply_baselines(files: list[FileReport], args: argparse.Namespace) -> list[FileReport]:
160
+ multi = len(files) > 1
161
+ if args.write_baseline:
162
+ everything = sorted(set().union(*(_file_fingerprints(r, multi) for r in files)))
163
+ Path(args.write_baseline).write_text(
164
+ json.dumps({"fingerprints": everything}, indent=2) + "\n", encoding="utf-8"
165
+ )
166
+ if args.baseline:
167
+ prior = set(json.loads(Path(args.baseline).read_text(encoding="utf-8")).get("fingerprints", []))
168
+ files = [
169
+ replace(r, findings=tuple(f for f in r.findings if fingerprint(f, r.path if multi else None) not in prior))
170
+ for r in files
171
+ ]
172
+ return files
173
+
174
+
175
+ _LOADER_POLICY: ContextVar[Policy | None] = ContextVar("loader_policy", default=None)
176
+
177
+
178
+ def _parse_inputs(inputs: list[InputFile]) -> tuple[list[Graph], list[str]]:
179
+ """Parse every input safely (no network, bounded size); return the graphs and one message per failure.
180
+
181
+ The limits come from the policy of the current run (:data:`_LOADER_POLICY`, set by :func:`_lint`).
182
+ """
183
+ policy = _LOADER_POLICY.get() or Policy()
184
+ graphs: list[Graph] = []
185
+ errors: list[str] = []
186
+ for item in inputs:
187
+ try:
188
+ graphs.append(load_graph(item, policy))
189
+ except InputRejected as exc:
190
+ where = f" for {item.display}" if len(inputs) > 1 and item.display not in exc.message else ""
191
+ errors.append(f"{exc.rule_id}: {exc.message}{where}")
192
+ except Exception as exc: # noqa: BLE001 -- any other failure (unreadable file ...) is a reportable input error.
193
+ errors.append(_parse_error(exc, item.display if len(inputs) > 1 else None))
194
+ return graphs, errors
195
+
196
+
197
+ def _parse_error(exc: Exception, display: str | None) -> str:
198
+ """The ``INPUT001`` line for a parse failure; ``display`` names the file when several were given."""
199
+ where = f" for {display}" if display else ""
200
+ name, detail = (exc.cause_type, exc.detail) if isinstance(exc, ParseFailure) else (type(exc).__name__, str(exc))
201
+ return f"INPUT001: RDF input parsing failed{where}: {name}: {detail}"
202
+
203
+
204
+ def _info_command(args: argparse.Namespace) -> int | None:
205
+ """Handle --version, --list-rules and --explain; ``None`` means a normal lint run."""
206
+ registry = default_registry()
207
+ if args.version:
208
+ print(f"ontolint {package_version()}")
209
+ elif args.list_rules:
210
+ print(render_rule_list(rule_rows(registry), "json" if args.format != "text" else "text"))
211
+ elif args.explain:
212
+ try:
213
+ print(render_explanation(explain_rule(args.explain, registry), "json" if args.format != "text" else "text"))
214
+ except UnknownRuleError:
215
+ print(f"TOOL001: unknown rule {args.explain!r}; use --list-rules to see valid identifiers", file=sys.stderr)
216
+ return 2
217
+ else:
218
+ return None
219
+ return 0
220
+
221
+
222
+ def _profile(args: argparse.Namespace) -> Profile | None:
223
+ return PROFILES[args.profile] if args.profile else None
224
+
225
+
226
+ def _plan(args: argparse.Namespace, config: Policy, shapes: Graph | None, registry: RuleRegistry) -> RunPlan:
227
+ profile = _profile(args)
228
+ selection = resolve_selection(registry, profile, split_selectors(args.select), split_selectors(args.ignore))
229
+ overrides = {**dict(profile.severity_overrides if profile else ()), **config.severity_mapping}
230
+ return RunPlan(_policy(args, config, config.ignored_rules), selection, overrides, shapes, registry)
231
+
232
+
233
+ def _emit(output: str, args: argparse.Namespace) -> None:
234
+ if args.output:
235
+ Path(args.output).write_text(output + "\n", encoding="utf-8")
236
+ else:
237
+ print(output)
238
+
239
+
240
+ def _lint(args: argparse.Namespace, stage: list[str]) -> RunReport | int:
241
+ """Run the whole lint; ``stage`` tracks progress for error messages. Returns a report or an exit code."""
242
+ registry = default_registry()
243
+ config = Policy.from_mapping(json.loads(Path(args.config).read_text(encoding="utf-8")) if args.config else {})
244
+ stage[0] = "RDF input parsing"
245
+ limits = _policy(args, config, config.ignored_rules)
246
+ inputs = resolve_inputs(args.ontology, Path.cwd())
247
+ _LOADER_POLICY.set(limits)
248
+ graphs, errors = _parse_inputs(inputs)
249
+ if errors:
250
+ print("\n".join(errors), file=sys.stderr)
251
+ return 2
252
+ shapes: Graph | None = None
253
+ if args.shapes:
254
+ try:
255
+ shapes = load_graph(resolve_inputs([args.shapes], Path.cwd())[0], limits)
256
+ except InputRejected as exc:
257
+ print(f"{exc.rule_id}: {exc.message}", file=sys.stderr)
258
+ return 2
259
+ except ParseFailure as exc:
260
+ print(_parse_error(exc, None), file=sys.stderr)
261
+ return 2
262
+ stage[0] = "lint execution"
263
+ plan = _plan(args, config, shapes, registry)
264
+ files = [_lint_file(item, graph, plan, args) for item, graph in zip(inputs, graphs, strict=True)]
265
+ return RunReport(tuple(_apply_baselines(files, args)), plan.selection.run + plan.selection.ignored, plan.overrides)
266
+
267
+
268
+ def main(argv: Sequence[str] | None = None) -> int:
269
+ """Run the linter CLI and return its process exit code (0 clean, 1 gated findings, 2 tool failure)."""
270
+ parser = build_parser()
271
+ args = parser.parse_args(argv)
272
+ info = _info_command(args)
273
+ if info is not None:
274
+ return info
275
+ if not args.ontology:
276
+ parser.error("at least one ONTOLOGY path is required")
277
+ stage = ["configuration"]
278
+ try:
279
+ outcome = _lint(args, stage)
280
+ except (UnknownSelectorError, SelectorSyntaxError) as exc:
281
+ proposed = frozenset(row.rule_id for row in planned_rows())
282
+ message = unknown_selector_help(exc, proposed) if isinstance(exc, UnknownSelectorError) else str(exc)
283
+ print(f"TOOL001: configuration failed: {type(exc).__name__}: {message}", file=sys.stderr)
284
+ return 2
285
+ except Exception as exc:
286
+ # Parser/configuration errors are tool failures, not SHACL conformance results.
287
+ code = "INPUT001" if stage[0] == "RDF input parsing" else "TOOL001"
288
+ print(f"{code}: {stage[0]} failed: {type(exc).__name__}: {exc}", file=sys.stderr)
289
+ return 2
290
+ if isinstance(outcome, int):
291
+ return outcome
292
+ return _finish(outcome, args)
293
+
294
+
295
+ def _finish(report: RunReport, args: argparse.Namespace) -> int:
296
+ profile = _profile(args)
297
+ fail_on = args.fail_on or (profile.fail_on if profile else "error")
298
+ code = run_exit_code(report, fail_on)
299
+ _emit(render_report(report, args.format, code), args)
300
+ for path, x in report.broken:
301
+ print(f"TOOL001: rule {x.rule_id} {x.status.value} for {path}: {x.detail}", file=sys.stderr)
302
+ return code