Coverage for src/keel/scaffold.py: 100%
45 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 18:07 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 18:07 +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
14import yaml
16from . import consent
18#: marker file (checked in order) -> stack name.
19_MARKERS: tuple[tuple[str, str], ...] = (
20 ("pubspec.yaml", "flutter"),
21 ("build.gradle", "android"),
22 ("build.gradle.kts", "android"),
23 ("pyproject.toml", "python"),
24 ("setup.py", "python"),
25 ("package.json", "node"),
26)
28#: per-stack defaults: platform, build cmd, lint cmd (or None), tier-3 globs.
29_TEMPLATES: dict[str, dict] = {
30 "flutter": {"platform": "flutter", "build": "flutter test", "lint": "flutter analyze",
31 "globs": ("lib/**/*.dart",)},
32 "python": {"platform": "python", "build": "make test", "lint": "ruff check .",
33 "globs": ("src/**/*.py",)},
34 "node": {"platform": "node", "build": "npm test", "lint": "npm run lint",
35 "globs": ("src/**/*.ts", "src/**/*.js")},
36 "android": {"platform": "android", "build": "./gradlew test", "lint": "./gradlew lint",
37 "globs": ("app/src/**",)},
38 "generic": {"platform": "generic", "build": "make test", "lint": None, "globs": ()},
39}
42def detect_stack(root: str | Path) -> str:
43 """Detect the project stack from marker files (``generic`` if none match)."""
44 root = Path(root)
45 for marker, stack in _MARKERS:
46 if (root / marker).exists():
47 return stack
48 return "generic"
51def render_config(
52 *, repo: str = "my-repo", base_branch: str = "main", platform: str = "generic",
53 build_cmd: str = "make test", lint_cmd: str | None = None,
54 tier3_globs: tuple[str, ...] = (), timezone: str | None = None,
55 merge_window: str | None = None, consent_mode: str = "explicit",
56 generator: str = "keel init",
57) -> str:
58 """Render a valid ``project.yaml`` from explicit values (passes ``keel validate``)."""
59 if consent_mode not in consent.CONSENT_MODES:
60 raise ValueError(
61 f"unknown consent mode {consent_mode!r}; valid: {', '.join(consent.CONSENT_MODES)}"
62 )
63 generator_comment = " ".join(str(generator).splitlines())
64 lines = [
65 f"# keel consumer config (generated by `{generator_comment}`)",
66 "extends: keel",
67 'core_version: "^1.0"',
68 f"repo: {_yaml_scalar(repo)}",
69 f"base_branch: {_yaml_scalar(base_branch)}",
70 f"platform: {_yaml_scalar(platform)}",
71 f"consent_mode: {_yaml_scalar(consent_mode)}",
72 ]
73 if timezone:
74 lines.append(f"timezone: {_yaml_scalar(timezone)}")
75 if merge_window:
76 lines.append(f"merge_window: {_yaml_scalar(merge_window)}")
77 lines += ["", "knobs:", f" build_gate_cmd: {_yaml_scalar(build_cmd)}"]
78 if lint_cmd:
79 lines.append(f" lint_cmd: {_yaml_scalar(lint_cmd)}")
80 if tier3_globs:
81 lines.append(" tier3_globs:")
82 lines += [f" - {_yaml_scalar(g)}" for g in tier3_globs]
83 gates = "[build, lint]" if lint_cmd else "[build]"
84 lines += ["", f"gates: {gates}", "extensions: {}", "extensions_dir: .keel/extensions", ""]
85 return "\n".join(lines)
88def _yaml_scalar(value: str) -> str:
89 """Render a scalar as inline YAML so scaffolded values cannot inject new keys."""
90 return yaml.safe_dump(
91 str(value),
92 default_style='"',
93 default_flow_style=True,
94 width=10**6,
95 sort_keys=False,
96 ).strip()
99def default_config(stack: str, *, repo: str = "my-repo", base_branch: str = "main") -> str:
100 """Render the default ``project.yaml`` for ``stack`` (non-interactive)."""
101 t = _TEMPLATES.get(stack, _TEMPLATES["generic"])
102 return render_config(repo=repo, base_branch=base_branch, platform=t["platform"],
103 build_cmd=t["build"], lint_cmd=t["lint"], tier3_globs=t["globs"])
106def wizard(stack: str, ask: Callable[[str, str], str], *, repo: str = "my-repo") -> str:
107 """Build a config by asking for each value, defaulting to the stack template.
109 ``ask(prompt, default)`` returns the chosen value (an empty answer ⇒ the default).
110 Pure given ``ask`` — the CLI passes a real `input`-based implementation.
111 """
112 t = _TEMPLATES.get(stack, _TEMPLATES["generic"])
113 base = ask("Base branch", "main")
114 tz = ask("Timezone (IANA, blank to skip)", "Europe/Istanbul")
115 win = ask("Merge window HH:MM-HH:MM (blank to skip)", "07:00-01:30")
116 mode = ask("Consent mode (explicit, standing, agent)", "explicit") or "explicit"
117 build = ask("Build/test command", t["build"])
118 lint = ask("Lint command (blank to skip)", t["lint"] or "")
119 return render_config(
120 repo=repo, base_branch=base, platform=t["platform"], build_cmd=build,
121 lint_cmd=lint or None, tier3_globs=t["globs"],
122 timezone=tz or None, merge_window=win or None, consent_mode=mode,
123 generator="keel init --wizard",
124 )