mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 01:03:45 +01:00
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>
569 lines
18 KiB
Python
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())
|