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.
Files changed (82) hide show
  1. collab/__init__.py +77 -0
  2. collab/__main__.py +11 -0
  3. collab_runtime-0.2.9.dist-info/METADATA +218 -0
  4. collab_runtime-0.2.9.dist-info/RECORD +82 -0
  5. collab_runtime-0.2.9.dist-info/WHEEL +5 -0
  6. collab_runtime-0.2.9.dist-info/entry_points.txt +3 -0
  7. collab_runtime-0.2.9.dist-info/licenses/LICENSE +21 -0
  8. collab_runtime-0.2.9.dist-info/top_level.txt +10 -0
  9. scripts/cleanup.py +395 -0
  10. scripts/collab_git_hook.py +190 -0
  11. scripts/format_code.py +594 -0
  12. scripts/generate_tests.py +560 -0
  13. scripts/validate_code.py +1397 -0
  14. src/__init__.py +4 -0
  15. src/dashboard/index.html +1131 -0
  16. src/live_locks_watcher.py +1982 -0
  17. src/lock_client.py +4268 -0
  18. src/logging_config.py +259 -0
  19. src/main.py +436 -0
  20. tests/backend/__init__.py +0 -0
  21. tests/backend/functional/__init__.py +0 -0
  22. tests/backend/functional/test_package_imports.py +43 -0
  23. tests/backend/integration/__init__.py +0 -0
  24. tests/backend/integration/test_cli_contract_parity.py +220 -0
  25. tests/backend/performance/__init__.py +0 -0
  26. tests/backend/reliability/__init__.py +0 -0
  27. tests/backend/security/__init__.py +0 -0
  28. tests/backend/unit/live_locks_watcher/__init__.py +5 -0
  29. tests/backend/unit/live_locks_watcher/_helpers.py +123 -0
  30. tests/backend/unit/live_locks_watcher/conftest.py +18 -0
  31. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_dashboard.py +188 -0
  32. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_developer.py +56 -0
  33. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_graceful_shutdown.py +459 -0
  34. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_main.py +1925 -0
  35. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_module.py +187 -0
  36. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_multi_session.py +320 -0
  37. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_notify.py +67 -0
  38. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_parsing.py +155 -0
  39. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_process_helpers.py +684 -0
  40. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_processing.py +173 -0
  41. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_prompt_abort.py +71 -0
  42. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_reconcile.py +516 -0
  43. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_scan.py +296 -0
  44. tests/backend/unit/lock_client/__init__.py +1 -0
  45. tests/backend/unit/lock_client/_helpers.py +132 -0
  46. tests/backend/unit/lock_client/test_lock_client_acquire.py +214 -0
  47. tests/backend/unit/lock_client/test_lock_client_active.py +104 -0
  48. tests/backend/unit/lock_client/test_lock_client_api.py +63 -0
  49. tests/backend/unit/lock_client/test_lock_client_cli.py +682 -0
  50. tests/backend/unit/lock_client/test_lock_client_daemon.py +3730 -0
  51. tests/backend/unit/lock_client/test_lock_client_dashboard.py +438 -0
  52. tests/backend/unit/lock_client/test_lock_client_discover.py +241 -0
  53. tests/backend/unit/lock_client/test_lock_client_force_release.py +354 -0
  54. tests/backend/unit/lock_client/test_lock_client_helper_branches.py +1890 -0
  55. tests/backend/unit/lock_client/test_lock_client_history.py +301 -0
  56. tests/backend/unit/lock_client/test_lock_client_isolation.py +316 -0
  57. tests/backend/unit/lock_client/test_lock_client_pid.py +75 -0
  58. tests/backend/unit/lock_client/test_lock_client_reconcile.py +464 -0
  59. tests/backend/unit/lock_client/test_lock_client_release.py +77 -0
  60. tests/backend/unit/lock_client/test_lock_client_shutdown.py +1110 -0
  61. tests/backend/unit/lock_client/test_lock_client_utils.py +474 -0
  62. tests/backend/unit/lock_client/test_lock_client_watch.py +866 -0
  63. tests/backend/unit/scripts/__init__.py +1 -0
  64. tests/backend/unit/scripts/_helpers.py +42 -0
  65. tests/backend/unit/scripts/test_cleanup.py +285 -0
  66. tests/backend/unit/scripts/test_collab_git_hook.py +280 -0
  67. tests/backend/unit/scripts/test_collab_git_hook_ported.py +50 -0
  68. tests/backend/unit/scripts/test_format_code.py +368 -0
  69. tests/backend/unit/scripts/test_format_code_ported.py +177 -0
  70. tests/backend/unit/scripts/test_generate_tests.py +305 -0
  71. tests/backend/unit/scripts/test_hook_templates.py +357 -0
  72. tests/backend/unit/scripts/test_setup_hook_overlay.py +95 -0
  73. tests/backend/unit/scripts/test_validate_code.py +867 -0
  74. tests/backend/unit/scripts/test_validate_code_ported.py +237 -0
  75. tests/backend/unit/test_entrypoints_main_run.py +83 -0
  76. tests/backend/unit/test_logging_config.py +529 -0
  77. tests/backend/unit/test_main_watch_pid_file.py +278 -0
  78. tests/conftest.py +167 -0
  79. tests/frontend/__init__.py +0 -0
  80. tests/frontend/jest/__init__.py +0 -0
  81. tests/frontend/playwright/__init__.py +0 -0
  82. tests/packaging/test_smoke_install.py +76 -0
@@ -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())