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

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 

14import yaml 

15 

16from . import consent 

17 

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) 

27 

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} 

40 

41 

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" 

49 

50 

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) 

86 

87 

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

97 

98 

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

104 

105 

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. 

108 

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 )