Coverage for little_loops / init / core.py: 13%

52 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:55 -0500

1"""Config building for headless ll-init.""" 

2 

3from __future__ import annotations 

4 

5from typing import Any 

6 

7from little_loops.init.detect import TemplateMatch 

8 

9SCHEMA_URL = ( 

10 "https://raw.githubusercontent.com/BrennonTWilliams/little-loops/main/config-schema.json" 

11) 

12 

13_DEFAULT_ANALYTICS_CAPTURE: dict[str, Any] = { 

14 "skills": ["*"], 

15 "cli_commands": ["*"], 

16 "corrections": True, 

17 "file_events": True, 

18} 

19 

20 

21def strip_none_leaves(config: dict[str, Any]) -> dict[str, Any]: 

22 """Return a deep copy of *config* with all ``None``-valued leaves removed. 

23 

24 ``config.core.deep_merge`` treats a ``None`` in the override as a key-removal 

25 sentinel (BUG-2310). ``build_config`` emitted ``None`` leaves (e.g. 

26 ``loops.run_defaults.mode``, ``project.build_cmd``) and the TUI emitted 

27 ``project.<cmd>`` ``None`` for cleared fields; merging those over an existing 

28 config would silently delete the user's corresponding keys. Stripping them 

29 from generated output makes the merge additive (fix for BUG-2311). 

30 

31 Nested dicts are recursed; every other value type passes through unchanged. 

32 """ 

33 result: dict[str, Any] = {} 

34 for key, value in config.items(): 

35 if value is None: 

36 continue 

37 if isinstance(value, dict): 

38 result[key] = strip_none_leaves(value) 

39 else: 

40 result[key] = value 

41 return result 

42 

43 

44def build_config( 

45 template: TemplateMatch, 

46 choices: dict[str, Any] | None = None, 

47) -> dict[str, Any]: 

48 """Build the output ll-config.json dict from a template and optional choices. 

49 

50 Ports the skill's Step 4 and Step 8 config-building logic. The resulting 

51 dict is ready for serialisation with ``atomic_write_json``. 

52 

53 Args: 

54 template: Matched template from detect_project_type(). 

55 choices: Optional overrides. Recognised keys: 

56 - ``project_name`` (str): value written to project.name. 

57 - ``src_dir`` (str): override project.src_dir. 

58 - ``product_enabled`` (bool, default True): include product section. 

59 - ``analytics_enabled`` (bool, default True): include analytics section. 

60 - ``context_monitor_enabled`` (bool, default True): include context_monitor. 

61 - ``learning_tests_enabled`` (bool, default True): include learning_tests. 

62 - ``decisions_enabled`` (bool, default False): include decisions section. 

63 - ``scratch_pad_enabled`` (bool, default False): include scratch_pad section. 

64 - ``session_capture_enabled`` (bool, default False): include session_capture. 

65 - ``prompt_optimization_enabled`` (bool, default True): when False, write 

66 prompt_optimization.enabled=false (opt-out of a default-on feature). 

67 - ``loop_clear_default`` (bool, default True): write loops.run_defaults.clear. 

68 - ``loop_show_diagrams_default`` (str | None, default "clean"): write loops.run_defaults.show_diagrams. 

69 

70 Returns: 

71 Complete config dict (``$schema`` key first, then sections). 

72 """ 

73 choices = choices or {} 

74 data = template.data 

75 

76 config: dict[str, Any] = {"$schema": SCHEMA_URL} 

77 

78 # --- project --- 

79 project: dict[str, Any] = dict(data.get("project", {})) 

80 if choices.get("project_name"): 

81 project["name"] = choices["project_name"] 

82 if choices.get("src_dir"): 

83 project["src_dir"] = choices["src_dir"] 

84 config["project"] = project 

85 

86 # --- issues --- 

87 config["issues"] = dict(data.get("issues", {})) 

88 

89 # --- scan --- 

90 config["scan"] = dict(data.get("scan", {})) 

91 

92 # --- learning_tests (always written) --- 

93 learning_tests_enabled = bool(choices.get("learning_tests_enabled", True)) 

94 config["learning_tests"] = {"enabled": learning_tests_enabled} 

95 

96 # --- analytics (always written) --- 

97 analytics_enabled = bool(choices.get("analytics_enabled", True)) 

98 if analytics_enabled: 

99 config["analytics"] = { 

100 "enabled": True, 

101 "capture": dict(_DEFAULT_ANALYTICS_CAPTURE), 

102 } 

103 else: 

104 config["analytics"] = {"enabled": False} 

105 

106 # --- context_monitor (omit if disabled) --- 

107 context_monitor_enabled = bool(choices.get("context_monitor_enabled", True)) 

108 if context_monitor_enabled: 

109 config["context_monitor"] = {"enabled": True} 

110 

111 # --- product (omit if disabled; default True for --yes mode) --- 

112 product_enabled = bool(choices.get("product_enabled", True)) 

113 if product_enabled: 

114 config["product"] = {"enabled": True} 

115 

116 # --- decisions (opt-in; omit if disabled) --- 

117 if choices.get("decisions_enabled"): 

118 config["decisions"] = {"enabled": True} 

119 

120 # --- scratch_pad (opt-in; omit if disabled) --- 

121 if choices.get("scratch_pad_enabled"): 

122 config["scratch_pad"] = {"enabled": True} 

123 

124 # --- session_capture (opt-in; omit if disabled) --- 

125 if choices.get("session_capture_enabled"): 

126 config["session_capture"] = {"enabled": True} 

127 

128 # --- prompt_optimization (default-on; only write when opting out) --- 

129 if choices.get("prompt_optimization_enabled", True) is False: 

130 config["prompt_optimization"] = {"enabled": False} 

131 

132 # --- history.session_digest (always written) --- 

133 session_digest_enabled = bool(choices.get("session_digest_enabled", True)) 

134 config["history"] = { 

135 "session_digest": { 

136 "enabled": session_digest_enabled, 

137 "days": 7, 

138 } 

139 } 

140 

141 # --- loops.run_defaults (always written; exposes the feature at init time) --- 

142 loop_clear = bool(choices.get("loop_clear_default", True)) 

143 loop_show_diagrams = choices.get("loop_show_diagrams_default", "clean") 

144 config["loops"] = { 

145 "run_defaults": { 

146 "clear": loop_clear, 

147 "show_diagrams": loop_show_diagrams, 

148 } 

149 } 

150 

151 return strip_none_leaves(config)