Repository: linkml/linkml

Issue:
Recommended rule set for linter
Currently the linter makes use a [file that defines defaults](https://github.com/linkml/linkml/blob/main/linkml/linter/config/default.yaml) for every rule. To be as unopinionated as possible the default `level` of each rule is `disabled`. When a user provides a configuration file, the user's configuration is merged into the defaults at runtime to produce a final configuration used for linting.

I think it would be useful to define a recommended rule set as well. It would be used in two ways.

If no config file is provided (either by passing the `--config` command line argument or the existence of a `.linkmllint.yaml` file in the current working directory), the recommended rule set would be used.

Additionally, the recommended rule set can be extended by custom configuration files. Currently linter configuration files are a list of rules at the top level. E.g.:
```yaml
# current
a_rule_name:
  level: error
```
I propose moving the rules specifications under a `rules` top-level key and adding an `extends` top-level key. If the values of `extends` is `recommended` then the user's rule configurations will be merged into the recommended set and then merged into the defaults at runtime to define the final configuration. For example:
```yaml
# proposed
extends: recommended
rules:
  a_rule_name:
    level: error
```
If the recommended rule set has `level: warning` for `a_rule_name`, it will be overridden to have `level: error`. But otherwise the user will get the recommended rule set.

I would propose the following as the recommended rule set:
```yaml
recommended:
  level: warning
no_xsd_int_type:
  level: error
no_invalid_slot_usage:
  level: error
standard_naming:
  level: warning
canonical_prefixes:
  level: warning
```

The `recommended` (that's the one that flags missing descriptions) and `standard_naming` rules tend to generate a lot of failures for large existing schemas that I looked at (biolink, NMDC), but I think they could help get new projects on the right track from the start. 

Discussion:
codecov-commenter: # [Codecov](https://codecov.io/gh/linkml/linkml/pull/970?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=linkml) Report
> Merging [#970](https://codecov.io/gh/linkml/linkml/pull/970?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=linkml) (f9f4da6) into [main](https://codecov.io/gh/linkml/linkml/commit/623c666776fca6c9503658a6220f9ac457b7749c?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=linkml) (623c666) will **increase** coverage by `0.01%`.
> The diff coverage is `90.62%`.

> :exclamation: Current head f9f4da6 differs from pull request most recent head fd3ef38. Consider uploading reports for the commit fd3ef38 to get more accurate results

```diff
@@            Coverage Diff             @@
##             main     #970      +/-   ##
==========================================
+ Coverage   77.39%   77.41%   +0.01%     
==========================================
  Files          74       74              
  Lines        8357     8377      +20     
  Branches     2079     2083       +4     
==========================================
+ Hits         6468     6485      +17     
- Misses       1499     1500       +1     
- Partials      390      392       +2     
```


| [Impacted Files](https://codecov.io/gh/linkml/linkml/pull/970?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=linkml) | Coverage Δ | |
|---|---|---|
| [linkml/linter/config/datamodel/config.py](https://codecov.io/gh/linkml/linkml/pull/970/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=linkml#diff-bGlua21sL2xpbnRlci9jb25maWcvZGF0YW1vZGVsL2NvbmZpZy5weQ==) | `86.02% <89.28%> (-0.13%)` | :arrow_down: |
| [linkml/generators/jsonldcontextgen.py](https://codecov.io/gh/linkml/linkml/pull/970/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=linkml#diff-bGlua21sL2dlbmVyYXRvcnMvanNvbmxkY29udGV4dGdlbi5weQ==) | `91.04% <100.00%> (ø)` | |
| [linkml/linter/linter.py](https://codecov.io/gh/linkml/linkml/pull/970/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=linkml#diff-bGlua21sL2xpbnRlci9saW50ZXIucHk=) | `84.74% <100.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=linkml). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=linkml)


Patch:
diff --git a/linkml/linter/cli.py b/linkml/linter/cli.py
index 3cbd838d..bc99323b 100644
--- a/linkml/linter/cli.py
+++ b/linkml/linter/cli.py
@@ -1,4 +1,5 @@
 import os
+import sys
 from pathlib import Path
 from typing import Iterable
 
@@ -61,7 +62,7 @@ def main(schema: Path, fix: bool, config: str, format: str, output):
         with open(config_file) as f:
             config_dict = yaml.safe_load(f)
     else:
-        config_dict = {}
+        config_dict = {"extends": "recommended"}
 
     linter = Linter(config_dict)
     if format == "terminal":
@@ -73,15 +74,19 @@ def main(schema: Path, fix: bool, config: str, format: str, output):
     elif format == "tsv":
         formatter = TsvFormatter(output)
 
+    exit_code = 0
     formatter.start_report()
     for path in get_yaml_files(schema):
         formatter.start_schema(path)
         report = linter.lint(path, fix=fix)
         for problem in report:
+            exit_code = 1
             formatter.handle_problem(problem)
         formatter.end_schema()
     formatter.end_report()
 
+    sys.exit(exit_code)
+
 
 if __name__ == "__main__":
     main()
diff --git a/linkml/linter/config/datamodel/.linkmllint.yaml b/linkml/linter/config/datamodel/.linkmllint.yaml
index 7155c852..cc44617a 100644
--- a/linkml/linter/config/datamodel/.linkmllint.yaml
+++ b/linkml/linter/config/datamodel/.linkmllint.yaml
@@ -1,10 +1,11 @@
 # 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
+rules:
+  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/config.py b/linkml/linter/config/datamodel/config.py
index ff2ab62c..a40327d4 100644
--- a/linkml/linter/config/datamodel/config.py
+++ b/linkml/linter/config/datamodel/config.py
@@ -1,5 +1,5 @@
 # Auto generated from config.yaml by pythongen.py version: 0.9.0
-# Generation date: 2022-09-06T15:57:37
+# Generation date: 2022-09-19T10:30:48
 # Schema: linter-config
 #
 # id: https://w3id.org/linkml/linter/config
@@ -59,6 +59,28 @@ class Config(YAMLRoot):
     class_name: ClassVar[str] = "Config"
     class_model_uri: ClassVar[URIRef] = LINTCFG.Config
 
+    extends: Optional[Union[str, "ExtendableConfigs"]] = None
+    rules: Optional[Union[dict, "Rules"]] = None
+
+    def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]):
+        if self.extends is not None and not isinstance(self.extends, ExtendableConfigs):
+            self.extends = ExtendableConfigs(self.extends)
+
+        if self.rules is not None and not isinstance(self.rules, Rules):
+            self.rules = Rules(**as_dict(self.rules))
+
+        super().__post_init__(**kwargs)
+
+
+@dataclass
+class Rules(YAMLRoot):
+    _inherited_slots: ClassVar[List[str]] = []
+
+    class_class_uri: ClassVar[URIRef] = LINTCFG.Rules
+    class_class_curie: ClassVar[str] = "lintcfg:Rules"
+    class_name: ClassVar[str] = "Rules"
+    class_model_uri: ClassVar[URIRef] = LINTCFG.Rules
+
     no_empty_title: Optional[Union[dict, "RuleConfig"]] = None
     permissible_values_format: Optional[
         Union[dict, "PermissibleValuesFormatRuleConfig"]
@@ -287,6 +309,21 @@ class CanonicalPrefixesConfig(RuleConfig):
 
 
 # Enumerations
+class ExtendableConfigs(EnumDefinitionImpl):
+    """
+    The permissible values for the `extends` field of a config file
+    """
+
+    recommended = PermissibleValue(
+        text="recommended", description="Extend the recommended rule set"
+    )
+
+    _defn = EnumDefinition(
+        name="ExtendableConfigs",
+        description="The permissible values for the `extends` field of a config file",
+    )
+
+
 class RuleLevel(EnumDefinitionImpl):
     """
     The permissible values for the `level` option of all rules
@@ -315,74 +352,92 @@ class slots:
     pass
 
 
-slots.config__no_empty_title = Slot(
+slots.config__extends = Slot(
+    uri=LINTCFG.extends,
+    name="config__extends",
+    curie=LINTCFG.curie("extends"),
+    model_uri=LINTCFG.config__extends,
+    domain=None,
+    range=Optional[Union[str, "ExtendableConfigs"]],
+)
+
+slots.config__rules = Slot(
+    uri=LINTCFG.rules,
+    name="config__rules",
+    curie=LINTCFG.curie("rules"),
+    model_uri=LINTCFG.config__rules,
+    domain=None,
+    range=Optional[Union[dict, Rules]],
+)
+
+slots.rules__no_empty_title = Slot(
     uri=LINTCFG.no_empty_title,
-    name="config__no_empty_title",
+    name="rules__no_empty_title",
     curie=LINTCFG.curie("no_empty_title"),
-    model_uri=LINTCFG.config__no_empty_title,
+    model_uri=LINTCFG.rules__no_empty_title,
     domain=None,
     range=Optional[Union[dict, RuleConfig]],
 )
 
-slots.config__permissible_values_format = Slot(
+slots.rules__permissible_values_format = Slot(
     uri=LINTCFG.permissible_values_format,
-    name="config__permissible_values_format",
+    name="rules__permissible_values_format",
     curie=LINTCFG.curie("permissible_values_format"),
-    model_uri=LINTCFG.config__permissible_values_format,
+    model_uri=LINTCFG.rules__permissible_values_format,
     domain=None,
     range=Optional[Union[dict, PermissibleValuesFormatRuleConfig]],
 )
 
-slots.config__tree_root_class = Slot(
+slots.rules__tree_root_class = Slot(
     uri=LINTCFG.tree_root_class,
-    name="config__tree_root_class",
+    name="rules__tree_root_class",
     curie=LINTCFG.curie("tree_root_class"),
-    model_uri=LINTCFG.config__tree_root_class,
+    model_uri=LINTCFG.rules__tree_root_class,
     domain=None,
     range=Optional[Union[dict, TreeRootClassRuleConfig]],
 )
 
-slots.config__recommended = Slot(
+slots.rules__recommended = Slot(
     uri=LINTCFG.recommended,
-    name="config__recommended",
+    name="rules__recommended",
     curie=LINTCFG.curie("recommended"),
-    model_uri=LINTCFG.config__recommended,
+    model_uri=LINTCFG.rules__recommended,
     domain=None,
     range=Optional[Union[dict, RecommendedRuleConfig]],
 )
 
-slots.config__no_xsd_int_type = Slot(
+slots.rules__no_xsd_int_type = Slot(
     uri=LINTCFG.no_xsd_int_type,
-    name="config__no_xsd_int_type",
+    name="rules__no_xsd_int_type",
     curie=LINTCFG.curie("no_xsd_int_type"),
-    model_uri=LINTCFG.config__no_xsd_int_type,
+    model_uri=LINTCFG.rules__no_xsd_int_type,
     domain=None,
     range=Optional[Union[dict, RuleConfig]],
 )
 
-slots.config__no_invalid_slot_usage = Slot(
+slots.rules__no_invalid_slot_usage = Slot(
     uri=LINTCFG.no_invalid_slot_usage,
-    name="config__no_invalid_slot_usage",
+    name="rules__no_invalid_slot_usage",
     curie=LINTCFG.curie("no_invalid_slot_usage"),
-    model_uri=LINTCFG.config__no_invalid_slot_usage,
+    model_uri=LINTCFG.rules__no_invalid_slot_usage,
     domain=None,
     range=Optional[Union[dict, RuleConfig]],
 )
 
-slots.config__standard_naming = Slot(
+slots.rules__standard_naming = Slot(
     uri=LINTCFG.standard_naming,
-    name="config__standard_naming",
+    name="rules__standard_naming",
     curie=LINTCFG.curie("standard_naming"),
-    model_uri=LINTCFG.config__standard_naming,
+    model_uri=LINTCFG.rules__standard_naming,
     domain=None,
     range=Optional[Union[dict, StandardNamingConfig]],
 )
 
-slots.config__canonical_prefixes = Slot(
+slots.rules__canonical_prefixes = Slot(
     uri=LINTCFG.canonical_prefixes,
-    name="config__canonical_prefixes",
+    name="rules__canonical_prefixes",
     curie=LINTCFG.curie("canonical_prefixes"),
-    model_uri=LINTCFG.config__canonical_prefixes,
+    model_uri=LINTCFG.rules__canonical_prefixes,
     domain=None,
     range=Optional[Union[dict, CanonicalPrefixesConfig]],
 )
diff --git a/linkml/linter/config/datamodel/config.yaml b/linkml/linter/config/datamodel/config.yaml
index 05485995..ff99bc37 100644
--- a/linkml/linter/config/datamodel/config.yaml
+++ b/linkml/linter/config/datamodel/config.yaml
@@ -20,9 +20,22 @@ 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.
+      This is the top-level representation of a LinkML linter configuration file. It allows 
+      defining a set of rules while also optionally extending a predefined set of rules.
+    attributes:
+      extends:
+        range: ExtendableConfigs
+        description: >-
+          This optional field allows a configuration to extend a predefined configuration.
+      rules:
+        range: Rules
+        description: >-
+          This is where a configuration defines its rules and the configuration of those rules.
+  
+  Rules:
+    description: >-
+      Each attribute of this class represents a rule that can be enabled and possibly 
+      configured by a configuration file. 
     attributes:
       no_empty_title:
         range: RuleConfig
@@ -156,6 +169,11 @@ classes:
           https://github.com/linkml/prefixmaps#usage
 
 enums:
+  ExtendableConfigs:
+    description: The permissible values for the `extends` field of a config file
+    permissible_values:
+      recommended:
+        description: Extend the recommended rule set
   RuleLevel:
     description: >-
       The permissible values for the `level` option of all rules
diff --git a/linkml/linter/config/default.yaml b/linkml/linter/config/default.yaml
index 844cbd10..563ea9dc 100644
--- a/linkml/linter/config/default.yaml
+++ b/linkml/linter/config/default.yaml
@@ -1,24 +1,25 @@
-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
-canonical_prefixes:
-  level: disabled
-  prefixmaps_contexts:
-    - merged
+rules:
+  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
+  canonical_prefixes:
+    level: disabled
+    prefixmaps_contexts:
+      - merged
diff --git a/linkml/linter/config/recommended.yaml b/linkml/linter/config/recommended.yaml
new file mode 100644
index 00000000..89ddd0fc
--- /dev/null
+++ b/linkml/linter/config/recommended.yaml
@@ -0,0 +1,11 @@
+rules:
+  recommended:
+    level: warning
+  no_xsd_int_type:
+    level: error
+  no_invalid_slot_usage:
+    level: error
+  standard_naming:
+    level: warning
+  canonical_prefixes:
+    level: warning
diff --git a/linkml/linter/linter.py b/linkml/linter/linter.py
index ce8375b4..c4eb92e5 100644
--- a/linkml/linter/linter.py
+++ b/linkml/linter/linter.py
@@ -10,7 +10,7 @@ from linkml_runtime import SchemaView
 from linkml_runtime.dumpers import yaml_dumper
 from linkml_runtime.linkml_model import SchemaDefinition
 
-from .config.datamodel.config import Config, RuleLevel
+from .config.datamodel.config import Config, ExtendableConfigs, RuleLevel
 
 
 @dataclass
@@ -23,8 +23,8 @@ class LinterProblem:
 
 
 @lru_cache
-def get_default_config() -> Dict[str, Any]:
-    config_path = str(Path(__file__).parent / "config/default.yaml")
+def get_named_config(name: str) -> Dict[str, Any]:
+    config_path = str(Path(__file__).parent / f"config/{name}.yaml")
     with open(config_path) as config_file:
         return yaml.safe_load(config_file)
 
@@ -41,8 +41,14 @@ def merge_configs(original: dict, other: dict):
 
 class Linter:
     def __init__(self, config: Dict[str, Any] = {}) -> None:
-        default_config = deepcopy(get_default_config())
-        merged_config = merge_configs(default_config, config)
+        default_config = deepcopy(get_named_config("default"))
+        merged_config = config
+        if config.get("extends") == ExtendableConfigs.recommended.text:
+            recommended_config = deepcopy(
+                get_named_config(ExtendableConfigs.recommended.text)
+            )
+            merged_config = merge_configs(recommended_config, merged_config)
+        merged_config = merge_configs(default_config, merged_config)
         self.config = Config(**merged_config)
 
         from . import rules
@@ -68,7 +74,7 @@ class Linter:
             )
             return
 
-        for rule_id, rule_config in self.config.__dict__.items():
+        for rule_id, rule_config in self.config.rules.__dict__.items():
             rule_cls = self._rules_map.get(rule_id, None)
             if rule_cls is None:
                 raise ValueError("Unknown rule id: " + rule_id)
diff --git a/linkml/linter/rules.py b/linkml/linter/rules.py
index d5d5d480..8c32509c 100644
--- a/linkml/linter/rules.py
+++ b/linkml/linter/rules.py
@@ -1,3 +1,4 @@
+from functools import cache
 import re
 from abc import ABC, abstractmethod
 from typing import Callable, Iterable, List
@@ -93,21 +94,27 @@ class PermissibleValuesFormatRule(LinterRule):
                     )
 
 
+@cache
+def _get_recommended_metamodel_slots() -> List[str]:
+    meta_schema_view = SchemaView(LOCAL_METAMODEL_YAML_FILE)
+    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:
+                recommended_meta_slots.append(f"{class_name}__{slot.name}")
+    return recommended_meta_slots
+
+
 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}")
+        self.config = config
 
     def check(self, schema_view: SchemaView, fix: bool = False):
+        recommended_meta_slots = _get_recommended_metamodel_slots()
         for element_name, element_definition in schema_view.all_elements(
             imports=False
         ).items():
@@ -118,7 +125,7 @@ class RecommendedRule(LinterRule):
             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:
+                if key in recommended_meta_slots and not meta_slot_value:
                     yield LinterProblem(
                         f"{meta_class_name} '{element_name}' does not have recommended slot '{meta_slot_name}'"
                     )
diff --git a/pyproject.toml b/pyproject.toml
index befaf50b..41bf7dc7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -93,7 +93,7 @@ click = ">=7.0"
 graphviz = ">=0.10.1"
 hbreader = "*"
 isodate = ">=0.6.0"
-jinja2 = "*"
+jinja2 = ">= 3.1.0"
 jsonasobj2 = "==1.*,>=1.0.0,>=1.0.3"
 jsonschema = ">=3.0.1"
 linkml-dataops = "*"


Test patch:
diff --git a/tests/test_linter/test_cli.py b/tests/test_linter/test_cli.py
new file mode 100644
index 00000000..ee4cb89d
--- /dev/null
+++ b/tests/test_linter/test_cli.py
@@ -0,0 +1,170 @@
+import unittest
+from pathlib import Path
+
+from click.testing import CliRunner
+
+from linkml.linter.cli import main
+
+SCHEMA_FILE = "schema.yaml"
+
+
+def write_schema_file() -> None:
+    with open(SCHEMA_FILE, "w") as f:
+        f.write(
+            """
+id: http://example.org/test
+name: test
+
+slots:
+  age_in_years:
+    range: integer
+    minimum_value: 0
+    maximum_value: 999
+
+classes:
+  person:
+    description: a person
+    slots:
+      - age_in_years
+  
+  Adult:
+    is_a: person
+    slot_usage:
+      age_in_yeas:
+        minimum_value: 18
+"""
+        )
+
+
+def write_config_file(name: str, extends_recommended: bool = False) -> None:
+    with open(name, "w") as f:
+        if extends_recommended:
+            f.write(
+                """
+extends: recommended
+"""
+            )
+        f.write(
+            """
+rules:
+  tree_root_class:
+    level: error
+"""
+        )
+
+
+class TestLinterCli(unittest.TestCase):
+    def setUp(self) -> None:
+        self.runner = CliRunner()
+
+    def test_no_config(self):
+        with self.runner.isolated_filesystem():
+            write_schema_file()
+
+            result = self.runner.invoke(main, [SCHEMA_FILE])
+            self.assertEqual(result.exit_code, 1)
+            self.assertIn(
+                "warning  class_definition 'Adult' does not have recommended slot 'description'  (recommended)",
+                result.stdout,
+            )
+            self.assertIn(
+                "error    Slot 'age_in_yeas' not found on class 'Adult'  (no_invalid_slot_usage)",
+                result.stdout,
+            )
+            self.assertIn(
+                "warning  Class has name 'person'  (standard_naming)", result.stdout
+            )
+
+    def test_implicit_config_file(self):
+        with self.runner.isolated_filesystem():
+            write_schema_file()
+            write_config_file(".linkmllint.yaml")
+
+            result = self.runner.invoke(main, [SCHEMA_FILE])
+            self.assertEqual(result.exit_code, 1)
+            self.assertIn(
+                "error    Schema does not have class with `tree_root: true`  (tree_root_class)",
+                result.stdout,
+            )
+            self.assertNotIn("Class has name 'person'", result.stdout)
+
+    def test_explicit_config_file(self):
+        config_file = "config.yaml"
+        with self.runner.isolated_filesystem():
+            write_schema_file()
+            write_config_file(config_file)
+
+            result = self.runner.invoke(main, ["--config", config_file, SCHEMA_FILE])
+            self.assertEqual(result.exit_code, 1)
+            self.assertIn(
+                "error    Schema does not have class with `tree_root: true`  (tree_root_class)",
+                result.stdout,
+            )
+            self.assertNotIn("Class has name 'person'", result.stdout)
+
+    def test_config_extends_recommended(self):
+        config_file = "config.yaml"
+        with self.runner.isolated_filesystem():
+            write_schema_file()
+            write_config_file(config_file, extends_recommended=True)
+
+            result = self.runner.invoke(main, ["--config", config_file, SCHEMA_FILE])
+            self.assertEqual(result.exit_code, 1)
+            self.assertIn(
+                "error    Schema does not have class with `tree_root: true`  (tree_root_class)",
+                result.stdout,
+            )
+            self.assertIn(
+                "warning  Class has name 'person'  (standard_naming)", result.stdout
+            )
+
+    def test_no_schema_errors(self):
+        with self.runner.isolated_filesystem():
+            with open(SCHEMA_FILE, "w") as f:
+                f.write(
+                    """
+id: http://example.org/test
+name: test
+
+classes:
+  Person:
+    description: An individual human
+"""
+                )
+
+            result = self.runner.invoke(main, [SCHEMA_FILE])
+            self.assertEqual(result.exit_code, 0)
+
+    def test_directory_of_files(self):
+        schema_dir = Path("schemas")
+        schema_a = schema_dir / "schema_a.yaml"
+        schema_b = schema_dir / "schema_b.yaml"
+        with self.runner.isolated_filesystem():
+            schema_dir.mkdir()
+            schema_a.write_text(
+                """
+id: http://example.org/test_a
+name: test_a
+
+classes:
+  person:
+    description: An individual human
+"""
+            )
+            schema_b.write_text(
+                """
+id: http://example.org/test_b
+name: test_b
+
+slots:
+  a slot:
+    description: A slot to hold thing
+"""
+            )
+
+            result = self.runner.invoke(main, [str(schema_dir)])
+            self.assertEqual(result.exit_code, 1)
+            self.assertIn(str(schema_a), result.stdout)
+            self.assertIn("Class has name 'person'", result.stdout)
+            self.assertIn(str(schema_b), result.stdout)
+            self.assertIn("Slot has name 'a slot'", result.stdout)
diff --git a/tests/test_linter/test_linter.py b/tests/test_linter/test_linter.py
index e1d0a300..77610bd2 100644
--- a/tests/test_linter/test_linter.py
+++ b/tests/test_linter/test_linter.py
@@ -12,8 +12,9 @@ class TestLinter(unittest.TestCase):
 
         config = yaml.safe_load(
             """
-no_empty_title:
-  level: error
+rules:
+  no_empty_title:
+    level: error
 """
         )
         builder = SchemaBuilder()
@@ -42,8 +43,9 @@ no_empty_title:
     def test_rule_level_warning(self):
         config = yaml.safe_load(
             """
-no_empty_title:
-  level: warning
+rules:
+  no_empty_title:
+    level: warning
 """
         )
         builder = SchemaBuilder()
@@ -72,8 +74,9 @@ no_empty_title:
     def test_rule_level_disabled(self):
         config = yaml.safe_load(
             """
-no_empty_title:
-  level: disabled
+rules:
+  no_empty_title:
+    level: disabled
 """
         )
         builder = SchemaBuilder()
@@ -85,3 +88,72 @@ no_empty_title:
         report = list(linter.lint(builder.schema))
 
         self.assertEqual(len(report), 0)
+
+    def test_no_extends(self):
+        config = yaml.safe_load(
+            """
+rules:
+  canonical_prefixes:
+    level: error
+    prefixmaps_contexts:
+      - obo
+      - prefixcc
+  no_empty_title:
+    level: warning
+"""
+        )
+        linter = Linter(config)
+
+        # the level is changed by the custom rules
+        self.assertEqual(
+            str(linter.config.rules.canonical_prefixes.level), RuleLevel.error.text
+        )
+        self.assertEqual(
+            linter.config.rules.canonical_prefixes.prefixmaps_contexts,
+            ["obo", "prefixcc"],
+        )
+
+        # this is not in the custom rules and should come from the default
+        self.assertEqual(
+            str(linter.config.rules.tree_root_class.level), RuleLevel.disabled.text
+        )
+
+    def test_extends_recommended(self):
+        config = yaml.safe_load(
+            """
+extends: recommended
+rules:
+  canonical_prefixes:
+    level: error
+    prefixmaps_contexts:
+      - obo
+      - prefixcc
+  no_empty_title:
+    level: warning
+"""
+        )
+        linter = Linter(config)
+
+        # this rule is in the recommended set, the level is changed by the custom rules
+        self.assertEqual(
+            str(linter.config.rules.canonical_prefixes.level), RuleLevel.error.text
+        )
+        self.assertEqual(
+            linter.config.rules.canonical_prefixes.prefixmaps_contexts,
+            ["obo", "prefixcc"],
+        )
+
+        # this should come directly from the recommended set with no customization
+        self.assertEqual(
+            str(linter.config.rules.no_xsd_int_type.level), RuleLevel.error.text
+        )
+
+        # this is in the custom rules but not in the recommended set
+        self.assertEqual(
+            str(linter.config.rules.no_empty_title.level), RuleLevel.warning.text
+        )
+
+        # this is not in the recommended or custom rules and should come from the default
+        self.assertEqual(
+            str(linter.config.rules.tree_root_class.level), RuleLevel.disabled.text
+        )