Repository: linkml/linkml

Issue:
Rename schema_fixer to linter and add additional checks and repairs
We have a class SchemaFixer:

https://github.com/linkml/linkml/blob/596bedfb0b5a3abd611fa627aaff04c3ae19510b/linkml/utils/schema_fixer.py#L20-L23

This should be renamed to something like Linter

We may want to move it out of utils, as utils is becoming a big grab-bag

Some additional functionality:

- report on missing descriptions
- naming conventions and style checks
     - CamelCase for classes
     - snake_case for slots
     - CamelCase for enums
     - ALL_CAPS or snake_case for PVs

We may want to have a mechanism to suppress this where deviation is intentional, e.g.

```
name: biolink
annotations:
   schema.style: TitlesAsNames
```

Not everything can be repaired by the linter - e.g missing descriptions. However, schema-automator can suggest missing descriptions (by looking up entities with the same IRI). So there is an argument for having the linter live in SA, but I think the downsides outweigh this

Discussion:

Patch:
diff --git a/Makefile b/Makefile
index 41d7568d..89481bd1 100644
--- a/Makefile
+++ b/Makefile
@@ -49,6 +49,9 @@ examples/%-data.nt: examples/%-data.jsonld
 linkml/workspaces/datamodel/workspaces.py: linkml/workspaces/datamodel/workspaces.yaml
 	$(RUN) gen-python $< > $@.tmp && mv $@.tmp $@
 
+linkml/linter/config/datamodel/config.py: linkml/linter/config/datamodel/config.yaml
+	$(RUN) gen-python $< > $@.tmp && mv $@.tmp $@
+
 TUTORIALS = 01 02 03 04 05 06 07 08 09 10
 test-tutorials: $(patsubst %, test-tutorial-%, $(TUTORIALS))
 test-tutorial-%: sphinx/intro/tutorial%.md
