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

1"""Core configuration dataclasses and the root BRConfig aggregator. 

2 

3ProjectConfig holds project-level settings. BRConfig is the single entry 

4point that loads ll-config.json and exposes all domain configs via properties. 

5""" 

6 

7from __future__ import annotations 

8 

9import json 

10import os 

11import warnings 

12from dataclasses import dataclass 

13from pathlib import Path 

14from typing import Any, cast 

15 

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 

35 

36CONFIG_FILENAME = "ll-config.json" 

37CONFIG_DIR = ".ll" 

38CODEX_CONFIG_DIR = ".codex" 

39 

40 

41def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: 

42 """Deep-merge *override* into *base* for config-style overlays. 

43 

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: 

47 

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. 

52 

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. 

57 

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 

69 

70 

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. 

75 

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. 

79 

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. 

84 

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 

94 

95 

96def resolve_config_path(project_root: Path) -> Path | None: 

97 """Return the path to ``ll-config.json`` for *project_root* if found. 

98 

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). 

105 

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). 

109 

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 

118 

119 

120@dataclass 

121class ProjectConfig: 

122 """Project-level configuration.""" 

123 

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 

133 

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 ) 

148 

149 

150class BRConfig: 

151 """Main configuration class for little-loops. 

152 

153 Loads configuration from .ll/ll-config.json and merges with defaults. 

154 Provides convenient property access to all configuration values. 

155 

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 """ 

162 

163 CONFIG_FILENAME = CONFIG_FILENAME 

164 CONFIG_DIR = CONFIG_DIR 

165 

166 def __init__(self, project_root: Path) -> None: 

167 """Initialize configuration from project root. 

168 

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() 

175 

176 def _load_config(self) -> dict[str, Any]: 

177 """Load configuration from file. 

178 

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 {} 

188 

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 

194 

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 ) 

220 

221 @property 

222 def project(self) -> ProjectConfig: 

223 """Get project configuration.""" 

224 return self._project 

225 

226 @property 

227 def issues(self) -> IssuesConfig: 

228 """Get issues configuration.""" 

229 return self._issues 

230 

231 @property 

232 def automation(self) -> AutomationConfig: 

233 """Get automation configuration.""" 

234 return self._automation 

235 

236 @property 

237 def parallel(self) -> ParallelAutomationConfig: 

238 """Get parallel automation configuration.""" 

239 return self._parallel 

240 

241 @property 

242 def commands(self) -> CommandsConfig: 

243 """Get commands configuration.""" 

244 return self._commands 

245 

246 @property 

247 def scan(self) -> ScanConfig: 

248 """Get scan configuration.""" 

249 return self._scan 

250 

251 @property 

252 def sprints(self) -> SprintsConfig: 

253 """Get sprints configuration.""" 

254 return self._sprints 

255 

256 @property 

257 def loops(self) -> LoopsConfig: 

258 """Get loops configuration.""" 

259 return self._loops 

260 

261 @property 

262 def learning_tests(self) -> LearningTestsConfig: 

263 """Get learning tests configuration.""" 

264 return self._learning_tests 

265 

266 @property 

267 def sync(self) -> SyncConfig: 

268 """Get sync configuration.""" 

269 return self._sync 

270 

271 @property 

272 def dependency_mapping(self) -> DependencyMappingConfig: 

273 """Get dependency mapping configuration.""" 

274 return self._dependency_mapping 

275 

276 @property 

277 def cli(self) -> CliConfig: 

278 """Get CLI output configuration.""" 

279 return self._cli 

280 

281 @property 

282 def refine_status(self) -> RefineStatusConfig: 

283 """Get refine-status display configuration.""" 

284 return self._refine_status 

285 

286 @property 

287 def events(self) -> EventsConfig: 

288 """Get events configuration.""" 

289 return self._events 

290 

291 @property 

292 def orchestration(self) -> OrchestrationConfig: 

293 """Get orchestration configuration.""" 

294 return self._orchestration 

295 

296 @property 

297 def design_tokens(self) -> DesignTokensConfig: 

298 """Get design tokens configuration.""" 

299 return self._design_tokens 

300 

301 @property 

302 def extensions(self) -> list[str]: 

303 """Get extension config paths (e.g. ``["module:Class", ...]``).""" 

304 return self._raw_config.get("extensions", []) 

305 

306 @property 

307 def repo_path(self) -> Path: 

308 """Get the repository root path.""" 

309 return self.project_root 

310 

311 # Convenience methods for common operations 

312 

313 def get_issue_dir(self, category: str) -> Path: 

314 """Get the directory path for an issue category. 

315 

316 Args: 

317 category: Category key (e.g., "bugs", "features") 

318 

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 

327 

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 

336 

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 

345 

346 def get_issue_prefix(self, category: str) -> str: 

347 """Get the issue ID prefix for a category. 

348 

349 Args: 

350 category: Category key (e.g., "bugs", "features") 

351 

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] 

358 

359 def get_category_action(self, category: str) -> str: 

360 """Get the default action for a category. 

361 

362 Args: 

363 category: Category key (e.g., "bugs", "features") 

364 

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" 

371 

372 def get_loops_dir(self) -> Path: 

373 """Get the loops directory path.""" 

374 return self.project_root / self._loops.loops_dir 

375 

376 def get_src_path(self) -> Path: 

377 """Get the source directory path.""" 

378 return self.project_root / self._project.src_dir 

379 

380 def get_worktree_base(self) -> Path: 

381 """Get the worktree base directory path.""" 

382 return self.project_root / self._automation.worktree_base 

383 

384 def get_state_file(self) -> Path: 

385 """Get the state file path.""" 

386 return self.project_root / self._automation.state_file 

387 

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 

391 

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. 

416 

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 

433 

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 ) 

473 

474 @property 

475 def issue_categories(self) -> list[str]: 

476 """Get list of configured issue category names.""" 

477 return list(self._issues.categories.keys()) 

478 

479 @property 

480 def issue_priorities(self) -> list[str]: 

481 """Get list of valid priority prefixes.""" 

482 return self._issues.priorities 

483 

484 def to_dict(self) -> dict[str, Any]: 

485 """Convert configuration to dictionary. 

486 

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 } 

662 

663 def resolve_variable(self, var_path: str) -> str | None: 

664 """Resolve a variable path like 'project.src_dir' to its value. 

665 

666 Args: 

667 var_path: Dot-separated path to configuration value 

668 

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() 

674 

675 for part in parts: 

676 if isinstance(value, dict) and part in value: 

677 value = value[part] 

678 else: 

679 return None 

680 

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) 

686 

687 

688# Backwards compatibility alias 

689CLConfig = BRConfig