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/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,6 @@
1
+ """Configuration module for strict-module."""
2
+
3
+ from ._config import Config, LocCapConfig
4
+ from ._version import __version__
5
+
6
+ __all__ = ["Config", "LocCapConfig", "__version__"]
@@ -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
@@ -0,0 +1,3 @@
1
+ """Version single-source for strict-module."""
2
+
3
+ __version__ = "0.5.0"
@@ -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
@@ -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