diff --git a/linkml/linter/__init__.py b/linkml/linter/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/linkml/linter/cli.py b/linkml/linter/cli.py
new file mode 100644
index 00000000..2d8408f0
--- /dev/null
+++ b/linkml/linter/cli.py
@@ -0,0 +1,86 @@
+import os
+from pathlib import Path
+from typing import Iterable
+
+import click
+import yaml
+
+from .formatters import (JsonFormatter, MarkdownFormatter, TerminalFormatter,
+                         TsvFormatter)
+from .linter import Linter
+
+YAML_SUFFIXES = [".yml", ".yaml"]
+DEFAULT_CONFIG_FILES = [".linkmllint.yaml", ".linkmllint.yml"]
+
+
+def get_yaml_files(root: Path) -> Iterable[str]:
+    if root.is_file():
+        if root.suffix not in YAML_SUFFIXES:
+            raise click.UsageError("SCHEMA must be a YAML file")
+        yield str(root)
+    else:
+        for dir, _, files in os.walk(root):
+            for file in files:
+                if file in DEFAULT_CONFIG_FILES:
+                    continue
+                path = Path(dir, file)
+                if path.suffix in YAML_SUFFIXES:
+                    yield str(path)
+
+
+@click.command()
+@click.argument(
+    "schema",
+    type=click.Path(
+        exists=True, dir_okay=True, file_okay=True, resolve_path=True, path_type=Path
+    ),
+)
+@click.option(
+    "-c", "--config", type=click.Path(exists=True, dir_okay=False, resolve_path=True)
+)
+@click.option(
+    "-f",
+    "--format",
+    type=click.Choice(["terminal", "markdown", "json", "tsv"]),
+    default="terminal",
+)
+@click.option("-o", "--output", type=click.File("w"), default="-")
+@click.option("--fix/--no-fix", default=False)
+def main(schema: Path, fix: bool, config: str, format: str, output):
+    if config:
+        config_file = config
+    else:
+        for default_config in DEFAULT_CONFIG_FILES:
+            path = Path(os.getcwd()) / default_config
+            if path.exists():
+                config_file = path
+                break
+
+    if config_file:
+        with open(config_file) as f:
+            config_dict = yaml.safe_load(f)
+    else:
+        config_dict = {}
+
+    linter = Linter(config_dict)
+    if format == "terminal":
+        formatter = TerminalFormatter(output)
+    elif format == "markdown":
+        formatter = MarkdownFormatter(output)
+    elif format == "json":
+        formatter = JsonFormatter(output)
+    elif format == "tsv":
+        formatter = TsvFormatter(output)
+
+    formatter.start_report()
+    for path in get_yaml_files(schema):
+        formatter.start_schema(path)
+        report = linter.lint(path, fix=fix)
+        for problem in report:
+            formatter.handle_problem(problem)
+        formatter.end_schema()
+    formatter.end_report()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/linkml/linter/config/datamodel/.linkmllint.yaml b/linkml/linter/config/datamodel/.linkmllint.yaml
new file mode 100644
index 00000000..7155c852
--- /dev/null
+++ b/linkml/linter/config/datamodel/.linkmllint.yaml
@@ -0,0 +1,10 @@
+# linkml-lint configuration to check the linkml-link configuration definition schema
+recommended: 
+  level: error
+permissible_values_format:
+  level: error
+  format: snake
+tree_root_class:
+  level: error
+  root_class_name: Config
+  validate_existing_class_name: false
diff --git a/linkml/linter/config/datamodel/__init__.py b/linkml/linter/config/datamodel/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/linkml/linter/config/datamodel/config.py b/linkml/linter/config/datamodel/config.py
new file mode 100644
index 00000000..4aabda0a
--- /dev/null
+++ b/linkml/linter/config/datamodel/config.py
@@ -0,0 +1,404 @@
+# Auto generated from config.yaml by pythongen.py version: 0.9.0
+# Generation date: 2022-08-25T11:43:24
+# Schema: linter-config
+#
+# id: https://w3id.org/linkml/linter/config
+# description: A datamodel describing the configuration file accepted by the linkml-lint command
+# license: https://creativecommons.org/publicdomain/zero/1.0/
+
+import dataclasses
+import re
+import sys
+from dataclasses import dataclass
+from typing import Any, ClassVar, Dict, List, Optional, Union
+
+from jsonasobj2 import JsonObj, as_dict
+from linkml_runtime.linkml_model.meta import (EnumDefinition, PermissibleValue,
+                                              PvFormulaOptions)
+from linkml_runtime.linkml_model.types import Boolean, String
+from linkml_runtime.utils.curienamespace import CurieNamespace
+from linkml_runtime.utils.dataclass_extensions_376 import \
+    dataclasses_init_fn_with_kwargs
+from linkml_runtime.utils.enumerations import EnumDefinitionImpl
+from linkml_runtime.utils.formatutils import camelcase, sfx, underscore
+from linkml_runtime.utils.metamodelcore import (Bool, bnode, empty_dict,
+                                                empty_list)
+from linkml_runtime.utils.slot import Slot
+from linkml_runtime.utils.yamlutils import (YAMLRoot, extended_float,
+                                            extended_int, extended_str)
+from rdflib import Namespace, URIRef
+
+metamodel_version = "1.7.0"
+version = None
+
+# Overwrite dataclasses _init_fn to add **kwargs in __init__
+dataclasses._init_fn = dataclasses_init_fn_with_kwargs
+
+# Namespaces
+LINKML = CurieNamespace("linkml", "https://w3id.org/linkml/")
+LINTCFG = CurieNamespace("lintcfg", "https://w3id.org/linkml/linter/config")
+DEFAULT_ = LINTCFG
+
+
+# Types
+
+# Class references
+
+
+@dataclass
+class Config(YAMLRoot):
+    """
+    This is the top-level representation of a LinkML linter configuration file. Each attribute represents a rule that
+    can be enabled and possibly configured by a configuration file.
+    """
+
+    _inherited_slots: ClassVar[List[str]] = []
+
+    class_class_uri: ClassVar[URIRef] = LINTCFG.Config
+    class_class_curie: ClassVar[str] = "lintcfg:Config"
+    class_name: ClassVar[str] = "Config"
+    class_model_uri: ClassVar[URIRef] = LINTCFG.Config
+
+    no_empty_title: Optional[Union[dict, "RuleConfig"]] = None
+    permissible_values_format: Optional[
+        Union[dict, "PermissibleValuesFormatRuleConfig"]
+    ] = None
+    tree_root_class: Optional[Union[dict, "TreeRootClassRuleConfig"]] = None
+    recommended: Optional[Union[dict, "RecommendedRuleConfig"]] = None
+    no_xsd_int_type: Optional[Union[dict, "RuleConfig"]] = None
+    no_invalid_slot_usage: Optional[Union[dict, "RuleConfig"]] = None
+    standard_naming: Optional[Union[dict, "StandardNamingConfig"]] = None
+
+    def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]):
+        if self.no_empty_title is not None and not isinstance(
+            self.no_empty_title, RuleConfig
+        ):
+            self.no_empty_title = RuleConfig(**as_dict(self.no_empty_title))
+
+        if self.permissible_values_format is not None and not isinstance(
+            self.permissible_values_format, PermissibleValuesFormatRuleConfig
+        ):
+            self.permissible_values_format = PermissibleValuesFormatRuleConfig(
+                **as_dict(self.permissible_values_format)
+            )
+
+        if self.tree_root_class is not None and not isinstance(
+            self.tree_root_class, TreeRootClassRuleConfig
+        ):
+            self.tree_root_class = TreeRootClassRuleConfig(
+                **as_dict(self.tree_root_class)
+            )
+
+        if self.recommended is not None and not isinstance(
+            self.recommended, RecommendedRuleConfig
+        ):
+            self.recommended = RecommendedRuleConfig(**as_dict(self.recommended))
+
+        if self.no_xsd_int_type is not None and not isinstance(
+            self.no_xsd_int_type, RuleConfig
+        ):
+            self.no_xsd_int_type = RuleConfig(**as_dict(self.no_xsd_int_type))
+
+        if self.no_invalid_slot_usage is not None and not isinstance(
+            self.no_invalid_slot_usage, RuleConfig
+        ):
+            self.no_invalid_slot_usage = RuleConfig(
+                **as_dict(self.no_invalid_slot_usage)
+            )
+
+        if self.standard_naming is not None and not isinstance(
+            self.standard_naming, StandardNamingConfig
+        ):
+            self.standard_naming = StandardNamingConfig(**as_dict(self.standard_naming))
+
+        super().__post_init__(**kwargs)
+
+
+@dataclass
+class RuleConfig(YAMLRoot):
+    """
+    This is the base class for linter rules. It contains configuration options that are  common to all rules.
+    """
+
+    _inherited_slots: ClassVar[List[str]] = []
+
+    class_class_uri: ClassVar[URIRef] = LINTCFG.RuleConfig
+    class_class_curie: ClassVar[str] = "lintcfg:RuleConfig"
+    class_name: ClassVar[str] = "RuleConfig"
+    class_model_uri: ClassVar[URIRef] = LINTCFG.RuleConfig
+
+    level: Union[str, "RuleLevel"] = None
+
+    def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]):
+        if self._is_empty(self.level):
+            self.MissingRequiredField("level")
+        if not isinstance(self.level, RuleLevel):
+            self.level = RuleLevel(self.level)
+
+        super().__post_init__(**kwargs)
+
+
+@dataclass
+class PermissibleValuesFormatRuleConfig(RuleConfig):
+    """
+    Additional configuration options for the `permissible_values_format` rule
+    """
+
+    _inherited_slots: ClassVar[List[str]] = []
+
+    class_class_uri: ClassVar[URIRef] = LINTCFG.PermissibleValuesFormatRuleConfig
+    class_class_curie: ClassVar[str] = "lintcfg:PermissibleValuesFormatRuleConfig"
+    class_name: ClassVar[str] = "PermissibleValuesFormatRuleConfig"
+    class_model_uri: ClassVar[URIRef] = LINTCFG.PermissibleValuesFormatRuleConfig
+
+    level: Union[str, "RuleLevel"] = None
+    format: Optional[str] = None
+
+    def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]):
+        if self.format is not None and not isinstance(self.format, str):
+            self.format = str(self.format)
+
+        super().__post_init__(**kwargs)
+
+
+@dataclass
+class TreeRootClassRuleConfig(RuleConfig):
+    """
+    Additional configuration options for the `tree_root_class` rule
+    """
+
+    _inherited_slots: ClassVar[List[str]] = []
+
+    class_class_uri: ClassVar[URIRef] = LINTCFG.TreeRootClassRuleConfig
+    class_class_curie: ClassVar[str] = "lintcfg:TreeRootClassRuleConfig"
+    class_name: ClassVar[str] = "TreeRootClassRuleConfig"
+    class_model_uri: ClassVar[URIRef] = LINTCFG.TreeRootClassRuleConfig
+
+    level: Union[str, "RuleLevel"] = None
+    root_class_name: Optional[str] = None
+    validate_existing_class_name: Optional[Union[bool, Bool]] = None
+
+    def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]):
+        if self.root_class_name is not None and not isinstance(
+            self.root_class_name, str
+        ):
+            self.root_class_name = str(self.root_class_name)
+
+        if self.validate_existing_class_name is not None and not isinstance(
+            self.validate_existing_class_name, Bool
+        ):
+            self.validate_existing_class_name = Bool(self.validate_existing_class_name)
+
+        super().__post_init__(**kwargs)
+
+
+@dataclass
+class RecommendedRuleConfig(RuleConfig):
+    """
+    Additional configuration options for the `recommended` rule
+    """
+
+    _inherited_slots: ClassVar[List[str]] = []
+
+    class_class_uri: ClassVar[URIRef] = LINTCFG.RecommendedRuleConfig
+    class_class_curie: ClassVar[str] = "lintcfg:RecommendedRuleConfig"
+    class_name: ClassVar[str] = "RecommendedRuleConfig"
+    class_model_uri: ClassVar[URIRef] = LINTCFG.RecommendedRuleConfig
+
+    level: Union[str, "RuleLevel"] = None
+    include: Optional[Union[str, List[str]]] = empty_list()
+    exclude: Optional[Union[str, List[str]]] = empty_list()
+
+    def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]):
+        if not isinstance(self.include, list):
+            self.include = [self.include] if self.include is not None else []
+        self.include = [v if isinstance(v, str) else str(v) for v in self.include]
+
+        if not isinstance(self.exclude, list):
+            self.exclude = [self.exclude] if self.exclude is not None else []
+        self.exclude = [v if isinstance(v, str) else str(v) for v in self.exclude]
+
+        super().__post_init__(**kwargs)
+
+
+@dataclass
+class StandardNamingConfig(RuleConfig):
+    """
+    Additional configuration options for the `standard_naming` rule
+    """
+
+    _inherited_slots: ClassVar[List[str]] = []
+
+    class_class_uri: ClassVar[URIRef] = LINTCFG.StandardNamingConfig
+    class_class_curie: ClassVar[str] = "lintcfg:StandardNamingConfig"
+    class_name: ClassVar[str] = "StandardNamingConfig"
+    class_model_uri: ClassVar[URIRef] = LINTCFG.StandardNamingConfig
+
+    level: Union[str, "RuleLevel"] = None
+    permissible_values_upper_case: Optional[Union[bool, Bool]] = None
+
+    def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]):
+        if self.permissible_values_upper_case is not None and not isinstance(
+            self.permissible_values_upper_case, Bool
+        ):
+            self.permissible_values_upper_case = Bool(
+                self.permissible_values_upper_case
+            )
+
+        super().__post_init__(**kwargs)
+
+
+# Enumerations
+class RuleLevel(EnumDefinitionImpl):
+    """
+    The permissible values for the `level` option of all rules
+    """
+
+    disabled = PermissibleValue(
+        text="disabled", description="The rule will not be checked"
+    )
+    warning = PermissibleValue(
+        text="warning",
+        description="A violation of a rule at this level is a minor issue that should be fixed",
+    )
+    error = PermissibleValue(
+        text="error",
+        description="A violation of a rule at this level is a major issue that must be fixed",
+    )
+
+    _defn = EnumDefinition(
+        name="RuleLevel",
+        description="The permissible values for the `level` option of all rules",
+    )
+
+
+# Slots
+class slots:
+    pass
+
+
+slots.config__no_empty_title = Slot(
+    uri=LINTCFG.no_empty_title,
+    name="config__no_empty_title",
+    curie=LINTCFG.curie("no_empty_title"),
+    model_uri=LINTCFG.config__no_empty_title,
+    domain=None,
+    range=Optional[Union[dict, RuleConfig]],
+)
+
+slots.config__permissible_values_format = Slot(
+    uri=LINTCFG.permissible_values_format,
+    name="config__permissible_values_format",
+    curie=LINTCFG.curie("permissible_values_format"),
+    model_uri=LINTCFG.config__permissible_values_format,
+    domain=None,
+    range=Optional[Union[dict, PermissibleValuesFormatRuleConfig]],
+)
+
+slots.config__tree_root_class = Slot(
+    uri=LINTCFG.tree_root_class,
+    name="config__tree_root_class",
+    curie=LINTCFG.curie("tree_root_class"),
+    model_uri=LINTCFG.config__tree_root_class,
+    domain=None,
+    range=Optional[Union[dict, TreeRootClassRuleConfig]],
+)
+
+slots.config__recommended = Slot(
+    uri=LINTCFG.recommended,
+    name="config__recommended",
+    curie=LINTCFG.curie("recommended"),
+    model_uri=LINTCFG.config__recommended,
+    domain=None,
+    range=Optional[Union[dict, RecommendedRuleConfig]],
+)
+
+slots.config__no_xsd_int_type = Slot(
+    uri=LINTCFG.no_xsd_int_type,
+    name="config__no_xsd_int_type",
+    curie=LINTCFG.curie("no_xsd_int_type"),
+    model_uri=LINTCFG.config__no_xsd_int_type,
+    domain=None,
+    range=Optional[Union[dict, RuleConfig]],
+)
+
+slots.config__no_invalid_slot_usage = Slot(
+    uri=LINTCFG.no_invalid_slot_usage,
+    name="config__no_invalid_slot_usage",
+    curie=LINTCFG.curie("no_invalid_slot_usage"),
+    model_uri=LINTCFG.config__no_invalid_slot_usage,
+    domain=None,
+    range=Optional[Union[dict, RuleConfig]],
+)
+
+slots.config__standard_naming = Slot(
+    uri=LINTCFG.standard_naming,
+    name="config__standard_naming",
+    curie=LINTCFG.curie("standard_naming"),
+    model_uri=LINTCFG.config__standard_naming,
+    domain=None,
+    range=Optional[Union[dict, StandardNamingConfig]],
+)
+
+slots.ruleConfig__level = Slot(
+    uri=LINTCFG.level,
+    name="ruleConfig__level",
+    curie=LINTCFG.curie("level"),
+    model_uri=LINTCFG.ruleConfig__level,
+    domain=None,
+    range=Union[str, "RuleLevel"],
+)
+
+slots.permissibleValuesFormatRuleConfig__format = Slot(
+    uri=LINTCFG.format,
+    name="permissibleValuesFormatRuleConfig__format",
+    curie=LINTCFG.curie("format"),
+    model_uri=LINTCFG.permissibleValuesFormatRuleConfig__format,
+    domain=None,
+    range=Optional[str],
+)
+
+slots.treeRootClassRuleConfig__root_class_name = Slot(
+    uri=LINTCFG.root_class_name,
+    name="treeRootClassRuleConfig__root_class_name",
+    curie=LINTCFG.curie("root_class_name"),
+    model_uri=LINTCFG.treeRootClassRuleConfig__root_class_name,
+    domain=None,
+    range=Optional[str],
+)
+
+slots.treeRootClassRuleConfig__validate_existing_class_name = Slot(
+    uri=LINTCFG.validate_existing_class_name,
+    name="treeRootClassRuleConfig__validate_existing_class_name",
+    curie=LINTCFG.curie("validate_existing_class_name"),
+    model_uri=LINTCFG.treeRootClassRuleConfig__validate_existing_class_name,
+    domain=None,
+    range=Optional[Union[bool, Bool]],
+)
+
+slots.recommendedRuleConfig__include = Slot(
+    uri=LINTCFG.include,
+    name="recommendedRuleConfig__include",
+    curie=LINTCFG.curie("include"),
+    model_uri=LINTCFG.recommendedRuleConfig__include,
+    domain=None,
+    range=Optional[Union[str, List[str]]],
+)
+
+slots.recommendedRuleConfig__exclude = Slot(
+    uri=LINTCFG.exclude,
+    name="recommendedRuleConfig__exclude",
+    curie=LINTCFG.curie("exclude"),
+    model_uri=LINTCFG.recommendedRuleConfig__exclude,
+    domain=None,
+    range=Optional[Union[str, List[str]]],
+)
+
+slots.standardNamingConfig__permissible_values_upper_case = Slot(
+    uri=LINTCFG.permissible_values_upper_case,
+    name="standardNamingConfig__permissible_values_upper_case",
+    curie=LINTCFG.curie("permissible_values_upper_case"),
+    model_uri=LINTCFG.standardNamingConfig__permissible_values_upper_case,
+    domain=None,
+    range=Optional[Union[bool, Bool]],
+)
diff --git a/linkml/linter/config/datamodel/config.yaml b/linkml/linter/config/datamodel/config.yaml
new file mode 100644
index 00000000..2d4d53f1
--- /dev/null
+++ b/linkml/linter/config/datamodel/config.yaml
@@ -0,0 +1,149 @@
+# yaml-language-server: $schema=https://w3id.org/linkml/meta.schema.json
+id: https://w3id.org/linkml/linter/config
+name: linter-config
+title: LinkML Linter Configuration Datamodel
+description: >-
+  A datamodel describing the configuration file accepted by the linkml-lint command
+license: https://creativecommons.org/publicdomain/zero/1.0/
+
+prefixes:
+  linkml: https://w3id.org/linkml/
+  lintcfg: https://w3id.org/linkml/linter/config
+
+default_prefix: lintcfg
+default_range: string
+
+imports:
+  - linkml:types
+
+classes:
+  Config:
+    tree_root: true
+    description: >-
+      This is the top-level representation of a LinkML linter configuration file. Each
+      attribute represents a rule that can be enabled and possibly configured by a 
+      configuration file.
+    attributes:
+      no_empty_title:
+        range: RuleConfig
+        description: >-
+          Disallow empty titles on schema elements. Autofix will transform the element's
+          name into a title.
+      permissible_values_format:
+        range: PermissibleValuesFormatRuleConfig
+        description: >-
+          Enforce consistent formatting of enum permissible values. This rule may conflict
+          with the standard_naming rule. Not auto-fixable.
+      tree_root_class:
+        range: TreeRootClassRuleConfig
+        description: >-
+          Require a single class with `tree_root: true` and optionally verify that class's 
+          name. Autofix will create a new class with `tree_root: true`, a name based on
+          the rule configuration, and slots for existing classes.
+      recommended:
+        range: RecommendedRuleConfig
+        description: >-
+          Require any slot in the metamodel with `recommended: true` (e.g. description)
+          to be provided. Not auto-fixable.
+      no_xsd_int_type:
+        range: RuleConfig
+        description: >-
+          Disallow use of `uri: xsd:int` in type definitions. Autofix will change the 
+          uri to xsd:integer.
+      no_invalid_slot_usage:
+        range: RuleConfig
+        description: >-
+          Disallow slot_usage definitions where the name of the slot does not refer
+          to an existing slot. Not auto-fixable.
+      standard_naming:
+        range: StandardNamingConfig
+        description: >-
+          Enforce standard naming conventions: CamelCase for classes, snake_case for slots,
+          CamelCase for enums, snake_case (default) or UPPER_SNAKE for permissible_values 
+          (see permissible_values_upper_case option). This rule may conflict with the
+          permissible_values_format rule. Not auto-fixable.
+
+  RuleConfig:
+    description: >-
+      This is the base class for linter rules. It contains configuration options that are 
+      common to all rules.
+    attributes:
+      level:
+        description: >-
+          Default: `disabled`
+          To enable a rule set the level to `error` or `warning`. If set to `disabled`, the
+          rule will not be checked.
+        range: RuleLevel
+        required: true
+
+  PermissibleValuesFormatRuleConfig:
+    is_a: RuleConfig
+    description: >-
+      Additional configuration options for the `permissible_values_format` rule
+    attributes:
+      format:
+        range: string
+        description: >-
+          Default: "uppersnake"
+          Special values "snake", "uppersnake", "camel", and "kebab" will be recognized,
+          otherwise the value will be interpreted as a regular expression.
+
+  TreeRootClassRuleConfig:
+    is_a: RuleConfig
+    description: >-
+      Additional configuration options for the `tree_root_class` rule
+    attributes:
+      root_class_name:
+        range: string
+        description: >-
+          Default: Container
+          If a class definition with `tree_root: true` is not found and autofix is enabled,
+          this string will be used as the name of the class added to the schema.
+      validate_existing_class_name:
+        range: boolean
+        description: >-
+          Default: false
+          If true, in addition to validating that a `tree_root: true` ClassDefinition exists,
+          the rule will also validate that is has the name provided by the `class_name` option.
+
+  RecommendedRuleConfig:
+    description: >-
+      Additional configuration options for the `recommended` rule
+    is_a: RuleConfig
+    attributes:
+      include:
+        range: string
+        multivalued: true
+        description: >-
+          Default: []
+          If specified, only elements with the provided names will be checked
+      exclude:
+        range: string
+        multivalued: true
+        description: >-
+          Default: []
+          All elements except the ones with names specified in this list will be checked 
+        
+  StandardNamingConfig:
+    description: >-
+      Additional configuration options for the `standard_naming` rule
+    is_a: RuleConfig
+    attributes:
+      permissible_values_upper_case:
+        range: boolean
+        description: >-
+          Default: false
+          If true, permissible values will be checked for snake_case, otherwise UPPER_SNAKE
+
+
+enums:
+  RuleLevel:
+    description: >-
+      The permissible values for the `level` option of all rules
+    permissible_values:
+      disabled:
+        description: The rule will not be checked
+      warning:
+        description: A violation of a rule at this level is a minor issue that should be fixed
+      error:
+        description: A violation of a rule at this level is a major issue that must be fixed
diff --git a/linkml/linter/config/default.yaml b/linkml/linter/config/default.yaml
new file mode 100644
index 00000000..68f2288e
--- /dev/null
+++ b/linkml/linter/config/default.yaml
@@ -0,0 +1,20 @@
+no_empty_title:
+  level: disabled
+permissible_values_format:
+  level: disabled
+  format: uppersnake
+tree_root_class:
+  level: disabled
+  root_class_name: Container
+  validate_existing_class_name: false
+recommended:
+  level: disabled
+  include: []
+  exclude: []
+no_xsd_int_type:
+  level: disabled
+no_invalid_slot_usage:
+  level: disabled
+standard_naming:
+  level: disabled
+  permissible_values_upper_case: false
diff --git a/linkml/linter/formatters/__init__.py b/linkml/linter/formatters/__init__.py
new file mode 100644
index 00000000..11d954e7
--- /dev/null
+++ b/linkml/linter/formatters/__init__.py
@@ -0,0 +1,6 @@
+from .json_formatter import JsonFormatter
+from .markdown_formatter import MarkdownFormatter
+from .terminal_formatter import TerminalFormatter
+from .tsv_formatter import TsvFormatter
+
+__all__ = ["JsonFormatter", "MarkdownFormatter", "TerminalFormatter", "TsvFormatter"]
diff --git a/linkml/linter/formatters/formatter.py b/linkml/linter/formatters/formatter.py
new file mode 100644
index 00000000..110f2d8b
--- /dev/null
+++ b/linkml/linter/formatters/formatter.py
@@ -0,0 +1,28 @@
+from typing import IO, Any, Optional
+
+import click
+
+from ..linter import LinterProblem
+
+
+class Formatter:
+    def __init__(self, file: Optional[IO[Any]] = None) -> None:
+        self.file = file
+
+    def write(self, message: str):
+        click.echo(message, file=self.file)
+
+    def start_report(self):
+        pass
+
+    def start_schema(self, name: str):
+        pass
+
+    def handle_problem(self, problem: LinterProblem):
+        pass
+
+    def end_schema(self):
+        pass
+
+    def end_report(self):
+        pass
diff --git a/linkml/linter/formatters/json_formatter.py b/linkml/linter/formatters/json_formatter.py
new file mode 100644
index 00000000..5fb80ba8
--- /dev/null
+++ b/linkml/linter/formatters/json_formatter.py
@@ -0,0 +1,27 @@
+import json
+from typing import IO, Any, Optional
+
+from ..config.datamodel.config import RuleLevel
+from ..linter import LinterProblem
+from .formatter import Formatter
+
+
+class Encoder(json.JSONEncoder):
+    def default(self, o: Any) -> Any:
+        if isinstance(o, LinterProblem):
+            return o.__dict__
+        if isinstance(o, RuleLevel):
+            return str(o)
+        return json.JSONEncoder.default(self, o)
+
+
+class JsonFormatter(Formatter):
+    def __init__(self, file: Optional[IO[Any]] = None) -> None:
+        super().__init__(file)
+        self.problems = []
+
+    def handle_problem(self, problem: LinterProblem):
+        self.problems.append(problem)
+
+    def end_report(self):
+        self.write(json.dumps(self.problems, indent=2, cls=Encoder))
diff --git a/linkml/linter/formatters/markdown_formatter.py b/linkml/linter/formatters/markdown_formatter.py
new file mode 100644
index 00000000..cb8eca7c
--- /dev/null
+++ b/linkml/linter/formatters/markdown_formatter.py
@@ -0,0 +1,80 @@
+from collections import defaultdict
+from typing import IO, Any, List, Optional
+
+from linkml.linter.config.datamodel.config import RuleLevel
+from linkml.linter.linter import LinterProblem
+
+from .formatter import Formatter
+
+
+class MarkdownFormatter(Formatter):
+    def __init__(self, file: Optional[IO[Any]] = None) -> None:
+        super().__init__(file)
+        self.total = 0
+        self.total_errors = 0
+        self.total_warnings = 0
+        self.schemas_with_error = set()
+        self.schemas_with_warning = set()
+        self.problems = defaultdict(list)
+
+    def start_schema(self, name: str):
+        self.total += 1
+        self.current_schema = name
+
+    def handle_problem(self, problem: LinterProblem):
+        if str(problem.level) == RuleLevel.warning.text:
+            self.total_warnings += 1
+            self.schemas_with_warning.add(self.current_schema)
+        elif str(problem.level) == RuleLevel.error.text:
+            self.total_errors += 1
+            self.schemas_with_error.add(self.current_schema)
+        self.problems[self.current_schema].append(problem)
+
+    def write_summary(self):
+        self.write(
+            f"""
+## Summary
+
+|                      | Count |
+|----------------------|-------|
+| Schemas Checked      | {self.total} |
+| Schemas with Error   | {len(self.schemas_with_error)} |
+| Schemas with Warning | {len(self.schemas_with_warning)} |
+| Total Errors         | {self.total_errors} |
+| Total Warnings       | {self.total_warnings} |
+
+"""
+        )
+
+    def write_details(self):
+        if len(self.problems):
+            self.write("## Problems per Schema\n")
+            for name, problems in self.problems.items():
+                self.write(f"### {name}")
+                self.write_schema_problems(problems)
+
+    def write_schema_problems(self, problems: List[LinterProblem]):
+        errors = [p for p in problems if str(p.level) == RuleLevel.error.text]
+        warnings = [p for p in problems if str(p.level) == RuleLevel.warning.text]
+        if errors:
+            self.write("#### Errors")
+            for error in errors:
+                self.write_problem(error)
+
+        if warnings:
+            self.write("#### Warnings")
+            for warning in warnings:
+                self.write_problem(warning)
+
+        self.write("")
+
+    def write_problem(self, problem: LinterProblem):
+        formatted = "* "
+        if problem.rule_name:
+            formatted += problem.rule_name + ": "
+        formatted += problem.message
+        self.write(formatted)
+
+    def end_report(self):
+        self.write_summary()
+        self.write_details()
diff --git a/linkml/linter/formatters/terminal_formatter.py b/linkml/linter/formatters/terminal_formatter.py
new file mode 100644
index 00000000..4dac4ebf
--- /dev/null
+++ b/linkml/linter/formatters/terminal_formatter.py
@@ -0,0 +1,59 @@
+from collections import defaultdict
+from typing import IO, Any, Optional
+
+import click
+
+from linkml.linter.config.datamodel.config import RuleLevel
+
+from ..linter import LinterProblem
+from .formatter import Formatter
+
+
+def plural(word: str, count: int):
+    return word + ("s" if count != 1 else "")
+
+
+class TerminalFormatter(Formatter):
+    def __init__(self, file: Optional[IO[Any]] = None) -> None:
+        super().__init__(file)
+        self.problem_counts = defaultdict(int)
+        self.current_schema = None
+
+    def start_schema(self, name: str):
+        self.current_schema = name
+
+    def handle_problem(self, problem: LinterProblem):
+        key = self.current_schema
+        if key not in self.problem_counts:
+            self.write(click.style(key, underline=True))
+
+        self.problem_counts[key] += 1
+
+        formatted = click.style(
+            "  " + str(problem.level).ljust(9),
+            fg="yellow" if str(problem.level) is RuleLevel.warning.text else "red",
+        )
+        formatted += problem.message
+        if problem.rule_name:
+            formatted += "  " + click.style(f"({problem.rule_name})", dim=True)
+        self.write(formatted)
+
+    def end_schema(self):
+        if self.current_schema in self.problem_counts:
+            self.write("")
+
+    def end_report(self):
+        total_problems = sum(self.problem_counts.values())
+        if total_problems > 0:
+            problem_schemas = len(self.problem_counts)
+            self.write(
+                click.style("\u2716", fg="red")
+                + " Found "
+                + str(total_problems)
+                + " "
+                + plural("problem", total_problems)
+                + " in "
+                + str(problem_schemas)
+                + " "
+                + plural("schema", problem_schemas)
+            )
diff --git a/linkml/linter/formatters/tsv_formatter.py b/linkml/linter/formatters/tsv_formatter.py
new file mode 100644
index 00000000..eaca6ad3
--- /dev/null
+++ b/linkml/linter/formatters/tsv_formatter.py
@@ -0,0 +1,30 @@
+import csv
+import io
+from typing import IO, Any, Optional
+
+from ..linter import LinterProblem
+from .formatter import Formatter
+
+
+class TsvFormatter(Formatter):
+    def __init__(self, file: Optional[IO[Any]] = None) -> None:
+        super().__init__(file)
+        self.output = io.StringIO()
+        self.writer = csv.writer(self.output, delimiter="\t", quoting=csv.QUOTE_MINIMAL)
+
+    def start_report(self):
+        self.writer.writerow(["source", "schema name", "rule name", "level", "message"])
+
+    def handle_problem(self, problem: LinterProblem):
+        self.writer.writerow(
+            [
+                problem.schema_source,
+                problem.schema_name,
+                problem.rule_name,
+                str(problem.level),
+                problem.message,
+            ]
+        )
+
+    def end_report(self):
+        self.write(self.output.getvalue())
diff --git a/linkml/linter/linter.py b/linkml/linter/linter.py
new file mode 100644
index 00000000..d388f1a8
--- /dev/null
+++ b/linkml/linter/linter.py
@@ -0,0 +1,90 @@
+import inspect
+from copy import deepcopy
+from dataclasses import dataclass
+from functools import lru_cache
+from pathlib import Path
+from typing import Any, Dict, Iterable, Union
+
+import yaml
+from linkml_runtime import SchemaView
+from linkml_runtime.linkml_model import SchemaDefinition
+from linkml_runtime.dumpers import yaml_dumper
+
+from .config.datamodel.config import Config, RuleLevel
+
+
+@dataclass
+class LinterProblem:
+    message: str
+    level: Union[RuleLevel, None] = None
+    schema_name: Union[str, None] = None
+    schema_source: Union[str, None] = None
+    rule_name: Union[str, None] = None
+
+
+@lru_cache
+def get_default_config() -> Dict[str, Any]:
+    config_path = str(Path(__file__).parent / "config/default.yaml")
+    with open(config_path) as config_file:
+        return yaml.safe_load(config_file)
+
+
+def merge_configs(original: dict, other: dict):
+    result = deepcopy(original)
+    for key, value in other.items():
+        if isinstance(value, dict):
+            result[key] = merge_configs(result.get(key, {}), value)
+        else:
+            result[key] = value
+    return result
+
+
+class Linter:
+    def __init__(self, config: Dict[str, Any] = {}) -> None:
+        default_config = deepcopy(get_default_config())
+        merged_config = merge_configs(default_config, config)
+        self.config = Config(**merged_config)
+
+        from . import rules
+
+        self._rules_map = dict(
+            [
+                (cls.id, cls)
+                for _, cls in inspect.getmembers(rules, inspect.isclass)
+                if issubclass(cls, rules.LinterRule)
+            ]
+        )
+
+    def lint(
+        self, schema=Union[str, SchemaDefinition], fix: bool = False
+    ) -> Iterable[LinterProblem]:
+        try:
+            schema_view = SchemaView(schema)
+        except:
+            yield LinterProblem(
+                message="File is not a valid LinkML schema",
+                level=RuleLevel(RuleLevel.error),
+                schema_source=(schema if isinstance(schema, str) else None),
+            )
+            return
+
+        for rule_id, rule_config in self.config.__dict__.items():
+            rule_cls = self._rules_map.get(rule_id, None)
+            if rule_cls is None:
+                raise ValueError("Unknown rule id: " + rule_id)
+
+            if str(rule_config.level) is RuleLevel.disabled.text:
+                continue
+
+            rule = rule_cls(rule_config)
+
+            for problem in rule.check(schema_view, fix=fix):
+                problem.level = rule.config.level
+                problem.rule_name = rule.id
+                problem.schema_name = schema_view.schema.name
+                if isinstance(schema, str):
+                    problem.schema_source = schema
+                yield problem
+
+        if fix and schema_view.schema.source_file:
+            yaml_dumper.dump(schema_view.schema, schema_view.schema.source_file)
diff --git a/linkml/linter/rules.py b/linkml/linter/rules.py
new file mode 100644
index 00000000..f1553fc5
--- /dev/null
+++ b/linkml/linter/rules.py
@@ -0,0 +1,267 @@
+import re
+from abc import ABC, abstractmethod
+from typing import Callable, Iterable, List
+
+from linkml_runtime.linkml_model import (ClassDefinition, ClassDefinitionName,
+                                         SlotDefinition)
+from linkml_runtime.utils.schemaview import SchemaView
+
+from linkml import LOCAL_METAMODEL_YAML_FILE
+
+from .config.datamodel.config import (RecommendedRuleConfig, RuleConfig,
+                                      StandardNamingConfig,
+                                      TreeRootClassRuleConfig)
+from .linter import LinterProblem
+
+
+class LinterRule(ABC):
+
+    PATTERNS = {
+        "snake": re.compile(r"[a-z][_a-z0-9]+"),
+        "uppersnake": re.compile(r"[A-Z][_A-Z0-9]+"),
+        "camel": re.compile(r"[a-z][a-zA-Z0-9]+"),
+        "uppercamel": re.compile(r"[A-Z][a-zA-Z0-9]+"),
+        "kebab": re.compile(r"[a-z][\-a-z0-9]+"),
+        "_uncamel": re.compile(r"(?<!^)(?=[A-Z])"),
+    }
+
+    def __init__(self, config: RuleConfig) -> None:
+        super().__init__()
+        self.config = config
+
+    @property
+    @abstractmethod
+    def id(self) -> str:
+        pass
+
+    @abstractmethod
+    def check(self, schema_view: SchemaView, fix: bool) -> Iterable[LinterProblem]:
+        pass
+
+    @staticmethod
+    def uncamel(n: str) -> str:
+        return LinterRule.PATTERNS._uncamel.sub(" ", n)
+
+
+class NoEmptyTitleRule(LinterRule):
+
+    id = "no_empty_title"
+
+    def check(
+        self, schema_view: SchemaView, fix: bool = False
+    ) -> Iterable[LinterProblem]:
+        for e in schema_view.all_elements(imports=False).values():
+            if fix and e.title is None:
+                title = e.name.replace("_", " ")
+                title = self.uncamel(title).lower()
+                e.title = title
+            if e.title is None:
+                problem = LinterProblem(
+                    message=f"{type(e).__name__} '{e.name}' has no title"
+                )
+                yield problem
+
+
+class NoXsdIntTypeRule(LinterRule):
+
+    id = "no_xsd_int_type"
+
+    def check(self, schema_view: SchemaView, fix: bool = False):
+        for type_name, type_definition in schema_view.all_types(imports=False).items():
+            if type_definition.uri == "xsd:int":
+                if fix:
+                    type_definition.uri = "xsd:integer"
+                else:
+                    yield LinterProblem(f"Type '{type_name}' has uri xsd:int")
+
+
+class PermissibleValuesFormatRule(LinterRule):
+
+    id = "permissible_values_format"
+
+    def check(
+        self, schema_view: SchemaView, fix: bool = False
+    ) -> Iterable[LinterProblem]:
+        pattern = self.PATTERNS.get(self.config.format, re.compile(self.config.format))
+        for enum_name, enum_def in schema_view.all_enums(imports=False).items():
+            for value in enum_def.permissible_values.keys():
+                if pattern.fullmatch(value) is None:
+                    yield LinterProblem(
+                        f"Enum '{enum_name}' has permissible value '{value}'"
+                    )
+
+
+class RecommendedRule(LinterRule):
+
+    id = "recommended"
+
+    def __init__(self, config: RecommendedRuleConfig) -> None:
+        super().__init__(config)
+        meta_schema_view = SchemaView(LOCAL_METAMODEL_YAML_FILE)
+        self.recommended_meta_slots = []
+        for class_name in meta_schema_view.all_classes(imports=False).keys():
+            class_slots = meta_schema_view.class_induced_slots(class_name)
+            for slot in class_slots:
+                if slot.recommended:
+                    self.recommended_meta_slots.append(f"{class_name}__{slot.name}")
+
+    def check(self, schema_view: SchemaView, fix: bool = False):
+        for element_name, element_definition in schema_view.all_elements(
+            imports=False
+        ).items():
+            if self.config.include and element_name not in self.config.include:
+                continue
+            if element_name in self.config.exclude:
+                continue
+            for meta_slot_name, meta_slot_value in vars(element_definition).items():
+                meta_class_name = type(element_definition).class_name
+                key = f"{meta_class_name}__{meta_slot_name}"
+                if key in self.recommended_meta_slots and not meta_slot_value:
+                    yield LinterProblem(
+                        f"{meta_class_name} '{element_name}' does not have recommended slot '{meta_slot_name}'"
+                    )
+
+
+class TreeRootClassRule(LinterRule):
+
+    id = "tree_root_class"
+
+    def __init__(self, config: TreeRootClassRuleConfig) -> None:
+        super().__init__(config)
+
+    def check(
+        self, schema_view: SchemaView, fix: bool = False
+    ) -> Iterable[LinterProblem]:
+        tree_roots = [
+            c for c in schema_view.all_classes(imports=False).values() if c.tree_root
+        ]
+        if len(tree_roots) > 0:
+            if self.config.validate_existing_class_name:
+                for tree_root in tree_roots:
+                    if str(tree_root.name) != self.config.root_class_name:
+                        yield LinterProblem(
+                            message=f"Tree root class has name '{tree_root.name}'"
+                        )
+        else:
+            if fix:
+                container = ClassDefinition(self.config.root_class_name, tree_root=True)
+                schema_view.add_class(container)
+                self.add_index_slots(schema_view, container.name)
+            else:
+                yield LinterProblem("Schema does not have class with `tree_root: true`")
+
+    def add_index_slots(
+        self,
+        schema_view: SchemaView,
+        container_name: ClassDefinitionName,
+        inlined_as_list=False,
+        must_have_identifier=False,
+        slot_name_func: Callable = None,
+        convert_camel_case=False,
+    ) -> List[SlotDefinition]:
+        """
+        Adds index slots to a container pointing at all top-level classes
+
+        :param schema: input schema, will be modified in place
+        :param container_name:
+        :param inlined_as_list:
+        :param must_have_identifier:
+        :param slot_name_func: function to determine the name of the slot from the class
+        :return: new slots
+        """
+        container = schema_view.get_class(container_name)
+        ranges = set()
+        for cn in schema_view.all_classes():
+            for s in schema_view.class_induced_slots(cn):
+                ranges.add(s.range)
+        top_level_classes = [
+            c
+            for c in schema_view.all_classes().values()
+            if not c.tree_root and c.name not in ranges
+        ]
+        if must_have_identifier:
+            top_level_classes = [
+                c
+                for c in top_level_classes
+                if schema_view.get_identifier_slot(c.name) is not None
+            ]
+        index_slots = []
+        for c in top_level_classes:
+            has_identifier = schema_view.get_identifier_slot(c.name)
+            if slot_name_func:
+                sn = slot_name_func(c)
+            else:
+                cn = c.name
+                if convert_camel_case:
+                    cn = self.uncamel(cn).lower()
+                cn = cn.replace(" ", "_")
+                sn = f"{cn}_index"
+            index_slot = SlotDefinition(
+                sn,
+                range=c.name,
+                multivalued=True,
+                inlined_as_list=not has_identifier or inlined_as_list,
+            )
+            index_slots.append(index_slot)
+            schema_view.add_slot(index_slot)
+            container.slots.append(index_slot.name)
+        return index_slots
+
+
+class NoInvalidSlotUsageRule(LinterRule):
+
+    id = "no_invalid_slot_usage"
+
+    def check(
+        self, schema_view: SchemaView, fix: bool = False
+    ) -> Iterable[LinterProblem]:
+        for class_name, class_definition in schema_view.all_classes(
+            imports=False
+        ).items():
+            slot_usage = class_definition.slot_usage
+            if not slot_usage:
+                continue
+            class_slots = schema_view.class_slots(class_name)
+            for slot_usage_name in slot_usage.keys():
+                if slot_usage_name not in class_slots:
+                    yield LinterProblem(
+                        f"Slot '{slot_usage_name}' not found on class '{class_name}'"
+                    )
+
+
+class StandardNamingRule(LinterRule):
+
+    id = "standard_naming"
+
+    def __init__(self, config: StandardNamingConfig) -> None:
+        self.config = config
+
+    def check(
+        self, schema_view: SchemaView, fix: bool = False
+    ) -> Iterable[LinterProblem]:
+        class_pattern = self.PATTERNS["uppercamel"]
+        slot_pattern = self.PATTERNS["snake"]
+        enum_pattern = self.PATTERNS["uppercamel"]
+        permissible_value_pattern = (
+            self.PATTERNS["uppersnake"]
+            if self.config.permissible_values_upper_case
+            else self.PATTERNS["snake"]
+        )
+
+        for class_name in schema_view.all_classes(imports=False).keys():
+            if class_pattern.fullmatch(class_name) is None:
+                yield LinterProblem(f"Class has name '{class_name}'")
+
+        for slot_name in schema_view.all_slots(imports=False).keys():
+            if slot_pattern.fullmatch(slot_name) is None:
+                yield LinterProblem(f"Slot has name '{slot_name}'")
+
+        for enum_name, enum_definition in schema_view.all_enums(imports=False).items():
+            if enum_pattern.fullmatch(enum_name) is None:
+                yield LinterProblem(f"Enum has name '{enum_name}'")
+
+            for permissible_value_name in enum_definition.permissible_values.keys():
+                if permissible_value_pattern.fullmatch(permissible_value_name) is None:
+                    yield LinterProblem(
+                        f"Permissible value of enum '{enum_name}' has name '{permissible_value_name}'"
+                    )
diff --git a/linkml/utils/schema_builder.py b/linkml/utils/schema_builder.py
index 3a9da49c..3b63e8ea 100644
--- a/linkml/utils/schema_builder.py
+++ b/linkml/utils/schema_builder.py
@@ -3,7 +3,8 @@ from typing import Dict, List, Union
 
 from linkml_runtime.linkml_model import (ClassDefinition, EnumDefinition,
                                          PermissibleValue, Prefix,
-                                         SchemaDefinition, SlotDefinition)
+                                         SchemaDefinition, SlotDefinition,
+                                         TypeDefinition)
 from linkml_runtime.utils.formatutils import camelcase, underscore
 
 
