diff --git a/docs/cookbook/attribute-matches-type.md b/docs/cookbook/attribute-matches-type.md index e588835..2c06070 100644 --- a/docs/cookbook/attribute-matches-type.md +++ b/docs/cookbook/attribute-matches-type.md @@ -9,6 +9,7 @@ When an attribute holds a repository, service, or other typed object, keeping th ```yaml rules: - name: attribute-matches-type + description: Attribute names must match their type annotation in snake_case type: variable filter: { target: attribute } naming: { source: type_annotation, transform: snake_case } @@ -46,7 +47,8 @@ The `{prefix}_{expected}` form is also allowed. For example, `source_object_cont ``` $ pnl check contexts/billing/domain/service.py:5 - [attribute-matches-type] repo (expected: subscription_repository) + [attribute-matches-type] Attribute names must match their type annotation in snake_case + repo (expected: subscription_repository) Found 1 violation(s). ``` diff --git a/docs/cookbook/bool-method-prefix.md b/docs/cookbook/bool-method-prefix.md index db79aa2..2507fdc 100644 --- a/docs/cookbook/bool-method-prefix.md +++ b/docs/cookbook/bool-method-prefix.md @@ -9,6 +9,7 @@ Functions that return `bool` are easier to read at call sites when their names r ```yaml rules: - name: bool-method-prefix + description: Bool-returning functions must start with is_, has_, or should_ type: function filter: { return_type: bool } naming: { prefix: [is_, has_, should_] } @@ -44,7 +45,8 @@ class SubscriptionService: ``` $ pnl check src/domain/service.py:4 - [bool-method-prefix] validate (expected prefix: is_ | has_ | should_) + [bool-method-prefix] Bool-returning functions must start with is_, has_, or should_ + validate (expected prefix: is_ | has_ | should_) Found 1 violation(s). ``` diff --git a/docs/cookbook/constant-upper-case.md b/docs/cookbook/constant-upper-case.md index 5feb932..dceb038 100644 --- a/docs/cookbook/constant-upper-case.md +++ b/docs/cookbook/constant-upper-case.md @@ -9,6 +9,7 @@ Module-level constants are easier to distinguish from regular variables when the ```yaml rules: - name: constant-upper-case + description: Module-level constants must use UPPER_CASE type: variable filter: { target: constant } naming: { case: UPPER_CASE } @@ -42,10 +43,12 @@ DEFAULT_TIMEOUT_SECONDS = 30 ``` $ pnl check src/config.py:3 - [constant-upper-case] max_retry_count (expected case: UPPER_CASE) + [constant-upper-case] Module-level constants must use UPPER_CASE + max_retry_count (expected case: UPPER_CASE) src/config.py:4 - [constant-upper-case] default_timeout_seconds (expected case: UPPER_CASE) + [constant-upper-case] Module-level constants must use UPPER_CASE + default_timeout_seconds (expected case: UPPER_CASE) Found 2 violation(s). ``` diff --git a/docs/cookbook/decorator-filtering.md b/docs/cookbook/decorator-filtering.md index a3f20a5..02dcddd 100644 --- a/docs/cookbook/decorator-filtering.md +++ b/docs/cookbook/decorator-filtering.md @@ -11,6 +11,7 @@ Some naming conventions only apply to a specific kind of function or class. Deco ```yaml rules: - name: static-factory-prefix + description: Static factory methods must start with create_ or build_ type: function filter: { decorator: staticmethod } naming: { prefix: [create_, build_] } @@ -26,6 +27,7 @@ apply: ```yaml rules: - name: dataclass-naming + description: Dataclass names must end with Data or Config type: class filter: { decorator: dataclass } naming: { suffix: [Data, Config] } @@ -75,10 +77,12 @@ class OrderData: ``` $ pnl check src/domain/order.py:5 - [static-factory-prefix] from_dict (expected prefix: create_ | build_) + [static-factory-prefix] Static factory methods must start with create_ or build_ + from_dict (expected prefix: create_ | build_) src/domain/order.py:9 - [dataclass-naming] OrderPayload (expected suffix: Data | Config) + [dataclass-naming] Dataclass names must end with Data or Config + OrderPayload (expected suffix: Data | Config) Found 2 violation(s). ``` diff --git a/docs/cookbook/exception-naming.md b/docs/cookbook/exception-naming.md index 0d4ad18..9a52b3f 100644 --- a/docs/cookbook/exception-naming.md +++ b/docs/cookbook/exception-naming.md @@ -9,6 +9,7 @@ Consistent exception names make error handling code easier to scan and understan ```yaml rules: - name: exception-naming + description: "Exception classes must follow the Error pattern" type: class filter: { base_class: Exception } naming: { regex: "^[A-Z][a-zA-Z]+(NotFound|Invalid|Denied|Conflict|Failed)Error$" } @@ -42,7 +43,8 @@ class FilterNotFoundError(Exception): ``` $ pnl check src/domain/exceptions.py:3 - [exception-naming] FilterError (expected pattern: ^[A-Z][a-zA-Z]+(NotFound|Invalid|...)Error$) + [exception-naming] Exception classes must follow the Error pattern + FilterError (expected pattern: ^[A-Z][a-zA-Z]+(NotFound|Invalid|...)Error$) Found 1 violation(s). ``` diff --git a/docs/cookbook/layer-based-rules.md b/docs/cookbook/layer-based-rules.md index ccb7432..22137fa 100644 --- a/docs/cookbook/layer-based-rules.md +++ b/docs/cookbook/layer-based-rules.md @@ -9,25 +9,30 @@ Real projects have distinct layers — domain, infrastructure, API — each with ```yaml rules: - name: attribute-matches-type + description: Attribute names must match their type annotation in snake_case type: variable filter: { target: attribute } naming: { source: type_annotation, transform: snake_case } - name: bool-method-prefix + description: Bool-returning functions must start with is_, has_, or should_ type: function filter: { return_type: bool } naming: { prefix: [is_, has_, should_] } - name: domain-module-naming + description: Module filename must match the primary class name in snake_case type: module naming: { source: class_name, transform: snake_case } - name: constant-upper-case + description: Module-level constants must use UPPER_CASE type: variable filter: { target: constant } naming: { case: UPPER_CASE } - name: exception-naming + description: "Exception classes must follow the Error pattern" type: class filter: { base_class: Exception } naming: { regex: "^[A-Z][a-zA-Z]+(NotFound|Invalid|Denied|Conflict|Failed)Error$" } @@ -91,13 +96,16 @@ class BillingNotFoundError(Exception): ``` $ pnl check contexts/billing/domain/service.py:3 - [constant-upper-case] max_retry (expected case: UPPER_CASE) + [constant-upper-case] Module-level constants must use UPPER_CASE + max_retry (expected case: UPPER_CASE) contexts/billing/domain/service.py:6 - [bool-method-prefix] validate (expected prefix: is_ | has_ | should_) + [bool-method-prefix] Bool-returning functions must start with is_, has_, or should_ + validate (expected prefix: is_ | has_ | should_) contexts/billing/domain/exceptions.py:3 - [exception-naming] BillingError (expected pattern: ^[A-Z][a-zA-Z]+(NotFound|Invalid|...)Error$) + [exception-naming] Exception classes must follow the Error pattern + BillingError (expected pattern: ^[A-Z][a-zA-Z]+(NotFound|Invalid|...)Error$) Found 3 violation(s). ``` diff --git a/docs/cookbook/module-matches-class.md b/docs/cookbook/module-matches-class.md index bda62b2..f7c6e8c 100644 --- a/docs/cookbook/module-matches-class.md +++ b/docs/cookbook/module-matches-class.md @@ -9,6 +9,7 @@ When each module contains one primary class, keeping the filename in sync with t ```yaml rules: - name: domain-module-naming + description: Module filename must match the primary class name in snake_case type: module naming: { source: class_name, transform: snake_case } @@ -41,7 +42,8 @@ class CustomObject: ``` $ pnl check contexts/catalog/domain/custom.py:1 - [domain-module-naming] custom (expected: custom_object) + [domain-module-naming] Module filename must match the primary class name in snake_case + custom (expected: custom_object) Found 1 violation(s). ``` diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index 8bf9501..2095fb2 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -9,11 +9,13 @@ Create `.python-naming-linter.yaml` in your project root and define your naming ```yaml rules: - name: bool-method-prefix + description: Bool-returning functions must start with is_, has_, or should_ type: function filter: { return_type: bool } naming: { prefix: [is_, has_, should_] } - name: exception-naming + description: "Exception classes must follow the Error pattern" type: class filter: { base_class: Exception } naming: { regex: "^[A-Z][a-zA-Z]+(NotFound|Invalid|Denied|Conflict|Failed)Error$" } @@ -43,14 +45,16 @@ pnl check ## Step 3: Review the Output -Violations are reported with the file path, line number, rule name, and what was expected: +Violations are reported with the file path, line number, rule name, description, and what was expected: ``` src/domain/service.py:12 - [bool-method-prefix] validate (expected prefix: is_ | has_ | should_) + [bool-method-prefix] Bool-returning functions must start with is_, has_, or should_ + validate (expected prefix: is_ | has_ | should_) src/domain/exceptions.py:8 - [exception-naming] FilterError (expected pattern: ^[A-Z][a-zA-Z]+(NotFound|Invalid|...)Error$) + [exception-naming] Exception classes must follow the Error pattern + FilterError (expected pattern: ^[A-Z][a-zA-Z]+(NotFound|Invalid|...)Error$) Found 2 violation(s). ``` diff --git a/docs/guide/rules.md b/docs/guide/rules.md index 029cf45..8fc4d62 100644 --- a/docs/guide/rules.md +++ b/docs/guide/rules.md @@ -4,11 +4,12 @@ Rules are the core building blocks of `pnl`. Each rule targets a specific kind o ## Structure -Every rule has three required fields and two optional ones: +Every rule has three required fields and three optional ones: ```yaml rules: - name: my-rule # Unique identifier for this rule + description: ... # (optional) Human-readable description shown in violation output type: variable # What kind of name to lint filter: { ... } # (optional) Narrow which names are checked naming: { ... } # How the name must be formed @@ -21,6 +22,7 @@ The `name` is used to reference the rule in `apply` blocks and in `# pnl: ignore | Field | Required | Description | |-------|----------|-------------| | `name` | Yes | Unique identifier, referenced in `apply` and `# pnl: ignore` | +| `description` | No | Human-readable description shown in violation output | | `type` | Yes | What kind of name to lint (`variable`, `function`, `class`, `module`, `package`) | | `filter` | No | Narrow which names are checked (see [Filters](#filters) below) | | `naming` | Yes | How the name must be formed (see [Naming Constraints](#naming-constraints) below) | @@ -916,6 +918,7 @@ apply: | Field | Required | Description | |-------|----------|-------------| | `name` | Yes | Unique identifier, referenced in `apply` and `# pnl: ignore` | +| `description` | No | Human-readable description shown in violation output | | `type` | Yes | What kind of name to lint (`variable`, `function`, `class`, `module`, `package`) | | `filter` | No | Narrow which names are checked | | `naming` | Yes | How the name must be formed | diff --git a/docs/index.md b/docs/index.md index ca3fe50..cf00734 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,11 +34,13 @@ pip install python-naming-linter ```yaml rules: - name: bool-method-prefix + description: Bool-returning functions must start with is_, has_, or should_ type: function filter: { return_type: bool } naming: { prefix: [is_, has_, should_] } - name: exception-naming + description: "Exception classes must follow the Error pattern" type: class filter: { base_class: Exception } naming: { regex: "^[A-Z][a-zA-Z]+(NotFound|Invalid|Denied|Conflict|Failed)Error$" } @@ -59,10 +61,12 @@ pnl check ``` src/domain/service.py:12 - [bool-method-prefix] validate (expected prefix: is_ | has_ | should_) + [bool-method-prefix] Bool-returning functions must start with is_, has_, or should_ + validate (expected prefix: is_ | has_ | should_) src/domain/exceptions.py:8 - [exception-naming] FilterError (expected pattern: ^[A-Z][a-zA-Z]+(NotFound|Invalid|...)Error$) + [exception-naming] Exception classes must follow the Error pattern + FilterError (expected pattern: ^[A-Z][a-zA-Z]+(NotFound|Invalid|...)Error$) Found 2 violation(s). ``` diff --git a/python_naming_linter/checkers/__init__.py b/python_naming_linter/checkers/__init__.py index 55a67f3..e6c7665 100644 --- a/python_naming_linter/checkers/__init__.py +++ b/python_naming_linter/checkers/__init__.py @@ -10,3 +10,4 @@ class Violation: lineno: int name: str message: str + rule_description: str | None = None diff --git a/python_naming_linter/checkers/class_.py b/python_naming_linter/checkers/class_.py index e533dc2..9948562 100644 --- a/python_naming_linter/checkers/class_.py +++ b/python_naming_linter/checkers/class_.py @@ -154,6 +154,7 @@ def check_class(tree: ast.Module, rule: Rule, file_path: str) -> list[Violation] lineno=node.lineno, name=class_name, message=msg, + rule_description=rule.description, ) ) diff --git a/python_naming_linter/checkers/function.py b/python_naming_linter/checkers/function.py index 11a2952..eb96e90 100644 --- a/python_naming_linter/checkers/function.py +++ b/python_naming_linter/checkers/function.py @@ -131,6 +131,7 @@ def check_function(tree: ast.Module, rule: Rule, file_path: str) -> list[Violati lineno=node.lineno, name=func_name, message=msg, + rule_description=rule.description, ) ) diff --git a/python_naming_linter/checkers/module.py b/python_naming_linter/checkers/module.py index 659b8fc..790e08e 100644 --- a/python_naming_linter/checkers/module.py +++ b/python_naming_linter/checkers/module.py @@ -49,6 +49,7 @@ def check_module(tree: ast.Module, rule: Rule, file_path: str) -> list[Violation lineno=0, name=module_name, message=msg, + rule_description=rule.description, ) ) @@ -63,6 +64,7 @@ def check_module(tree: ast.Module, rule: Rule, file_path: str) -> list[Violation lineno=0, name=module_name, message=msg, + rule_description=rule.description, ) ) @@ -86,6 +88,7 @@ def check_module(tree: ast.Module, rule: Rule, file_path: str) -> list[Violation lineno=0, name=module_name, message=msg, + rule_description=rule.description, ) ) diff --git a/python_naming_linter/checkers/package.py b/python_naming_linter/checkers/package.py index 3621ee8..b0d24d2 100644 --- a/python_naming_linter/checkers/package.py +++ b/python_naming_linter/checkers/package.py @@ -19,6 +19,7 @@ def check_package(rule: Rule, package_name: str) -> list[Violation]: lineno=0, name=package_name, message="expected: snake_case", + rule_description=rule.description, ) ) @@ -31,6 +32,7 @@ def check_package(rule: Rule, package_name: str) -> list[Violation]: lineno=0, name=package_name, message=f"expected pattern: {naming['regex']}", + rule_description=rule.description, ) ) diff --git a/python_naming_linter/checkers/variable.py b/python_naming_linter/checkers/variable.py index 4698398..3a7bd99 100644 --- a/python_naming_linter/checkers/variable.py +++ b/python_naming_linter/checkers/variable.py @@ -170,6 +170,7 @@ def check_variable(tree: ast.Module, rule: Rule, file_path: str) -> list[Violati lineno=lineno, name=var_name, message=msg, + rule_description=rule.description, ) ) return violations diff --git a/python_naming_linter/config.py b/python_naming_linter/config.py index a4f0d12..2486789 100644 --- a/python_naming_linter/config.py +++ b/python_naming_linter/config.py @@ -15,6 +15,7 @@ class Rule: type: str naming: dict filter: dict = field(default_factory=dict) + description: str | None = None @dataclass @@ -49,6 +50,7 @@ def _parse_rules(rules_data: list[dict]) -> list[Rule]: type=r["type"], naming=r.get("naming", {}), filter=r.get("filter", {}), + description=r.get("description"), ) ) return rules diff --git a/python_naming_linter/reporter.py b/python_naming_linter/reporter.py index 646ce91..159acdf 100644 --- a/python_naming_linter/reporter.py +++ b/python_naming_linter/reporter.py @@ -10,7 +10,11 @@ def format_violations(file_path: str, violations: list[Violation]) -> str: lines = [] for v in violations: lines.append(f"{file_path}:{v.lineno}") - lines.append(f" [{v.rule_name}] {v.name} ({v.message})") + if v.rule_description: + lines.append(f" [{v.rule_name}] {v.rule_description}") + else: + lines.append(f" [{v.rule_name}]") + lines.append(f" {v.name} ({v.message})") lines.append("") return "\n".join(lines) diff --git a/tests/test_config.py b/tests/test_config.py index c060690..9a393b7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -20,6 +20,7 @@ def test_load_yaml_rule_fields(): assert rule.type == "variable" assert rule.filter == {"target": "attribute"} assert rule.naming == {"source": "type_annotation", "transform": "snake_case"} + assert rule.description is None def test_load_yaml_apply_fields(): @@ -30,6 +31,27 @@ def test_load_yaml_apply_fields(): assert apply.modules == "contexts.*.domain" +def test_load_yaml_rule_with_description(tmp_path): + config_content = """\ +rules: + - name: bool-method-prefix + description: "Bool-returning functions must use a semantic prefix" + type: function + filter: { return_type: bool } + naming: { prefix: [is_, has_, should_] } +apply: + - name: all + rules: [bool-method-prefix] + modules: "**" +""" + config_file = tmp_path / "config.yaml" + config_file.write_text(config_content) + config = load_config(config_file) + assert config.rules[0].description == ( + "Bool-returning functions must use a semantic prefix" + ) + + def test_load_yaml_with_include_exclude(tmp_path): config_content = """\ include: diff --git a/tests/test_reporter.py b/tests/test_reporter.py index 936ef70..290124e 100644 --- a/tests/test_reporter.py +++ b/tests/test_reporter.py @@ -19,6 +19,44 @@ def test_format_single_violation(): assert "subscription_repository" in output +def test_format_violation_with_description(): + violations = [ + Violation( + rule_name="bool-method-prefix", + file_path="src/service.py", + lineno=4, + name="validate", + message="expected prefix: is_ | has_ | should_", + rule_description="Bool-returning functions must use a semantic prefix", + ) + ] + output = format_violations("src/service.py", violations) + lines = output.strip().split("\n") + assert lines[0] == "src/service.py:4" + expected = ( + " [bool-method-prefix] Bool-returning functions must use a semantic prefix" + ) + assert lines[1] == expected + assert lines[2] == " validate (expected prefix: is_ | has_ | should_)" + + +def test_format_violation_without_description(): + violations = [ + Violation( + rule_name="attr-naming", + file_path="src/models.py", + lineno=15, + name="repo", + message="expected: subscription_repository", + ) + ] + output = format_violations("src/models.py", violations) + lines = output.strip().split("\n") + assert lines[0] == "src/models.py:15" + assert lines[1] == " [attr-naming]" + assert lines[2] == " repo (expected: subscription_repository)" + + def test_format_multiple_violations(): violations = [ Violation("r1", "f.py", 1, "a", "msg1"),