Coverage for src/keel/scaffold.py: 100%

66 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-18 12:05 +0000

1"""`keel init` — scaffold a default `.keel/project.yaml`, or build one with a wizard. 

2 

3Pure + deterministic: :func:`detect_stack` is a function of which marker files exist, 

4:func:`render_config` renders YAML from explicit values, and :func:`wizard` builds those 

5values through an injectable `ask` callback (so the interactive flow is unit-tested 

6offline). The CLI supplies the real `input`-based `ask` and does the file I/O. 

7""" 

8 

9from __future__ import annotations 

10 

11from collections.abc import Callable 

12from pathlib import Path 

13 

14from . import consent 

15from . import yaml_helper as yaml 

16 

17#: marker file (checked in order) -> stack name. 

18_MARKERS: tuple[tuple[str, str], ...] = ( 

19 ("pubspec.yaml", "flutter"), 

20 ("build.gradle", "android"), 

21 ("build.gradle.kts", "android"), 

22 ("pom.xml", "java"), 

23 ("Cargo.toml", "rust"), 

24 ("go.mod", "go"), 

25 ("pyproject.toml", "python"), 

26 ("setup.py", "python"), 

27 ("requirements.txt", "python"), 

28 ("Pipfile", "python"), 

29 ("package.json", "node"), 

30) 

31 

32#: per-stack defaults: platform, build cmd, lint cmd (or None), tier-3 globs. 

33_TEMPLATES: dict[str, dict] = { 

34 "flutter": {"platform": "flutter", "build": "flutter test", "lint": "flutter analyze", 

35 "globs": ("lib/**/*.dart",)}, 

36 "python": {"platform": "python", "build": "make test", "lint": "ruff check .", 

37 "globs": ("src/**/*.py",)}, 

38 "node": {"platform": "node", "build": "npm test", "lint": "npm run lint", 

39 "globs": ("src/**/*.ts", "src/**/*.js")}, 

40 "android": {"platform": "android", "build": "./gradlew test", "lint": "./gradlew lint", 

41 "globs": ("app/src/**",)}, 

42 "rust": {"platform": "rust", "build": "cargo test", "lint": "cargo clippy", 

43 "globs": ("src/**/*.rs",)}, 

44 "go": {"platform": "go", "build": "go test ./...", "lint": "golangci-lint run", 

45 "globs": ("**/*.go",)}, 

46 "java": {"platform": "java", "build": "mvn test", "lint": "mvn checkstyle:check", 

47 "globs": ("src/main/**",)}, 

48 "generic": {"platform": "generic", "build": "make test", "lint": None, "globs": ()}, 

49} 

50 

51 

52def detect_stack(root: str | Path) -> str: 

53 """Detect the project stack from marker files (``generic`` if none match).""" 

54 root = Path(root) 

55 for marker, stack in _MARKERS: 

56 if (root / marker).exists(): 

57 return stack 

58 return "generic" 

59 

60 

61def detect_base_branch(root: str | Path) -> str: 

62 """Detect the repository's default base branch (defaults to ``main``).""" 

63 root = Path(root) 

64 head_file = root / ".git" / "HEAD" 

65 if head_file.exists(): 

66 try: 

67 content = head_file.read_text(encoding="utf-8").strip() 

68 if content.startswith("ref: refs/heads/"): 

69 ref_name = content[len("ref: refs/heads/"):].strip() 

70 if ref_name in ("main", "master", "develop", "trunk"): 

71 return ref_name 

72 except OSError: 

73 pass 

74 return "main" 

75 

76 

77def auto_detect_config( 

78 root: str | Path, 

79 *, 

80 repo: str = "my-repo", 

81) -> tuple[str, dict]: 

82 """Inspect the repository stack and base branch, returning (yaml_text, metadata).""" 

83 root = Path(root) 

84 stack = detect_stack(root) 

85 base_branch = detect_base_branch(root) 

86 t = _TEMPLATES.get(stack, _TEMPLATES["generic"]) 

87 meta = { 

88 "stack": stack, 

89 "platform": t["platform"], 

90 "base_branch": base_branch, 

91 "build_cmd": t["build"], 

92 "lint_cmd": t["lint"], 

93 "tier3_globs": t["globs"], 

94 } 

