collab-runtime 0.2.9__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.
- collab/__init__.py +77 -0
- collab/__main__.py +11 -0
- collab_runtime-0.2.9.dist-info/METADATA +218 -0
- collab_runtime-0.2.9.dist-info/RECORD +82 -0
- collab_runtime-0.2.9.dist-info/WHEEL +5 -0
- collab_runtime-0.2.9.dist-info/entry_points.txt +3 -0
- collab_runtime-0.2.9.dist-info/licenses/LICENSE +21 -0
- collab_runtime-0.2.9.dist-info/top_level.txt +10 -0
- scripts/cleanup.py +395 -0
- scripts/collab_git_hook.py +190 -0
- scripts/format_code.py +594 -0
- scripts/generate_tests.py +560 -0
- scripts/validate_code.py +1397 -0
- src/__init__.py +4 -0
- src/dashboard/index.html +1131 -0
- src/live_locks_watcher.py +1982 -0
- src/lock_client.py +4268 -0
- src/logging_config.py +259 -0
- src/main.py +436 -0
- tests/backend/__init__.py +0 -0
- tests/backend/functional/__init__.py +0 -0
- tests/backend/functional/test_package_imports.py +43 -0
- tests/backend/integration/__init__.py +0 -0
- tests/backend/integration/test_cli_contract_parity.py +220 -0
- tests/backend/performance/__init__.py +0 -0
- tests/backend/reliability/__init__.py +0 -0
- tests/backend/security/__init__.py +0 -0
- tests/backend/unit/live_locks_watcher/__init__.py +5 -0
- tests/backend/unit/live_locks_watcher/_helpers.py +123 -0
- tests/backend/unit/live_locks_watcher/conftest.py +18 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_dashboard.py +188 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_developer.py +56 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_graceful_shutdown.py +459 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_main.py +1925 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_module.py +187 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_multi_session.py +320 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_notify.py +67 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_parsing.py +155 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_process_helpers.py +684 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_processing.py +173 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_prompt_abort.py +71 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_reconcile.py +516 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_scan.py +296 -0
- tests/backend/unit/lock_client/__init__.py +1 -0
- tests/backend/unit/lock_client/_helpers.py +132 -0
- tests/backend/unit/lock_client/test_lock_client_acquire.py +214 -0
- tests/backend/unit/lock_client/test_lock_client_active.py +104 -0
- tests/backend/unit/lock_client/test_lock_client_api.py +63 -0
- tests/backend/unit/lock_client/test_lock_client_cli.py +682 -0
- tests/backend/unit/lock_client/test_lock_client_daemon.py +3730 -0
- tests/backend/unit/lock_client/test_lock_client_dashboard.py +438 -0
- tests/backend/unit/lock_client/test_lock_client_discover.py +241 -0
- tests/backend/unit/lock_client/test_lock_client_force_release.py +354 -0
- tests/backend/unit/lock_client/test_lock_client_helper_branches.py +1890 -0
- tests/backend/unit/lock_client/test_lock_client_history.py +301 -0
- tests/backend/unit/lock_client/test_lock_client_isolation.py +316 -0
- tests/backend/unit/lock_client/test_lock_client_pid.py +75 -0
- tests/backend/unit/lock_client/test_lock_client_reconcile.py +464 -0
- tests/backend/unit/lock_client/test_lock_client_release.py +77 -0
- tests/backend/unit/lock_client/test_lock_client_shutdown.py +1110 -0
- tests/backend/unit/lock_client/test_lock_client_utils.py +474 -0
- tests/backend/unit/lock_client/test_lock_client_watch.py +866 -0
- tests/backend/unit/scripts/__init__.py +1 -0
- tests/backend/unit/scripts/_helpers.py +42 -0
- tests/backend/unit/scripts/test_cleanup.py +285 -0
- tests/backend/unit/scripts/test_collab_git_hook.py +280 -0
- tests/backend/unit/scripts/test_collab_git_hook_ported.py +50 -0
- tests/backend/unit/scripts/test_format_code.py +368 -0
- tests/backend/unit/scripts/test_format_code_ported.py +177 -0
- tests/backend/unit/scripts/test_generate_tests.py +305 -0
- tests/backend/unit/scripts/test_hook_templates.py +357 -0
- tests/backend/unit/scripts/test_setup_hook_overlay.py +95 -0
- tests/backend/unit/scripts/test_validate_code.py +867 -0
- tests/backend/unit/scripts/test_validate_code_ported.py +237 -0
- tests/backend/unit/test_entrypoints_main_run.py +83 -0
- tests/backend/unit/test_logging_config.py +529 -0
- tests/backend/unit/test_main_watch_pid_file.py +278 -0
- tests/conftest.py +167 -0
- tests/frontend/__init__.py +0 -0
- tests/frontend/jest/__init__.py +0 -0
- tests/frontend/playwright/__init__.py +0 -0
- tests/packaging/test_smoke_install.py +76 -0
scripts/validate_code.py
ADDED
|
@@ -0,0 +1,1397 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Comprehensive Code Validation Script.
|
|
3
|
+
|
|
4
|
+
This script runs all validation checks that should pass before committing code.
|
|
5
|
+
It simulates the CI pipeline locally to catch issues early.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python scripts/validate_code.py # Run all checks (full suite)
|
|
9
|
+
python scripts/validate_code.py --backend # Only backend checks
|
|
10
|
+
python scripts/validate_code.py --frontend # Only frontend checks
|
|
11
|
+
python scripts/validate_code.py --docs # Only documentation checks
|
|
12
|
+
python scripts/validate_code.py --quick # Smart mode: targeted tests only
|
|
13
|
+
|
|
14
|
+
Smart --quick mode (three-tier priority):
|
|
15
|
+
Tier 1 — git-diff: Maps changed files to their test dirs and runs only
|
|
16
|
+
those directories (most precise, fastest).
|
|
17
|
+
Tier 2 — fallback: No changes detected or global file changed -> runs the
|
|
18
|
+
full suite without coverage (fast, safe).
|
|
19
|
+
Skip — no-op: No backend/frontend changes in that category -> skipped.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import hashlib
|
|
24
|
+
import io
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import shutil
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
import tempfile
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Callable, Dict, List, Literal, Optional, Tuple
|
|
34
|
+
|
|
35
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
36
|
+
from cleanup import clean_default, clean_packaging # noqa: E402
|
|
37
|
+
|
|
38
|
+
# Load .env variables so validate_code.py knows about local configuration
|
|
39
|
+
_load_dotenv: Optional[Callable[..., bool]]
|
|
40
|
+
try:
|
|
41
|
+
from dotenv import load_dotenv as _load_dotenv
|
|
42
|
+
except ImportError:
|
|
43
|
+
_load_dotenv = None # python-dotenv might not be installed in base env.
|
|
44
|
+
|
|
45
|
+
if _load_dotenv is not None:
|
|
46
|
+
_load_dotenv()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _configure_coverage_data_file() -> None:
|
|
50
|
+
"""Route coverage data outside the repository tree on local machines only.
|
|
51
|
+
|
|
52
|
+
This prevents `.coverage.*` shard files from cluttering the workspace root when
|
|
53
|
+
pytest-cov writes parallel data. Skip this in CI environments where coverage file
|
|
54
|
+
persistence across subprocess invocations is critical.
|
|
55
|
+
"""
|
|
56
|
+
if os.getenv("COVERAGE_FILE"):
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
# Skip temp directory routing in CI/GitHub Actions where coverage file
|
|
60
|
+
# must be written to project root for `coverage report` to find it.
|
|
61
|
+
if os.getenv("CI") or os.getenv("GITHUB_ACTIONS"):
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
project_root = Path(__file__).resolve().parent.parent
|
|
66
|
+
digest = hashlib.sha1(
|
|
67
|
+
str(project_root).encode("utf-8"), usedforsecurity=False
|
|
68
|
+
).hexdigest()[:12]
|
|
69
|
+
cov_dir = Path(tempfile.gettempdir()) / "collab" / "coverage" / digest
|
|
70
|
+
cov_dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
os.environ["COVERAGE_FILE"] = str(cov_dir / ".coverage")
|
|
72
|
+
except Exception:
|
|
73
|
+
# Best effort: fallback to tool default behavior if temp setup fails.
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_configure_coverage_data_file()
|
|
78
|
+
|
|
79
|
+
# Fix Windows console encoding for UTF-8 output
|
|
80
|
+
if sys.stdout.encoding != "utf-8":
|
|
81
|
+
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
|
82
|
+
if sys.stderr.encoding != "utf-8":
|
|
83
|
+
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
|
84
|
+
|
|
85
|
+
# Additional fix for Windows UnicodeEncodeError when printing special characters
|
|
86
|
+
# Only call reconfigure if it exists and sys.stdout is a standard stream
|
|
87
|
+
# (not a wrapped TextIOWrapper)
|
|
88
|
+
if sys.platform == "win32":
|
|
89
|
+
orig_stdout = sys.__stdout__ if hasattr(sys, "__stdout__") else None
|
|
90
|
+
if orig_stdout and hasattr(orig_stdout, "reconfigure"):
|
|
91
|
+
try:
|
|
92
|
+
orig_stdout.reconfigure(encoding="utf-8")
|
|
93
|
+
except Exception:
|
|
94
|
+
pass # Fallback to default behavior if reconfigure fails
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Colors:
|
|
98
|
+
HEADER = "\033[95m"
|
|
99
|
+
OKBLUE = "\033[94m"
|
|
100
|
+
OKCYAN = "\033[96m"
|
|
101
|
+
OKGREEN = "\033[92m"
|
|
102
|
+
WARNING = "\033[93m"
|
|
103
|
+
FAIL = "\033[91m"
|
|
104
|
+
ENDC = "\033[0m"
|
|
105
|
+
BOLD = "\033[1m"
|
|
106
|
+
UNDERLINE = "\033[4m"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def print_header(message: str) -> None:
|
|
110
|
+
print(f"\n{Colors.HEADER}{Colors.BOLD}{'=' * 80}{Colors.ENDC}")
|
|
111
|
+
print(f"{Colors.HEADER}{Colors.BOLD}{message.center(80)}{Colors.ENDC}")
|
|
112
|
+
print(f"{Colors.HEADER}{Colors.BOLD}{'=' * 80}{Colors.ENDC}\n")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def print_section(message: str) -> None:
|
|
116
|
+
print(f"\n{Colors.OKBLUE}{Colors.BOLD}{'-' * 80}{Colors.ENDC}")
|
|
117
|
+
print(f"{Colors.OKBLUE}{Colors.BOLD}{message}{Colors.ENDC}")
|
|
118
|
+
print(f"{Colors.OKBLUE}{Colors.BOLD}{'-' * 80}{Colors.ENDC}\n")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def print_success(message: str) -> None:
|
|
122
|
+
print(f"{Colors.OKGREEN}[OK] {message}{Colors.ENDC}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def print_error(message: str) -> None:
|
|
126
|
+
print(f"{Colors.FAIL}[FAIL] {message}{Colors.ENDC}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def print_warning(message: str) -> None:
|
|
130
|
+
print(f"{Colors.WARNING}[WARN] {message}{Colors.ENDC}")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def print_skipped(message: str) -> None:
|
|
134
|
+
print(f"{Colors.OKCYAN}[SKIPPED] {message}{Colors.ENDC}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
ValidationStatus = Literal["passed", "failed", "skipped"]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _check_succeeded(status: ValidationStatus | bool) -> bool:
|
|
141
|
+
if status == "skipped":
|
|
142
|
+
return True
|
|
143
|
+
return bool(status)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _print_check_summary(name: str, status: ValidationStatus | bool) -> None:
|
|
147
|
+
if status == "skipped":
|
|
148
|
+
print_skipped(name)
|
|
149
|
+
elif status:
|
|
150
|
+
print_success(name)
|
|
151
|
+
else:
|
|
152
|
+
print_error(name)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
_MAX_FAILURE_OUTPUT_LINES = 150
|
|
156
|
+
_FAILURE_HEAD_LINES = 20
|
|
157
|
+
_FAILURE_TAIL_LINES = 40
|
|
158
|
+
_PYTEST_SECTION_HEADER_RE = re.compile(
|
|
159
|
+
r"=+\s*(FAILURES|ERRORS|warnings summary|short test summary info)\s*=+",
|
|
160
|
+
re.IGNORECASE,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _dedupe_output_blocks(*blocks: str) -> List[str]:
|
|
165
|
+
seen = set()
|
|
166
|
+
unique_blocks: List[str] = []
|
|
167
|
+
for block in blocks:
|
|
168
|
+
if block is None:
|
|
169
|
+
continue
|
|
170
|
+
normalized = block.strip()
|
|
171
|
+
if normalized and normalized not in seen:
|
|
172
|
+
seen.add(normalized)
|
|
173
|
+
unique_blocks.append(normalized)
|
|
174
|
+
return unique_blocks
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _find_pytest_section_ranges(lines: List[str]) -> Dict[str, Tuple[int, int]]:
|
|
178
|
+
matches: List[Tuple[str, int]] = []
|
|
179
|
+
for index, line in enumerate(lines):
|
|
180
|
+
match = _PYTEST_SECTION_HEADER_RE.match(line.strip())
|
|
181
|
+
if match:
|
|
182
|
+
matches.append((match.group(1).lower(), index))
|
|
183
|
+
|
|
184
|
+
section_ranges: Dict[str, Tuple[int, int]] = {}
|
|
185
|
+
for idx, (name, start) in enumerate(matches):
|
|
186
|
+
end = matches[idx + 1][1] if idx + 1 < len(matches) else len(lines)
|
|
187
|
+
section_ranges[name] = (start, end)
|
|
188
|
+
return section_ranges
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _extract_coverage_block(lines: List[str]) -> str:
|
|
192
|
+
coverage_markers = [
|
|
193
|
+
idx
|
|
194
|
+
for idx, line in enumerate(lines)
|
|
195
|
+
if "coverage:" in line.lower() or "required test coverage" in line.lower()
|
|
196
|
+
]
|
|
197
|
+
if not coverage_markers:
|
|
198
|
+
return ""
|
|
199
|
+
|
|
200
|
+
start = max(coverage_markers[0] - 2, 0)
|
|
201
|
+
end = min(len(lines), coverage_markers[-1] + 20)
|
|
202
|
+
return "\n".join(lines[start:end]).strip()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _truncate_generic_failure_output(lines: List[str]) -> str:
|
|
206
|
+
total = len(lines)
|
|
207
|
+
if total <= _MAX_FAILURE_OUTPUT_LINES:
|
|
208
|
+
return "\n".join(lines).strip()
|
|
209
|
+
|
|
210
|
+
hidden = total - (_FAILURE_HEAD_LINES + _FAILURE_TAIL_LINES)
|
|
211
|
+
head = "\n".join(lines[:_FAILURE_HEAD_LINES]).strip()
|
|
212
|
+
tail = "\n".join(lines[-_FAILURE_TAIL_LINES:]).strip()
|
|
213
|
+
return (
|
|
214
|
+
"First lines:\n"
|
|
215
|
+
f"{head}\n\n"
|
|
216
|
+
f"... [{hidden} lines omitted for brevity] ...\n\n"
|
|
217
|
+
"Last lines:\n"
|
|
218
|
+
f"{tail}"
|
|
219
|
+
).strip()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def format_failure_output(stdout: str, stderr: str) -> str:
|
|
223
|
+
blocks = _dedupe_output_blocks(stdout, stderr)
|
|
224
|
+
if not blocks:
|
|
225
|
+
return ""
|
|
226
|
+
|
|
227
|
+
combined_output = "\n\n".join(blocks)
|
|
228
|
+
lines = combined_output.splitlines()
|
|
229
|
+
section_ranges = _find_pytest_section_ranges(lines)
|
|
230
|
+
|
|
231
|
+
if not section_ranges and not any(
|
|
232
|
+
marker in combined_output.lower()
|
|
233
|
+
for marker in ("test session starts", "short test summary info", "failed ")
|
|
234
|
+
):
|
|
235
|
+
return _truncate_generic_failure_output(lines)
|
|
236
|
+
|
|
237
|
+
report_sections: List[str] = []
|
|
238
|
+
|
|
239
|
+
short_summary_range = section_ranges.get("short test summary info")
|
|
240
|
+
if short_summary_range:
|
|
241
|
+
start, end = short_summary_range
|
|
242
|
+
report_sections.append(
|
|
243
|
+
"Pytest short summary:\n" + "\n".join(lines[start:end]).strip()
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
for section_name in ("failures", "errors"):
|
|
247
|
+
section_range = section_ranges.get(section_name)
|
|
248
|
+
if section_range:
|
|
249
|
+
start, end = section_range
|
|
250
|
+
title = "Failure details" if section_name == "failures" else "Error details"
|
|
251
|
+
report_sections.append(f"{title}:\n" + "\n".join(lines[start:end]).strip())
|
|
252
|
+
|
|
253
|
+
coverage_block = _extract_coverage_block(lines)
|
|
254
|
+
if coverage_block:
|
|
255
|
+
report_sections.append("Coverage details:\n" + coverage_block)
|
|
256
|
+
|
|
257
|
+
tail_lines = lines[-_FAILURE_TAIL_LINES:]
|
|
258
|
+
tail_block = "\n".join(tail_lines).strip()
|
|
259
|
+
if tail_block:
|
|
260
|
+
report_sections.append("Raw output tail:\n" + tail_block)
|
|
261
|
+
|
|
262
|
+
deduped_sections: List[str] = []
|
|
263
|
+
seen = set()
|
|
264
|
+
for section in report_sections:
|
|
265
|
+
normalized = section.strip()
|
|
266
|
+
if normalized and normalized not in seen:
|
|
267
|
+
seen.add(normalized)
|
|
268
|
+
deduped_sections.append(normalized)
|
|
269
|
+
|
|
270
|
+
return "\n\n".join(deduped_sections)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _print_failure_output(stdout: str, stderr: str) -> None:
|
|
274
|
+
formatted_output = format_failure_output(stdout, stderr)
|
|
275
|
+
if not formatted_output:
|
|
276
|
+
return
|
|
277
|
+
print(f"\n{Colors.FAIL}Failure details:{Colors.ENDC}")
|
|
278
|
+
print(formatted_output)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _print_output_tail(output: str, label: str, color: str) -> None:
|
|
282
|
+
"""Print the tail of *output*, truncating the head when it is very long.
|
|
283
|
+
|
|
284
|
+
When the output has more than ``_MAX_FAILURE_OUTPUT_LINES`` lines only the last
|
|
285
|
+
``_MAX_FAILURE_OUTPUT_LINES`` are printed so that failure summaries and error
|
|
286
|
+
details are always visible.
|
|
287
|
+
"""
|
|
288
|
+
if not output:
|
|
289
|
+
return
|
|
290
|
+
lines = output.splitlines()
|
|
291
|
+
total = len(lines)
|
|
292
|
+
print(f"\n{color}{label}{Colors.ENDC}")
|
|
293
|
+
if total > _MAX_FAILURE_OUTPUT_LINES:
|
|
294
|
+
hidden = total - _MAX_FAILURE_OUTPUT_LINES
|
|
295
|
+
print(
|
|
296
|
+
f"{Colors.WARNING}... [{hidden} lines hidden — showing last "
|
|
297
|
+
f"{_MAX_FAILURE_OUTPUT_LINES} of {total}] ...{Colors.ENDC}"
|
|
298
|
+
)
|
|
299
|
+
print("\n".join(lines[-_MAX_FAILURE_OUTPUT_LINES:]))
|
|
300
|
+
else:
|
|
301
|
+
print(output)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
_PYTHON_TOOL_MODULES: Dict[str, str] = {
|
|
305
|
+
"isort": "isort",
|
|
306
|
+
"black": "black",
|
|
307
|
+
"docformatter": "docformatter",
|
|
308
|
+
"ruff": "ruff",
|
|
309
|
+
"flake8": "flake8",
|
|
310
|
+
"mypy": "mypy",
|
|
311
|
+
"bandit": "bandit",
|
|
312
|
+
"pytest": "pytest",
|
|
313
|
+
"coverage": "coverage",
|
|
314
|
+
"yamllint": "yamllint",
|
|
315
|
+
"diff-cover": "diff_cover.diff_cover_tool",
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _get_python_executable() -> str:
|
|
320
|
+
"""Get the Python executable, preferring .venv if available.
|
|
321
|
+
|
|
322
|
+
This ensures that tools installed in the project's virtual environment are used,
|
|
323
|
+
even if the script isn't run from within an activated venv.
|
|
324
|
+
"""
|
|
325
|
+
scripts_dir = "Scripts" if sys.platform == "win32" else "bin"
|
|
326
|
+
project_root = Path(__file__).parent.parent
|
|
327
|
+
venv_python = (
|
|
328
|
+
project_root
|
|
329
|
+
/ ".venv"
|
|
330
|
+
/ scripts_dir
|
|
331
|
+
/ ("python.exe" if sys.platform == "win32" else "python")
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
if venv_python.exists():
|
|
335
|
+
return str(venv_python)
|
|
336
|
+
return sys.executable
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _python_module_fallback_command(command: List[str]) -> Optional[List[str]]:
|
|
340
|
+
"""Return a Python module command fallback for known tools.
|
|
341
|
+
|
|
342
|
+
This avoids PATH/PATHEXT issues in hook shells by running tools with the .venv
|
|
343
|
+
Python interpreter when available.
|
|
344
|
+
"""
|
|
345
|
+
if not command:
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
executable = command[0]
|
|
349
|
+
if os.path.isabs(executable) or "/" in executable or "\\" in executable:
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
module = _PYTHON_TOOL_MODULES.get(executable.lower())
|
|
353
|
+
if not module:
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
return [_get_python_executable(), "-m", module] + command[1:]
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _git_ref_exists(ref: str) -> bool:
|
|
360
|
+
"""Return True if *ref* resolves to a commit."""
|
|
361
|
+
try:
|
|
362
|
+
result = subprocess.run(
|
|
363
|
+
["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
|
|
364
|
+
capture_output=True,
|
|
365
|
+
text=True,
|
|
366
|
+
encoding="utf-8",
|
|
367
|
+
errors="replace",
|
|
368
|
+
check=False,
|
|
369
|
+
)
|
|
370
|
+
return result.returncode == 0
|
|
371
|
+
except (FileNotFoundError, OSError):
|
|
372
|
+
return False
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _git_remote_origin_exists() -> bool:
|
|
376
|
+
"""Return True when the repository has an `origin` remote configured."""
|
|
377
|
+
try:
|
|
378
|
+
result = subprocess.run(
|
|
379
|
+
["git", "remote", "get-url", "origin"],
|
|
380
|
+
capture_output=True,
|
|
381
|
+
text=True,
|
|
382
|
+
encoding="utf-8",
|
|
383
|
+
errors="replace",
|
|
384
|
+
check=False,
|
|
385
|
+
)
|
|
386
|
+
return result.returncode == 0
|
|
387
|
+
except (FileNotFoundError, OSError):
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _resolve_diff_compare_branch(quick: bool) -> Tuple[Optional[str], Optional[str]]:
|
|
392
|
+
"""Resolve a strict compare branch for diff-cover.
|
|
393
|
+
|
|
394
|
+
Quick mode always compares against HEAD. Full mode prefers origin/main and local
|
|
395
|
+
main, and finally falls back to HEAD~1 when no remote/mainline branch can be
|
|
396
|
+
resolved.
|
|
397
|
+
"""
|
|
398
|
+
if quick:
|
|
399
|
+
return "HEAD", None
|
|
400
|
+
|
|
401
|
+
candidates = ["origin/main", "main"]
|
|
402
|
+
for ref in candidates:
|
|
403
|
+
if _git_ref_exists(ref):
|
|
404
|
+
return ref, None
|
|
405
|
+
|
|
406
|
+
if _git_remote_origin_exists():
|
|
407
|
+
try:
|
|
408
|
+
subprocess.run(
|
|
409
|
+
["git", "fetch", "origin", "--prune"],
|
|
410
|
+
capture_output=True,
|
|
411
|
+
text=True,
|
|
412
|
+
encoding="utf-8",
|
|
413
|
+
errors="replace",
|
|
414
|
+
check=False,
|
|
415
|
+
)
|
|
416
|
+
except (FileNotFoundError, OSError):
|
|
417
|
+
pass
|
|
418
|
+
|
|
419
|
+
for ref in candidates:
|
|
420
|
+
if _git_ref_exists(ref):
|
|
421
|
+
return ref, "Compare branch resolved after fetching remote refs."
|
|
422
|
+
|
|
423
|
+
if _git_ref_exists("HEAD~1"):
|
|
424
|
+
return (
|
|
425
|
+
"HEAD~1",
|
|
426
|
+
"No mainline branch found; using previous commit (HEAD~1) for diff-cover.",
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
return None, (
|
|
430
|
+
"Unable to resolve a compare branch for diff-cover. Configure an origin/main "
|
|
431
|
+
"(or equivalent) branch or create at least one prior commit."
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def run_command(
|
|
436
|
+
command: List[str],
|
|
437
|
+
description: str,
|
|
438
|
+
check: bool = True,
|
|
439
|
+
force_all_apps: bool = False,
|
|
440
|
+
env: Optional[Dict[str, str]] = None,
|
|
441
|
+
ignore_failure: bool = False,
|
|
442
|
+
) -> Tuple[bool, str]:
|
|
443
|
+
"""Run a shell command and return success status and output.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
command: Command and arguments as a list
|
|
447
|
+
description: Human-readable description of what's being checked
|
|
448
|
+
check: Whether to check return code (default: True)
|
|
449
|
+
force_all_apps: Whether to force enable all configuration (default: False)
|
|
450
|
+
env: Optional dictionary of environment variables to merge
|
|
451
|
+
ignore_failure: If True, do not print error on failure (default: False)
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
Tuple of (success: bool, output: str)
|
|
455
|
+
"""
|
|
456
|
+
try:
|
|
457
|
+
# Prefer python -m for known tools to ensure strict virtual environment
|
|
458
|
+
# affinity and avoid PATH resolution ambiguities (especially on Windows).
|
|
459
|
+
resolved_command = _python_module_fallback_command(command)
|
|
460
|
+
active_command = resolved_command if resolved_command else command
|
|
461
|
+
|
|
462
|
+
print(f"Running: {' '.join(active_command)}")
|
|
463
|
+
|
|
464
|
+
# On Windows, npm/npx are .cmd files that need cmd.exe to execute.
|
|
465
|
+
# Instead of shell=True (B602 security risk), prefix with cmd /c.
|
|
466
|
+
if sys.platform == "win32" and active_command[0] in ("npm", "npx"):
|
|
467
|
+
active_command = ["cmd", "/c"] + active_command
|
|
468
|
+
|
|
469
|
+
# IRONCLAD MODE: Fresh, minimal env to mirror CI clean state.
|
|
470
|
+
# Blocks local shell variables from masking configuration gaps.
|
|
471
|
+
# Auto-detect and prepend local .venv if it exists (robustness for pre-commit)
|
|
472
|
+
current_path = os.environ.get("PATH", "")
|
|
473
|
+
scripts_dir = "Scripts" if sys.platform == "win32" else "bin"
|
|
474
|
+
project_root = Path(__file__).parent.parent
|
|
475
|
+
venv_scripts = project_root / ".venv" / scripts_dir
|
|
476
|
+
if venv_scripts.exists():
|
|
477
|
+
current_path = f"{venv_scripts}{os.pathsep}{current_path}"
|
|
478
|
+
|
|
479
|
+
ironclad_env = {
|
|
480
|
+
"PATH": current_path,
|
|
481
|
+
"PYTHONPATH": os.environ.get("PYTHONPATH", str(project_root)),
|
|
482
|
+
"SYSTEMROOT": os.environ.get("SYSTEMROOT", ""),
|
|
483
|
+
"PYTHONIOENCODING": "utf-8",
|
|
484
|
+
"TESTING": "1",
|
|
485
|
+
"CI": "true",
|
|
486
|
+
"COLLAB_SILENT_DAEMON": "1",
|
|
487
|
+
"COLLAB_TEST_MODE": "1",
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
# Propagate test-isolation state dir so module-level code in
|
|
491
|
+
# lock_client / live_locks_watcher resolves a sandboxed PID path
|
|
492
|
+
# *before* conftest.py even runs.
|
|
493
|
+
_state_dir = os.environ.get("COLLAB_STATE_DIR")
|
|
494
|
+
if _state_dir:
|
|
495
|
+
ironclad_env["COLLAB_STATE_DIR"] = _state_dir
|
|
496
|
+
|
|
497
|
+
coverage_file = os.environ.get("COVERAGE_FILE")
|
|
498
|
+
if coverage_file:
|
|
499
|
+
ironclad_env["COVERAGE_FILE"] = coverage_file
|
|
500
|
+
|
|
501
|
+
# Whitelist other system-essential variables, including Windows path roots
|
|
502
|
+
for key in [
|
|
503
|
+
"APPDATA",
|
|
504
|
+
"LOCALAPPDATA",
|
|
505
|
+
"PROGRAMDATA",
|
|
506
|
+
"SYSTEMDRIVE",
|
|
507
|
+
"HOMEDRIVE",
|
|
508
|
+
"HOMEPATH",
|
|
509
|
+
"TEMP",
|
|
510
|
+
"TMP",
|
|
511
|
+
"USERPROFILE",
|
|
512
|
+
"COMSPEC",
|
|
513
|
+
"PATHEXT",
|
|
514
|
+
"WINDIR",
|
|
515
|
+
]:
|
|
516
|
+
if key in os.environ:
|
|
517
|
+
ironclad_env[key] = os.environ[key]
|
|
518
|
+
|
|
519
|
+
# Allow caller-provided overrides for command-specific execution context.
|
|
520
|
+
if env:
|
|
521
|
+
ironclad_env.update(env)
|
|
522
|
+
|
|
523
|
+
result = subprocess.run(
|
|
524
|
+
active_command,
|
|
525
|
+
capture_output=True,
|
|
526
|
+
text=True,
|
|
527
|
+
encoding="utf-8",
|
|
528
|
+
errors="replace",
|
|
529
|
+
check=False,
|
|
530
|
+
env=ironclad_env,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
if result.returncode == 0:
|
|
534
|
+
print_success(f"{description} passed")
|
|
535
|
+
return True, result.stdout or ""
|
|
536
|
+
else:
|
|
537
|
+
print_error(f"{description} failed")
|
|
538
|
+
if not ignore_failure:
|
|
539
|
+
_print_failure_output(result.stdout or "", result.stderr or "")
|
|
540
|
+
return False, result.stderr or result.stdout or ""
|
|
541
|
+
|
|
542
|
+
except subprocess.CalledProcessError as e:
|
|
543
|
+
print_error(f"{description} failed with return code {e.returncode}")
|
|
544
|
+
_print_failure_output(e.stdout, e.stderr)
|
|
545
|
+
return False, e.stderr or e.stdout
|
|
546
|
+
except FileNotFoundError:
|
|
547
|
+
print_error(f"{description} failed - command not found: {command[0]}")
|
|
548
|
+
print_warning(f"Please ensure {command[0]} is installed")
|
|
549
|
+
return False, f"Command not found: {command[0]}"
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
_FULL_SUITE_FILENAMES: frozenset = frozenset(
|
|
553
|
+
[
|
|
554
|
+
"pyproject.toml",
|
|
555
|
+
".env",
|
|
556
|
+
"requirements.txt",
|
|
557
|
+
"requirements-dev.txt",
|
|
558
|
+
]
|
|
559
|
+
)
|
|
560
|
+
_FULL_SUITE_PREFIXES: tuple = ("scripts/", ".github/")
|
|
561
|
+
|
|
562
|
+
_BACKEND_MAP: List[Tuple[str, List[str]]] = [
|
|
563
|
+
("src/", ["tests/backend/unit/"]),
|
|
564
|
+
("src/dashboard/", []),
|
|
565
|
+
("tests/backend/", ["tests/backend/"]),
|
|
566
|
+
("scripts/", ["tests/backend/"]),
|
|
567
|
+
]
|
|
568
|
+
|
|
569
|
+
_FRONTEND_MAP: List[Tuple[str, List[str]]] = [
|
|
570
|
+
("src/dashboard/", ["tests/frontend/"]),
|
|
571
|
+
("tests/frontend/", ["tests/frontend/"]),
|
|
572
|
+
]
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _get_changed_files() -> List[str]:
|
|
576
|
+
changed: set = set()
|
|
577
|
+
git_cmds = [
|
|
578
|
+
["git", "diff", "--name-only", "HEAD"],
|
|
579
|
+
["git", "diff", "--name-only", "--cached"],
|
|
580
|
+
["git", "ls-files", "--others", "--exclude-standard"],
|
|
581
|
+
]
|
|
582
|
+
for cmd in git_cmds:
|
|
583
|
+
try:
|
|
584
|
+
result = subprocess.run(
|
|
585
|
+
cmd,
|
|
586
|
+
capture_output=True,
|
|
587
|
+
text=True,
|
|
588
|
+
check=False,
|
|
589
|
+
encoding="utf-8",
|
|
590
|
+
)
|
|
591
|
+
if result.returncode == 0:
|
|
592
|
+
for line in result.stdout.splitlines():
|
|
593
|
+
line = line.strip()
|
|
594
|
+
if line:
|
|
595
|
+
changed.add(line.replace("\\", "/"))
|
|
596
|
+
except (FileNotFoundError, OSError):
|
|
597
|
+
return []
|
|
598
|
+
return sorted(changed)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _expand_input_paths(paths: List[str]) -> List[str]:
|
|
602
|
+
expanded: set[str] = set()
|
|
603
|
+
cwd = Path.cwd()
|
|
604
|
+
ignored_dirnames = {
|
|
605
|
+
".git",
|
|
606
|
+
".venv",
|
|
607
|
+
"node_modules",
|
|
608
|
+
"__pycache__",
|
|
609
|
+
".pytest_cache",
|
|
610
|
+
"htmlcov",
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
for raw in paths:
|
|
614
|
+
if not raw:
|
|
615
|
+
continue
|
|
616
|
+
|
|
617
|
+
p = Path(raw)
|
|
618
|
+
if p.exists() and p.is_dir():
|
|
619
|
+
for child in p.rglob("*"):
|
|
620
|
+
if not child.is_file():
|
|
621
|
+
continue
|
|
622
|
+
if any(part in ignored_dirnames for part in child.parts):
|
|
623
|
+
continue
|
|
624
|
+
try:
|
|
625
|
+
rel = child.resolve().relative_to(cwd).as_posix()
|
|
626
|
+
except ValueError:
|
|
627
|
+
rel = child.resolve().as_posix()
|
|
628
|
+
expanded.add(rel)
|
|
629
|
+
continue
|
|
630
|
+
|
|
631
|
+
if p.exists() and p.is_file():
|
|
632
|
+
try:
|
|
633
|
+
rel = p.resolve().relative_to(cwd).as_posix()
|
|
634
|
+
except ValueError:
|
|
635
|
+
rel = p.resolve().as_posix()
|
|
636
|
+
expanded.add(rel)
|
|
637
|
+
continue
|
|
638
|
+
|
|
639
|
+
expanded.add(raw.replace("\\", "/"))
|
|
640
|
+
|
|
641
|
+
return sorted(expanded)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def detect_changed_scopes(files: Optional[List[str]] = None) -> Dict[str, Any]:
|
|
645
|
+
if files is not None:
|
|
646
|
+
changed = _expand_input_paths(files)
|
|
647
|
+
else:
|
|
648
|
+
changed = _get_changed_files()
|
|
649
|
+
|
|
650
|
+
if not changed:
|
|
651
|
+
return {
|
|
652
|
+
"full_suite": True,
|
|
653
|
+
"backend": [],
|
|
654
|
+
"frontend": [],
|
|
655
|
+
"reason": None,
|
|
656
|
+
"changed_files": [],
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
for f in changed:
|
|
660
|
+
normalized = f.lstrip("./")
|
|
661
|
+
if "/" not in normalized and normalized in _FULL_SUITE_FILENAMES:
|
|
662
|
+
reason = f"Global config changed ({f!r}) — full suite required."
|
|
663
|
+
return {
|
|
664
|
+
"full_suite": True,
|
|
665
|
+
"backend": [],
|
|
666
|
+
"frontend": [],
|
|
667
|
+
"reason": reason,
|
|
668
|
+
"changed_files": changed,
|
|
669
|
+
}
|
|
670
|
+
if any(f.startswith(p) for p in _FULL_SUITE_PREFIXES):
|
|
671
|
+
reason = f"Infrastructure file changed ({f!r}) — full suite required."
|
|
672
|
+
return {
|
|
673
|
+
"full_suite": True,
|
|
674
|
+
"backend": [],
|
|
675
|
+
"frontend": [],
|
|
676
|
+
"reason": reason,
|
|
677
|
+
"changed_files": changed,
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
backend: set = set()
|
|
681
|
+
frontend: set = set()
|
|
682
|
+
|
|
683
|
+
for f in changed:
|
|
684
|
+
for prefix, dirs in _BACKEND_MAP:
|
|
685
|
+
if f.startswith(prefix):
|
|
686
|
+
backend.update(dirs)
|
|
687
|
+
break
|
|
688
|
+
|
|
689
|
+
for prefix, dirs in _FRONTEND_MAP:
|
|
690
|
+
if f.startswith(prefix):
|
|
691
|
+
frontend.update(dirs)
|
|
692
|
+
break
|
|
693
|
+
|
|
694
|
+
return {
|
|
695
|
+
"full_suite": False,
|
|
696
|
+
"backend": sorted(backend),
|
|
697
|
+
"frontend": sorted(frontend),
|
|
698
|
+
"reason": None,
|
|
699
|
+
"changed_files": changed,
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def validate_python_backend(
|
|
704
|
+
quick: bool = False, force_all_apps: bool = True, files: Optional[List[str]] = None
|
|
705
|
+
) -> bool:
|
|
706
|
+
"""Run all Python backend validation checks."""
|
|
707
|
+
python_targets = []
|
|
708
|
+
template_targets = []
|
|
709
|
+
bandit_targets = []
|
|
710
|
+
test_targets = []
|
|
711
|
+
|
|
712
|
+
if files:
|
|
713
|
+
expanded_files = _expand_input_paths(files)
|
|
714
|
+
clean_files = [f for f in expanded_files if not Path(f).name.startswith(".")]
|
|
715
|
+
|
|
716
|
+
python_targets = [f for f in clean_files if f.endswith(".py")]
|
|
717
|
+
template_targets = [f for f in clean_files if f.endswith(".html")]
|
|
718
|
+
_bandit_prefixes = ("src/", "scripts/")
|
|
719
|
+
bandit_targets = [
|
|
720
|
+
f
|
|
721
|
+
for f in clean_files
|
|
722
|
+
if f.endswith(".py")
|
|
723
|
+
and (any(f.startswith(p) for p in _bandit_prefixes) or "/" not in f)
|
|
724
|
+
]
|
|
725
|
+
test_targets = [
|
|
726
|
+
f
|
|
727
|
+
for f in clean_files
|
|
728
|
+
if f.endswith(".py") and Path(f).name.startswith("test_")
|
|
729
|
+
]
|
|
730
|
+
|
|
731
|
+
if not any([python_targets, template_targets, bandit_targets, test_targets]):
|
|
732
|
+
return True
|
|
733
|
+
|
|
734
|
+
print_header("BACKEND VALIDATION")
|
|
735
|
+
checks: List[Tuple[str, ValidationStatus | bool]] = []
|
|
736
|
+
success: ValidationStatus | bool = True
|
|
737
|
+
|
|
738
|
+
# Full run (no specific files provided)
|
|
739
|
+
if not files:
|
|
740
|
+
python_targets = [
|
|
741
|
+
"src",
|
|
742
|
+
"tests",
|
|
743
|
+
"scripts",
|
|
744
|
+
]
|
|
745
|
+
|
|
746
|
+
if python_targets:
|
|
747
|
+
print_section("Step 1/11: Import Sorting (isort)")
|
|
748
|
+
success, _ = run_command(
|
|
749
|
+
["isort"] + python_targets + ["--check-only"],
|
|
750
|
+
"Import sorting check",
|
|
751
|
+
force_all_apps=force_all_apps,
|
|
752
|
+
)
|
|
753
|
+
checks.append(("Import Sorting", success))
|
|
754
|
+
|
|
755
|
+
if python_targets:
|
|
756
|
+
print_section("Step 2/11: Code Formatting (black)")
|
|
757
|
+
success, _ = run_command(
|
|
758
|
+
["black", "--check"] + python_targets,
|
|
759
|
+
"Code formatting check",
|
|
760
|
+
force_all_apps=force_all_apps,
|
|
761
|
+
)
|
|
762
|
+
checks.append(("Code Formatting", success))
|
|
763
|
+
|
|
764
|
+
if python_targets:
|
|
765
|
+
print_section("Step 3/11: Docstring Formatting (docformatter)")
|
|
766
|
+
success, _ = run_command(
|
|
767
|
+
["docformatter", "--check", "-r"] + python_targets,
|
|
768
|
+
"Docstring formatting check",
|
|
769
|
+
force_all_apps=force_all_apps,
|
|
770
|
+
)
|
|
771
|
+
checks.append(("Docstring Formatting", success))
|
|
772
|
+
|
|
773
|
+
if python_targets:
|
|
774
|
+
print_section("Step 4/11: Linting (ruff)")
|
|
775
|
+
success, _ = run_command(
|
|
776
|
+
["ruff", "check", "--no-cache"] + python_targets,
|
|
777
|
+
"Ruff linting",
|
|
778
|
+
force_all_apps=force_all_apps,
|
|
779
|
+
)
|
|
780
|
+
checks.append(("Ruff Linting", success))
|
|
781
|
+
|
|
782
|
+
if python_targets:
|
|
783
|
+
print_section("Step 5/11: Additional Linting (flake8)")
|
|
784
|
+
exclude_dirs = (
|
|
785
|
+
".venv,node_modules,__pycache__,.git,"
|
|
786
|
+
".pytest_cache,htmlcov,playwright-report"
|
|
787
|
+
)
|
|
788
|
+
flake8_cmd = (
|
|
789
|
+
["flake8"]
|
|
790
|
+
+ python_targets
|
|
791
|
+
+ [
|
|
792
|
+
f"--exclude={exclude_dirs}",
|
|
793
|
+
"--count",
|
|
794
|
+
"--show-source",
|
|
795
|
+
"--statistics",
|
|
796
|
+
"--max-line-length=88",
|
|
797
|
+
]
|
|
798
|
+
)
|
|
799
|
+
success, _ = run_command(
|
|
800
|
+
flake8_cmd,
|
|
801
|
+
"Flake8 linting",
|
|
802
|
+
force_all_apps=force_all_apps,
|
|
803
|
+
)
|
|
804
|
+
checks.append(("Flake8 Linting", success))
|
|
805
|
+
|
|
806
|
+
if python_targets:
|
|
807
|
+
print_section("Step 6/11: Type Checking (mypy)")
|
|
808
|
+
# CRITICAL: Remove .mypy_cache to ensure clean state.
|
|
809
|
+
# Even with --no-incremental, stale cache can interfere with type inference.
|
|
810
|
+
# This ensures local validation matches CI exactly (CI runs on fresh VMs).
|
|
811
|
+
mypy_cache_dir = Path(".mypy_cache")
|
|
812
|
+
if mypy_cache_dir.exists():
|
|
813
|
+
try:
|
|
814
|
+
shutil.rmtree(mypy_cache_dir)
|
|
815
|
+
msg = "[INFO] Cleaned stale .mypy_cache for fresh type check."
|
|
816
|
+
print(f"{Colors.OKCYAN}{msg}{Colors.ENDC}")
|
|
817
|
+
except Exception as e:
|
|
818
|
+
msg = f"[WARN] Could not remove .mypy_cache: {e}"
|
|
819
|
+
print(f"{Colors.WARNING}{msg}{Colors.ENDC}")
|
|
820
|
+
success, _ = run_command(
|
|
821
|
+
["mypy", "--no-incremental"] + python_targets,
|
|
822
|
+
"Type checking",
|
|
823
|
+
force_all_apps=force_all_apps,
|
|
824
|
+
)
|
|
825
|
+
checks.append(("Type Checking", success))
|
|
826
|
+
|
|
827
|
+
print_section("Step 7/11: Security Scanning (bandit)")
|
|
828
|
+
if files:
|
|
829
|
+
if bandit_targets:
|
|
830
|
+
success, _ = run_command(
|
|
831
|
+
["bandit"] + bandit_targets + ["-ll"],
|
|
832
|
+
"Security scanning",
|
|
833
|
+
)
|
|
834
|
+
else:
|
|
835
|
+
msg = (
|
|
836
|
+
f"{Colors.OKCYAN}[INFO] No source files targeted — "
|
|
837
|
+
f"skipping bandit.{Colors.ENDC}"
|
|
838
|
+
)
|
|
839
|
+
print(msg)
|
|
840
|
+
success = "skipped"
|
|
841
|
+
else:
|
|
842
|
+
success, _ = run_command(
|
|
843
|
+
[
|
|
844
|
+
"bandit",
|
|
845
|
+
"-r",
|
|
846
|
+
"src/",
|
|
847
|
+
"scripts/",
|
|
848
|
+
"-ll",
|
|
849
|
+
],
|
|
850
|
+
"Security scanning",
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
checks.append(("Security Scanning", success))
|
|
854
|
+
|
|
855
|
+
print_section("Step 8/11: Template Linting (djlint)")
|
|
856
|
+
python_exe = _get_python_executable()
|
|
857
|
+
if files:
|
|
858
|
+
if template_targets:
|
|
859
|
+
success, _ = run_command(
|
|
860
|
+
[python_exe, "-m", "djlint", "--check"] + template_targets,
|
|
861
|
+
"HTML template linting",
|
|
862
|
+
)
|
|
863
|
+
else:
|
|
864
|
+
msg = (
|
|
865
|
+
f"{Colors.OKCYAN}[INFO] No templates targeted — skipping.{Colors.ENDC}"
|
|
866
|
+
)
|
|
867
|
+
print(msg)
|
|
868
|
+
success = "skipped"
|
|
869
|
+
else:
|
|
870
|
+
success, _ = run_command(
|
|
871
|
+
[python_exe, "-m", "djlint", "--check", "src/dashboard"],
|
|
872
|
+
"HTML template linting",
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
if not success:
|
|
876
|
+
print_warning("DjLint found issues (soft failure for now)")
|
|
877
|
+
checks.append(("Template Linting", "skipped"))
|
|
878
|
+
else:
|
|
879
|
+
checks.append(("Template Linting", success))
|
|
880
|
+
|
|
881
|
+
_FULL_TESTPATHS = ["tests/backend", "tests/frontend"]
|
|
882
|
+
_cov_sources = [
|
|
883
|
+
"--cov=src",
|
|
884
|
+
"--cov=scripts",
|
|
885
|
+
]
|
|
886
|
+
quick_cov_args = _cov_sources + ["--cov-report=xml"]
|
|
887
|
+
|
|
888
|
+
if quick:
|
|
889
|
+
print_section("Step 9/11: Targeted Tests (with Diff Coverage)")
|
|
890
|
+
scopes = detect_changed_scopes(files)
|
|
891
|
+
|
|
892
|
+
if scopes["full_suite"]:
|
|
893
|
+
reason = (
|
|
894
|
+
f" ({scopes.get('reason')})"
|
|
895
|
+
if scopes.get("reason")
|
|
896
|
+
else " (Global changes)"
|
|
897
|
+
)
|
|
898
|
+
print_warning(
|
|
899
|
+
f"Quick mode: Full suite required{reason} — running all tests."
|
|
900
|
+
)
|
|
901
|
+
success, _ = run_command(
|
|
902
|
+
["pytest", "-c", "pytest.ini", "-p", "no:cacheprovider"]
|
|
903
|
+
+ quick_cov_args
|
|
904
|
+
+ ["-x", "--tb=short"]
|
|
905
|
+
+ _FULL_TESTPATHS,
|
|
906
|
+
"Quick test run (full scope)",
|
|
907
|
+
force_all_apps=force_all_apps,
|
|
908
|
+
env={"COLLAB_KEEP_ROOT_COVERAGE": "1"},
|
|
909
|
+
)
|
|
910
|
+
elif scopes["backend"]:
|
|
911
|
+
scope_str = " ".join(scopes["backend"])
|
|
912
|
+
print_warning(f"Quick mode [Smart Scoping]: Running: {scope_str}")
|
|
913
|
+
success, _ = run_command(
|
|
914
|
+
["pytest", "-c", "pytest.ini", "-p", "no:cacheprovider"]
|
|
915
|
+
+ quick_cov_args
|
|
916
|
+
+ ["-x", "--tb=short"]
|
|
917
|
+
+ scopes["backend"],
|
|
918
|
+
"Smart test run",
|
|
919
|
+
force_all_apps=force_all_apps,
|
|
920
|
+
env={"COLLAB_KEEP_ROOT_COVERAGE": "1"},
|
|
921
|
+
)
|
|
922
|
+
else:
|
|
923
|
+
print_warning("Quick mode: No relevant changes — skipping tests.")
|
|
924
|
+
success = "skipped"
|
|
925
|
+
|
|
926
|
+
checks.append(("Tests", success))
|
|
927
|
+
|
|
928
|
+
else:
|
|
929
|
+
print_section("Step 9/11: Full Test Suite with Coverage")
|
|
930
|
+
success, _ = run_command(
|
|
931
|
+
[
|
|
932
|
+
"pytest",
|
|
933
|
+
"-c",
|
|
934
|
+
"pytest.ini",
|
|
935
|
+
"-p",
|
|
936
|
+
"no:cacheprovider",
|
|
937
|
+
]
|
|
938
|
+
+ _cov_sources
|
|
939
|
+
+ [
|
|
940
|
+
"--cov-report=term-missing",
|
|
941
|
+
"--cov-report=html",
|
|
942
|
+
"--cov-report=xml",
|
|
943
|
+
]
|
|
944
|
+
+ _FULL_TESTPATHS,
|
|
945
|
+
"Full test suite with discovery",
|
|
946
|
+
force_all_apps=force_all_apps,
|
|
947
|
+
env={"COLLAB_KEEP_ROOT_COVERAGE": "1"},
|
|
948
|
+
)
|
|
949
|
+
checks.append(("Full Discovery Suite", success))
|
|
950
|
+
|
|
951
|
+
print_section("Step 10/11: Total Coverage Validation")
|
|
952
|
+
if not quick:
|
|
953
|
+
success, _ = run_command(
|
|
954
|
+
[
|
|
955
|
+
"coverage",
|
|
956
|
+
"report",
|
|
957
|
+
"--fail-under=85",
|
|
958
|
+
],
|
|
959
|
+
"Coverage threshold check (>= 85%)",
|
|
960
|
+
force_all_apps=force_all_apps,
|
|
961
|
+
)
|
|
962
|
+
checks.append(("Total Coverage Threshold", success))
|
|
963
|
+
else:
|
|
964
|
+
msg10 = (
|
|
965
|
+
f"{Colors.OKCYAN}[INFO] Quick mode: Skipping overall coverage "
|
|
966
|
+
f"threshold check.{Colors.ENDC}"
|
|
967
|
+
)
|
|
968
|
+
print(msg10)
|
|
969
|
+
checks.append(("Total Coverage Threshold", "skipped"))
|
|
970
|
+
|
|
971
|
+
print_section("Step 11/11: Diff (Patch) Coverage")
|
|
972
|
+
if not os.path.exists("coverage.xml"):
|
|
973
|
+
msg_cov = (
|
|
974
|
+
f"{Colors.OKCYAN}[INFO] coverage.xml not found (no tests run?), skipping "
|
|
975
|
+
f"diff-cover.{Colors.ENDC}"
|
|
976
|
+
)
|
|
977
|
+
print(msg_cov)
|
|
978
|
+
checks.append(("Diff Coverage", "skipped"))
|
|
979
|
+
else:
|
|
980
|
+
success, _ = run_command(
|
|
981
|
+
["diff-cover", "--version"], "Check diff-cover", check=False
|
|
982
|
+
)
|
|
983
|
+
if success:
|
|
984
|
+
compare_branch, branch_warning = _resolve_diff_compare_branch(quick)
|
|
985
|
+
if not compare_branch:
|
|
986
|
+
checks.append(("Diff Coverage", False))
|
|
987
|
+
print_error("Diff Coverage Check (New Code needs 92% coverage) failed")
|
|
988
|
+
if branch_warning:
|
|
989
|
+
print_warning(branch_warning)
|
|
990
|
+
print_section("Python Backend Validation Summary")
|
|
991
|
+
all_passed = all(_check_succeeded(status) for _, status in checks)
|
|
992
|
+
for check_name, status in checks:
|
|
993
|
+
_print_check_summary(check_name, status)
|
|
994
|
+
return all_passed
|
|
995
|
+
|
|
996
|
+
if branch_warning:
|
|
997
|
+
print_warning(branch_warning)
|
|
998
|
+
|
|
999
|
+
diff_cover_cmd = [
|
|
1000
|
+
"diff-cover",
|
|
1001
|
+
"coverage.xml",
|
|
1002
|
+
f"--compare-branch={compare_branch}",
|
|
1003
|
+
"--fail-under=92",
|
|
1004
|
+
"--include-untracked",
|
|
1005
|
+
]
|
|
1006
|
+
|
|
1007
|
+
if quick and not scopes["full_suite"]:
|
|
1008
|
+
py_files = [
|
|
1009
|
+
f for f in scopes.get("changed_files", []) if f.endswith(".py")
|
|
1010
|
+
]
|
|
1011
|
+
if py_files:
|
|
1012
|
+
diff_cover_cmd.append("--include")
|
|
1013
|
+
diff_cover_cmd.extend(py_files)
|
|
1014
|
+
|
|
1015
|
+
success, _ = run_command(
|
|
1016
|
+
diff_cover_cmd,
|
|
1017
|
+
"Diff Coverage Check (New Code needs 92% coverage)",
|
|
1018
|
+
)
|
|
1019
|
+
checks.append(("Diff Coverage", success))
|
|
1020
|
+
else:
|
|
1021
|
+
msg_dc = (
|
|
1022
|
+
f"{Colors.OKCYAN}[INFO] diff-cover not installed. Run "
|
|
1023
|
+
f"'pip install diff-cover' to enable patch checks.{Colors.ENDC}"
|
|
1024
|
+
)
|
|
1025
|
+
print(msg_dc)
|
|
1026
|
+
checks.append(("Diff Coverage", "skipped"))
|
|
1027
|
+
|
|
1028
|
+
# Print summary
|
|
1029
|
+
print_section("Python Backend Validation Summary")
|
|
1030
|
+
all_passed = all(_check_succeeded(status) for _, status in checks)
|
|
1031
|
+
for check_name, status in checks:
|
|
1032
|
+
_print_check_summary(check_name, status)
|
|
1033
|
+
|
|
1034
|
+
return all_passed
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def validate_others(files: Optional[List[str]] = None) -> bool:
|
|
1038
|
+
doc_paths = []
|
|
1039
|
+
if files:
|
|
1040
|
+
doc_paths = [
|
|
1041
|
+
f
|
|
1042
|
+
for f in files
|
|
1043
|
+
if f.endswith((".md", ".json", ".yml", ".yaml"))
|
|
1044
|
+
and not f.startswith(".venv")
|
|
1045
|
+
and not Path(f).name.startswith(".")
|
|
1046
|
+
]
|
|
1047
|
+
if not doc_paths:
|
|
1048
|
+
return True
|
|
1049
|
+
|
|
1050
|
+
print_header("OTHERS VALIDATION")
|
|
1051
|
+
checks: List[Tuple[str, ValidationStatus | bool]] = []
|
|
1052
|
+
success: ValidationStatus | bool = True
|
|
1053
|
+
|
|
1054
|
+
print_section("Step 1/1: Documentation Formatting (prettier)")
|
|
1055
|
+
if not doc_paths:
|
|
1056
|
+
doc_globs = [
|
|
1057
|
+
"docs/**/*.md",
|
|
1058
|
+
"*.md",
|
|
1059
|
+
"*.json",
|
|
1060
|
+
".github/**/*.md",
|
|
1061
|
+
"tests/**/*.md",
|
|
1062
|
+
".agents/**/*.md",
|
|
1063
|
+
]
|
|
1064
|
+
doc_paths = [pattern for pattern in doc_globs if list(Path.cwd().glob(pattern))]
|
|
1065
|
+
|
|
1066
|
+
try:
|
|
1067
|
+
npm_cmd = ["cmd", "/c", "npm"] if sys.platform == "win32" else ["npm"]
|
|
1068
|
+
check_prettier = subprocess.run(
|
|
1069
|
+
npm_cmd + ["list", "prettier"],
|
|
1070
|
+
cwd=os.getcwd(),
|
|
1071
|
+
capture_output=True,
|
|
1072
|
+
check=False,
|
|
1073
|
+
)
|
|
1074
|
+
if check_prettier.returncode == 0:
|
|
1075
|
+
success, _ = run_command(
|
|
1076
|
+
["npx", "prettier", "--check"] + doc_paths,
|
|
1077
|
+
"Documentation formatting check",
|
|
1078
|
+
)
|
|
1079
|
+
checks.append(("Documentation Linting", success))
|
|
1080
|
+
else:
|
|
1081
|
+
print(
|
|
1082
|
+
f"{Colors.OKCYAN}[INFO] Prettier not installed - "
|
|
1083
|
+
f"skipping documentation linting.{Colors.ENDC}"
|
|
1084
|
+
)
|
|
1085
|
+
checks.append(("Documentation Linting", "skipped"))
|
|
1086
|
+
except Exception:
|
|
1087
|
+
print(
|
|
1088
|
+
f"{Colors.OKCYAN}[INFO] Error checking for Prettier - "
|
|
1089
|
+
f"skipping documentation linting.{Colors.ENDC}"
|
|
1090
|
+
)
|
|
1091
|
+
checks.append(("Documentation Linting", "skipped"))
|
|
1092
|
+
|
|
1093
|
+
print_section("Others Validation Summary")
|
|
1094
|
+
all_passed = all(_check_succeeded(status) for _, status in checks)
|
|
1095
|
+
for check_name, status in checks:
|
|
1096
|
+
_print_check_summary(check_name, status)
|
|
1097
|
+
|
|
1098
|
+
return all_passed
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def _load_package_json_scripts() -> Dict[str, str]:
|
|
1102
|
+
"""Return package.json scripts or an empty mapping when unavailable."""
|
|
1103
|
+
package_json = Path("package.json")
|
|
1104
|
+
if not package_json.exists():
|
|
1105
|
+
return {}
|
|
1106
|
+
|
|
1107
|
+
try:
|
|
1108
|
+
payload = json.loads(package_json.read_text(encoding="utf-8"))
|
|
1109
|
+
except (OSError, json.JSONDecodeError):
|
|
1110
|
+
return {}
|
|
1111
|
+
|
|
1112
|
+
scripts = payload.get("scripts")
|
|
1113
|
+
if isinstance(scripts, dict):
|
|
1114
|
+
return {str(key): str(value) for key, value in scripts.items()}
|
|
1115
|
+
return {}
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def _has_playwright_test_files() -> bool:
|
|
1119
|
+
"""Return True when the frontend Playwright directory has runnable tests."""
|
|
1120
|
+
test_dir = Path("tests/frontend/playwright")
|
|
1121
|
+
if not test_dir.exists():
|
|
1122
|
+
return False
|
|
1123
|
+
|
|
1124
|
+
patterns = (
|
|
1125
|
+
"**/*.spec.js",
|
|
1126
|
+
"**/*.spec.ts",
|
|
1127
|
+
"**/*.test.js",
|
|
1128
|
+
"**/*.test.ts",
|
|
1129
|
+
)
|
|
1130
|
+
return any(any(test_dir.glob(pattern)) for pattern in patterns)
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def validate_javascript_frontend(
|
|
1134
|
+
quick: bool = False, force_all_apps: bool = True, files: Optional[List[str]] = None
|
|
1135
|
+
) -> bool:
|
|
1136
|
+
"""Run JavaScript frontend validation checks when relevant files exist."""
|
|
1137
|
+
npm_available = shutil.which("npm") is not None
|
|
1138
|
+
|
|
1139
|
+
if not npm_available:
|
|
1140
|
+
msg_npm = (
|
|
1141
|
+
f"{Colors.OKCYAN}[INFO] npm not found - frontend validation "
|
|
1142
|
+
f"will be skipped locally.{Colors.ENDC}"
|
|
1143
|
+
)
|
|
1144
|
+
print(msg_npm)
|
|
1145
|
+
return True
|
|
1146
|
+
|
|
1147
|
+
eslint_targets = []
|
|
1148
|
+
html_targets = []
|
|
1149
|
+
jest_targets = []
|
|
1150
|
+
|
|
1151
|
+
if files:
|
|
1152
|
+
eslint_targets = [
|
|
1153
|
+
f for f in files if f.endswith((".js", ".jsx", ".ts", ".tsx"))
|
|
1154
|
+
]
|
|
1155
|
+
html_targets = [f for f in files if f.endswith(".html")]
|
|
1156
|
+
jest_targets = [f for f in files if f.endswith(".test.js")]
|
|
1157
|
+
if not any([eslint_targets, html_targets, jest_targets]):
|
|
1158
|
+
return True
|
|
1159
|
+
else:
|
|
1160
|
+
glob_patterns = [
|
|
1161
|
+
"src/**/*.js",
|
|
1162
|
+
"src/**/*.css",
|
|
1163
|
+
"tests/frontend/**/*.js",
|
|
1164
|
+
]
|
|
1165
|
+
discovered = [
|
|
1166
|
+
pattern for pattern in glob_patterns if list(Path.cwd().glob(pattern))
|
|
1167
|
+
]
|
|
1168
|
+
if not discovered:
|
|
1169
|
+
return True
|
|
1170
|
+
|
|
1171
|
+
print_header("JAVASCRIPT FRONTEND VALIDATION")
|
|
1172
|
+
checks: List[Tuple[str, ValidationStatus | bool]] = []
|
|
1173
|
+
success: ValidationStatus | bool = True
|
|
1174
|
+
|
|
1175
|
+
# Step 1: ESLint (or skip if not configured)
|
|
1176
|
+
print_section("Step 1/3: JavaScript Linting (eslint)")
|
|
1177
|
+
if files and eslint_targets:
|
|
1178
|
+
success, _ = run_command(
|
|
1179
|
+
["npx", "eslint"] + eslint_targets + ["--report-unused-disable-directives"],
|
|
1180
|
+
"ESLint check",
|
|
1181
|
+
force_all_apps=force_all_apps,
|
|
1182
|
+
check=False,
|
|
1183
|
+
)
|
|
1184
|
+
else:
|
|
1185
|
+
success, _ = run_command(
|
|
1186
|
+
[
|
|
1187
|
+
"npx",
|
|
1188
|
+
"eslint",
|
|
1189
|
+
"tests/frontend/playwright",
|
|
1190
|
+
"--report-unused-disable-directives",
|
|
1191
|
+
],
|
|
1192
|
+
"ESLint check",
|
|
1193
|
+
force_all_apps=force_all_apps,
|
|
1194
|
+
check=False,
|
|
1195
|
+
)
|
|
1196
|
+
if not success:
|
|
1197
|
+
print_warning("ESLint unavailable or not configured - skipping strict failure.")
|
|
1198
|
+
success = "skipped"
|
|
1199
|
+
checks.append(("ESLint", success))
|
|
1200
|
+
|
|
1201
|
+
# Step 2: Jest (or skip if test script is missing)
|
|
1202
|
+
print_section("Step 2/3: JavaScript Tests (jest)")
|
|
1203
|
+
if quick:
|
|
1204
|
+
print(
|
|
1205
|
+
f"{Colors.OKCYAN}[INFO] Quick mode: skipping frontend test execution "
|
|
1206
|
+
f"unless explicitly requested.{Colors.ENDC}"
|
|
1207
|
+
)
|
|
1208
|
+
success = "skipped"
|
|
1209
|
+
else:
|
|
1210
|
+
package_scripts = _load_package_json_scripts()
|
|
1211
|
+
if "test" not in package_scripts:
|
|
1212
|
+
print(
|
|
1213
|
+
f"{Colors.OKCYAN}[INFO] No npm 'test' script configured — skipping "
|
|
1214
|
+
f"Jest coverage run.{Colors.ENDC}"
|
|
1215
|
+
)
|
|
1216
|
+
success = "skipped"
|
|
1217
|
+
else:
|
|
1218
|
+
success, _ = run_command(
|
|
1219
|
+
["npm", "run", "test", "--", "--coverage"],
|
|
1220
|
+
"Jest tests with coverage",
|
|
1221
|
+
force_all_apps=force_all_apps,
|
|
1222
|
+
check=False,
|
|
1223
|
+
)
|
|
1224
|
+
if not success:
|
|
1225
|
+
print_warning("Jest tests failed; skipping strict frontend failure.")
|
|
1226
|
+
success = "skipped"
|
|
1227
|
+
checks.append(("Jest Tests", success))
|
|
1228
|
+
|
|
1229
|
+
# Step 3: Playwright (non-quick mode only)
|
|
1230
|
+
print_section("Step 3/3: E2E Tests (playwright)")
|
|
1231
|
+
if quick:
|
|
1232
|
+
print(f"{Colors.OKCYAN}[INFO] Quick mode: skipping E2E tests.{Colors.ENDC}")
|
|
1233
|
+
success = "skipped"
|
|
1234
|
+
else:
|
|
1235
|
+
if not _has_playwright_test_files():
|
|
1236
|
+
print(
|
|
1237
|
+
f"{Colors.OKCYAN}[INFO] No Playwright test files found — skipping "
|
|
1238
|
+
f"E2E validation.{Colors.ENDC}"
|
|
1239
|
+
)
|
|
1240
|
+
success = "skipped"
|
|
1241
|
+
else:
|
|
1242
|
+
success, _ = run_command(
|
|
1243
|
+
["npx", "playwright", "test", "--project=chromium"],
|
|
1244
|
+
"Playwright E2E tests",
|
|
1245
|
+
force_all_apps=force_all_apps,
|
|
1246
|
+
check=False,
|
|
1247
|
+
)
|
|
1248
|
+
if not success:
|
|
1249
|
+
print_warning(
|
|
1250
|
+
"Playwright tests failed; skipping strict frontend failure."
|
|
1251
|
+
)
|
|
1252
|
+
success = "skipped"
|
|
1253
|
+
checks.append(("E2E Tests", success))
|
|
1254
|
+
|
|
1255
|
+
print_section("Frontend Validation Summary")
|
|
1256
|
+
all_passed = all(_check_succeeded(status) for _, status in checks)
|
|
1257
|
+
for check_name, status in checks:
|
|
1258
|
+
_print_check_summary(check_name, status)
|
|
1259
|
+
return all_passed
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def _run_cleanup() -> None:
|
|
1263
|
+
print_header("CLEANUP")
|
|
1264
|
+
# Default cleanup (coverage + test output)
|
|
1265
|
+
count_default = clean_default(dry_run=False)
|
|
1266
|
+
|
|
1267
|
+
# Packaging cleanup (dist/, build/, wheel metadata, *.egg-info, .venv.verify)
|
|
1268
|
+
# Run unconditionally so packaging artifacts are removed automatically
|
|
1269
|
+
# after validation runs. We call the function directly (no interactive
|
|
1270
|
+
# prompts) because this is a programmatic invocation.
|
|
1271
|
+
count_packaging = clean_packaging(dry_run=False)
|
|
1272
|
+
|
|
1273
|
+
total = count_default + count_packaging
|
|
1274
|
+
if total:
|
|
1275
|
+
print_success(f"Removed {total} generated artifact(s) — repo is clean.")
|
|
1276
|
+
else:
|
|
1277
|
+
print_success("Nothing to clean — repo is already clean.")
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def main():
|
|
1281
|
+
parser = argparse.ArgumentParser(description="Comprehensive code validation script")
|
|
1282
|
+
parser.add_argument(
|
|
1283
|
+
"--backend", action="store_true", help="Run only Python backend validation"
|
|
1284
|
+
)
|
|
1285
|
+
parser.add_argument(
|
|
1286
|
+
"--frontend",
|
|
1287
|
+
action="store_true",
|
|
1288
|
+
help="Run only JavaScript frontend validation",
|
|
1289
|
+
)
|
|
1290
|
+
parser.add_argument(
|
|
1291
|
+
"--docs",
|
|
1292
|
+
action="store_true",
|
|
1293
|
+
help="Run only Documentation validation",
|
|
1294
|
+
)
|
|
1295
|
+
parser.add_argument(
|
|
1296
|
+
"--quick",
|
|
1297
|
+
action="store_true",
|
|
1298
|
+
help="Quick mode: Smart scoping for faster feedback",
|
|
1299
|
+
)
|
|
1300
|
+
parser.add_argument("files", nargs="*", help="Specific files to validate")
|
|
1301
|
+
|
|
1302
|
+
args, unknown = parser.parse_known_args()
|
|
1303
|
+
if unknown:
|
|
1304
|
+
args.files = list(args.files or []) + list(unknown)
|
|
1305
|
+
|
|
1306
|
+
if args.files:
|
|
1307
|
+
args.files = _expand_input_paths(args.files)
|
|
1308
|
+
|
|
1309
|
+
run_all = not (args.backend or args.frontend or args.docs)
|
|
1310
|
+
run_backend = args.backend or run_all
|
|
1311
|
+
run_frontend = args.frontend or run_all
|
|
1312
|
+
run_docs = args.docs or run_all
|
|
1313
|
+
|
|
1314
|
+
if args.files:
|
|
1315
|
+
has_backend = any(
|
|
1316
|
+
[
|
|
1317
|
+
[
|
|
1318
|
+
f
|
|
1319
|
+
for f in args.files
|
|
1320
|
+
if (f.endswith(".py")) and not Path(f).name.startswith(".")
|
|
1321
|
+
],
|
|
1322
|
+
[f for f in args.files if f.startswith("src/")],
|
|
1323
|
+
]
|
|
1324
|
+
)
|
|
1325
|
+
has_docs = any(
|
|
1326
|
+
[
|
|
1327
|
+
f
|
|
1328
|
+
for f in args.files
|
|
1329
|
+
if f.endswith((".md", ".json", ".yml", ".yaml"))
|
|
1330
|
+
and not Path(f).name.startswith(".")
|
|
1331
|
+
]
|
|
1332
|
+
)
|
|
1333
|
+
has_frontend = any(
|
|
1334
|
+
[
|
|
1335
|
+
f
|
|
1336
|
+
for f in args.files
|
|
1337
|
+
if f.endswith((".js", ".jsx", ".ts", ".tsx", ".css"))
|
|
1338
|
+
and not Path(f).name.startswith(".")
|
|
1339
|
+
]
|
|
1340
|
+
)
|
|
1341
|
+
|
|
1342
|
+
run_backend = run_backend and has_backend
|
|
1343
|
+
run_frontend = run_frontend and has_frontend
|
|
1344
|
+
run_docs = run_docs and has_docs
|
|
1345
|
+
|
|
1346
|
+
if not any([run_backend, run_frontend, run_docs]):
|
|
1347
|
+
return 0
|
|
1348
|
+
|
|
1349
|
+
print_header("COLLAB RUNTIME CODE VALIDATION")
|
|
1350
|
+
print(f"{Colors.OKCYAN}This script simulates the CI pipeline locally.{Colors.ENDC}")
|
|
1351
|
+
print(f"{Colors.OKCYAN}All checks must pass before committing code.{Colors.ENDC}\n")
|
|
1352
|
+
|
|
1353
|
+
results = []
|
|
1354
|
+
|
|
1355
|
+
if run_backend:
|
|
1356
|
+
backend_passed = validate_python_backend(
|
|
1357
|
+
quick=args.quick,
|
|
1358
|
+
files=args.files if args.files else None,
|
|
1359
|
+
)
|
|
1360
|
+
results.append(("Backend", backend_passed))
|
|
1361
|
+
|
|
1362
|
+
if run_frontend:
|
|
1363
|
+
frontend_passed = validate_javascript_frontend(
|
|
1364
|
+
quick=args.quick,
|
|
1365
|
+
files=args.files if args.files else None,
|
|
1366
|
+
)
|
|
1367
|
+
results.append(("Frontend", frontend_passed))
|
|
1368
|
+
|
|
1369
|
+
if run_docs:
|
|
1370
|
+
docs_passed = validate_others(files=args.files if args.files else None)
|
|
1371
|
+
results.append(("Documentation", docs_passed))
|
|
1372
|
+
|
|
1373
|
+
print_header("FINAL VALIDATION SUMMARY")
|
|
1374
|
+
|
|
1375
|
+
all_passed = all(passed for _, passed in results)
|
|
1376
|
+
|
|
1377
|
+
for category, passed in results:
|
|
1378
|
+
if passed:
|
|
1379
|
+
print_success(f"{category} Validation: PASSED")
|
|
1380
|
+
else:
|
|
1381
|
+
print_error(f"{category} Validation: FAILED")
|
|
1382
|
+
|
|
1383
|
+
print()
|
|
1384
|
+
if all_passed:
|
|
1385
|
+
print_success("All validation checks passed!")
|
|
1386
|
+
print(f"{Colors.OKGREEN}You can safely commit your changes.{Colors.ENDC}")
|
|
1387
|
+
_run_cleanup()
|
|
1388
|
+
return 0
|
|
1389
|
+
else:
|
|
1390
|
+
print_error("Some validation checks failed!")
|
|
1391
|
+
print(f"{Colors.FAIL}Please fix the issues before committing.{Colors.ENDC}")
|
|
1392
|
+
_run_cleanup()
|
|
1393
|
+
return 1
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
if __name__ == "__main__":
|
|
1397
|
+
sys.exit(main())
|