Files
EOS/scripts/lint_filenames.py
T
Bobby NoelteandGitHub e8dcfecf73 feat: add filename linter and AI coding guidelines (#1477)
Add a configurable filename linter to enforce naming conventions and an AGENTS.md to guide
AI coding agents in following project conventions.

The guidelines steer code generation; the linter independently verifies filename compliance.
Together, they improve consistency and reduce unnecessary files and review effort.

Conflicting file names were renamed.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2026-10-30 15:17:12 +01:00

569 lines
18 KiB
Python

"""Lint filenames against repository-specific naming conventions.
This script validates filenames using rules configured in ``pyproject.toml``.
Rules can target directories, enforce regular-expression patterns, allow
specific filenames, set maximum filename lengths, and exclude paths.
Usage:
Check all applicable repository files::
uv run python scripts/lint_filenames.py
Check selected files::
uv run python scripts/lint_filenames.py tests/test_adapter.py
Specify a configuration file::
uv run python scripts/lint_filenames.py --config pyproject.toml
Run the pre-commit hook::
uv run pre-commit run eos-filename-linter --all-files
Configuration:
Configure ``[tool.eos-filename-linter]`` in ``pyproject.toml``.
Global ``exclude`` patterns exclude files and directories.
``max_filename_length`` sets the default maximum filename length,
including the extension. The default is 64 characters.
Each ``[[tool.eos-filename-linter.rules]]`` entry defines a rule with:
name: Unique descriptive rule name.
paths: Repository-relative glob patterns.
pattern: Regular expression matched against the filename.
allowed_names: Optional filenames exempt from the pattern.
exclude: Optional additional exclusion patterns.
max_filename_length: Optional rule-specific length limit.
suggestion: Optional filename normalization strategy:
test_prefix_alnum: "test_" followed by lowercase letters and digits.
single_test_prefix_alnum: "single_test_" followed by lowercase
letters and digits.
lowercase_alnum: Lowercase letters and digits only, beginning
with a letter.
lowercase_snake: Lowercase snake_case.
Rules are evaluated in configuration order; the first applicable rule wins.
A rule that excludes a path is skipped, allowing a later rule to apply.
Glob patterns support ``*``, ``?``, and ``**``.
Test filenames must use the ``test_`` prefix followed by lowercase
letters and digits only. For example, ``test_adapter123.py`` is valid,
while ``test_adapter_device.py`` is not.
Excluded paths are not checked. During repository-wide scans, excluded
directories are pruned to avoid traversing their contents.
Exit status:
0: No violations found.
1: One or more filename violations found.
2: Invalid configuration, invalid arguments, or an operational error.
The linter never renames files. Suggestions are diagnostic hints only.
"""
from __future__ import annotations
import argparse
import os
import re
import sys
import tomllib
from pathlib import Path
from typing import Any
DEFAULT_MAX_FILENAME_LENGTH = 64
PYTHON_EXTENSION = ".py"
def _glob_to_regex(pattern: str) -> re.Pattern[str]:
"""Compile a repository-relative glob into a regular expression.
Args:
pattern: A slash-separated glob pattern.
Returns:
A regular expression that matches the entire relative path.
"""
pattern = pattern.replace("\\", "/")
parts: list[str] = []
index = 0
while index < len(pattern):
if pattern[index : index + 2] == "**":
index += 2
if index < len(pattern) and pattern[index] == "/":
parts.append("(?:.*/)?")
index += 1
else:
parts.append(".*")
elif pattern[index] == "*":
parts.append("[^/]*")
index += 1
elif pattern[index] == "?":
parts.append("[^/]")
index += 1
else:
parts.append(re.escape(pattern[index]))
index += 1
return re.compile("^" + "".join(parts) + "$")
def _matches_any(path: str, patterns: list[str]) -> bool:
"""Check whether a path matches at least one glob pattern.
Args:
path: A repository-relative POSIX path.
patterns: Glob patterns to evaluate.
Returns:
True if at least one pattern matches.
"""
return any(_glob_to_regex(pattern).fullmatch(path) for pattern in patterns)
def _is_excluded(path: str, patterns: list[str]) -> bool:
"""Check whether a file or directory is excluded.
A directory exclusion also excludes every descendant of that directory.
Args:
path: A repository-relative POSIX path.
patterns: Glob patterns to evaluate.
Returns:
True if the path is excluded.
"""
normalized = path.strip("/")
for pattern in patterns:
normalized_pattern = pattern.replace("\\", "/").strip("/")
if _matches_any(normalized, [normalized_pattern]):
return True
# An exclusion such as "docs/generated/**" should also match
# the directory "docs/generated" so that it can be pruned.
prefix = normalized_pattern.removesuffix("/**").rstrip("/")
if prefix and (
normalized == prefix or normalized.startswith(prefix + "/")
):
return True
return False
def _normalize_alphanumeric(stem: str) -> str:
"""Convert a filename stem to lowercase alphanumeric characters.
CamelCase word boundaries are handled before punctuation and underscores
are removed.
Args:
stem: Filename stem without its extension.
Returns:
A lowercase alphanumeric string, or an empty string if no characters
remain.
"""
stem = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", stem)
stem = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", stem)
return re.sub(r"[^a-zA-Z0-9]", "", stem).lower()
def _normalize_snake_case(stem: str) -> str:
"""Convert a filename stem to lowercase snake_case.
Args:
stem: Filename stem without its extension.
Returns:
A lowercase snake_case string.
"""
stem = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", stem)
stem = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", stem)
stem = re.sub(r"[^a-zA-Z0-9]+", "_", stem)
return stem.strip("_").lower()
def _suggest_filename(
filename: str,
rule: dict[str, Any],
max_length: int = DEFAULT_MAX_FILENAME_LENGTH,
) -> str | None:
"""Suggest a filename that complies with a configured naming rule.
The rule's ``suggestion`` setting determines the normalization strategy:
- ``test_prefix_alnum``: Require ``test_`` followed by lowercase letters
and digits.
- ``lowercase_alnum``: Use lowercase letters and digits only.
- ``lowercase_snake``: Use lowercase snake_case.
The generated candidate is checked against the rule's regular expression
and maximum filename length before it is returned.
Args:
filename: Original filename, including its extension.
rule: Applicable naming rule.
max_length: Maximum filename length, including the extension.
Returns:
A compliant suggested filename, or None if a valid suggestion cannot
be generated.
"""
path = Path(filename)
strategy = rule.get("suggestion")
pattern = rule.get("pattern")
if not isinstance(pattern, str):
return None
extension = path.suffix.lower()
stem = path.stem
if strategy == "test_prefix_alnum":
prefix = "test_"
if stem.startswith(prefix):
stem = stem[len(prefix):]
normalized = _normalize_alphanumeric(stem) or "module"
available = max_length - len(prefix) - len(extension)
if available < 1:
return None
candidate = f"{prefix}{normalized[:available]}{extension}"
elif strategy == "single_test_prefix_alnum":
prefix = "single_test_"
if stem.startswith(prefix):
stem = stem[len(prefix):]
normalized = _normalize_alphanumeric(stem) or "module"
available = max_length - len(prefix) - len(extension)
if available < 1:
return None
candidate = f"{prefix}{normalized[:available]}{extension}"
elif strategy == "lowercase_alnum":
normalized = _normalize_alphanumeric(stem) or "module"
if not normalized[0].isalpha():
normalized = f"module{normalized}"
available = max_length - len(extension)
if available < 1:
return None
candidate = f"{normalized[:available]}{extension}"
elif strategy == "lowercase_snake":
normalized = _normalize_snake_case(stem) or "module"
available = max_length - len(extension)
if available < 1:
return None
candidate = f"{normalized[:available].rstrip('_') or 'm'}{extension}"
else:
# Unknown strategies should not silently produce misleading names.
return None
if len(candidate) > max_length:
return None
if re.fullmatch(pattern, candidate) is None:
return None
return candidate
def _load_config(config_path: Path) -> dict[str, Any]:
"""Load and validate the filename linter configuration.
Args:
config_path: Path to the TOML configuration file.
Returns:
The linter's configuration dictionary.
Raises:
ValueError: If required configuration values are invalid.
OSError: If the configuration cannot be read.
tomllib.TOMLDecodeError: If TOML parsing fails.
"""
with config_path.open("rb") as config_file:
document = tomllib.load(config_file)
config = document.get("tool", {}).get("eos-filename-linter")
if not isinstance(config, dict):
raise ValueError("Missing [tool.eos-filename-linter] configuration.")
global_limit = config.get(
"max_filename_length", DEFAULT_MAX_FILENAME_LENGTH
)
if type(global_limit) is not int or global_limit < 1:
raise ValueError("max_filename_length must be a positive integer.")
for key in ("exclude",):
if key in config and (
not isinstance(config[key], list)
or not all(isinstance(item, str) for item in config[key])
):
raise ValueError(f"{key} must be a list of strings.")
rules = config.get("rules")
if not isinstance(rules, list) or not rules:
raise ValueError("At least one filename linter rule is required.")
seen_names: set[str] = set()
for index, rule in enumerate(rules, start=1):
if not isinstance(rule, dict):
raise ValueError(f"Rule {index} must be a TOML table.")
name = rule.get("name")
if not isinstance(name, str) or not name:
raise ValueError(f"Rule {index} requires a non-empty name.")
if name in seen_names:
raise ValueError(f"Duplicate rule name: {name!r}.")
seen_names.add(name)
paths = rule.get("paths")
if not isinstance(paths, list) or not paths or not all(
isinstance(item, str) and item for item in paths
):
raise ValueError(f"Rule {name!r} requires non-empty path patterns.")
pattern = rule.get("pattern")
if not isinstance(pattern, str):
raise ValueError(f"Rule {name!r} requires a regex pattern.")
try:
re.compile(pattern)
except re.error as exc:
raise ValueError(f"Rule {name!r} has invalid regex: {exc}") from exc
for key in ("allowed_names", "exclude"):
if key in rule and (
not isinstance(rule[key], list)
or not all(isinstance(item, str) for item in rule[key])
):
raise ValueError(f"Rule {name!r}: {key} must be a list of strings.")
limit = rule.get("max_filename_length", global_limit)
if type(limit) is not int or limit < 1:
raise ValueError(
f"Rule {name!r}: max_filename_length must be a positive integer."
)
suggestion = rule.get("suggestion")
valid_suggestions = {
"test_prefix_alnum",
"single_test_prefix_alnum",
"lowercase_alnum",
"lowercase_snake",
}
if suggestion is not None and suggestion not in valid_suggestions:
raise ValueError(
f"Rule {name!r}: unknown suggestion strategy "
f"{suggestion!r}. Expected one of "
f"{sorted(valid_suggestions)}."
)
return config
def _relative_path(path: str, root: Path) -> str | None:
"""Resolve a path to a repository-relative POSIX path.
Args:
path: The supplied path.
root: The repository root.
Returns:
A relative path, or None if the path is outside the repository.
"""
candidate = Path(path)
if not candidate.is_absolute():
candidate = root / candidate
try:
return candidate.resolve().relative_to(root.resolve()).as_posix()
except ValueError:
return None
def _find_rule(
relative_path: str, rules: list[dict[str, Any]]
) -> dict[str, Any] | None:
"""Return the first applicable rule matching a repository-relative path.
A rule is applicable when its ``paths`` patterns match the path and the
path is not excluded by that rule's ``exclude`` patterns. Excluded rules
are skipped so that a later, more specific rule can still apply to the
same path. (For example, a general ``tests/**/*.py`` rule that excludes
``tests/single_test_*`` must not shadow the dedicated single-test rule.)
Args:
relative_path: The path to match.
rules: Rules in configuration order.
Returns:
The first applicable rule, or None.
"""
for rule in rules:
if not _matches_any(relative_path, rule["paths"]):
continue
if _is_excluded(relative_path, rule.get("exclude", [])):
continue
return rule
return None
def _iter_repository_files(
root: Path, config: dict[str, Any]
) -> list[str]:
"""Find repository files while pruning excluded directories.
Args:
root: Repository root.
config: Validated linter configuration.
Returns:
Repository-relative paths for candidate files.
"""
global_excludes = config.get("exclude", [])
rules = config["rules"]
paths: list[str] = []
for current_root, directory_names, filenames in os.walk(root):
current = Path(current_root)
relative_dir = current.relative_to(root).as_posix()
if relative_dir == ".":
relative_dir = ""
directory_names[:] = [
name
for name in directory_names
if not _is_excluded(
f"{relative_dir}/{name}".lstrip("/"), global_excludes
)
]
for filename in filenames:
relative_path = f"{relative_dir}/{filename}".lstrip("/")
if _is_excluded(relative_path, global_excludes):
continue
if _find_rule(relative_path, rules) is not None:
paths.append(relative_path)
return paths
def lint_paths(
paths: list[str],
root: Path,
config: dict[str, Any],
) -> int:
"""Validate filenames and print diagnostics for violations.
Args:
paths: Files to check.
root: Repository root.
config: Validated linter configuration.
Returns:
The number of violations found.
"""
global_excludes = config.get("exclude", [])
global_limit = config.get(
"max_filename_length", DEFAULT_MAX_FILENAME_LENGTH
)
rules = config["rules"]
violations = 0
for supplied_path in paths:
relative_path = _relative_path(supplied_path, root)
if relative_path is None:
print(f"ERROR: {supplied_path}: outside repository root {root}", file=sys.stderr)
violations += 1
continue
if _is_excluded(relative_path, global_excludes):
continue
# ``_find_rule`` already skips rules whose own ``exclude`` patterns
# match the path, so a rule returned here is applicable.
rule = _find_rule(relative_path, rules)
if rule is None:
continue
filename = Path(relative_path).name
max_length = rule.get("max_filename_length", global_limit)
allowed_names = rule.get("allowed_names", [])
pattern_matches = re.fullmatch(rule["pattern"], filename) is not None
too_long = len(filename) > max_length
if filename in allowed_names and not too_long:
continue
if pattern_matches and not too_long:
continue
suggestion = _suggest_filename(filename, rule, max_length)
print(f"{relative_path}")
if not pattern_matches and filename not in allowed_names:
print(f" ERROR: Filename violates rule {rule['name']!r}.")
print(f" Expected pattern: {rule['pattern']}")
if too_long:
print(
f" ERROR: Filename is {len(filename)} characters long; "
f"maximum is {max_length}."
)
if suggestion is not None:
print(f" Suggested name: {suggestion}")
else:
print(" Suggested name: No safe suggestion available.")
violations += 1
return violations
def main(argv: list[str] | None = None) -> int:
"""Run the filename linter command-line interface.
Args:
argv: Optional command-line arguments.
Returns:
Exit status: 0 for success, 1 for violations, or 2 for errors.
"""
parser = argparse.ArgumentParser(
description="Lint filenames using Akkudoktor-EOS naming conventions."
)
parser.add_argument(
"--config",
type=Path,
default=Path("pyproject.toml"),
help="Path to pyproject.toml.",
)
parser.add_argument("paths", nargs="*", help="Files to check.")
args = parser.parse_args(argv)
config_path = args.config.resolve()
try:
config = _load_config(config_path)
root = config_path.parent.resolve()
paths = args.paths or _iter_repository_files(root, config)
violations = lint_paths(paths, root, config)
except (OSError, ValueError, tomllib.TOMLDecodeError) as exc:
print(f"Filename linter error: {exc}", file=sys.stderr)
return 2
if violations:
print(f"\nFound {violations} filename violation(s).", file=sys.stderr)
return 1
print("Filename lint passed.")
return 0
if __name__ == "__main__":
sys.exit(main())