mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 09:13:30 +01:00
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>
This commit is contained in:
@@ -0,0 +1,568 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user