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
« 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.
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"""
9from __future__ import annotations
11from collections.abc import Callable
12from pathlib import Path
14from . import consent
15from . import yaml_helper as yaml
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)
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}
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"
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"
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
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)
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()
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"])
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.
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 )