Coverage for little_loops / config / core.py: 48%
190 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-28 13:07 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-28 13:07 -0500
1"""Core configuration dataclasses and the root BRConfig aggregator.
3ProjectConfig holds project-level settings. BRConfig is the single entry
4point that loads ll-config.json and exposes all domain configs via properties.
5"""
7from __future__ import annotations
9import json
10import os
11import warnings
12from dataclasses import dataclass
13from pathlib import Path
14from typing import Any, cast
16from little_loops.config.automation import (
17 AutomationConfig,
18 CommandsConfig,
19 DependencyMappingConfig,
20 ParallelAutomationConfig,
21)
22from little_loops.config.cli import CliConfig, RefineStatusConfig
23from little_loops.config.features import (
24 DesignTokensConfig,
25 EventsConfig,
26 IssuesConfig,
27 LearningTestsConfig,
28 LoopsConfig,
29 ScanConfig,
30 SprintsConfig,
31 SyncConfig,
32)
33from little_loops.config.orchestration import OrchestrationConfig
34from little_loops.parallel.types import ParallelConfig
36CONFIG_FILENAME = "ll-config.json"
37CONFIG_DIR = ".ll"
38CODEX_CONFIG_DIR = ".codex"
41def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
42 """Deep-merge *override* into *base* for config-style overlays.
44 Python port of the inline bash version at ``hooks/scripts/session-start.sh:30-43``
45 used to apply ``.ll/ll.local.md`` frontmatter overrides on top of the base
46 ``ll-config.json``. Semantics:
48 - Nested dicts are merged recursively at every level.
49 - All other value types (strings, ints, bools, lists) **replace** the base
50 value — arrays do not append.
51 - An explicit ``None`` in *override* **removes** the key from the result.
53 Differs from ``little_loops.fsm.fragments._deep_merge`` only in the
54 null-removal semantic — fragments-merge passes ``None`` through as a value,
55 while config-merge treats it as a key-removal sentinel. The config variant
56 is required for ``ll.local.md`` to be able to unset base keys.
58 Returns a new dict; neither input is mutated.
59 """
60 result = dict(base)
61 for key, value in override.items():
62 if value is None:
63 result.pop(key, None)
64 elif isinstance(value, dict) and isinstance(result.get(key), dict):
65 result[key] = deep_merge(result[key], value)
66 else:
67 result[key] = value
68 return result
71def _config_candidates(
72 project_root: Path, *, host: str | None, state_dir: str | None
73) -> list[Path]:
74 """Return the ordered list of ``ll-config.json`` candidate paths to probe.
76 Default order (no host env vars set): ``.ll/ll-config.json`` then
77 root-level ``ll-config.json`` — preserves the historical behavior of
78 :func:`resolve_config_path` before host-aware probing was added.
80 When ``host == "codex"`` or ``state_dir == ".codex"`` (FEAT-957),
81 ``.codex/ll-config.json`` is prepended so Codex CLI projects pick up
82 their host-specific config before falling through to the default
83 candidates. Other host values pass through unchanged.
85 Future hosts (e.g. FEAT-992 Pi) add a new branch here rather than a
86 new code path elsewhere.
87 """
88 candidates: list[Path] = []
89 if host == "codex" or state_dir == CODEX_CONFIG_DIR:
90 candidates.append(project_root / CODEX_CONFIG_DIR / CONFIG_FILENAME)
91 candidates.append(project_root / CONFIG_DIR / CONFIG_FILENAME)
92 candidates.append(project_root / CONFIG_FILENAME)
93 return candidates
96def resolve_config_path(project_root: Path) -> Path | None:
97 """Return the path to ``ll-config.json`` for *project_root* if found.
99 Iterates an ordered candidate list (see :func:`_config_candidates`) and
100 returns the first existing file. The default order is
101 ``<root>/.ll/ll-config.json`` then ``<root>/ll-config.json`` (parity
102 with the legacy bash ``ll_resolve_config``); when ``LL_HOOK_HOST=codex``
103 or ``LL_STATE_DIR=.codex`` is set on the environment,
104 ``<root>/.codex/ll-config.json`` is probed first (FEAT-957).
106 Pure lookup — does not create directories or mutate global state (the
107 bash version's ``mkdir -p .ll`` side effect is intentionally dropped;
108 callers that need ``.ll/`` to exist must create it themselves).
110 Returns ``None`` if no candidate exists.
111 """
112 host = os.environ.get("LL_HOOK_HOST")
113 state_dir = os.environ.get("LL_STATE_DIR")
114 for candidate in _config_candidates(project_root, host=host, state_dir=state_dir):
115 if candidate.is_file():
116 return candidate
117 return None
120@dataclass
121class ProjectConfig:
122 """Project-level configuration."""
124 name: str = ""
125 src_dir: str = "src/"
126 test_dir: str = "tests"
127 test_cmd: str = "pytest"
128 lint_cmd: str = "ruff check ."
129 type_cmd: str | None = "mypy"
130 format_cmd: str | None = "ruff format ."
131 build_cmd: str | None = None
132 run_cmd: str | None = None
134 @classmethod
135 def from_dict(cls, data: dict[str, Any]) -> ProjectConfig:
136 """Create ProjectConfig from dictionary."""
137 return cls(
138 name=data.get("name", ""),
139 src_dir=data.get("src_dir", "src/"),
140 test_dir=data.get("test_dir", "tests"),
141 test_cmd=data.get("test_cmd", "pytest"),
142 lint_cmd=data.get("lint_cmd", "ruff check ."),
143 type_cmd=data.get("type_cmd", "mypy"),
144 format_cmd=data.get("format_cmd", "ruff format ."),
145 build_cmd=data.get("build_cmd"),
146 run_cmd=data.get("run_cmd"),
147 )
150class BRConfig:
151 """Main configuration class for little-loops.
153 Loads configuration from .ll/ll-config.json and merges with defaults.
154 Provides convenient property access to all configuration values.
156 Example:
157 config = BRConfig(Path.cwd())
158 print(config.project.src_dir) # "src/"
159 print(config.issues.base_dir) # ".issues"
160 print(config.get_issue_dir("bugs")) # Path(".issues/bugs")
161 """
163 CONFIG_FILENAME = CONFIG_FILENAME
164 CONFIG_DIR = CONFIG_DIR
166 def __init__(self, project_root: Path) -> None:
167 """Initialize configuration from project root.
169 Args:
170 project_root: Path to the project root directory
171 """
172 self.project_root = project_root.resolve()
173 self._raw_config = self._load_config()
174 self._parse_config()
176 def _load_config(self) -> dict[str, Any]:
177 """Load configuration from file.
179 Uses :func:`resolve_config_path` which checks ``.ll/ll-config.json``
180 first then falls back to a root-level ``ll-config.json`` (parity with
181 ``hooks/scripts/lib/common.sh:ll_resolve_config``).
182 """
183 config_path = resolve_config_path(self.project_root)
184 if config_path is not None:
185 with open(config_path, encoding="utf-8") as f:
186 return cast(dict[str, Any], json.load(f))
187 return {}
189 def _parse_config(self) -> None:
190 """Parse raw config into typed dataclasses."""
191 self._project = ProjectConfig.from_dict(self._raw_config.get("project", {}))
192 if not self._project.name:
193 self._project.name = self.project_root.name
195 self._issues = IssuesConfig.from_dict(self._raw_config.get("issues", {}))
196 self._automation = AutomationConfig.from_dict(self._raw_config.get("automation", {}))
197 self._parallel = ParallelAutomationConfig.from_dict(self._raw_config.get("parallel", {}))
198 self._commands = CommandsConfig.from_dict(self._raw_config.get("commands", {}))
199 self._scan = ScanConfig.from_dict(self._raw_config.get("scan", {}))
200 self._sprints = SprintsConfig.from_dict(self._raw_config.get("sprints", {}))
201 self._loops = LoopsConfig.from_dict(self._raw_config.get("loops", {}))
202 self._learning_tests = LearningTestsConfig.from_dict(
203 self._raw_config.get("learning_tests", {})
204 )
205 self._sync = SyncConfig.from_dict(self._raw_config.get("sync", {}))
206 self._dependency_mapping = DependencyMappingConfig.from_dict(
207 self._raw_config.get("dependency_mapping", {})
208 )
209 self._cli = CliConfig.from_dict(self._raw_config.get("cli", {}))
210 self._refine_status = RefineStatusConfig.from_dict(
211 self._raw_config.get("refine_status", {})
212 )
213 self._events = EventsConfig.from_dict(self._raw_config.get("events", {}))
214 self._orchestration = OrchestrationConfig.from_dict(
215 self._raw_config.get("orchestration", {})
216 )
217 self._design_tokens = DesignTokensConfig.from_dict(
218 self._raw_config.get("design_tokens", {})
219 )
221 @property
222 def project(self) -> ProjectConfig:
223 """Get project configuration."""
224 return self._project
226 @property
227 def issues(self) -> IssuesConfig:
228 """Get issues configuration."""
229 return self._issues
231 @property
232 def automation(self) -> AutomationConfig:
233 """Get automation configuration."""
234 return self._automation
236 @property
237 def parallel(self) -> ParallelAutomationConfig:
238 """Get parallel automation configuration."""
239 return self._parallel
241 @property
242 def commands(self) -> CommandsConfig:
243 """Get commands configuration."""
244 return self._commands
246 @property
247 def scan(self) -> ScanConfig:
248 """Get scan configuration."""
249 return self._scan
251 @property
252 def sprints(self) -> SprintsConfig:
253 """Get sprints configuration."""
254 return self._sprints
256 @property
257 def loops(self) -> LoopsConfig:
258 """Get loops configuration."""
259 return self._loops
261 @property
262 def learning_tests(self) -> LearningTestsConfig:
263 """Get learning tests configuration."""
264 return self._learning_tests
266 @property
267 def sync(self) -> SyncConfig:
268 """Get sync configuration."""
269 return self._sync
271 @property
272 def dependency_mapping(self) -> DependencyMappingConfig:
273 """Get dependency mapping configuration."""
274 return self._dependency_mapping
276 @property
277 def cli(self) -> CliConfig:
278 """Get CLI output configuration."""
279 return self._cli
281 @property
282 def refine_status(self) -> RefineStatusConfig:
283 """Get refine-status display configuration."""
284 return self._refine_status
286 @property
287 def events(self) -> EventsConfig:
288 """Get events configuration."""
289 return self._events
291 @property
292 def orchestration(self) -> OrchestrationConfig:
293 """Get orchestration configuration."""
294 return self._orchestration
296 @property
297 def design_tokens(self) -> DesignTokensConfig:
298 """Get design tokens configuration."""
299 return self._design_tokens
301 @property
302 def extensions(self) -> list[str]:
303 """Get extension config paths (e.g. ``["module:Class", ...]``)."""
304 return self._raw_config.get("extensions", [])
306 @property
307 def repo_path(self) -> Path:
308 """Get the repository root path."""
309 return self.project_root
311 # Convenience methods for common operations
313 def get_issue_dir(self, category: str) -> Path:
314 """Get the directory path for an issue category.
316 Args:
317 category: Category key (e.g., "bugs", "features")
319 Returns:
320 Path to the issue category directory
321 """
322 if category in self._issues.categories:
323 dir_name = self._issues.categories[category].dir
324 else:
325 dir_name = category
326 return self.project_root / self._issues.base_dir / dir_name
328 def get_completed_dir(self) -> Path:
329 """Get the path to the completed issues directory."""
330 warnings.warn(
331 "BRConfig.get_completed_dir() is deprecated; use IssueInfo.status instead",
332 DeprecationWarning,
333 stacklevel=2,
334 )
335 return self.project_root / self._issues.base_dir / self._issues.completed_dir
337 def get_deferred_dir(self) -> Path:
338 """Get the path to the deferred issues directory."""
339 warnings.warn(
340 "BRConfig.get_deferred_dir() is deprecated; use IssueInfo.status instead",
341 DeprecationWarning,
342 stacklevel=2,
343 )
344 return self.project_root / self._issues.base_dir / self._issues.deferred_dir
346 def get_issue_prefix(self, category: str) -> str:
347 """Get the issue ID prefix for a category.
349 Args:
350 category: Category key (e.g., "bugs", "features")
352 Returns:
353 Issue prefix (e.g., "BUG", "FEAT")
354 """
355 if category in self._issues.categories:
356 return self._issues.categories[category].prefix
357 return category.upper()[:3]
359 def get_category_action(self, category: str) -> str:
360 """Get the default action for a category.
362 Args:
363 category: Category key (e.g., "bugs", "features")
365 Returns:
366 Action verb (e.g., "fix", "implement")
367 """
368 if category in self._issues.categories:
369 return self._issues.categories[category].action
370 return "fix"
372 def get_loops_dir(self) -> Path:
373 """Get the loops directory path."""
374 return self.project_root / self._loops.loops_dir
376 def get_src_path(self) -> Path:
377 """Get the source directory path."""
378 return self.project_root / self._project.src_dir
380 def get_worktree_base(self) -> Path:
381 """Get the worktree base directory path."""
382 return self.project_root / self._automation.worktree_base
384 def get_state_file(self) -> Path:
385 """Get the state file path."""
386 return self.project_root / self._automation.state_file
388 def get_parallel_state_file(self) -> Path:
389 """Get the parallel state file path."""
390 return self.project_root / self._parallel.base.state_file
392 def create_parallel_config(
393 self,
394 *,
395 max_workers: int | None = None,
396 priority_filter: list[str] | None = None,
397 max_issues: int = 0,
398 dry_run: bool = False,
399 timeout_seconds: int | None = None,
400 idle_timeout_per_issue: int | None = None,
401 stream_output: bool | None = None,
402 show_model: bool | None = None,
403 only_ids: set[str] | None = None,
404 skip_ids: set[str] | None = None,
405 type_prefixes: set[str] | None = None,
406 label_filter: set[str] | None = None,
407 merge_pending: bool = False,
408 clean_start: bool = False,
409 ignore_pending: bool = False,
410 overlap_detection: bool = False,
411 serialize_overlapping: bool = True,
412 base_branch: str = "main",
413 remote_name: str | None = None,
414 ) -> ParallelConfig:
415 """Create a ParallelConfig from BRConfig settings with optional overrides.
417 Args:
418 max_workers: Override max_workers (default: from config)
419 priority_filter: Override priority filter (default: from issues config)
420 max_issues: Maximum issues to process (default: 0 = unlimited)
421 dry_run: Preview mode (default: False)
422 timeout_seconds: Per-issue timeout (default: from config)
423 idle_timeout_per_issue: Kill worker if no output for N seconds (0 to disable, default: 0)
424 stream_output: Stream output (default: from config)
425 show_model: Make API call to verify model (default: False)
426 only_ids: If provided, only process these issue IDs
427 skip_ids: Issue IDs to skip (in addition to completed/failed)
428 merge_pending: Attempt to merge pending worktrees (default: False)
429 clean_start: Remove all worktrees without checking (default: False)
430 ignore_pending: Report pending work but continue (default: False)
431 overlap_detection: Enable pre-flight overlap detection (default: False)
432 serialize_overlapping: If True, defer overlapping issues; if False, just warn
434 Returns:
435 ParallelConfig configured from BRConfig
436 """
437 return ParallelConfig(
438 max_workers=max_workers or self._parallel.base.max_workers,
439 p0_sequential=self._parallel.p0_sequential,
440 worktree_base=Path(self._parallel.base.worktree_base),
441 state_file=Path(self._parallel.base.state_file),
442 max_merge_retries=self._parallel.max_merge_retries,
443 priority_filter=priority_filter or self._issues.priorities,
444 max_issues=max_issues,
445 dry_run=dry_run,
446 timeout_per_issue=timeout_seconds or self._parallel.base.timeout_seconds,
447 idle_timeout_per_issue=idle_timeout_per_issue
448 if idle_timeout_per_issue is not None
449 else 0,
450 stream_subprocess_output=(
451 stream_output if stream_output is not None else self._parallel.base.stream_output
452 ),
453 show_model=show_model if show_model is not None else False,
454 command_prefix=self._parallel.command_prefix,
455 ready_command=self._parallel.ready_command,
456 manage_command=self._parallel.manage_command,
457 decide_command=self._parallel.decide_command,
458 only_ids=only_ids,
459 skip_ids=skip_ids,
460 type_prefixes=type_prefixes,
461 label_filter=label_filter,
462 worktree_copy_files=self._parallel.worktree_copy_files,
463 require_code_changes=self._parallel.require_code_changes,
464 use_feature_branches=self._parallel.use_feature_branches,
465 merge_pending=merge_pending,
466 clean_start=clean_start,
467 ignore_pending=ignore_pending,
468 overlap_detection=overlap_detection,
469 serialize_overlapping=serialize_overlapping,
470 base_branch=base_branch,
471 remote_name=remote_name if remote_name is not None else self._parallel.remote_name,
472 )
474 @property
475 def issue_categories(self) -> list[str]:
476 """Get list of configured issue category names."""
477 return list(self._issues.categories.keys())
479 @property
480 def issue_priorities(self) -> list[str]:
481 """Get list of valid priority prefixes."""
482 return self._issues.priorities
484 def to_dict(self) -> dict[str, Any]:
485 """Convert configuration to dictionary.
487 Useful for variable substitution in command templates.
488 """
489 return {
490 "project": {
491 "name": self._project.name,
492 "src_dir": self._project.src_dir,
493 "test_dir": self._project.test_dir,
494 "test_cmd": self._project.test_cmd,
495 "lint_cmd": self._project.lint_cmd,
496 "type_cmd": self._project.type_cmd,
497 "format_cmd": self._project.format_cmd,
498 "build_cmd": self._project.build_cmd,
499 "run_cmd": self._project.run_cmd,
500 },
501 "issues": {
502 "base_dir": self._issues.base_dir,
503 "categories": {
504 k: {"prefix": v.prefix, "dir": v.dir, "action": v.action}
505 for k, v in self._issues.categories.items()
506 },
507 "priorities": self._issues.priorities,
508 "templates_dir": self._issues.templates_dir,
509 "capture_template": self._issues.capture_template,
510 },
511 "automation": {
512 "timeout_seconds": self._automation.timeout_seconds,
513 "idle_timeout_seconds": self._automation.idle_timeout_seconds,
514 "state_file": self._automation.state_file,
515 "worktree_base": self._automation.worktree_base,
516 "max_workers": self._automation.max_workers,
517 "stream_output": self._automation.stream_output,
518 "max_continuations": self._automation.max_continuations,
519 },
520 "parallel": {
521 "max_workers": self._parallel.base.max_workers,
522 "p0_sequential": self._parallel.p0_sequential,
523 "worktree_base": self._parallel.base.worktree_base,
524 "state_file": self._parallel.base.state_file,
525 "timeout_per_issue": self._parallel.base.timeout_seconds,
526 "max_merge_retries": self._parallel.max_merge_retries,
527 "stream_subprocess_output": self._parallel.base.stream_output,
528 "command_prefix": self._parallel.command_prefix,
529 "ready_command": self._parallel.ready_command,
530 "manage_command": self._parallel.manage_command,
531 "decide_command": self._parallel.decide_command,
532 "worktree_copy_files": self._parallel.worktree_copy_files,
533 "require_code_changes": self._parallel.require_code_changes,
534 "use_feature_branches": self._parallel.use_feature_branches,
535 "remote_name": self._parallel.remote_name,
536 },
537 "commands": {
538 "pre_implement": self._commands.pre_implement,
539 "post_implement": self._commands.post_implement,
540 "custom_verification": self._commands.custom_verification,
541 "confidence_gate": {
542 "enabled": self._commands.confidence_gate.enabled,
543 "readiness_threshold": self._commands.confidence_gate.readiness_threshold,
544 "outcome_threshold": self._commands.confidence_gate.outcome_threshold,
545 },
546 "tdd_mode": self._commands.tdd_mode,
547 "rate_limits": {
548 "max_wait_seconds": self._commands.rate_limits.max_wait_seconds,
549 "long_wait_ladder": self._commands.rate_limits.long_wait_ladder,
550 "circuit_breaker_enabled": self._commands.rate_limits.circuit_breaker_enabled,
551 "circuit_breaker_path": self._commands.rate_limits.circuit_breaker_path,
552 },
553 "recursive_refine": {
554 "max_depth": self._commands.recursive_refine.max_depth,
555 },
556 },
557 "scan": {
558 "focus_dirs": self._scan.focus_dirs,
559 "exclude_patterns": self._scan.exclude_patterns,
560 "custom_agents": self._scan.custom_agents,
561 },
562 "sprints": {
563 "sprints_dir": self._sprints.sprints_dir,
564 "default_timeout": self._sprints.default_timeout,
565 "default_max_workers": self._sprints.default_max_workers,
566 },
567 "loops": {
568 "loops_dir": self._loops.loops_dir,
569 "queue_wait_timeout_seconds": self._loops.queue_wait_timeout_seconds,
570 "glyphs": self._loops.glyphs.to_dict(),
571 },
572 "learning_tests": {
573 "stale_after_days": self._learning_tests.stale_after_days,
574 },
575 "design_tokens": {
576 "enabled": self._design_tokens.enabled,
577 "path": self._design_tokens.path,
578 "primitives_file": self._design_tokens.primitives_file,
579 "semantic_file": self._design_tokens.semantic_file,
580 "themes_dir": self._design_tokens.themes_dir,
581 "active_theme": self._design_tokens.active_theme,
582 },
583 "events": {
584 "transports": list(self._events.transports),
585 "socket": {
586 "path": self._events.socket.path,
587 "max_clients": self._events.socket.max_clients,
588 },
589 "otel": {
590 "endpoint": self._events.otel.endpoint,
591 "service_name": self._events.otel.service_name,
592 },
593 "webhook": {
594 "url": self._events.webhook.url,
595 "batch_ms": self._events.webhook.batch_ms,
596 "headers": dict(self._events.webhook.headers),
597 },
598 },
599 "sync": {
600 "enabled": self._sync.enabled,
601 "provider": self._sync.provider,
602 "github": {
603 "repo": self._sync.github.repo,
604 "label_mapping": self._sync.github.label_mapping,
605 "priority_labels": self._sync.github.priority_labels,
606 "sync_completed": self._sync.github.sync_completed,
607 "state_file": self._sync.github.state_file,
608 "pull_template": self._sync.github.pull_template,
609 },
610 },
611 "dependency_mapping": {
612 "overlap_min_files": self._dependency_mapping.overlap_min_files,
613 "overlap_min_ratio": self._dependency_mapping.overlap_min_ratio,
614 "min_directory_depth": self._dependency_mapping.min_directory_depth,
615 "conflict_threshold": self._dependency_mapping.conflict_threshold,
616 "high_conflict_threshold": self._dependency_mapping.high_conflict_threshold,
617 "confidence_modifier": self._dependency_mapping.confidence_modifier,
618 "scoring_weights": {
619 "semantic": self._dependency_mapping.scoring_weights.semantic,
620 "section": self._dependency_mapping.scoring_weights.section,
621 "type": self._dependency_mapping.scoring_weights.type,
622 },
623 "exclude_common_files": self._dependency_mapping.exclude_common_files,
624 },
625 "cli": {
626 "color": self._cli.color,
627 "colors": {
628 "logger": {
629 "info": self._cli.colors.logger.info,
630 "success": self._cli.colors.logger.success,
631 "warning": self._cli.colors.logger.warning,
632 "error": self._cli.colors.logger.error,
633 },
634 "priority": {
635 "P0": self._cli.colors.priority.P0,
636 "P1": self._cli.colors.priority.P1,
637 "P2": self._cli.colors.priority.P2,
638 "P3": self._cli.colors.priority.P3,
639 "P4": self._cli.colors.priority.P4,
640 "P5": self._cli.colors.priority.P5,
641 },
642 "type": {
643 "BUG": self._cli.colors.type.BUG,
644 "FEAT": self._cli.colors.type.FEAT,
645 "ENH": self._cli.colors.type.ENH,
646 },
647 "fsm_active_state": self._cli.colors.fsm_active_state,
648 "fsm_edge_labels": {
649 "yes": self._cli.colors.fsm_edge_labels.yes,
650 "no": self._cli.colors.fsm_edge_labels.no,
651 "error": self._cli.colors.fsm_edge_labels.error,
652 "partial": self._cli.colors.fsm_edge_labels.partial,
653 "next": self._cli.colors.fsm_edge_labels.next,
654 "default": self._cli.colors.fsm_edge_labels.default,
655 "blocked": self._cli.colors.fsm_edge_labels.blocked,
656 "retry_exhausted": self._cli.colors.fsm_edge_labels.retry_exhausted,
657 "rate_limit_exhausted": self._cli.colors.fsm_edge_labels.rate_limit_exhausted,
658 },
659 },
660 },
661 }
663 def resolve_variable(self, var_path: str) -> str | None:
664 """Resolve a variable path like 'project.src_dir' to its value.
666 Args:
667 var_path: Dot-separated path to configuration value
669 Returns:
670 The resolved value as a string, or None if not found
671 """
672 parts = var_path.split(".")
673 value: Any = self.to_dict()
675 for part in parts:
676 if isinstance(value, dict) and part in value:
677 value = value[part]
678 else:
679 return None
681 if value is None:
682 return None
683 if isinstance(value, list):
684 return " ".join(str(v) for v in value)
685 return str(value)
688# Backwards compatibility alias
689CLConfig = BRConfig