@@ -145,3 +146,17 @@ class SchemaBuilder:
         self.add_prefix("linkml", "https://w3id.org/linkml/")
         self.add_prefix(name, f"{uri}/")
         return self
+
+    def add_type(self, type: Union[TypeDefinition, Dict, str]) -> "SchemaBuilder":
+        """
+        Adds the type to the schema
+
+        :param type:
+        :return: builder
+        """
+        if isinstance(type, str):
+            type = TypeDefinition(type)
+        elif isinstance(type, dict):
+            type = TypeDefinition(**type)
+        self.schema.types[type.name] = type
+        return self
diff --git a/pyproject.toml b/pyproject.toml
index 78c2ab93..7c78bd85 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -77,6 +77,7 @@ gen-project = "linkml.generators.projectgen:cli"
 run-tutorial = "linkml.utils.execute_tutorial:cli"
 linkml-ws = "linkml.workspaces.cli:main"
 linkml-convert = "linkml.utils.converter:cli"
+linkml-lint = "linkml.linter.cli:main"
 linkml-sqldb = "linkml.utils.sqlutils:main"
 linkml-validate = "linkml.validators.jsonschemavalidator:cli"
 linkml-jsonschema-validate = "linkml.validators.jsonschemavalidator:cli"


Test patch:
diff --git a/tests/test_linter/__init__.py b/tests/test_linter/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/test_linter/test_formatters.py b/tests/test_linter/test_formatters.py
new file mode 100644
index 00000000..98376a14
--- /dev/null
+++ b/tests/test_linter/test_formatters.py
@@ -0,0 +1,143 @@
+import io
+import unittest
+
+from linkml.linter.config.datamodel.config import RuleLevel
+from linkml.linter.formatters import (JsonFormatter, MarkdownFormatter,
+                                      TerminalFormatter, TsvFormatter)
+from linkml.linter.linter import LinterProblem
+
+
+def populate_report(formatter):
+    formatter.start_report()
+
+    formatter.start_schema("a.yaml")
+    formatter.handle_problem(
+        LinterProblem(
+            message="this is an error",
+            level=RuleLevel(RuleLevel.error),
+            schema_name="a name",
+            schema_source="a.yaml",
+            rule_name="rule_1",
+        )
+    )
+    formatter.handle_problem(
+        LinterProblem(
+            message="this is a warning",
+            level=RuleLevel(RuleLevel.warning),
+            schema_name="a name",
+            schema_source="a.yaml",
+            rule_name="rule_2",
+        )
+    )
+    formatter.end_schema()
+
+    formatter.start_schema("b.yaml")
+    formatter.handle_problem(
+        LinterProblem(
+            message="this is another error",
+            level=RuleLevel(RuleLevel.error),
+            schema_name="b name",
+            schema_source="b.yaml",
+            rule_name="rule_3",
+        )
+    )
+    formatter.end_schema()
+
+    formatter.end_report()
+
+
+class TestFormatters(unittest.TestCase):
+    def test_terminal_formatter(self):
+        output = io.StringIO()
+        formatter = TerminalFormatter(file=output)
+        populate_report(formatter)
+
+        expected = """
+a.yaml
+  error    this is an error  (rule_1)
+  warning  this is a warning  (rule_2)
+
+b.yaml
+  error    this is another error  (rule_3)
+
+✖ Found 3 problems in 2 schemas
+"""
+        self.assertEqual(output.getvalue().strip(), expected.strip())
+
+    def test_markdown_formatter(self):
+        output = io.StringIO()
+        formatter = MarkdownFormatter(file=output)
+        populate_report(formatter)
+
+        expected = """
+## Summary
+
+|                      | Count |
+|----------------------|-------|
+| Schemas Checked      | 2 |
+| Schemas with Error   | 2 |
+| Schemas with Warning | 1 |
+| Total Errors         | 2 |
+| Total Warnings       | 1 |
+
+
+## Problems per Schema
+
+### a.yaml
+#### Errors
+* rule_1: this is an error
+#### Warnings
+* rule_2: this is a warning
+
+### b.yaml
+#### Errors
+* rule_3: this is another error
+"""
+        self.assertEqual(output.getvalue().strip(), expected.strip())
+
+    def test_json_formatter(self):
+        output = io.StringIO()
+        formatter = JsonFormatter(file=output)
+        populate_report(formatter)
+
+        expected = """
+[
+  {
+    "message": "this is an error",
+    "level": "error",
+    "schema_name": "a name",
+    "schema_source": "a.yaml",
+    "rule_name": "rule_1"
+  },
+  {
+    "message": "this is a warning",
+    "level": "warning",
+    "schema_name": "a name",
+    "schema_source": "a.yaml",
+    "rule_name": "rule_2"
+  },
+  {
+    "message": "this is another error",
+    "level": "error",
+    "schema_name": "b name",
+    "schema_source": "b.yaml",
+    "rule_name": "rule_3"
+  }
+]
+"""
+        self.assertEqual(output.getvalue().strip(), expected.strip())
+
+    def test_tsv_formatter(self):
+        output = io.StringIO()
+        formatter = TsvFormatter(file=output)
+        populate_report(formatter)
+
+        expected = """
+source	schema name	rule name	level	message
+a.yaml	a name	rule_1	error	this is an error
+a.yaml	a name	rule_2	warning	this is a warning
+b.yaml	b name	rule_3	error	this is another error
+"""
+        actual = output.getvalue()
+        actual_normalized = actual.strip().replace("\r\n", "\n").replace("\r", "\n")
+        self.assertEqual(actual_normalized, expected.strip())
diff --git a/tests/test_linter/test_linter.py b/tests/test_linter/test_linter.py
new file mode 100644
index 00000000..e1d0a300
--- /dev/null
+++ b/tests/test_linter/test_linter.py
@@ -0,0 +1,87 @@
+import unittest
+
+import yaml
+
+from linkml.linter.config.datamodel.config import RuleLevel
+from linkml.linter.linter import Linter
+from linkml.utils.schema_builder import SchemaBuilder
+
+
+class TestLinter(unittest.TestCase):
+    def test_rule_level_error(self):
+
+        config = yaml.safe_load(
+            """
+no_empty_title:
+  level: error
+"""
+        )
+        builder = SchemaBuilder()
+        builder.add_class("MyClass")
+        builder.add_slot("my slot")
+        builder.add_enum("my_enum")
+
+        linter = Linter(config)
+        report = list(linter.lint(builder.schema))
+
+        messages = [p.message for p in report]
+        levels = {str(p.level) for p in report}
+        rule_names = {p.rule_name for p in report}
+
+        self.assertEqual(len(messages), 3)
+        self.assertTrue(any("MyClass" in m for m in messages))
+        self.assertTrue(any("my slot" in m for m in messages))
+        self.assertTrue(any("my_enum" in m for m in messages))
+
+        self.assertEqual(len(levels), 1)
+        self.assertEqual(levels.pop(), RuleLevel.error.text)
+
+        self.assertEqual(len(rule_names), 1)
+        self.assertEqual(rule_names.pop(), "no_empty_title")
+
+    def test_rule_level_warning(self):
+        config = yaml.safe_load(
+            """
+no_empty_title:
+  level: warning
+"""
+        )
+        builder = SchemaBuilder()
+        builder.add_class("MyClass")
+        builder.add_slot("my slot")
+        builder.add_enum("my_enum")
+
+        linter = Linter(config)
+        report = list(linter.lint(builder.schema))
+
+        messages = [p.message for p in report]
+        levels = {str(p.level) for p in report}
+        rule_names = {p.rule_name for p in report}
+
+        self.assertEqual(len(messages), 3)
+        self.assertTrue(any("MyClass" in m for m in messages))
+        self.assertTrue(any("my slot" in m for m in messages))
+        self.assertTrue(any("my_enum" in m for m in messages))
+
+        self.assertEqual(len(levels), 1)
+        self.assertEqual(levels.pop(), RuleLevel.warning.text)
+
+        self.assertEqual(len(rule_names), 1)
+        self.assertEqual(rule_names.pop(), "no_empty_title")
+
+    def test_rule_level_disabled(self):
+        config = yaml.safe_load(
+            """
+no_empty_title:
+  level: disabled
+"""
+        )
+        builder = SchemaBuilder()
+        builder.add_class("MyClass")
+        builder.add_slot("my slot")
+        builder.add_enum("my_enum")
+
+        linter = Linter(config)
+        report = list(linter.lint(builder.schema))
+
+        self.assertEqual(len(report), 0)
diff --git a/tests/test_linter/test_rule_no_invalid_slot_usage.py b/tests/test_linter/test_rule_no_invalid_slot_usage.py
new file mode 100644
index 00000000..5ce0ef0f
--- /dev/null
+++ b/tests/test_linter/test_rule_no_invalid_slot_usage.py
@@ -0,0 +1,63 @@
+import unittest
+
+from linkml_runtime import SchemaView
+from linkml_runtime.linkml_model import ClassDefinition
+
+from linkml.linter.config.datamodel.config import RuleConfig, RuleLevel
+from linkml.linter.rules import NoInvalidSlotUsageRule
+from linkml.utils.schema_builder import SchemaBuilder
+
+
+class TestNoInvalidSlotUsageRule(unittest.TestCase):
+    def test_invalid_slot_usage(self):
+        builder = SchemaBuilder()
+        builder.add_class("Person")
+        builder.add_slot(
+            {
+                "name": "age_in_years",
+                "range": "integer",
+                "minimum_value": 0,
+                "maximum_value": 999,
+            },
+            "Person",
+        )
+        builder.add_class(
+            ClassDefinition(name="Adult", is_a="Person"),
+            slot_usage={"age_in_days": {"minimum_value": 18}},
+        )
+
+        schema_view = SchemaView(builder.schema)
+        config = RuleConfig(level=RuleLevel.error.text)
+
+        rule = NoInvalidSlotUsageRule(config)
+        problems = list(rule.check(schema_view, fix=False))
+
+        self.assertEqual(len(problems), 1)
+        self.assertEqual(
+            problems[0].message, "Slot 'age_in_days' not found on class 'Adult'"
+        )
+
+    def test_valid_slot_usage(self):
+        builder = SchemaBuilder()
+        builder.add_class("Person")
+        builder.add_slot(
+            {
+                "name": "age_in_years",
+                "range": "integer",
+                "minimum_value": 0,
+                "maximum_value": 999,
+            },
+            "Person",
+        )
+        builder.add_class(
+            ClassDefinition(name="Adult", is_a="Person"),
+            slot_usage={"age_in_years": {"minimum_value": 18}},
+        )
+
+        schema_view = SchemaView(builder.schema)
+        config = RuleConfig(level=RuleLevel.error.text)
+
+        rule = NoInvalidSlotUsageRule(config)
+        problems = list(rule.check(schema_view, fix=False))
+
+        self.assertFalse(problems)
diff --git a/tests/test_linter/test_rule_no_xsd_int_type.py b/tests/test_linter/test_rule_no_xsd_int_type.py
new file mode 100644
index 00000000..90041ef2
--- /dev/null
+++ b/tests/test_linter/test_rule_no_xsd_int_type.py
@@ -0,0 +1,43 @@
+import unittest
+
+from linkml_runtime import SchemaView
+
+from linkml.linter.config.datamodel.config import RuleConfig, RuleLevel
+from linkml.linter.rules import NoXsdIntTypeRule
+from linkml.utils.schema_builder import SchemaBuilder
+
+
+class TestNoXsdIntTypeRule(unittest.TestCase):
+    def test_xsd_int_type_no_fix(self):
+        builder = SchemaBuilder()
+        builder.add_type({"name": "a_type", "uri": "xsd:int"})
+        builder.add_type({"name": "b_type", "uri": "xsd:integer"})
+        builder.add_type({"name": "c_type", "uri": "xsd:string"})
+
+        schema_view = SchemaView(builder.schema)
+        config = RuleConfig(level=RuleLevel.error.text)
+
+        rule = NoXsdIntTypeRule(config)
+        problems = list(rule.check(schema_view, fix=False))
+
+        self.assertEqual(len(problems), 1)
+        self.assertEqual(problems[0].message, "Type 'a_type' has uri xsd:int")
+
+    def test_xsd_int_type_fix(self):
+        builder = SchemaBuilder()
+        builder.add_type({"name": "a_type", "uri": "xsd:int"})
+        builder.add_type({"name": "b_type", "uri": "xsd:integer"})
+        builder.add_type({"name": "c_type", "uri": "xsd:string"})
+
+        schema_view = SchemaView(builder.schema)
+        config = RuleConfig(level=RuleLevel.error.text)
+
+        rule = NoXsdIntTypeRule(config)
+        problems = list(rule.check(schema_view, fix=True))
+
+        self.assertEqual(len(problems), 0)
+        self.assertEqual(schema_view.get_type("a_type").uri, "xsd:integer")
+
+        problems = list(rule.check(schema_view, fix=False))
+
+        self.assertEqual(len(problems), 0)
diff --git a/tests/test_linter/test_rule_permissible_values_format.py b/tests/test_linter/test_rule_permissible_values_format.py
new file mode 100644
index 00000000..c887df5c
--- /dev/null
+++ b/tests/test_linter/test_rule_permissible_values_format.py
@@ -0,0 +1,114 @@
+import unittest
+
+from linkml_runtime import SchemaView
+
+from linkml.linter.config.datamodel.config import (
+    PermissibleValuesFormatRuleConfig, RuleLevel)
+from linkml.linter.rules import PermissibleValuesFormatRule
+from linkml.utils.schema_builder import SchemaBuilder
+
+
+class TestRulePermissibleValuesFormat(unittest.TestCase):
+    def setUp(self) -> None:
+        schema_builder = SchemaBuilder()
+        schema_builder.add_enum(
+            "MENU",
+            [
+                "BIG_MAC",
+                "quarter_pounder_with_cheese",
+                "mcChicken",
+                "filet-o-fish",
+                "fries",
+            ],
+        )
+        self.schema_view = SchemaView(schema_builder.schema)
+
+    def test_format_snake(self):
+        config = PermissibleValuesFormatRuleConfig(
+            level=RuleLevel.error, format="snake"
+        )
+        rule = PermissibleValuesFormatRule(config)
+        problems = list(rule.check(self.schema_view))
+
+        self.assertEqual(len(problems), 3)
+
+        messages = [p.message for p in problems]
+
+        self.assertTrue(any("BIG_MAC" in m for m in messages))
+        self.assertTrue(any("mcChicken" in m for m in messages))
+        self.assertTrue(any("filet-o-fish" in m for m in messages))
+
+        self.assertFalse(any("quarter_pounder_with_cheese" in m for m in messages))
+        self.assertFalse(any("fries" in m for m in messages))
+
+    def test_format_upper_snake(self):
+        config = PermissibleValuesFormatRuleConfig(
+            level=RuleLevel.error, format="uppersnake"
+        )
+        rule = PermissibleValuesFormatRule(config)
+        problems = list(rule.check(self.schema_view))
+
+        self.assertEqual(len(problems), 4)
+
+        messages = [p.message for p in problems]
+
+        self.assertTrue(any("mcChicken" in m for m in messages))
+        self.assertTrue(any("filet-o-fish" in m for m in messages))
+        self.assertTrue(any("quarter_pounder_with_cheese" in m for m in messages))
+        self.assertTrue(any("fries" in m for m in messages))
+
+        self.assertFalse(any("BIG_MAC" in m for m in messages))
+
+    def test_format_camel(self):
+        config = PermissibleValuesFormatRuleConfig(
+            level=RuleLevel.error, format="camel"
+        )
+        rule = PermissibleValuesFormatRule(config)
+        problems = list(rule.check(self.schema_view))
+
+        self.assertEqual(len(problems), 3)
+
+        messages = [p.message for p in problems]
+
+        self.assertTrue(any("BIG_MAC" in m for m in messages))
+        self.assertTrue(any("filet-o-fish" in m for m in messages))
+        self.assertTrue(any("quarter_pounder_with_cheese" in m for m in messages))
+
+        self.assertFalse(any("mcChicken" in m for m in messages))
+        self.assertFalse(any("fries" in m for m in messages))
+
+    def test_format_kebab(self):
+        config = PermissibleValuesFormatRuleConfig(
+            level=RuleLevel.error, format="kebab"
+        )
+        rule = PermissibleValuesFormatRule(config)
+        problems = list(rule.check(self.schema_view))
+
+        self.assertEqual(len(problems), 3)
+
+        messages = [p.message for p in problems]
+
+        self.assertTrue(any("BIG_MAC" in m for m in messages))
+        self.assertTrue(any("mcChicken" in m for m in messages))
+        self.assertTrue(any("quarter_pounder_with_cheese" in m for m in messages))
+
+        self.assertFalse(any("filet-o-fish" in m for m in messages))
+        self.assertFalse(any("fries" in m for m in messages))
+
+    def test_format_custom_regex(self):
+        config = PermissibleValuesFormatRuleConfig(
+            level=RuleLevel.error, format="[a-z]+"
+        )
+        rule = PermissibleValuesFormatRule(config)
+        problems = list(rule.check(self.schema_view))
+
+        self.assertEqual(len(problems), 4)
+
+        messages = [p.message for p in problems]
+
+        self.assertTrue(any("BIG_MAC" in m for m in messages))
+        self.assertTrue(any("mcChicken" in m for m in messages))
+        self.assertTrue(any("quarter_pounder_with_cheese" in m for m in messages))
+        self.assertTrue(any("filet-o-fish" in m for m in messages))
+
+        self.assertFalse(any("fries" in m for m in messages))
diff --git a/tests/test_linter/test_rule_recommended.py b/tests/test_linter/test_rule_recommended.py
new file mode 100644
index 00000000..df0d29bc
--- /dev/null
+++ b/tests/test_linter/test_rule_recommended.py
@@ -0,0 +1,104 @@
+import unittest
+
+from linkml_runtime import SchemaView
+from linkml_runtime.linkml_model import (ClassDefinition, EnumDefinition,
+                                         SlotDefinition)
+
+from linkml.linter.config.datamodel.config import (RecommendedRuleConfig,
+                                                   RuleLevel)
+from linkml.linter.rules import RecommendedRule
+from linkml.utils.schema_builder import SchemaBuilder
+
+
+class TestRecommendedRule(unittest.TestCase):
+    def test_missing_descriptions(self):
+        builder = SchemaBuilder()
+        builder.add_class("MyClass")
+        builder.add_slot("my_slot")
+        builder.add_enum("MyEnum")
+
+        schema_view = SchemaView(builder.schema)
+        config = RecommendedRuleConfig(
+            level=RuleLevel.error.text, include=[], exclude=[]
+        )
+
+        rule = RecommendedRule(config)
+        problems = list(rule.check(schema_view))
+        self.assertEqual(len(problems), 3)
+
+        messages = [p.message for p in problems]
+        self.assertIn(
+            "class_definition 'MyClass' does not have recommended slot 'description'",
+            messages,
+        )
+        self.assertIn(
+            "slot_definition 'my_slot' does not have recommended slot 'description'",
+            messages,
+        )
+        self.assertIn(
+            "enum_definition 'MyEnum' does not have recommended slot 'description'",
+            messages,
+        )
+
+    def test_present_descriptions(self):
+        builder = SchemaBuilder()
+        builder.add_class(
+            ClassDefinition(name="MyClass", description="this is my class")
+        )
+        builder.add_slot(SlotDefinition(name="my_slot", description="this is my slot"))
+        builder.add_enum(EnumDefinition(name="MyEnum", description="this is my enum"))
+
+        schema_view = SchemaView(builder.schema)
+        config = RecommendedRuleConfig(
+            level=RuleLevel.error.text, include=[], exclude=[]
+        )
+
+        rule = RecommendedRule(config)
+        problems = list(rule.check(schema_view))
+        self.assertEqual(len(problems), 0)
+
+    def test_include(self):
+        builder = SchemaBuilder()
+        builder.add_class("MyClass")
+        builder.add_slot("my_slot")
+        builder.add_enum("MyEnum")
+
+        schema_view = SchemaView(builder.schema)
+        config = RecommendedRuleConfig(
+            level=RuleLevel.error.text, include=["my_slot"], exclude=[]
+        )
+
+        rule = RecommendedRule(config)
+        problems = list(rule.check(schema_view))
+        self.assertEqual(len(problems), 1)
+
+        messages = [p.message for p in problems]
+        self.assertIn(
+            "slot_definition 'my_slot' does not have recommended slot 'description'",
+            messages,
+        )
+
+    def test_exclude(self):
+        builder = SchemaBuilder()
+        builder.add_class("MyClass")
+        builder.add_slot("my_slot")
+        builder.add_enum("MyEnum")
+
+        schema_view = SchemaView(builder.schema)
+        config = RecommendedRuleConfig(
+            level=RuleLevel.error.text, include=[], exclude=["my_slot"]
+        )
+
+        rule = RecommendedRule(config)
+        problems = list(rule.check(schema_view))
+        self.assertEqual(len(problems), 2)
+
+        messages = [p.message for p in problems]
+        self.assertIn(
+            "class_definition 'MyClass' does not have recommended slot 'description'",
+            messages,
+        )
+        self.assertIn(
+            "enum_definition 'MyEnum' does not have recommended slot 'description'",
+            messages,
+        )
diff --git a/tests/test_linter/test_rule_standard_naming.py b/tests/test_linter/test_rule_standard_naming.py
new file mode 100644
index 00000000..a2831c3c
--- /dev/null
+++ b/tests/test_linter/test_rule_standard_naming.py
@@ -0,0 +1,103 @@
+import unittest
+
+from linkml_runtime import SchemaView
+
+from linkml.linter.config.datamodel.config import (RuleLevel,
+                                                   StandardNamingConfig)
+from linkml.linter.rules import StandardNamingRule
+from linkml.utils.schema_builder import SchemaBuilder
+
+
+class TestStandardNamingRule(unittest.TestCase):
+    def setUp(self) -> None:
+        builder = SchemaBuilder()
+
+        builder.add_class("GoodClass")
+        builder.add_class("bad class")
+        builder.add_class("0worseclass")
+
+        builder.add_slot("good_slot")
+        builder.add_slot("fineslot")
+        builder.add_slot("BadSlot")
+        builder.add_slot("worse slot")
+
+        builder.add_enum("GoodEnum", ["good_lower_pv", "great_lower_pv"])
+        builder.add_enum("GoodEnumWithBadPV", ["good_lower_pv", "Bad_PV"])
+
+        builder.add_enum("GoodEnumUpperPV", ["GOOD_UPPER_PV", "GREAT_UPPER_PV"])
+        builder.add_enum("GoodEnumBadUpperPV", ["GOOD_UPPER_PV", "bad_pv"])
+
+        builder.add_enum("bad_enum", ["good_lower_pv", "great_lower_pv"])
+
+        self.schema_view = SchemaView(builder.schema)
+
+    def test_standard_naming_lower_pv(self):
+        config = StandardNamingConfig(
+            level=RuleLevel.error.text, permissible_values_upper_case=False
+        )
+
+        rule = StandardNamingRule(config)
+        problems = list(rule.check(self.schema_view))
+
+        self.assertEqual(len(problems), 9)
+
+        messages = [p.message for p in problems]
+        self.assertIn("Class has name 'bad class'", messages)
+        self.assertIn("Class has name '0worseclass'", messages)
+        self.assertIn("Slot has name 'BadSlot'", messages)
+        self.assertIn("Slot has name 'worse slot'", messages)
+        self.assertIn(
+            "Permissible value of enum 'GoodEnumWithBadPV' has name 'Bad_PV'", messages
+        )
+        self.assertIn(
+            "Permissible value of enum 'GoodEnumUpperPV' has name 'GOOD_UPPER_PV'",
+            messages,
+        )
+        self.assertIn(
+            "Permissible value of enum 'GoodEnumUpperPV' has name 'GREAT_UPPER_PV'",
+            messages,
+        )
+        self.assertIn(
+            "Permissible value of enum 'GoodEnumBadUpperPV' has name 'GOOD_UPPER_PV'",
+            messages,
+        )
+        self.assertIn("Enum has name 'bad_enum'", messages)
+
+    def test_standard_naming_upper_pv(self):
+        config = StandardNamingConfig(
+            level=RuleLevel.error.text, permissible_values_upper_case=True
+        )
+
+        rule = StandardNamingRule(config)
+        problems = list(rule.check(self.schema_view))
+
+        self.assertEqual(len(problems), 12)
+
+        messages = [p.message for p in problems]
+        self.assertIn("Class has name 'bad class'", messages)
+        self.assertIn("Class has name '0worseclass'", messages)
+        self.assertIn("Slot has name 'BadSlot'", messages)
+        self.assertIn("Slot has name 'worse slot'", messages)
+        self.assertIn(
+            "Permissible value of enum 'GoodEnum' has name 'good_lower_pv'", messages
+        )
+        self.assertIn(
+            "Permissible value of enum 'GoodEnum' has name 'great_lower_pv'", messages
+        )
+        self.assertIn(
+            "Permissible value of enum 'GoodEnumWithBadPV' has name 'good_lower_pv'",
+            messages,
+        )
+        self.assertIn(
+            "Permissible value of enum 'GoodEnumWithBadPV' has name 'Bad_PV'", messages
+        )
+        self.assertIn(
+            "Permissible value of enum 'GoodEnumBadUpperPV' has name 'bad_pv'", messages
+        )
+        self.assertIn("Enum has name 'bad_enum'", messages)
+        self.assertIn(
+            "Permissible value of enum 'bad_enum' has name 'good_lower_pv'", messages
+        )
+        self.assertIn(
+            "Permissible value of enum 'bad_enum' has name 'great_lower_pv'", messages
+        )
diff --git a/tests/test_linter/test_rule_tree_root_class.py b/tests/test_linter/test_rule_tree_root_class.py
new file mode 100644
index 00000000..505c222d
--- /dev/null
+++ b/tests/test_linter/test_rule_tree_root_class.py
@@ -0,0 +1,96 @@
+import unittest
+
+from linkml_runtime import SchemaView
+
+from linkml.linter.config.datamodel.config import (RuleLevel,
+                                                   TreeRootClassRuleConfig)
+from linkml.linter.rules import TreeRootClassRule
+from linkml.utils.schema_builder import SchemaBuilder
+
+MY_CLASS = "MyClass"
+MY_ENUM = "MyEnum"
+FULL_NAME = "full_name"
+DESC = "description"
+CONTAINER = "ContainerClass"
+
+
+class TestTreeRootClassRule(unittest.TestCase):
+    def test_no_tree_root_class(self):
+        builder = SchemaBuilder()
+        slots = [FULL_NAME, DESC]
+        builder.add_class(MY_CLASS, slots)
+
+        schema_view = SchemaView(builder.schema)
+        config = TreeRootClassRuleConfig(
+            level=RuleLevel.error.text,
+            root_class_name=CONTAINER,
+            validate_existing_class_name=False,
+        )
+
+        rule = TreeRootClassRule(config)
+        problems = list(rule.check(schema_view))
+
+        self.assertEqual(len(problems), 1)
+        self.assertEqual(
+            problems[0].message, "Schema does not have class with `tree_root: true`"
+        )
+
+    def test_fix_no_tree_root_class(self):
+        builder = SchemaBuilder()
+        slots = [FULL_NAME, DESC]
+        builder.add_class(MY_CLASS, slots)
+
+        schema_view = SchemaView(builder.schema)
+        config = TreeRootClassRuleConfig(
+            level=RuleLevel.error.text,
+            root_class_name=CONTAINER,
+            validate_existing_class_name=False,
+        )
+
+        rule = TreeRootClassRule(config)
+        problems = list(rule.check(schema_view, fix=True))
+
+        # verify that no problems were reported and the fix was made
+        self.assertEqual(len(problems), 0)
+        container_class = schema_view.get_class(CONTAINER)
+        self.assertIsNotNone(container_class)
+        self.assertTrue(container_class.tree_root)
+
+        # verify that the schema view can be rechecked (no fix) without problems
+        problems = list(rule.check(schema_view))
+        self.assertEqual(len(problems), 0)
+
+    def test_existing_tree_root_class_name_matches(self):
+        builder = SchemaBuilder()
+        slots = [FULL_NAME, DESC]
+        builder.add_class(MY_CLASS, slots, tree_root=True)
+
+        schema_view = SchemaView(builder.schema)
+        config = TreeRootClassRuleConfig(
+            level=RuleLevel.error.text,
+            root_class_name=MY_CLASS,
+            validate_existing_class_name=True,
+        )
+
+        rule = TreeRootClassRule(config)
+        problems = list(rule.check(schema_view, fix=True))
+
+        self.assertEqual(len(problems), 0)
+
+    def test_existing_tree_root_class_name_mismatch(self):
+        builder = SchemaBuilder()
+        slots = [FULL_NAME, DESC]
+        builder.add_class(MY_CLASS, slots, tree_root=True)
+
+        schema_view = SchemaView(builder.schema)
+        config = TreeRootClassRuleConfig(
+            level=RuleLevel.error.text,
+            root_class_name=CONTAINER,
+            validate_existing_class_name=True,
+        )
+
+        rule = TreeRootClassRule(config)
+        problems = list(rule.check(schema_view, fix=True))
+
+        self.assertEqual(len(problems), 1)
+        self.assertEqual(problems[0].message, f"Tree root class has name '{MY_CLASS}'")