diff --git a/codeframe/cli/app.py b/codeframe/cli/app.py index 39671150..55f67b0e 100644 --- a/codeframe/cli/app.py +++ b/codeframe/cli/app.py @@ -82,6 +82,11 @@ def init( "--tech-stack-interactive", "-i", help="Interactively configure tech stack", ), + generate_config: bool = typer.Option( + False, + "--generate-config", + help="Generate starter CODEFRAME.md with project configuration", + ), ) -> None: """Initialize a CodeFRAME workspace for a repository. @@ -99,6 +104,8 @@ def init( codeframe init . --tech-stack "Rust project using cargo" codeframe init . --tech-stack "TypeScript monorepo with pnpm, Next.js frontend, FastAPI backend" codeframe init . --tech-stack-interactive + codeframe init . --generate-config + codeframe init . --detect --generate-config """ from codeframe.core.workspace import ( create_or_load_workspace, @@ -170,6 +177,15 @@ def init( else: console.print(f" Hook after_init: [yellow]failed[/yellow] ({hook_result.stderr[:100]})") + # Generate CODEFRAME.md if requested + if generate_config: + config_content = _generate_codeframe_md( + tech_stack=final_tech_stack or "", + ) + config_path = repo_path / "CODEFRAME.md" + config_path.write_text(config_content) + console.print(" Generated: CODEFRAME.md") + console.print() console.print("Next steps:") console.print(" codeframe prd add Add a PRD") @@ -317,6 +333,58 @@ def _interactive_tech_stack() -> str: return tech_stack +def _generate_codeframe_md(tech_stack: str = "") -> str: + """Generate a starter CODEFRAME.md file with YAML front matter and body. + + Args: + tech_stack: Natural language tech stack description. + + Returns: + Complete CODEFRAME.md content string. + """ + import yaml as _yaml + + yaml_section: dict = { + "engine": "react", + } + if tech_stack: + yaml_section["tech_stack"] = tech_stack + + # Detect gates from tech stack + gates: list[str] = [] + if tech_stack: + ts_lower = tech_stack.lower() + if "python" in ts_lower or "pytest" in ts_lower: + gates.extend(["ruff", "pytest"]) + elif "typescript" in ts_lower or "jest" in ts_lower: + gates.extend(["eslint", "jest"]) + if gates: + yaml_section["gates"] = gates + + yaml_section["batch"] = {"max_parallel": 2, "default_strategy": "auto"} + yaml_section["agent"] = {"max_iterations": 30, "verbose": False} + + front_matter = _yaml.dump(yaml_section, default_flow_style=False, sort_keys=False) + + body = """# Project Agent Instructions + +## Coding Standards +- Follow existing code patterns and conventions +- Write clear, self-documenting code +- Include appropriate error handling + +## Always Do +- Run tests before considering a task complete +- Follow the project's existing file structure + +## Never Do +- Delete or overwrite files without understanding their purpose +- Introduce new dependencies without justification +""" + + return f"---\n{front_matter}---\n\n{body}" + + @app.command() def status( repo_path: Optional[Path] = typer.Argument( diff --git a/codeframe/core/agents_config.py b/codeframe/core/agents_config.py index a9d05a83..03aa51e3 100644 --- a/codeframe/core/agents_config.py +++ b/codeframe/core/agents_config.py @@ -1,9 +1,12 @@ """Agent preferences loader for CodeFRAME v2. -Loads project-level preferences from AGENTS.md and CLAUDE.md files. +Loads project-level preferences from CODEFRAME.md, AGENTS.md, and CLAUDE.md files. These preferences guide agent decision-making for tactical choices like tooling, file handling, and code style. +CODEFRAME.md supports YAML front matter for typed configuration (engine, gates, +batch settings, hooks) plus a Markdown body for agent instructions. + Supports the AGENTS.md industry standard (OpenAI, Google, GitHub, Anthropic) as well as CLAUDE.md for Anthropic-specific instructions. @@ -14,9 +17,15 @@ - https://github.blog/ai-and-ml/github-copilot/how-to-write-a-great-agents-md-lessons-from-over-2500-repositories/ """ +import logging import re from dataclasses import dataclass, field from pathlib import Path +from typing import Optional + +import yaml + +logger = logging.getLogger(__name__) @dataclass @@ -98,6 +107,29 @@ def to_prompt_section(self) -> str: return "\n".join(sections) +@dataclass +class CodeframeConfig: + """Typed configuration from CODEFRAME.md YAML front matter. + + Attributes: + engine: Execution engine (react, plan, claude-code, etc.) + tech_stack: Natural language tech stack description + batch: Batch execution settings (max_parallel, default_strategy) + gates: Verification gates to run (ruff, pytest, mypy, etc.) + hooks: Lifecycle hooks (before_task, after_task, on_failure, etc.) + agent: Agent tuning parameters (max_iterations, verbose, etc.) + raw: Full parsed YAML dict for extensibility + """ + + engine: Optional[str] = None + tech_stack: Optional[str] = None + batch: dict = field(default_factory=dict) + gates: list[str] = field(default_factory=list) + hooks: dict = field(default_factory=dict) + agent: dict = field(default_factory=dict) + raw: dict = field(default_factory=dict) + + # Section header patterns for parsing AGENTS.md content SECTION_PATTERNS = { "always_do": re.compile( @@ -270,6 +302,89 @@ def _parse_agents_md(content: str) -> AgentPreferences: return prefs +def parse_codeframe_md(content: str) -> tuple[CodeframeConfig, AgentPreferences]: + """Parse a CODEFRAME.md file with YAML front matter + Markdown body. + + The file format is: + --- + engine: react + tech_stack: "Python with uv" + batch: + max_parallel: 4 + gates: + - ruff + - pytest + hooks: + before_task: "git checkout -b cf/{{task_id}}" + agent: + max_iterations: 30 + --- + + # Project Instructions + Markdown content here becomes the agent system prompt supplement... + + Returns: + Tuple of (CodeframeConfig, AgentPreferences) + CodeframeConfig has the typed YAML settings + AgentPreferences has the Markdown body as raw_content + any extracted sections + """ + config = CodeframeConfig() + prefs = AgentPreferences() + + if not content or not content.strip(): + return config, prefs + + # Extract YAML front matter between --- markers + yaml_data = {} + body = content + + stripped = content.strip() + if stripped.startswith("---"): + # Find the closing --- marker (skip the opening one) + after_opening = stripped[3:] + closing_idx = after_opening.find("\n---") + if closing_idx >= 0: + yaml_text = after_opening[:closing_idx].strip() + # Body starts after the closing --- + rest = after_opening[closing_idx + 4:] # skip "\n---" + body = rest.strip() + + # Parse YAML + try: + parsed = yaml.safe_load(yaml_text) + if isinstance(parsed, dict): + yaml_data = parsed + except yaml.YAMLError: + logger.warning("Invalid YAML front matter in CODEFRAME.md, skipping") + body = content # Treat entire content as body on YAML failure + else: + # No closing --- found, treat entire content as body + body = content + + # Build CodeframeConfig from YAML + if yaml_data: + config = CodeframeConfig( + engine=yaml_data.get("engine"), + tech_stack=yaml_data.get("tech_stack"), + batch=yaml_data.get("batch") or {}, + gates=yaml_data.get("gates") or [], + hooks=yaml_data.get("hooks") or {}, + agent=yaml_data.get("agent") or {}, + raw=yaml_data, + ) + + # Parse Markdown body for AgentPreferences + if body: + prefs = _parse_agents_md(body) + prefs.source_files = ["CODEFRAME.md"] + + # Cross-populate tech_stack into preferences tooling + if config.tech_stack: + prefs.tooling["tech_stack"] = config.tech_stack + + return config, prefs + + def _merge_preferences( base: AgentPreferences, override: AgentPreferences ) -> AgentPreferences: @@ -295,15 +410,16 @@ def _merge_preferences( def load_preferences(workspace_path: Path) -> AgentPreferences: - """Load and merge agent preferences from AGENTS.md and CLAUDE.md files. + """Load and merge agent preferences from CODEFRAME.md, AGENTS.md, and CLAUDE.md. Search order (closest wins): - 1. workspace_path/AGENTS.md - 2. workspace_path/CLAUDE.md - 3. Parent directories (walking up) - 4. ~/.codeframe/AGENTS.md (global defaults) + 1. workspace_path/CODEFRAME.md (highest priority) + 2. workspace_path/AGENTS.md + 3. workspace_path/CLAUDE.md (lowest priority) + 4. Parent directories (walking up, same precedence order) + 5. ~/.codeframe/AGENTS.md (global defaults) - AGENTS.md takes precedence over CLAUDE.md at the same directory level. + At each directory level, CODEFRAME.md > AGENTS.md > CLAUDE.md. Args: workspace_path: Path to the workspace/repository root @@ -316,12 +432,12 @@ def load_preferences(workspace_path: Path) -> AgentPreferences: found_files = [] # Search order: global defaults first, then walk up to workspace (closest wins) - search_paths = [] + search_paths: list[tuple[Path, bool]] = [] # (path, is_codeframe_md) # 1. Global defaults (lowest priority) global_config = Path.home() / ".codeframe" / "AGENTS.md" if global_config.exists(): - search_paths.append(global_config) + search_paths.append((global_config, False)) # 2. Walk from root to workspace (so workspace files override parents) current = workspace_path @@ -332,22 +448,31 @@ def load_preferences(workspace_path: Path) -> AgentPreferences: # Reverse so we go from ancestors to workspace (closest wins) for dir_path in reversed(path_chain): - # CLAUDE.md first (lower priority at same level) + # CLAUDE.md first (lowest priority at same level) claude_md = dir_path / "CLAUDE.md" if claude_md.exists(): - search_paths.append(claude_md) + search_paths.append((claude_md, False)) - # AGENTS.md second (higher priority at same level) + # AGENTS.md second (medium priority at same level) agents_md = dir_path / "AGENTS.md" if agents_md.exists(): - search_paths.append(agents_md) + search_paths.append((agents_md, False)) + + # CODEFRAME.md third (highest priority at same level) + codeframe_md = dir_path / "CODEFRAME.md" + if codeframe_md.exists(): + search_paths.append((codeframe_md, True)) # Process all found files - for file_path in search_paths: + for file_path, is_codeframe in search_paths: try: content = file_path.read_text(encoding="utf-8") - file_prefs = _parse_agents_md(content) - file_prefs.source_files = [str(file_path)] + if is_codeframe: + _, file_prefs = parse_codeframe_md(content) + file_prefs.source_files = [str(file_path)] + else: + file_prefs = _parse_agents_md(content) + file_prefs.source_files = [str(file_path)] prefs = _merge_preferences(prefs, file_prefs) found_files.append(str(file_path)) except (OSError, UnicodeDecodeError): @@ -413,3 +538,32 @@ def get_default_preferences() -> AgentPreferences: raw_content="", source_files=[""], ) + + +def get_codeframe_config(workspace_path: Path) -> Optional[CodeframeConfig]: + """Load CodeframeConfig from CODEFRAME.md if present. + + Searches for CODEFRAME.md starting from workspace_path, walking up to root. + Returns None if no CODEFRAME.md is found. + + Args: + workspace_path: Path to the workspace/repository root + + Returns: + CodeframeConfig if CODEFRAME.md is found, None otherwise + """ + workspace_path = Path(workspace_path).resolve() + + current = workspace_path + while current != current.parent: + codeframe_md = current / "CODEFRAME.md" + if codeframe_md.exists(): + try: + content = codeframe_md.read_text(encoding="utf-8") + config, _ = parse_codeframe_md(content) + return config + except (OSError, UnicodeDecodeError): + return None + current = current.parent + + return None diff --git a/codeframe/core/config.py b/codeframe/core/config.py index 0400cf91..a66954bb 100644 --- a/codeframe/core/config.py +++ b/codeframe/core/config.py @@ -306,23 +306,121 @@ def from_dict(cls, data: dict[str, Any]) -> "EnvironmentConfig": def load_environment_config(workspace_path: Path) -> Optional[EnvironmentConfig]: """Load environment configuration from workspace. + Checks .codeframe/config.yaml first. If not found, falls back to + reading CODEFRAME.md front matter and converting it to EnvironmentConfig. + Args: workspace_path: Path to the workspace root Returns: - EnvironmentConfig if file exists, None otherwise + EnvironmentConfig if configuration is found, None otherwise """ + # Try .codeframe/config.yaml first (existing behavior) config_file = workspace_path / ".codeframe" / ENV_CONFIG_FILE - if not config_file.exists(): - return None + if config_file.exists(): + with open(config_file) as f: + data = yaml.safe_load(f) + + if data is None: + return EnvironmentConfig() # empty file = defaults + + return EnvironmentConfig.from_dict(data) + + # Fallback: try CODEFRAME.md front matter + from codeframe.core.agents_config import get_codeframe_config + + cf_config = get_codeframe_config(workspace_path) + if cf_config is not None and _has_meaningful_config(cf_config): + return _codeframe_config_to_env_config(cf_config) + + return None + + +def _has_meaningful_config(cf_config: Any) -> bool: + """Check if a CodeframeConfig has any non-default settings. - with open(config_file) as f: - data = yaml.safe_load(f) + Returns False when the CODEFRAME.md exists but has no YAML front matter + (all fields are None or empty). + """ + return bool( + cf_config.engine + or cf_config.tech_stack + or cf_config.gates + or cf_config.hooks + or cf_config.batch + or cf_config.agent + ) + + +def _codeframe_config_to_env_config(cf_config: Any) -> EnvironmentConfig: + """Convert CodeframeConfig (from CODEFRAME.md) to EnvironmentConfig. - if data is None: - return EnvironmentConfig() # empty file = defaults + Maps structured fields from CODEFRAME.md front matter into the + EnvironmentConfig dataclass used throughout the runtime. - return EnvironmentConfig.from_dict(data) + Args: + cf_config: A CodeframeConfig instance from agents_config module. + + Returns: + Populated EnvironmentConfig. + """ + config = EnvironmentConfig() + + # Map engine directly + if cf_config.engine: + config.engine = cf_config.engine + + # Map tech_stack to package_manager/test_framework heuristics + if cf_config.tech_stack: + ts = cf_config.tech_stack.lower() + if "uv" in ts: + config.package_manager = "uv" + elif "poetry" in ts: + config.package_manager = "poetry" + elif "npm" in ts: + config.package_manager = "npm" + elif "pnpm" in ts: + config.package_manager = "pnpm" + elif "yarn" in ts: + config.package_manager = "yarn" + + if "pytest" in ts: + config.test_framework = "pytest" + elif "jest" in ts: + config.test_framework = "jest" + elif "vitest" in ts: + config.test_framework = "vitest" + + # Map gates to lint_tools (filter to lint-related gates only) + if cf_config.gates: + lint_gate_names = {"ruff", "pylint", "eslint", "prettier", "flake8", "mypy", "biome"} + config.lint_tools = [g for g in cf_config.gates if g in lint_gate_names] + + # Map hooks + if cf_config.hooks: + valid_hook_fields = {f.name for f in HooksConfig.__dataclass_fields__.values()} + filtered = {k: v for k, v in cf_config.hooks.items() if k in valid_hook_fields} + config.hooks = HooksConfig(**filtered) + + # Map batch + if cf_config.batch: + batch_kwargs: dict[str, Any] = {} + if "max_parallel" in cf_config.batch: + batch_kwargs["max_parallel"] = cf_config.batch["max_parallel"] + if batch_kwargs: + config.batch = BatchConfig(**batch_kwargs) + + # Map agent budget + if cf_config.agent: + budget_kwargs: dict[str, Any] = {} + if "max_iterations" in cf_config.agent: + budget_kwargs["max_iterations"] = cf_config.agent["max_iterations"] + # Also set base_iterations to match (they're conceptually the same) + budget_kwargs["base_iterations"] = cf_config.agent["max_iterations"] + if budget_kwargs: + config.agent_budget = AgentBudgetConfig(**budget_kwargs) + + return config def save_environment_config(workspace_path: Path, config: EnvironmentConfig) -> None: diff --git a/tests/cli/test_init_generate_config.py b/tests/cli/test_init_generate_config.py new file mode 100644 index 00000000..d5602877 --- /dev/null +++ b/tests/cli/test_init_generate_config.py @@ -0,0 +1,173 @@ +"""Tests for cf init --generate-config flag. + +Verifies that the --generate-config flag on cf init creates a +starter CODEFRAME.md with YAML front matter and helpful body content. +""" + +import pytest +import yaml +from typer.testing import CliRunner + +from codeframe.cli.app import app + +pytestmark = pytest.mark.v2 + +runner = CliRunner() + + +@pytest.fixture +def temp_repo(tmp_path): + """Empty temp directory usable as a repo path.""" + repo = tmp_path / "repo" + repo.mkdir() + return repo + + +def _parse_front_matter(content: str) -> dict: + """Extract YAML front matter from a CODEFRAME.md string.""" + parts = content.split("---") + assert len(parts) >= 3, f"Expected --- delimiters, got: {content[:200]}" + return yaml.safe_load(parts[1]) + + +class TestInitGenerateConfig: + """Tests for --generate-config flag on cf init.""" + + def test_init_generate_config_creates_file(self, temp_repo): + """CODEFRAME.md is created at workspace root.""" + result = runner.invoke(app, ["init", str(temp_repo), "--generate-config"]) + assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}" + + config_path = temp_repo / "CODEFRAME.md" + assert config_path.exists(), "CODEFRAME.md should be created" + + def test_init_generate_config_has_yaml_front_matter(self, temp_repo): + """Generated file has --- delimited YAML front matter.""" + runner.invoke(app, ["init", str(temp_repo), "--generate-config"]) + + content = (temp_repo / "CODEFRAME.md").read_text() + assert content.startswith("---\n"), "Should start with ---" + # Should have at least two --- delimiters + parts = content.split("---") + assert len(parts) >= 3, "Should have opening and closing --- delimiters" + + # YAML front matter should parse without error + front_matter = yaml.safe_load(parts[1]) + assert isinstance(front_matter, dict) + + def test_init_generate_config_includes_engine(self, temp_repo): + """YAML front matter includes engine: react.""" + runner.invoke(app, ["init", str(temp_repo), "--generate-config"]) + + content = (temp_repo / "CODEFRAME.md").read_text() + front_matter = _parse_front_matter(content) + assert front_matter["engine"] == "react" + + def test_init_generate_config_with_tech_stack(self, temp_repo): + """tech_stack is populated when --tech-stack is also provided.""" + runner.invoke( + app, + [ + "init", str(temp_repo), + "--generate-config", + "--tech-stack", "Python 3.11 with uv, pytest", + ], + ) + + content = (temp_repo / "CODEFRAME.md").read_text() + front_matter = _parse_front_matter(content) + assert front_matter["tech_stack"] == "Python 3.11 with uv, pytest" + + def test_init_generate_config_with_detect(self, temp_repo): + """tech_stack is populated from --detect when combined with --generate-config.""" + # Create a pyproject.toml so --detect finds something + (temp_repo / "pyproject.toml").write_text( + "[tool.ruff]\nline-length = 88\n[tool.pytest.ini_options]\n" + ) + (temp_repo / "uv.lock").write_text("") + + runner.invoke( + app, + ["init", str(temp_repo), "--generate-config", "--detect"], + ) + + content = (temp_repo / "CODEFRAME.md").read_text() + front_matter = _parse_front_matter(content) + assert "tech_stack" in front_matter + assert "python" in front_matter["tech_stack"].lower() + + def test_init_generate_config_includes_gates_python(self, temp_repo): + """Gates include ruff and pytest for a Python tech stack.""" + runner.invoke( + app, + [ + "init", str(temp_repo), + "--generate-config", + "--tech-stack", "Python with pytest and ruff", + ], + ) + + content = (temp_repo / "CODEFRAME.md").read_text() + front_matter = _parse_front_matter(content) + assert "gates" in front_matter + assert "ruff" in front_matter["gates"] + assert "pytest" in front_matter["gates"] + + def test_init_generate_config_includes_gates_typescript(self, temp_repo): + """Gates include eslint and jest for a TypeScript tech stack.""" + runner.invoke( + app, + [ + "init", str(temp_repo), + "--generate-config", + "--tech-stack", "TypeScript with jest", + ], + ) + + content = (temp_repo / "CODEFRAME.md").read_text() + front_matter = _parse_front_matter(content) + assert "gates" in front_matter + assert "eslint" in front_matter["gates"] + assert "jest" in front_matter["gates"] + + def test_init_generate_config_includes_body(self, temp_repo): + """Generated file includes markdown body with instructions.""" + runner.invoke(app, ["init", str(temp_repo), "--generate-config"]) + + content = (temp_repo / "CODEFRAME.md").read_text() + assert "# Project Agent Instructions" in content + assert "## Coding Standards" in content + assert "## Always Do" in content + assert "## Never Do" in content + + def test_init_without_generate_config(self, temp_repo): + """No CODEFRAME.md created when --generate-config is not passed.""" + runner.invoke(app, ["init", str(temp_repo)]) + + config_path = temp_repo / "CODEFRAME.md" + assert not config_path.exists(), "CODEFRAME.md should NOT be created without flag" + + def test_init_generate_config_includes_batch(self, temp_repo): + """YAML front matter includes batch configuration.""" + runner.invoke(app, ["init", str(temp_repo), "--generate-config"]) + + content = (temp_repo / "CODEFRAME.md").read_text() + front_matter = _parse_front_matter(content) + assert "batch" in front_matter + assert front_matter["batch"]["max_parallel"] == 2 + assert front_matter["batch"]["default_strategy"] == "auto" + + def test_init_generate_config_includes_agent(self, temp_repo): + """YAML front matter includes agent configuration.""" + runner.invoke(app, ["init", str(temp_repo), "--generate-config"]) + + content = (temp_repo / "CODEFRAME.md").read_text() + front_matter = _parse_front_matter(content) + assert "agent" in front_matter + assert front_matter["agent"]["max_iterations"] == 30 + assert front_matter["agent"]["verbose"] is False + + def test_init_generate_config_prints_confirmation(self, temp_repo): + """CLI output mentions CODEFRAME.md was generated.""" + result = runner.invoke(app, ["init", str(temp_repo), "--generate-config"]) + assert "Generated: CODEFRAME.md" in result.output diff --git a/tests/core/test_codeframe_config.py b/tests/core/test_codeframe_config.py new file mode 100644 index 00000000..79098a81 --- /dev/null +++ b/tests/core/test_codeframe_config.py @@ -0,0 +1,307 @@ +"""Tests for CODEFRAME.md unified workflow configuration. + +Tests the parse_codeframe_md() parser, integration into load_preferences(), +and the get_codeframe_config() accessor. +""" + +import pytest +from pathlib import Path +from tempfile import TemporaryDirectory + +from codeframe.core.agents_config import ( + load_preferences, + parse_codeframe_md, + get_codeframe_config, +) + + +pytestmark = pytest.mark.v2 + + +class TestParseCodeframeMdFull: + """Tests for parse_codeframe_md with full YAML front matter + markdown body.""" + + def test_parse_codeframe_md_full(self): + """YAML front matter + markdown body returns both config and preferences.""" + content = """\ +--- +engine: react +tech_stack: "Python with uv" +batch: + max_parallel: 4 + default_strategy: parallel +gates: + - ruff + - pytest +hooks: + before_task: "git checkout -b cf/{{task_id}}" + after_task: "git add -A" +agent: + max_iterations: 30 + verbose: true +--- + +# Project Instructions + +## Always Do + +- Run tests after changes +- Fix linting errors + +## Never Do + +- Commit secrets +""" + config, prefs = parse_codeframe_md(content) + + # CodeframeConfig fields + assert config.engine == "react" + assert config.tech_stack == "Python with uv" + assert config.batch == {"max_parallel": 4, "default_strategy": "parallel"} + assert config.gates == ["ruff", "pytest"] + assert config.hooks["before_task"] == "git checkout -b cf/{{task_id}}" + assert config.agent == {"max_iterations": 30, "verbose": True} + assert config.raw["engine"] == "react" + + # AgentPreferences from markdown body + assert "Run tests after changes" in prefs.always_do + assert "Commit secrets" in prefs.never_do + assert "CODEFRAME.md" in prefs.source_files[0] + + def test_parse_codeframe_md_yaml_only(self): + """YAML front matter with no body returns config and minimal prefs.""" + content = """\ +--- +engine: plan +gates: + - ruff +--- +""" + config, prefs = parse_codeframe_md(content) + + assert config.engine == "plan" + assert config.gates == ["ruff"] + # Preferences should still be valid but empty lists + assert prefs.always_do == [] + assert prefs.never_do == [] + + def test_parse_codeframe_md_body_only(self): + """No YAML front matter - just markdown - backward compat.""" + content = """\ +# Project Instructions + +## Always Do + +- Use type hints +- Write docstrings +""" + config, prefs = parse_codeframe_md(content) + + # Config should be empty defaults + assert config.engine is None + assert config.gates == [] + assert config.batch == {} + assert config.raw == {} + + # Preferences should be parsed from the body + assert "Use type hints" in prefs.always_do + assert "Write docstrings" in prefs.always_do + + def test_parse_codeframe_md_invalid_yaml(self): + """Invalid YAML should be handled gracefully.""" + content = """\ +--- +engine: react +bad_yaml: [unclosed + - broken +--- + +# Instructions + +## Always Do + +- Be helpful +""" + config, prefs = parse_codeframe_md(content) + + # Config should be empty due to parse failure + assert config.engine is None + assert config.raw == {} + + # Body should still be parsed + assert "Be helpful" in prefs.always_do + + def test_parse_codeframe_md_empty(self): + """Empty content should return empty config and prefs.""" + config, prefs = parse_codeframe_md("") + + assert config.engine is None + assert config.gates == [] + assert config.raw == {} + assert prefs.always_do == [] + assert prefs.raw_content == "" + + +class TestParseCodeframeMdFields: + """Tests for individual field extraction.""" + + def test_parse_codeframe_md_extracts_engine(self): + """engine field should be populated from YAML.""" + content = """\ +--- +engine: react +--- +""" + config, _ = parse_codeframe_md(content) + assert config.engine == "react" + + def test_parse_codeframe_md_extracts_gates(self): + """gates list should be populated from YAML.""" + content = """\ +--- +gates: + - ruff + - pytest + - mypy +--- +""" + config, _ = parse_codeframe_md(content) + assert config.gates == ["ruff", "pytest", "mypy"] + + def test_parse_codeframe_md_extracts_hooks(self): + """hooks dict should be populated from YAML.""" + content = """\ +--- +hooks: + before_task: "echo starting" + after_task: "echo done" + on_failure: "notify-send 'Task failed'" +--- +""" + config, _ = parse_codeframe_md(content) + assert config.hooks["before_task"] == "echo starting" + assert config.hooks["after_task"] == "echo done" + assert config.hooks["on_failure"] == "notify-send 'Task failed'" + + def test_parse_codeframe_md_extracts_batch(self): + """batch settings should be populated from YAML.""" + content = """\ +--- +batch: + max_parallel: 8 + default_strategy: auto +--- +""" + config, _ = parse_codeframe_md(content) + assert config.batch["max_parallel"] == 8 + assert config.batch["default_strategy"] == "auto" + + def test_parse_codeframe_md_tech_stack_in_preferences(self): + """tech_stack from YAML should also appear in preferences tooling.""" + content = """\ +--- +tech_stack: "TypeScript with npm, Next.js, jest" +--- +""" + config, prefs = parse_codeframe_md(content) + assert config.tech_stack == "TypeScript with npm, Next.js, jest" + assert prefs.tooling.get("tech_stack") == "TypeScript with npm, Next.js, jest" + + +class TestLoadPreferencesWithCodeframeMd: + """Tests for load_preferences() integration with CODEFRAME.md.""" + + def test_load_preferences_codeframe_highest_priority(self): + """CODEFRAME.md should override AGENTS.md at the same level.""" + with TemporaryDirectory() as tmpdir: + workspace = Path(tmpdir) + + agents_md = workspace / "AGENTS.md" + agents_md.write_text("""\ +# Tooling + +- **package_manager**: pip +""") + + codeframe_md = workspace / "CODEFRAME.md" + codeframe_md.write_text("""\ +--- +tech_stack: "Python with uv" +--- + +# Tooling + +- **package_manager**: uv +""") + + prefs = load_preferences(workspace) + assert prefs.tooling.get("package_manager") == "uv" + assert any("CODEFRAME.md" in f for f in prefs.source_files) + + def test_load_preferences_fallback_agents_md(self): + """Falls back to AGENTS.md when no CODEFRAME.md exists.""" + with TemporaryDirectory() as tmpdir: + workspace = Path(tmpdir) + + agents_md = workspace / "AGENTS.md" + agents_md.write_text("""\ +# Tooling + +- **package_manager**: yarn +""") + + prefs = load_preferences(workspace) + assert prefs.tooling.get("package_manager") == "yarn" + assert any("AGENTS.md" in f for f in prefs.source_files) + + def test_load_preferences_fallback_claude_md(self): + """Falls back to CLAUDE.md when no CODEFRAME.md or AGENTS.md exists.""" + with TemporaryDirectory() as tmpdir: + workspace = Path(tmpdir) + + claude_md = workspace / "CLAUDE.md" + claude_md.write_text("""\ +# Always Do + +- Write tests first +""") + + prefs = load_preferences(workspace) + assert "Write tests first" in prefs.always_do + assert any("CLAUDE.md" in f for f in prefs.source_files) + + +class TestGetCodeframeConfig: + """Tests for get_codeframe_config() accessor.""" + + def test_get_codeframe_config_found(self): + """Returns config when CODEFRAME.md exists.""" + with TemporaryDirectory() as tmpdir: + workspace = Path(tmpdir) + + codeframe_md = workspace / "CODEFRAME.md" + codeframe_md.write_text("""\ +--- +engine: react +gates: + - ruff + - pytest +batch: + max_parallel: 4 +--- + +# Instructions +""") + + config = get_codeframe_config(workspace) + assert config is not None + assert config.engine == "react" + assert config.gates == ["ruff", "pytest"] + assert config.batch["max_parallel"] == 4 + + def test_get_codeframe_config_not_found(self): + """Returns None when no CODEFRAME.md exists.""" + with TemporaryDirectory() as tmpdir: + workspace = Path(tmpdir) + config = get_codeframe_config(workspace) + assert config is None diff --git a/tests/core/test_config_codeframe_fallback.py b/tests/core/test_config_codeframe_fallback.py new file mode 100644 index 00000000..6039fde2 --- /dev/null +++ b/tests/core/test_config_codeframe_fallback.py @@ -0,0 +1,177 @@ +"""Tests for CODEFRAME.md fallback in load_environment_config. + +Verifies that when .codeframe/config.yaml is absent, the system +falls back to reading CODEFRAME.md front matter and converts it +to EnvironmentConfig. +""" + +import pytest +import yaml + +from codeframe.core.config import load_environment_config + +pytestmark = pytest.mark.v2 + + +def _write_codeframe_md(path, front_matter: dict, body: str = "") -> None: + """Helper to write a CODEFRAME.md with YAML front matter.""" + fm = yaml.dump(front_matter, default_flow_style=False, sort_keys=False) + content = f"---\n{fm}---\n\n{body}" + (path / "CODEFRAME.md").write_text(content) + + +def _write_config_yaml(path, data: dict) -> None: + """Helper to write .codeframe/config.yaml.""" + config_dir = path / ".codeframe" + config_dir.mkdir(parents=True, exist_ok=True) + with open(config_dir / "config.yaml", "w") as f: + yaml.dump(data, f) + + +class TestLoadEnvConfigFallback: + """Tests for load_environment_config falling back to CODEFRAME.md.""" + + def test_load_env_config_fallback_to_codeframe_md(self, tmp_path): + """When no config.yaml exists, reads CODEFRAME.md front matter.""" + _write_codeframe_md(tmp_path, { + "engine": "react", + "tech_stack": "Python with uv, pytest", + "gates": ["ruff", "pytest"], + }) + + config = load_environment_config(tmp_path) + + assert config is not None + assert config.engine == "react" + assert config.package_manager == "uv" + assert config.test_framework == "pytest" + assert "ruff" in config.lint_tools + + def test_load_env_config_yaml_takes_precedence(self, tmp_path): + """config.yaml wins over CODEFRAME.md when both exist.""" + _write_config_yaml(tmp_path, { + "package_manager": "poetry", + "test_framework": "pytest", + "engine": "plan", + }) + _write_codeframe_md(tmp_path, { + "engine": "react", + "tech_stack": "Python with uv", + }) + + config = load_environment_config(tmp_path) + + assert config is not None + assert config.engine == "plan" + assert config.package_manager == "poetry" + + def test_load_env_config_no_files_returns_none(self, tmp_path): + """Returns None when neither config.yaml nor CODEFRAME.md exists.""" + config = load_environment_config(tmp_path) + assert config is None + + def test_load_env_config_codeframe_md_no_front_matter(self, tmp_path): + """Returns None for CODEFRAME.md without YAML front matter.""" + (tmp_path / "CODEFRAME.md").write_text("# Just a markdown file\nNo front matter here.") + + config = load_environment_config(tmp_path) + assert config is None + + +class TestCodeframeConfigToEnvConfig: + """Tests for _codeframe_config_to_env_config conversion.""" + + def test_tech_stack_maps_to_package_manager_uv(self, tmp_path): + """tech_stack containing 'uv' maps to package_manager='uv'.""" + _write_codeframe_md(tmp_path, {"tech_stack": "Python with uv"}) + + config = load_environment_config(tmp_path) + assert config is not None + assert config.package_manager == "uv" + + def test_tech_stack_maps_to_package_manager_poetry(self, tmp_path): + """tech_stack containing 'poetry' maps to package_manager='poetry'.""" + _write_codeframe_md(tmp_path, {"tech_stack": "Python with poetry"}) + + config = load_environment_config(tmp_path) + assert config is not None + assert config.package_manager == "poetry" + + def test_tech_stack_maps_to_test_framework_pytest(self, tmp_path): + """tech_stack containing 'pytest' maps to test_framework='pytest'.""" + _write_codeframe_md(tmp_path, {"tech_stack": "Python with pytest"}) + + config = load_environment_config(tmp_path) + assert config is not None + assert config.test_framework == "pytest" + + def test_tech_stack_maps_to_test_framework_jest(self, tmp_path): + """tech_stack containing 'jest' maps to test_framework='jest'.""" + _write_codeframe_md(tmp_path, {"tech_stack": "TypeScript with jest"}) + + config = load_environment_config(tmp_path) + assert config is not None + assert config.test_framework == "jest" + + def test_gates_map_to_lint_tools(self, tmp_path): + """gates list maps to lint_tools filtering lint-related gates.""" + _write_codeframe_md(tmp_path, { + "gates": ["ruff", "pytest", "eslint"], + }) + + config = load_environment_config(tmp_path) + assert config is not None + assert "ruff" in config.lint_tools + assert "eslint" in config.lint_tools + # pytest is not a lint tool + assert "pytest" not in config.lint_tools + + def test_hooks_map_correctly(self, tmp_path): + """hooks dict maps to HooksConfig.""" + _write_codeframe_md(tmp_path, { + "hooks": { + "after_init": "echo initialized", + "before_task": "echo starting", + }, + }) + + config = load_environment_config(tmp_path) + assert config is not None + assert config.hooks.after_init == "echo initialized" + assert config.hooks.before_task == "echo starting" + + def test_batch_maps_correctly(self, tmp_path): + """batch config maps to BatchConfig.""" + _write_codeframe_md(tmp_path, { + "batch": {"max_parallel": 4}, + }) + + config = load_environment_config(tmp_path) + assert config is not None + assert config.batch.max_parallel == 4 + + def test_agent_maps_to_agent_budget(self, tmp_path): + """agent config maps to AgentBudgetConfig.""" + _write_codeframe_md(tmp_path, { + "agent": {"max_iterations": 50}, + }) + + config = load_environment_config(tmp_path) + assert config is not None + assert config.agent_budget.max_iterations == 50 + + def test_engine_maps_directly(self, tmp_path): + """engine value maps directly to EnvironmentConfig.engine.""" + _write_codeframe_md(tmp_path, {"engine": "plan"}) + + config = load_environment_config(tmp_path) + assert config is not None + assert config.engine == "plan" + + def test_empty_codeframe_config_returns_none(self, tmp_path): + """An empty front matter (no meaningful settings) returns None.""" + _write_codeframe_md(tmp_path, {}) + + config = load_environment_config(tmp_path) + # Empty {} front matter has no meaningful settings, so treated as no config + assert config is None