95 text = render_config( 

96 repo=repo, 

97 base_branch=base_branch, 

98 platform=t["platform"], 

99 build_cmd=t["build"], 

100 lint_cmd=t["lint"], 

101 tier3_globs=t["globs"], 

102 generator="keel init --auto", 

103 ) 

104 return text, meta 

105 

106 

107def render_config( 

108 *, repo: str = "my-repo", base_branch: str = "main", platform: str = "generic", 

109 build_cmd: str = "make test", lint_cmd: str | None = None, 

110 tier3_globs: tuple[str, ...] = (), timezone: str | None = None, 

111 merge_window: str | None = None, consent_mode: str = "explicit", 

112 generator: str = "keel init", 

113) -> str: 

114 """Render a valid ``project.yaml`` from explicit values (passes ``keel validate``).""" 

115 if consent_mode not in consent.CONSENT_MODES: 

116 raise ValueError( 

117 f"unknown consent mode {consent_mode!r}; valid: {', '.join(consent.CONSENT_MODES)}" 

118 ) 

119 generator_comment = " ".join(str(generator).splitlines()) 

120 lines = [ 

121 f"# keel consumer config (generated by `{generator_comment}`)", 

122 "extends: keel", 

123 'core_version: "^1.0"', 

124 f"repo: {_yaml_scalar(repo)}", 

125 f"base_branch: {_yaml_scalar(base_branch)}", 

126 f"platform: {_yaml_scalar(platform)}", 

127 f"consent_mode: {_yaml_scalar(consent_mode)}", 

128 ] 

129 if timezone: 

130 lines.append(f"timezone: {_yaml_scalar(timezone)}") 

131 if merge_window: 

132 lines.append(f"merge_window: {_yaml_scalar(merge_window)}") 

133 lines += ["", "knobs:", f" build_gate_cmd: {_yaml_scalar(build_cmd)}"] 

134 if lint_cmd: 

135 lines.append(f" lint_cmd: {_yaml_scalar(lint_cmd)}") 

136 if tier3_globs: 

137 lines.append(" tier3_globs:") 

138 lines += [f" - {_yaml_scalar(g)}" for g in tier3_globs] 

139 gates = "[build, lint]" if lint_cmd else "[build]" 

140 lines += ["", f"gates: {gates}", "extensions: {}", "extensions_dir: .keel/extensions", ""] 

141 return "\n".join(lines) 

142 

143 

144def _yaml_scalar(value: str) -> str: 

145 """Render a scalar as inline YAML so scaffolded values cannot inject new keys.""" 

146 return yaml.dump( 

147 str(value), 

148 default_style='"', 

149 default_flow_style=True, 

150 width=10**6, 

151 sort_keys=False, 

152 ).strip() 

153 

154 

155def default_config(stack: str, *, repo: str = "my-repo", base_branch: str = "main") -> str: 

156 """Render the default ``project.yaml`` for ``stack`` (non-interactive).""" 

157 t = _TEMPLATES.get(stack, _TEMPLATES["generic"]) 

158 return render_config(repo=repo, base_branch=base_branch, platform=t["platform"], 

159 build_cmd=t["build"], lint_cmd=t["lint"], tier3_globs=t["globs"]) 

160 

161 

162def wizard(stack: str, ask: Callable[[str, str], str], *, repo: str = "my-repo") -> str: 

163 """Build a config by asking for each value, defaulting to the stack template. 

164 

165 ``ask(prompt, default)`` returns the chosen value (an empty answer ⇒ the default). 

166 Pure given ``ask`` — the CLI passes a real `input`-based implementation. 

167 """ 

168 t = _TEMPLATES.get(stack, _TEMPLATES["generic"]) 

169 base = ask("Base branch", "main") 

170 tz = ask("Timezone (IANA, blank to skip)", "Europe/Istanbul") 

171 win = ask("Merge window HH:MM-HH:MM (blank to skip)", "07:00-01:30") 

172 mode = ask("Consent mode (explicit, standing, agent)", "explicit") or "explicit" 

173 build = ask("Build/test command", t["build"]) 

174 lint = ask("Lint command (blank to skip)", t["lint"] or "") 

175 return render_config( 

176 repo=repo, base_branch=base, platform=t["platform"], build_cmd=build, 

177 lint_cmd=lint or None, tier3_globs=t["globs"], 

178 timezone=tz or None, merge_window=win or None, consent_mode=mode, 

179 generator="keel init --wizard", 

180 )