strict-module 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- strict_module/__init__.py +7 -0
- strict_module/checkers.py +806 -0
- strict_module/cli.py +152 -0
- strict_module/config/__init__.py +6 -0
- strict_module/config/_config.py +97 -0
- strict_module/config/_version.py +3 -0
- strict_module/linter.py +212 -0
- strict_module/loc_cap.py +228 -0
- strict_module/py.typed +0 -0
- strict_module/rules.py +362 -0
- strict_module-0.5.0.dist-info/METADATA +740 -0
- strict_module-0.5.0.dist-info/RECORD +15 -0
- strict_module-0.5.0.dist-info/WHEEL +4 -0
- strict_module-0.5.0.dist-info/entry_points.txt +3 -0
- strict_module-0.5.0.dist-info/licenses/LICENSE +201 -0
strict_module/cli.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Command-line interface for strict-module (formerly dto-strict)."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .config import Config
|
|
9
|
+
from .linter import DtoStrictLinter
|
|
10
|
+
from .loc_cap import run_loc_cap
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def handle_loc_cap(args: list[str]) -> int:
|
|
14
|
+
"""Handle loc-cap subcommand (invoked when sys.argv[1] == 'loc-cap')."""
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="dto-strict loc-cap",
|
|
17
|
+
description="LOC cap enforcer with configurable hard/soft limits and ratchet baseline.",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument("path", help="Path to directory to scan")
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--config",
|
|
22
|
+
default="pyproject.toml",
|
|
23
|
+
help="Path to pyproject.toml config file (default: pyproject.toml)",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--hard-cap",
|
|
27
|
+
type=int,
|
|
28
|
+
help="Hard cap limit (default: 694, or from [tool.strict-module.loc-cap].hard_cap)",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--soft-target",
|
|
32
|
+
type=int,
|
|
33
|
+
help="Soft target limit (default: 500, or from [tool.strict-module.loc-cap].soft_target)",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--baseline",
|
|
37
|
+
help="Baseline file path (default: .loc-cap-baseline.txt, or from [tool.strict-module.loc-cap].baseline_file)",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--generate-baseline",
|
|
41
|
+
action="store_true",
|
|
42
|
+
help="Generate baseline from all Python files in path (stdout)",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
parsed = parser.parse_args(args[2:]) # Skip 'dto-strict' and 'loc-cap'
|
|
46
|
+
|
|
47
|
+
# Load config from pyproject
|
|
48
|
+
config_path = Path(parsed.config)
|
|
49
|
+
config = Config.from_pyproject(config_path)
|
|
50
|
+
|
|
51
|
+
# Determine final values: CLI flag > config file > default
|
|
52
|
+
hard_cap = (
|
|
53
|
+
parsed.hard_cap if parsed.hard_cap is not None else config.loc_cap.hard_cap
|
|
54
|
+
)
|
|
55
|
+
soft_target = (
|
|
56
|
+
parsed.soft_target
|
|
57
|
+
if parsed.soft_target is not None
|
|
58
|
+
else config.loc_cap.soft_target
|
|
59
|
+
)
|
|
60
|
+
baseline_file = parsed.baseline if parsed.baseline else config.loc_cap.baseline_file
|
|
61
|
+
|
|
62
|
+
# Run loc-cap checker
|
|
63
|
+
return run_loc_cap(
|
|
64
|
+
path=parsed.path,
|
|
65
|
+
hard_cap=hard_cap,
|
|
66
|
+
soft_target=soft_target,
|
|
67
|
+
baseline_file=baseline_file,
|
|
68
|
+
generate=parsed.generate_baseline,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main() -> int:
|
|
73
|
+
"""Main CLI entry point."""
|
|
74
|
+
# Check for loc-cap subcommand (dispatch before argparse)
|
|
75
|
+
if len(sys.argv) > 1 and sys.argv[1] == "loc-cap":
|
|
76
|
+
return handle_loc_cap(sys.argv)
|
|
77
|
+
|
|
78
|
+
parser = argparse.ArgumentParser(
|
|
79
|
+
description="AST-based linter for Python DTO discipline and facade-ban enforcement."
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument("path", nargs="?", help="Path to file or directory to lint")
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--config",
|
|
84
|
+
default="pyproject.toml",
|
|
85
|
+
help="Path to pyproject.toml config file (default: pyproject.toml)",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--format",
|
|
89
|
+
choices=["text", "github", "json"],
|
|
90
|
+
default="text",
|
|
91
|
+
help="Output format (default: text)",
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"--generate-baseline",
|
|
95
|
+
action="store_true",
|
|
96
|
+
help="Generate baseline JSON from all violations in path and output to stdout",
|
|
97
|
+
)
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"--baseline",
|
|
100
|
+
type=Path,
|
|
101
|
+
help="Path to baseline JSON file; violations in baseline are accepted (ratchet mode)",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
args = parser.parse_args()
|
|
105
|
+
|
|
106
|
+
# Load configuration
|
|
107
|
+
config_path = Path(args.config)
|
|
108
|
+
config = Config.from_pyproject(config_path)
|
|
109
|
+
|
|
110
|
+
# If generating baseline, require path
|
|
111
|
+
if args.generate_baseline:
|
|
112
|
+
if not args.path:
|
|
113
|
+
print("error: --generate-baseline requires PATH argument", file=sys.stderr)
|
|
114
|
+
return 1
|
|
115
|
+
|
|
116
|
+
target_path = Path(args.path)
|
|
117
|
+
linter = DtoStrictLinter(config)
|
|
118
|
+
violations = linter.lint_path(target_path)
|
|
119
|
+
baseline_data = linter.generate_baseline(violations)
|
|
120
|
+
print(json.dumps(baseline_data, indent=2))
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
# Normal linting mode
|
|
124
|
+
if not args.path:
|
|
125
|
+
print(
|
|
126
|
+
"error: PATH argument required (unless using --generate-baseline)",
|
|
127
|
+
file=sys.stderr,
|
|
128
|
+
)
|
|
129
|
+
return 1
|
|
130
|
+
|
|
131
|
+
target_path = Path(args.path)
|
|
132
|
+
|
|
133
|
+
# Load baseline if provided
|
|
134
|
+
baseline = None
|
|
135
|
+
if args.baseline:
|
|
136
|
+
baseline = DtoStrictLinter.load_baseline(args.baseline)
|
|
137
|
+
|
|
138
|
+
# Lint path
|
|
139
|
+
linter = DtoStrictLinter(config, baseline=baseline)
|
|
140
|
+
violations = linter.lint_path(target_path)
|
|
141
|
+
|
|
142
|
+
# Output results
|
|
143
|
+
if violations:
|
|
144
|
+
output = linter.format_violations(violations, args.format)
|
|
145
|
+
print(output)
|
|
146
|
+
|
|
147
|
+
# Return exit code
|
|
148
|
+
return linter.get_exit_code(violations)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
sys.exit(main())
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Configuration loading for strict-module (formerly dto-strict)."""
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
import tomllib # Python 3.11+ stdlib
|
|
5
|
+
except ModuleNotFoundError: # Python 3.10
|
|
6
|
+
import tomli as tomllib
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class LocCapConfig:
|
|
13
|
+
"""Configuration for LOC cap checker."""
|
|
14
|
+
|
|
15
|
+
hard_cap: int = 694
|
|
16
|
+
soft_target: int = 500
|
|
17
|
+
baseline_file: str = ".loc-cap-baseline.txt"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class Config:
|
|
22
|
+
"""Configuration for strict-module linter."""
|
|
23
|
+
|
|
24
|
+
service_paths: list[str] = field(
|
|
25
|
+
default_factory=lambda: ["apps/*/services/*.py", "**/services/*.py"]
|
|
26
|
+
)
|
|
27
|
+
dto_paths: list[str] = field(default_factory=lambda: ["**/dtos.py", "**/dtos/*.py"])
|
|
28
|
+
exception_tags: list[str] = field(
|
|
29
|
+
default_factory=lambda: ["facade — celery schedule", "FRAMEWORK"]
|
|
30
|
+
)
|
|
31
|
+
disabled_rules: list[str] = field(default_factory=list)
|
|
32
|
+
severity_overrides: dict[str, str] = field(default_factory=dict)
|
|
33
|
+
strict_collections: bool = field(default=False)
|
|
34
|
+
exception_tag_requires_justification: bool = field(default=False)
|
|
35
|
+
max_exception_tags_per_file: int | None = field(default=None)
|
|
36
|
+
r003_mode: str = field(default="canonical") # "canonical" or "legacy"
|
|
37
|
+
r006_paths: list[str] = field(
|
|
38
|
+
default_factory=lambda: ["apps/*/services/*.py", "**/services/*.py"]
|
|
39
|
+
)
|
|
40
|
+
min_dict_keys: int = field(default=3) # NEW: threshold for R002
|
|
41
|
+
r003_strict_repr: bool = field(
|
|
42
|
+
default=True
|
|
43
|
+
) # NEW: R003 strict mode (flags repr=False)
|
|
44
|
+
loc_cap: LocCapConfig = field(default_factory=LocCapConfig)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_pyproject(cls, path: Path) -> "Config":
|
|
48
|
+
"""Load configuration from pyproject.toml."""
|
|
49
|
+
if not path.exists():
|
|
50
|
+
return cls()
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
with open(path, "rb") as f:
|
|
54
|
+
data = tomllib.load(f)
|
|
55
|
+
except Exception:
|
|
56
|
+
return cls()
|
|
57
|
+
|
|
58
|
+
tool_data = data.get("tool", {})
|
|
59
|
+
config_data = tool_data.get("strict-module") or tool_data.get("dto-strict", {})
|
|
60
|
+
loc_cap_data = config_data.get("loc-cap", {})
|
|
61
|
+
|
|
62
|
+
loc_cap = LocCapConfig(
|
|
63
|
+
hard_cap=loc_cap_data.get("hard_cap", 694),
|
|
64
|
+
soft_target=loc_cap_data.get("soft_target", 500),
|
|
65
|
+
baseline_file=loc_cap_data.get("baseline_file", ".loc-cap-baseline.txt"),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return cls(
|
|
69
|
+
service_paths=config_data.get(
|
|
70
|
+
"service_paths",
|
|
71
|
+
["apps/*/services/*.py", "**/services/*.py"],
|
|
72
|
+
),
|
|
73
|
+
dto_paths=config_data.get("dto_paths", ["**/dtos.py", "**/dtos/*.py"]),
|
|
74
|
+
exception_tags=config_data.get(
|
|
75
|
+
"exception_tags",
|
|
76
|
+
["facade — celery schedule", "FRAMEWORK"],
|
|
77
|
+
),
|
|
78
|
+
disabled_rules=config_data.get("disabled_rules", []),
|
|
79
|
+
severity_overrides=config_data.get("severity_overrides", {}),
|
|
80
|
+
strict_collections=config_data.get("strict_collections", False),
|
|
81
|
+
exception_tag_requires_justification=config_data.get(
|
|
82
|
+
"exception_tag_requires_justification", False
|
|
83
|
+
),
|
|
84
|
+
max_exception_tags_per_file=config_data.get("max_exception_tags_per_file"),
|
|
85
|
+
r003_mode=config_data.get("r003_mode", "canonical"),
|
|
86
|
+
r006_paths=config_data.get(
|
|
87
|
+
"r006_paths",
|
|
88
|
+
["apps/*/services/*.py", "**/services/*.py"],
|
|
89
|
+
),
|
|
90
|
+
min_dict_keys=config_data.get("min_dict_keys", 3),
|
|
91
|
+
r003_strict_repr=config_data.get("r003_strict_repr", True),
|
|
92
|
+
loc_cap=loc_cap,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def is_rule_enabled(self, rule_id: str) -> bool:
|
|
96
|
+
"""Check if a rule is enabled."""
|
|
97
|
+
return rule_id not in self.disabled_rules
|
strict_module/linter.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Main linter implementation."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from .checkers import (
|
|
10
|
+
R001Checker,
|
|
11
|
+
R002Checker,
|
|
12
|
+
R003Checker,
|
|
13
|
+
R004Checker,
|
|
14
|
+
R005Checker,
|
|
15
|
+
R006Checker,
|
|
16
|
+
R007Checker,
|
|
17
|
+
R008Checker,
|
|
18
|
+
)
|
|
19
|
+
from .config import Config
|
|
20
|
+
from .rules import RuleSeverity, Violation
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class BaselineEntry:
|
|
25
|
+
"""Baseline entry for tracking accepted violations."""
|
|
26
|
+
|
|
27
|
+
file: str
|
|
28
|
+
line: int
|
|
29
|
+
rule_id: str
|
|
30
|
+
message_hash: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DtoStrictLinter:
|
|
34
|
+
"""AST-based linter for DTO discipline."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self, config: Config, baseline: dict[tuple[str, int, str], str] | None = None
|
|
38
|
+
):
|
|
39
|
+
self.config = config
|
|
40
|
+
self.violations: list[Violation] = []
|
|
41
|
+
self.baseline = (
|
|
42
|
+
baseline or {}
|
|
43
|
+
) # Key: (file, line, rule_id), Value: message_hash
|
|
44
|
+
|
|
45
|
+
def lint_file(self, file_path: Path) -> list[Violation]:
|
|
46
|
+
"""Lint a single Python file."""
|
|
47
|
+
if not file_path.suffix == ".py":
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
source = file_path.read_text()
|
|
52
|
+
except Exception:
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
tree = ast.parse(source)
|
|
57
|
+
except SyntaxError:
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
violations = []
|
|
61
|
+
|
|
62
|
+
# Run all checkers
|
|
63
|
+
for checker_cls in [
|
|
64
|
+
R001Checker,
|
|
65
|
+
R002Checker,
|
|
66
|
+
R003Checker,
|
|
67
|
+
R004Checker,
|
|
68
|
+
R005Checker,
|
|
69
|
+
R006Checker,
|
|
70
|
+
R007Checker,
|
|
71
|
+
R008Checker,
|
|
72
|
+
]:
|
|
73
|
+
checker = checker_cls(file_path, source, self.config)
|
|
74
|
+
checker.visit(tree)
|
|
75
|
+
violations.extend(checker.violations)
|
|
76
|
+
|
|
77
|
+
# Filter by enabled rules
|
|
78
|
+
filtered = []
|
|
79
|
+
for v in violations:
|
|
80
|
+
if self.config.is_rule_enabled(v.rule_id):
|
|
81
|
+
filtered.append(v)
|
|
82
|
+
|
|
83
|
+
# Apply baseline filtering if present
|
|
84
|
+
if self.baseline:
|
|
85
|
+
filtered = self._filter_by_baseline(filtered)
|
|
86
|
+
|
|
87
|
+
return filtered
|
|
88
|
+
|
|
89
|
+
def lint_path(self, path: Path) -> list[Violation]:
|
|
90
|
+
"""Lint all Python files in a directory or single file."""
|
|
91
|
+
all_violations = []
|
|
92
|
+
|
|
93
|
+
if path.is_file():
|
|
94
|
+
all_violations.extend(self.lint_file(path))
|
|
95
|
+
elif path.is_dir():
|
|
96
|
+
for py_file in path.rglob("*.py"):
|
|
97
|
+
all_violations.extend(self.lint_file(py_file))
|
|
98
|
+
|
|
99
|
+
# Filter by enabled rules
|
|
100
|
+
filtered = []
|
|
101
|
+
for v in all_violations:
|
|
102
|
+
if self.config.is_rule_enabled(v.rule_id):
|
|
103
|
+
filtered.append(v)
|
|
104
|
+
|
|
105
|
+
# Apply baseline filtering if present
|
|
106
|
+
if self.baseline:
|
|
107
|
+
filtered = self._filter_by_baseline(filtered)
|
|
108
|
+
|
|
109
|
+
# Apply severity overrides
|
|
110
|
+
for violation in filtered:
|
|
111
|
+
if violation.rule_id in self.config.severity_overrides:
|
|
112
|
+
new_severity = self.config.severity_overrides[violation.rule_id].upper()
|
|
113
|
+
if new_severity in ["HIGH", "MEDIUM", "LOW"]:
|
|
114
|
+
violation = Violation(
|
|
115
|
+
rule_id=violation.rule_id,
|
|
116
|
+
severity=RuleSeverity[new_severity],
|
|
117
|
+
file=violation.file,
|
|
118
|
+
line=violation.line,
|
|
119
|
+
col=violation.col,
|
|
120
|
+
message=violation.message,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return filtered
|
|
124
|
+
|
|
125
|
+
def _filter_by_baseline(self, violations: list[Violation]) -> list[Violation]:
|
|
126
|
+
"""Filter violations by baseline, removing accepted violations.
|
|
127
|
+
|
|
128
|
+
A violation is considered in baseline if file+line+rule_id match.
|
|
129
|
+
Message hash comparison adds extra confidence but is not strict
|
|
130
|
+
(allows for minor message wording changes).
|
|
131
|
+
"""
|
|
132
|
+
new_violations = []
|
|
133
|
+
for v in violations:
|
|
134
|
+
key = (v.file, v.line, v.rule_id)
|
|
135
|
+
if key not in self.baseline:
|
|
136
|
+
# New violation not in baseline
|
|
137
|
+
new_violations.append(v)
|
|
138
|
+
return new_violations
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _hash_message(message: str) -> str:
|
|
142
|
+
"""Hash violation message for baseline comparison."""
|
|
143
|
+
return hashlib.sha256(message.encode()).hexdigest()[:16]
|
|
144
|
+
|
|
145
|
+
def generate_baseline(self, violations: list[Violation]) -> list[dict]:
|
|
146
|
+
"""Generate baseline JSON from violations."""
|
|
147
|
+
baseline_data = []
|
|
148
|
+
for v in violations:
|
|
149
|
+
baseline_data.append(
|
|
150
|
+
{
|
|
151
|
+
"file": v.file,
|
|
152
|
+
"line": v.line,
|
|
153
|
+
"rule_id": v.rule_id,
|
|
154
|
+
"message_hash": self._hash_message(v.message),
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
return baseline_data
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def load_baseline(baseline_path: Path) -> dict[tuple[str, int, str], str]:
|
|
161
|
+
"""Load baseline JSON file."""
|
|
162
|
+
try:
|
|
163
|
+
with open(baseline_path, "r") as f:
|
|
164
|
+
data = json.load(f)
|
|
165
|
+
baseline = {}
|
|
166
|
+
for entry in data:
|
|
167
|
+
key = (entry["file"], entry["line"], entry["rule_id"])
|
|
168
|
+
baseline[key] = entry["message_hash"]
|
|
169
|
+
return baseline
|
|
170
|
+
except Exception:
|
|
171
|
+
return {}
|
|
172
|
+
|
|
173
|
+
def format_violations(
|
|
174
|
+
self, violations: list[Violation], format_type: str = "text"
|
|
175
|
+
) -> str:
|
|
176
|
+
"""Format violations for output."""
|
|
177
|
+
if format_type == "github":
|
|
178
|
+
return "\n".join(v.format_github() for v in violations)
|
|
179
|
+
elif format_type == "json":
|
|
180
|
+
import json
|
|
181
|
+
|
|
182
|
+
return json.dumps(
|
|
183
|
+
[
|
|
184
|
+
{
|
|
185
|
+
"rule_id": v.rule_id,
|
|
186
|
+
"severity": v.severity.value,
|
|
187
|
+
"file": v.file,
|
|
188
|
+
"line": v.line,
|
|
189
|
+
"col": v.col,
|
|
190
|
+
"message": v.message,
|
|
191
|
+
}
|
|
192
|
+
for v in violations
|
|
193
|
+
],
|
|
194
|
+
indent=2,
|
|
195
|
+
)
|
|
196
|
+
else: # text
|
|
197
|
+
return "\n".join(v.format_text() for v in violations)
|
|
198
|
+
|
|
199
|
+
def get_exit_code(self, violations: list[Violation]) -> int:
|
|
200
|
+
"""Determine exit code based on violation severities."""
|
|
201
|
+
if not violations:
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
has_high = any(v.severity == RuleSeverity.HIGH for v in violations)
|
|
205
|
+
has_medium = any(v.severity == RuleSeverity.MEDIUM for v in violations)
|
|
206
|
+
|
|
207
|
+
if has_high:
|
|
208
|
+
return 1
|
|
209
|
+
elif has_medium:
|
|
210
|
+
return 2
|
|
211
|
+
else:
|
|
212
|
+
return 3
|
strict_module/loc_cap.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""LOC cap enforcer for Python codebases.
|
|
2
|
+
|
|
3
|
+
Reads a baseline file (format: path:loc_count per line) and enforces:
|
|
4
|
+
- Hard cap: configurable (default 694 LOC)
|
|
5
|
+
- Soft target: configurable (default 500 LOC)
|
|
6
|
+
- Ratchet semantics: baselined files cannot grow; improvements are allowed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .rules import is_loc_test_file
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class LocCapConfig:
|
|
18
|
+
"""Configuration for LOC cap checker."""
|
|
19
|
+
|
|
20
|
+
hard_cap: int = 694
|
|
21
|
+
soft_target: int = 500
|
|
22
|
+
baseline_file: str = ".loc-cap-baseline.txt"
|
|
23
|
+
exclude_patterns: tuple[str, ...] = ("migrations", "management/commands")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def count_lines(file_path: str) -> int:
|
|
27
|
+
"""Count all lines (matches wc -l semantics for baseline compatibility)."""
|
|
28
|
+
try:
|
|
29
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
30
|
+
return sum(1 for _ in f)
|
|
31
|
+
except (OSError, UnicodeDecodeError):
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_baseline(baseline_file: str) -> dict[str, int]:
|
|
36
|
+
"""Load baseline LOC counts from file (with fallback for backward compatibility)."""
|
|
37
|
+
baseline = {}
|
|
38
|
+
|
|
39
|
+
actual_file = None
|
|
40
|
+
if os.path.isfile(baseline_file):
|
|
41
|
+
actual_file = baseline_file
|
|
42
|
+
elif baseline_file == ".loc-cap-baseline.txt":
|
|
43
|
+
fallback_names = [".strict-module-baseline.txt", ".dto-strict-baseline.txt"]
|
|
44
|
+
for fallback in fallback_names:
|
|
45
|
+
if os.path.isfile(fallback):
|
|
46
|
+
actual_file = fallback
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
if not actual_file:
|
|
50
|
+
return baseline
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
with open(actual_file, "r", encoding="utf-8") as f:
|
|
54
|
+
for line in f:
|
|
55
|
+
line = line.strip()
|
|
56
|
+
if not line:
|
|
57
|
+
continue
|
|
58
|
+
parts = line.rsplit(":", 1)
|
|
59
|
+
if len(parts) == 2:
|
|
60
|
+
path, loc_str = parts
|
|
61
|
+
try:
|
|
62
|
+
baseline[path.strip()] = int(loc_str.strip())
|
|
63
|
+
except ValueError:
|
|
64
|
+
continue
|
|
65
|
+
except OSError:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
return baseline
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def find_python_files(
|
|
72
|
+
root_path: str,
|
|
73
|
+
exclude_patterns: tuple[str, ...] = ("migrations", "management/commands"),
|
|
74
|
+
) -> dict[str, int]:
|
|
75
|
+
"""Find all Python files under root_path (excluding specified patterns and test files).
|
|
76
|
+
|
|
77
|
+
Test files are exempt from LOC cap enforcement. A file is a test file if:
|
|
78
|
+
- Its basename is conftest.py
|
|
79
|
+
- Its basename matches test_*.py
|
|
80
|
+
- It is under a tests/ directory
|
|
81
|
+
"""
|
|
82
|
+
current_locs = {}
|
|
83
|
+
root = Path(root_path).resolve() # Normalize to absolute path
|
|
84
|
+
|
|
85
|
+
if not root.exists():
|
|
86
|
+
return current_locs
|
|
87
|
+
|
|
88
|
+
for file_path in root.rglob("*.py"):
|
|
89
|
+
# Skip excluded patterns
|
|
90
|
+
file_str = str(file_path)
|
|
91
|
+
if any(excl in file_str for excl in exclude_patterns):
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
# Skip test files (conftest.py, test_*.py, or under tests/)
|
|
95
|
+
if is_loc_test_file(file_path):
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
loc = count_lines(file_str)
|
|
99
|
+
current_locs[file_str] = loc
|
|
100
|
+
|
|
101
|
+
return current_locs
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def generate_baseline(
|
|
105
|
+
root_path: str,
|
|
106
|
+
exclude_patterns: tuple[str, ...] = ("migrations", "management/commands"),
|
|
107
|
+
floor: int = 0,
|
|
108
|
+
) -> str:
|
|
109
|
+
"""Generate baseline output (path:loc format, sorted by LOC descending).
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
root_path: Root directory to scan
|
|
113
|
+
exclude_patterns: Patterns to exclude
|
|
114
|
+
floor: Only include files with LOC >= floor (default 0 includes all)
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Baseline text (path:loc per line, sorted by LOC desc)
|
|
118
|
+
"""
|
|
119
|
+
files = find_python_files(root_path, exclude_patterns)
|
|
120
|
+
|
|
121
|
+
# Filter by floor and sort by LOC descending, then by path for stability
|
|
122
|
+
filtered = [(path, loc) for path, loc in files.items() if loc >= floor]
|
|
123
|
+
sorted_files = sorted(filtered, key=lambda x: (-x[1], x[0]))
|
|
124
|
+
|
|
125
|
+
lines = [f"{path}:{loc}" for path, loc in sorted_files]
|
|
126
|
+
return "\n".join(lines)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run_loc_cap(
|
|
130
|
+
path: str,
|
|
131
|
+
hard_cap: int = 694,
|
|
132
|
+
soft_target: int = 500,
|
|
133
|
+
baseline_file: str = ".loc-cap-baseline.txt",
|
|
134
|
+
exclude_patterns: tuple[str, ...] = ("migrations", "management/commands"),
|
|
135
|
+
generate: bool = False,
|
|
136
|
+
) -> int:
|
|
137
|
+
"""Run LOC cap checker.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
path: Root directory to scan
|
|
141
|
+
hard_cap: Hard cap limit (default 694)
|
|
142
|
+
soft_target: Soft target limit (default 500)
|
|
143
|
+
baseline_file: Path to baseline file (default .loc-cap-baseline.txt)
|
|
144
|
+
exclude_patterns: Patterns to exclude (default migrations, management/commands)
|
|
145
|
+
generate: If True, generate baseline instead of checking
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Exit code (0 pass, 1 fail)
|
|
149
|
+
"""
|
|
150
|
+
if generate:
|
|
151
|
+
# Generate mode: compute LOC for all files and output baseline format
|
|
152
|
+
baseline_output = generate_baseline(path, exclude_patterns, floor=0)
|
|
153
|
+
print(baseline_output)
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
# Check mode: enforce LOC cap
|
|
157
|
+
baseline = load_baseline(baseline_file)
|
|
158
|
+
current = find_python_files(path, exclude_patterns)
|
|
159
|
+
|
|
160
|
+
soft_warnings = []
|
|
161
|
+
hard_violations = []
|
|
162
|
+
improvements = []
|
|
163
|
+
|
|
164
|
+
# Check each current file
|
|
165
|
+
for path_str, loc in current.items():
|
|
166
|
+
# Soft target warning (soft_target < loc <= hard_cap)
|
|
167
|
+
if soft_target < loc <= hard_cap:
|
|
168
|
+
soft_warnings.append(f" {loc} {path_str}")
|
|
169
|
+
|
|
170
|
+
# Hard cap violations
|
|
171
|
+
if loc > hard_cap:
|
|
172
|
+
if path_str not in baseline:
|
|
173
|
+
hard_violations.append(f" {loc} {path_str} (NEW OFFENDER)")
|
|
174
|
+
elif loc > baseline[path_str]:
|
|
175
|
+
delta = loc - baseline[path_str]
|
|
176
|
+
hard_violations.append(
|
|
177
|
+
f" {loc} {path_str} (was {baseline[path_str]}, grew by {delta})"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Check for improvements in baseline files
|
|
181
|
+
for baseline_path, baseline_loc in baseline.items():
|
|
182
|
+
current_loc = current.get(baseline_path)
|
|
183
|
+
|
|
184
|
+
if current_loc is None:
|
|
185
|
+
improvements.append(f" {baseline_path} (deleted)")
|
|
186
|
+
elif current_loc < baseline_loc:
|
|
187
|
+
if current_loc <= hard_cap:
|
|
188
|
+
delta = baseline_loc - current_loc
|
|
189
|
+
improvements.append(
|
|
190
|
+
f" {current_loc} {baseline_path} (was {baseline_loc}, improved by {delta})"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Output warnings for soft target
|
|
194
|
+
if soft_warnings:
|
|
195
|
+
print(
|
|
196
|
+
f"::warning::Files over soft target ({soft_target} LOC) — consider decomposition:"
|
|
197
|
+
)
|
|
198
|
+
print("\n".join(soft_warnings))
|
|
199
|
+
print()
|
|
200
|
+
|
|
201
|
+
# Output improvements
|
|
202
|
+
if improvements:
|
|
203
|
+
print(f"::notice::Files improved (under {hard_cap} LOC):")
|
|
204
|
+
print("\n".join(improvements))
|
|
205
|
+
print()
|
|
206
|
+
|
|
207
|
+
# Fail on hard violations
|
|
208
|
+
if hard_violations:
|
|
209
|
+
print(
|
|
210
|
+
f"::error::Files exceed hard cap of {hard_cap} LOC. Decompose by cohesion:"
|
|
211
|
+
)
|
|
212
|
+
print("\n".join(hard_violations))
|
|
213
|
+
print()
|
|
214
|
+
print(
|
|
215
|
+
"See memory/feedback_dto_refactor_autonomous_priority.md for cap rationale."
|
|
216
|
+
)
|
|
217
|
+
print(
|
|
218
|
+
"Tests: cap applies. Decompose by class-under-test; lift fixtures to conftest if needed."
|
|
219
|
+
)
|
|
220
|
+
print(
|
|
221
|
+
"Refactor approach: split by COHESION not line count. E.g., bedrock.py → bedrock/{invoke,embed,dtos}.py"
|
|
222
|
+
)
|
|
223
|
+
return 1
|
|
224
|
+
|
|
225
|
+
print(
|
|
226
|
+
f"✓ All Python files within {hard_cap} LOC cap (ratchet: baseline allows improvements)"
|
|
227
|
+
)
|
|
228
|
+
return 0
|
strict_module/py.typed
ADDED
|
File without changes
|