Coverage for .claude/hooks/moai/lib/project.py: 0.00%
227 statements
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-19 08:00 +0900
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-19 08:00 +0900
1#!/usr/bin/env python3
2"""Project metadata utilities
4Project information inquiry (language, Git, SPEC progress, etc.)
5"""
7import json
8import signal
9import socket
10import subprocess
11from contextlib import contextmanager
12from pathlib import Path
13from typing import Any
15# Cache directory for version check results
16CACHE_DIR_NAME = ".moai/cache"
19def find_project_root(start_path: str | Path = ".") -> Path:
20 """Find MoAI-ADK project root by searching upward for .moai/config/config.json
22 Traverses up the directory tree until it finds .moai/config/config.json or CLAUDE.md,
23 which indicates the project root. This ensures cache and other files are
24 always created in the correct location, regardless of where hooks execute.
26 Args:
27 start_path: Starting directory (default: current directory)
29 Returns:
30 Project root Path. If not found, returns start_path as absolute path.
32 Examples:
33 >>> find_project_root(".")
34 Path("/Users/user/my-project")
35 >>> find_project_root(".claude/hooks/alfred")
36 Path("/Users/user/my-project") # Found root 3 levels up
38 Notes:
39 - Searches for .moai/config/config.json first (most reliable)
40 - Falls back to CLAUDE.md if config.json not found
41 - Max depth: 10 levels up (prevent infinite loop)
42 - Returns absolute path for consistency
44 TDD History:
45 - RED: 4 test scenarios (root, nested, not found, symlinks)
46 - GREEN: Minimal upward search with .moai/config/config.json detection
47 - REFACTOR: Add CLAUDE.md fallback, max depth limit, absolute path return
48 """
49 current = Path(start_path).resolve()
50 max_depth = 10 # Prevent infinite loop
52 for _ in range(max_depth):
53 # Check for .moai/config/config.json (primary indicator)
54 if (current / ".moai" / "config.json").exists():
55 return current
57 # Check for CLAUDE.md (secondary indicator)
58 if (current / "CLAUDE.md").exists():
59 return current
61 # Move up one level
62 parent = current.parent
63 if parent == current: # Reached filesystem root
64 break
65 current = parent
67 # Not found - return start_path as absolute
68 return Path(start_path).resolve()
71class TimeoutError(Exception):
72 """Signal-based timeout exception"""
74 pass
77@contextmanager
78def timeout_handler(seconds: int):
79 """Hard timeout using SIGALRM (works on Unix systems including macOS)
81 This uses kernel-level signal to interrupt ANY blocking operation,
82 even if subprocess.run() timeout fails on macOS.
84 Args:
85 seconds: Timeout duration in seconds
87 Raises:
88 TimeoutError: If operation exceeds timeout
89 """
91 def _handle_timeout(signum, frame):
92 raise TimeoutError(f"Operation timed out after {seconds} seconds")
94 # Set the signal handler
95 old_handler = signal.signal(signal.SIGALRM, _handle_timeout)
96 signal.alarm(seconds)
97 try:
98 yield
99 finally:
100 signal.alarm(0) # Disable alarm
101 signal.signal(signal.SIGALRM, old_handler)
104def detect_language(cwd: str) -> str:
105 """Detect project language (supports 20 items languages)
107 Browse the File system to detect your project's main development language.
108 First, check configuration files such as pyproject.toml and tsconfig.json.
109 Apply TypeScript first principles (if tsconfig.json exists).
111 Args:
112 cwd: Project root directory path (both absolute and relative paths are possible)
114 Returns:
115 str: Detected language name (lowercase). If detection fails, "Unknown Language" is returned.
116 Supported languages: python, typescript, javascript, java, go, rust,
117 dart, swift, kotlin, php, ruby, elixir, scala,
118 clojure, cpp, c, csharp, haskell, shell, lua
120 Examples:
121 >>> detect_language("/path/to/python/project")
122 'python'
123 >>> detect_language("/path/to/typescript/project")
124 'typescript'
125 >>> detect_language("/path/to/unknown/project")
126 'Unknown Language'
128 TDD History:
129 - RED: Write a 21 items language detection test (20 items language + 1 items unknown)
130 - GREEN: 20 items language + unknown implementation, all tests passed
131 - REFACTOR: Optimize file inspection order, apply TypeScript priority principle
132 """
133 cwd_path = Path(cwd)
135 # Language detection mapping
136 language_files = {
137 "pyproject.toml": "python",
138 "tsconfig.json": "typescript",
139 "package.json": "javascript",
140 "pom.xml": "java",
141 "go.mod": "go",
142 "Cargo.toml": "rust",
143 "pubspec.yaml": "dart",
144 "Package.swift": "swift",
145 "build.gradle.kts": "kotlin",
146 "composer.json": "php",
147 "Gemfile": "ruby",
148 "mix.exs": "elixir",
149 "build.sbt": "scala",
150 "project.clj": "clojure",
151 "CMakeLists.txt": "cpp",
152 "Makefile": "c",
153 }
155 # Check standard language files
156 for file_name, language in language_files.items():
157 if (cwd_path / file_name).exists():
158 # Special handling for package.json - prefer typescript if tsconfig exists
159 if file_name == "package.json" and (cwd_path / "tsconfig.json").exists():
160 return "typescript"
161 return language
163 # Check for C# project files (*.csproj)
164 if any(cwd_path.glob("*.csproj")):
165 return "csharp"
167 # Check for Haskell project files (*.cabal)
168 if any(cwd_path.glob("*.cabal")):
169 return "haskell"
171 # Check for Shell scripts (*.sh)
172 if any(cwd_path.glob("*.sh")):
173 return "shell"
175 # Check for Lua files (*.lua)
176 if any(cwd_path.glob("*.lua")):
177 return "lua"
179 return "Unknown Language"
182def _run_git_command(args: list[str], cwd: str, timeout: int = 2) -> str:
183 """Git command execution with HARD timeout protection
185 Safely execute Git commands and return output.
186 Uses SIGALRM (kernel-level interrupt) to handle macOS subprocess timeout bug.
187 Eliminates code duplication and provides consistent error handling.
189 Args:
190 args: Git command argument list (git adds automatically)
191 cwd: Execution directory path
192 timeout: Timeout in seconds (default: 2 seconds)
194 Returns:
195 str: Git command output (stdout, removing leading and trailing spaces)
197 Raises:
198 subprocess.TimeoutExpired: Timeout exceeded (via TimeoutError)
199 subprocess.CalledProcessError: Git command failed
201 Examples:
202 >>> _run_git_command(["branch", "--show-current"], ".")
203 'main'
205 TDD History:
206 - RED: Git command hang scenario test
207 - GREEN: SIGALRM-based timeout implementation
208 - REFACTOR: Exception conversion to subprocess.TimeoutExpired
209 """
210 try:
211 with timeout_handler(timeout):
212 result = subprocess.run(
213 ["git"] + args,
214 cwd=cwd,
215 capture_output=True,
216 text=True,
217 check=False, # Don't raise on non-zero exit - we'll check manually
218 )
220 # Check exit code manually
221 if result.returncode != 0:
222 raise subprocess.CalledProcessError(
223 result.returncode, ["git"] + args, result.stdout, result.stderr
224 )
226 return result.stdout.strip()
228 except TimeoutError:
229 # Convert to subprocess.TimeoutExpired for consistent error handling
230 raise subprocess.TimeoutExpired(["git"] + args, timeout)
233def get_git_info(cwd: str) -> dict[str, Any]:
234 """Gather Git repository information
236 View the current status of a Git repository.
237 Returns the branch name, commit hash, number of changes, and last commit message.
238 If it is not a Git repository, it returns an empty dictionary.
240 Args:
241 cwd: Project root directory path
243 Returns:
244 Git information dictionary. Includes the following keys:
245 - branch: Current branch name (str)
246 - commit: Current commit hash (str, full hash)
247 - changes: Number of changed files (int, staged + unstaged)
248 - last_commit: Last commit message (str, subject only)
250 Empty dictionary {} if it is not a Git repository or the query fails.
252 Examples:
253 >>> get_git_info("/path/to/git/repo")
254 {'branch': 'main', 'commit': 'abc123...', 'changes': 3, 'last_commit': 'Fix bug'}
255 >>> get_git_info("/path/to/non-git")
256 {}
258 Notes:
259 - Timeout: 2 seconds for each Git command
260 - Security: Safe execution with subprocess.run(shell=False)
261 - Error handling: Returns an empty dictionary in case of all exceptions
262 - Commit message limited to 50 characters for display purposes
264 TDD History:
265 - RED: 3 items scenario test (Git repo, non-Git, error)
266 - GREEN: Implementation of subprocess-based Git command execution
267 - REFACTOR: Add timeout (2 seconds), strengthen exception handling, remove duplicates with helper function
268 - UPDATE: Added last_commit message field for SessionStart display
269 """
270 try:
271 # Check if it's a git repository
272 _run_git_command(["rev-parse", "--git-dir"], cwd)
274 # Get branch name, commit hash, and changes
275 branch = _run_git_command(["branch", "--show-current"], cwd)
276 commit = _run_git_command(["rev-parse", "HEAD"], cwd)
277 status_output = _run_git_command(["status", "--short"], cwd)
278 changes = len([line for line in status_output.splitlines() if line])
280 # Get last commit message (subject only, limited to 50 chars)
281 last_commit = _run_git_command(["log", "-1", "--format=%s"], cwd)
282 if len(last_commit) > 50:
283 last_commit = last_commit[:47] + "..."
285 return {
286 "branch": branch,
287 "commit": commit,
288 "changes": changes,
289 "last_commit": last_commit,
290 }
292 except (subprocess.TimeoutExpired, subprocess.CalledProcessError, FileNotFoundError):
293 return {}
296def count_specs(cwd: str) -> dict[str, int]:
297 """SPEC File count and progress calculation
299 Browse the .moai/specs/ directory to find the number of SPEC Files and
300 Counts the number of SPECs with status: completed.
302 Args:
303 cwd: Project root directory path (or any subdirectory, will search upward)
305 Returns:
306 SPEC progress dictionary. Includes the following keys:
307 - completed: Number of completed SPECs (int)
308 - total: total number of SPECs (int)
309 - percentage: completion percentage (int, 0~100)
311 All 0 if .moai/specs/ directory does not exist
313 Examples:
314 >>> count_specs("/path/to/project")
315 {'completed': 2, 'total': 5, 'percentage': 40}
316 >>> count_specs("/path/to/no-specs")
317 {'completed': 0, 'total': 0, 'percentage': 0}
319 Notes:
320 - SPEC File Location: .moai/specs/SPEC-{ID}/spec.md
321 - Completion condition: Include "status: completed" in YAML front matter
322 - If parsing fails, the SPEC is considered incomplete.
323 - Automatically finds project root to locate .moai/specs/
325 TDD History:
326 - RED: 5 items scenario test (0/0, 2/5, 5/5, no directory, parsing error)
327 - GREEN: SPEC search with Path.iterdir(), YAML parsing implementation
328 - REFACTOR: Strengthened exception handling, improved percentage calculation safety
329 - UPDATE: Add project root detection for consistent path resolution
330 """
331 # Find project root to ensure we read specs from correct location
332 project_root = find_project_root(cwd)
333 specs_dir = project_root / ".moai" / "specs"
335 if not specs_dir.exists():
336 return {"completed": 0, "total": 0, "percentage": 0}
338 completed = 0
339 total = 0
341 for spec_dir in specs_dir.iterdir():
342 if not spec_dir.is_dir() or not spec_dir.name.startswith("SPEC-"):
343 continue
345 spec_file = spec_dir / "spec.md"
346 if not spec_file.exists():
347 continue
349 total += 1
351 # Parse YAML front matter
352 try:
353 content = spec_file.read_text()
354 if content.startswith("---"):
355 yaml_end = content.find("---", 3)
356 if yaml_end > 0:
357 yaml_content = content[3:yaml_end]
358 if "status: completed" in yaml_content:
359 completed += 1
360 except (OSError, UnicodeDecodeError):
361 # File read failure or encoding error - considered incomplete
362 pass
364 percentage = int(completed / total * 100) if total > 0 else 0
366 return {
367 "completed": completed,
368 "total": total,
369 "percentage": percentage,
370 }
373def get_project_language(cwd: str) -> str:
374 """Determine the primary project language (prefers config.json).
376 Args:
377 cwd: Project root directory (or any subdirectory, will search upward).
379 Returns:
380 Language string in lower-case.
382 Notes:
383 - Reads ``.moai/config/config.json`` first for a quick answer.
384 - Falls back to ``detect_language`` if configuration is missing.
385 - Automatically finds project root to locate .moai/config/config.json
386 """
387 # Find project root to ensure we read config from correct location
388 project_root = find_project_root(cwd)
389 config_path = project_root / ".moai" / "config.json"
390 if config_path.exists():
391 try:
392 config = json.loads(config_path.read_text())
393 lang = config.get("language", "")
394 if lang:
395 return lang
396 except (OSError, json.JSONDecodeError):
397 # Fall back to detection on parse errors
398 pass
400 # Fall back to the original language detection routine (use project root)
401 return detect_language(str(project_root))
404def _validate_project_structure(cwd: str) -> bool:
405 """Validate that project has required MoAI-ADK structure
407 Args:
408 cwd: Project root directory path
410 Returns:
411 bool: True if .moai/config/config.json exists, False otherwise
412 """
413 project_root = find_project_root(cwd)
414 return (project_root / ".moai" / "config.json").exists()
417def get_version_check_config(cwd: str) -> dict[str, Any]:
418 """Read version check configuration from .moai/config/config.json
420 Returns version check settings with sensible defaults.
421 Supports frequency-based cache TTL configuration.
423 Args:
424 cwd: Project root directory path
426 Returns:
427 dict with keys:
428 - "enabled": Boolean (default: True)
429 - "frequency": "always" | "daily" | "weekly" | "never" (default: "daily")
430 - "cache_ttl_hours": TTL in hours based on frequency
432 Frequency to TTL mapping:
433 - "always": 0 hours (no caching)
434 - "daily": 24 hours
435 - "weekly": 168 hours (7 days)
436 - "never": infinity (never check)
438 TDD History:
439 - RED: 8 test scenarios (defaults, custom, disabled, TTL, etc.)
440 - GREEN: Minimal config reading with defaults
441 - REFACTOR: Add validation and error handling
442 """
443 # TTL mapping by frequency
444 ttl_by_frequency = {"always": 0, "daily": 24, "weekly": 168, "never": float("inf")}
446 # Default configuration
447 defaults = {"enabled": True, "frequency": "daily", "cache_ttl_hours": 24}
449 # Find project root to ensure we read config from correct location
450 project_root = find_project_root(cwd)
451 config_path = project_root / ".moai" / "config.json"
452 if not config_path.exists():
453 return defaults
455 try:
456 config = json.loads(config_path.read_text())
458 # Extract moai.version_check section
459 moai_config = config.get("moai", {})
460 version_check_config = moai_config.get("version_check", {})
462 # Read enabled flag (default: True)
463 enabled = version_check_config.get("enabled", defaults["enabled"])
465 # Read frequency (default: "daily")
466 frequency = moai_config.get("update_check_frequency", defaults["frequency"])
468 # Validate frequency
469 if frequency not in ttl_by_frequency:
470 frequency = defaults["frequency"]
472 # Calculate TTL from frequency
473 cache_ttl_hours = ttl_by_frequency[frequency]
475 # Allow explicit cache_ttl_hours override
476 if "cache_ttl_hours" in version_check_config:
477 cache_ttl_hours = version_check_config["cache_ttl_hours"]
479 return {"enabled": enabled, "frequency": frequency, "cache_ttl_hours": cache_ttl_hours}
481 except (OSError, json.JSONDecodeError, KeyError):
482 # Config read or parse error - return defaults
483 return defaults
486def is_network_available(timeout_seconds: float = 0.1) -> bool:
487 """Quick network availability check using socket.
489 Does NOT check PyPI specifically, just basic connectivity.
490 Returns immediately on success (< 50ms typically).
491 Returns False on any error without raising exceptions.
493 Args:
494 timeout_seconds: Socket timeout in seconds (default 0.1s)
496 Returns:
497 True if network appears available, False otherwise
499 Examples:
500 >>> is_network_available()
501 True # Network is available
502 >>> is_network_available(timeout_seconds=0.001)
503 False # Timeout too short, returns False
505 TDD History:
506 - RED: 3 test scenarios (success, failure, timeout)
507 - GREEN: Minimal socket.create_connection implementation
508 - REFACTOR: Add error handling for all exception types
509 """
510 try:
511 # Try connecting to Google's public DNS server (8.8.8.8:53)
512 # This is a reliable host that's typically reachable
513 connection = socket.create_connection(("8.8.8.8", 53), timeout=timeout_seconds)
514 connection.close()
515 return True
516 except (socket.timeout, OSError, Exception):
517 # Any connection error means network is unavailable
518 # This includes: timeout, connection refused, network unreachable, etc.
519 return False
522def is_major_version_change(current: str, latest: str) -> bool:
523 """Detect if version change is a major version bump.
525 A major version change is when the first (major) component increases:
526 - 0.8.1 → 1.0.0: True (0 → 1)
527 - 1.2.3 → 2.0.0: True (1 → 2)
528 - 0.8.1 → 0.9.0: False (0 → 0, minor changed)
529 - 1.2.3 → 1.3.0: False (1 → 1)
531 Args:
532 current: Current version string (e.g., "0.8.1")
533 latest: Latest version string (e.g., "1.0.0")
535 Returns:
536 True if major version increased, False otherwise
538 Examples:
539 >>> is_major_version_change("0.8.1", "1.0.0")
540 True
541 >>> is_major_version_change("0.8.1", "0.9.0")
542 False
543 >>> is_major_version_change("dev", "1.0.0")
544 False # Invalid versions return False
546 TDD History:
547 - RED: 4 test scenarios (0→1, 1→2, minor, invalid)
548 - GREEN: Minimal version parsing and comparison
549 - REFACTOR: Improve error handling for invalid versions
550 """
551 try:
552 # Parse version strings into integer components
553 current_parts = [int(x) for x in current.split(".")]
554 latest_parts = [int(x) for x in latest.split(".")]
556 # Compare major version (first component)
557 if len(current_parts) >= 1 and len(latest_parts) >= 1:
558 return latest_parts[0] > current_parts[0]
560 # If parsing succeeds but empty, no major change
561 return False
563 except (ValueError, AttributeError, IndexError):
564 # Invalid version format - return False (no exception)
565 return False
568def get_package_version_info(cwd: str = ".") -> dict[str, Any]:
569 """Check MoAI-ADK current and latest version with caching and offline support.
571 ⭐ CRITICAL GUARANTEE: This function ALWAYS returns the current installed version.
572 Network failures, cache issues, and timeouts NEVER result in "unknown" version.
574 Execution flow:
575 1. Get current installed version (ALWAYS succeeds) ← CRITICAL
576 2. Build minimal result with current version
577 3. Try to load from cache (< 50ms) - optional enhancement
578 4. If cache valid, return cached latest info
579 5. If cache invalid/miss, optionally query PyPI - optional enhancement
580 6. Save result to cache for next time - optional
582 Args:
583 cwd: Project root directory (for cache location)
585 Returns:
586 dict with keys:
587 - "current": Current installed version (ALWAYS valid, never empty)
588 - "latest": Latest version available on PyPI (may be "unknown")
589 - "update_available": Boolean indicating if update is available
590 - "upgrade_command": Recommended upgrade command (if update available)
591 - "release_notes_url": URL to release notes
592 - "is_major_update": Boolean indicating major version change
594 Guarantees:
595 - Cache hit (< 24 hours): Returns in ~20ms, no network access ✓
596 - Cache miss + online: Query PyPI (1s timeout), cache result ✓
597 - Cache miss + offline: Return current version only (~100ms) ✓
598 - Network timeout: Returns current + "unknown" latest (~50ms) ✓
599 - Any exception: Always returns current version ✓
601 TDD History:
602 - RED: 5 test scenarios (network detection, cache integration, offline mode)
603 - GREEN: Integrate VersionCache with network detection
604 - REFACTOR: Extract cache directory constant, improve error handling
605 """
606 import importlib.util
607 import urllib.error
608 import urllib.request
609 from importlib.metadata import PackageNotFoundError, version
611 # Import VersionCache from the same directory (using dynamic import for testing compatibility)
612 try:
613 version_cache_path = Path(__file__).parent / "version_cache.py"
614 spec = importlib.util.spec_from_file_location("version_cache", version_cache_path)
615 if spec and spec.loader:
616 version_cache_module = importlib.util.module_from_spec(spec)
617 spec.loader.exec_module(version_cache_module)
618 version_cache_class = version_cache_module.VersionCache
619 else:
620 # Skip caching if module can't be loaded
621 version_cache_class = None
622 except (ImportError, OSError):
623 # Graceful degradation: skip caching on import errors
624 version_cache_class = None
626 # 1. Find project root (ensure cache is always in correct location)
627 # This prevents creating .moai/cache in wrong locations when hooks run
628 # from subdirectories like .claude/hooks/alfred/
629 project_root = find_project_root(cwd)
631 # 2. Initialize cache (skip if VersionCache couldn't be imported)
632 cache_dir = project_root / CACHE_DIR_NAME
633 version_cache = version_cache_class(cache_dir) if version_cache_class else None
635 # 2. Get current installed version first (needed for cache validation)
636 current_version = "unknown"
637 try:
638 current_version = version("moai-adk")
639 except PackageNotFoundError:
640 current_version = "dev"
641 # Dev mode - skip cache and return immediately
642 return {
643 "current": "dev",
644 "latest": "unknown",
645 "update_available": False,
646 "upgrade_command": "",
647 }
649 # 3. Try to load from cache (fast path with version validation)
650 if version_cache and version_cache.is_valid():
651 cached_info = version_cache.load()
652 if cached_info:
653 # Only use cache if the cached version matches current installed version
654 # This prevents stale cache when package is upgraded locally
655 if cached_info.get("current") == current_version:
656 # Ensure new fields exist for backward compatibility
657 if "release_notes_url" not in cached_info:
658 # Add missing fields to old cached data
659 cached_info.setdefault("release_notes_url", None)
660 cached_info.setdefault("is_major_update", False)
661 return cached_info
662 # else: cache is stale (version changed), fall through to re-check
664 # 4. Cache miss or stale - need to query PyPI
665 result = {
666 "current": current_version,
667 "latest": "unknown",
668 "update_available": False,
669 "upgrade_command": "",
670 }
672 # 5. Check if version check is enabled in config
673 config = get_version_check_config(cwd)
674 if not config["enabled"]:
675 # Version check disabled - return only current version
676 return result
678 # 6. Check network before PyPI query
679 if not is_network_available():
680 # Offline mode - return current version only
681 return result
683 # 7. Network available - query PyPI
684 pypi_data = None
685 try:
686 with timeout_handler(1):
687 url = "https://pypi.org/pypi/moai-adk/json"
688 headers = {"Accept": "application/json"}
689 req = urllib.request.Request(url, headers=headers)
690 with urllib.request.urlopen(req, timeout=0.8) as response:
691 pypi_data = json.load(response)
692 result["latest"] = pypi_data.get("info", {}).get("version", "unknown")
694 # Extract release notes URL from project_urls
695 try:
696 project_urls = pypi_data.get("info", {}).get("project_urls", {})
697 release_url = project_urls.get("Changelog", "")
698 if not release_url:
699 # Fallback to GitHub releases URL pattern
700 release_url = (
701 f"https://github.com/modu-ai/moai-adk/releases/tag/v{result['latest']}"
702 )
703 result["release_notes_url"] = release_url
704 except (KeyError, AttributeError, TypeError):
705 result["release_notes_url"] = None
707 except (urllib.error.URLError, TimeoutError, Exception):
708 # PyPI query failed - return current version
709 result["release_notes_url"] = None
710 pass
712 # 7. Compare versions (simple comparison)
713 if result["current"] != "unknown" and result["latest"] != "unknown":
714 try:
715 # Parse versions for comparison
716 current_parts = [int(x) for x in result["current"].split(".")]
717 latest_parts = [int(x) for x in result["latest"].split(".")]
719 # Pad shorter version with zeros
720 max_len = max(len(current_parts), len(latest_parts))
721 current_parts.extend([0] * (max_len - len(current_parts)))
722 latest_parts.extend([0] * (max_len - len(latest_parts)))
724 if latest_parts > current_parts:
725 result["update_available"] = True
726 result["upgrade_command"] = "uv tool upgrade moai-adk"
728 # Detect major version change
729 result["is_major_update"] = is_major_version_change(
730 result["current"], result["latest"]
731 )
732 else:
733 result["is_major_update"] = False
734 except (ValueError, AttributeError):
735 # Version parsing failed - skip comparison
736 result["is_major_update"] = False
737 pass
739 # 8. Save result to cache (if caching is available)
740 if version_cache:
741 version_cache.save(result)
743 return result
746__all__ = [
747 "find_project_root",
748 "detect_language",
749 "get_git_info",
750 "count_specs",
751 "get_project_language",
752 "get_version_check_config",
753 "is_network_available",
754 "is_major_version_change",
755 "get_package_version_info",
756]