From 692062d1078def3dee70db468cc9ee3b3601c47b Mon Sep 17 00:00:00 2001 From: Shay Palachy Date: Sat, 18 Apr 2026 11:29:28 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Milestone=200=20=E2=80=94=20project?= =?UTF-8?q?=20foundation=20and=20package=20skeleton=20(v0.1.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bootstraps the leadforge codebase from empty to a fully installable, testable, lint-clean package skeleton. All Milestone 0 acceptance criteria pass: `pip install -e .` works, `leadforge --help` shows all four commands, `leadforge list-recipes` returns the v1 recipe, and CI is configured. Package scaffold - pyproject.toml: setuptools build, Typer+PyYAML runtime deps, ruff/mypy/ pytest dev deps, `leadforge` CLI entry point - Full subpackage skeleton with __init__.py stubs for every module in the canonical layout (api, cli, core, narrative, schema, structure, mechanisms, simulation, render, exposure, validation, recipes) - leadforge/version.py: __version__ = "0.1.0" - README.md: install, quickstart, API snippet, doc links - .pre-commit-config.yaml: ruff (lint+format) + pre-commit-hooks Core primitives (leadforge/core/) - enums.py: ExposureMode (StrEnum), DifficultyProfile (StrEnum) - exceptions.py: LeadforgeError base + 6 typed subclasses - models.py: GenerationConfig, WorldSpec, WorldBundle dataclass stubs - rng.py, ids.py: documented stubs for Milestone 1 Recipe system (leadforge/recipes/) - registry.py: list_recipes() + load_recipe() reading from YAML files - b2b_saas_procurement_v1/recipe.yaml: id, title, primary_task, supported_modes, supported_difficulty, default_population CLI (leadforge/cli/) - main.py: Typer app with --version flag and four registered commands - commands/list_recipes.py: fully implemented with Rich table output - commands/generate.py: stub with full option spec (--recipe, --seed, --mode, --out, --difficulty, --n-accounts, --n-contacts, --n-leads, --horizon-days, --override); exits 1 with "coming in v0.2.0" - commands/inspect.py, validate.py: stubs with correct argument spec CI (.github/workflows/ci.yml) - Three jobs: lint (ruff check+format), typecheck (mypy), test matrix (Python 3.11 + 3.12 with pytest-cov + coverage artifact upload for pr-agent-context integration) Tests (20 passing) - tests/test_cli.py: help, version, list-recipes output, stub exit codes - tests/core/test_enums.py: values and string construction - tests/core/test_exceptions.py: hierarchy and message preservation - tests/recipes/test_registry.py: list/load, required fields, error case Co-Authored-By: Claude Sonnet 4.6 --- .agent-plan.md | 76 ++++++++----------- .github/workflows/ci.yml | 59 ++++++++++++++ .pre-commit-config.yaml | 15 ++++ README.md | 75 +++++++++++++++++- leadforge/__init__.py | 5 ++ leadforge/api/__init__.py | 0 leadforge/cli/__init__.py | 0 leadforge/cli/commands/__init__.py | 0 leadforge/cli/commands/generate.py | 35 +++++++++ leadforge/cli/commands/inspect.py | 14 ++++ leadforge/cli/commands/list_recipes.py | 34 +++++++++ leadforge/cli/commands/validate.py | 14 ++++ leadforge/cli/main.py | 42 ++++++++++ leadforge/core/__init__.py | 0 leadforge/core/enums.py | 16 ++++ leadforge/core/exceptions.py | 26 +++++++ leadforge/core/ids.py | 16 ++++ leadforge/core/models.py | 43 +++++++++++ leadforge/core/rng.py | 6 ++ leadforge/examples/configs/.gitkeep | 0 leadforge/examples/notebooks/.gitkeep | 0 leadforge/exposure/__init__.py | 0 leadforge/mechanisms/__init__.py | 0 leadforge/narrative/__init__.py | 0 leadforge/recipes/__init__.py | 0 .../b2b_saas_procurement_v1/__init__.py | 0 .../b2b_saas_procurement_v1/recipe.yaml | 20 +++++ leadforge/recipes/registry.py | 37 +++++++++ leadforge/render/__init__.py | 0 leadforge/sample_data/instructor/.gitkeep | 0 leadforge/sample_data/public/.gitkeep | 0 leadforge/schema/__init__.py | 0 leadforge/simulation/__init__.py | 0 leadforge/structure/__init__.py | 0 leadforge/validation/__init__.py | 0 leadforge/version.py | 1 + pyproject.toml | 69 +++++++++++++++++ tests/__init__.py | 0 tests/core/__init__.py | 0 tests/core/test_enums.py | 23 ++++++ tests/core/test_exceptions.py | 36 +++++++++ tests/recipes/__init__.py | 0 tests/recipes/test_registry.py | 39 ++++++++++ tests/test_cli.py | 45 +++++++++++ 44 files changed, 699 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .pre-commit-config.yaml create mode 100644 leadforge/__init__.py create mode 100644 leadforge/api/__init__.py create mode 100644 leadforge/cli/__init__.py create mode 100644 leadforge/cli/commands/__init__.py create mode 100644 leadforge/cli/commands/generate.py create mode 100644 leadforge/cli/commands/inspect.py create mode 100644 leadforge/cli/commands/list_recipes.py create mode 100644 leadforge/cli/commands/validate.py create mode 100644 leadforge/cli/main.py create mode 100644 leadforge/core/__init__.py create mode 100644 leadforge/core/enums.py create mode 100644 leadforge/core/exceptions.py create mode 100644 leadforge/core/ids.py create mode 100644 leadforge/core/models.py create mode 100644 leadforge/core/rng.py create mode 100644 leadforge/examples/configs/.gitkeep create mode 100644 leadforge/examples/notebooks/.gitkeep create mode 100644 leadforge/exposure/__init__.py create mode 100644 leadforge/mechanisms/__init__.py create mode 100644 leadforge/narrative/__init__.py create mode 100644 leadforge/recipes/__init__.py create mode 100644 leadforge/recipes/b2b_saas_procurement_v1/__init__.py create mode 100644 leadforge/recipes/b2b_saas_procurement_v1/recipe.yaml create mode 100644 leadforge/recipes/registry.py create mode 100644 leadforge/render/__init__.py create mode 100644 leadforge/sample_data/instructor/.gitkeep create mode 100644 leadforge/sample_data/public/.gitkeep create mode 100644 leadforge/schema/__init__.py create mode 100644 leadforge/simulation/__init__.py create mode 100644 leadforge/structure/__init__.py create mode 100644 leadforge/validation/__init__.py create mode 100644 leadforge/version.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/core/__init__.py create mode 100644 tests/core/test_enums.py create mode 100644 tests/core/test_exceptions.py create mode 100644 tests/recipes/__init__.py create mode 100644 tests/recipes/test_registry.py create mode 100644 tests/test_cli.py diff --git a/.agent-plan.md b/.agent-plan.md index f8977c4..3bd62fe 100644 --- a/.agent-plan.md +++ b/.agent-plan.md @@ -6,66 +6,50 @@ ## Current System State -Repository initialized. Codebase empty. Architecture, design, and roadmap documents are locked in `docs/`. Agent-context files (`CLAUDE.md`, `llms.txt`, `.agent-plan.md`) are initialized. Branch/PR workflow rules (including label taxonomy and milestone map) are locked in `CLAUDE.md` and backed by a local `.git/hooks/pre-push` convenience hook (not versioned) and GitHub branch protection. GitHub labels and milestones (v0.1–v1.0) are created. pr-agent-context CI and refresh workflows are live in `.github/workflows/`. +**v0.1.0 shipped.** Installable package, full CLI skeleton (`list-recipes` implemented, others stubbed), core enums/exceptions/models, recipe registry, CI workflow, pre-commit config. All 20 tests pass. --- -## Active Task Breakdown — Phase 1: Milestone 0 (Project Foundation) - -Goal: Create a professional-quality, installable package skeleton with working tooling and CI, so all subsequent work has a stable base. - -- [ ] **1. Repository bootstrap + package skeleton** - - Create `pyproject.toml` (package name `leadforge`, entry point `leadforge = "leadforge.cli.main:app"`) - - Create `leadforge/__init__.py` and `leadforge/version.py` (initial `__version__ = "0.1.0"`) - - Create top-level directories matching canonical layout: `leadforge/{api,cli,core,narrative,schema,structure,mechanisms,simulation,render,exposure,validation,recipes,examples,sample_data}/` - - Add `__init__.py` stubs to all subpackages - - Add `LICENSE` (MIT) - - Add minimal `README.md` with one-liner, install instructions, and quickstart placeholder - - Verify: `pip install -e .` works - -- [ ] **2. Tooling + CI + pre-commit** - - Add `ruff` (lint + format), `mypy` or `pyright`, `pytest` to dev dependencies - - Create `ruff.toml` or inline `[tool.ruff]` config - - Create `pre-commit-config.yaml` with ruff + type-check hooks - - Create `.github/workflows/ci.yml` — runs `pytest`, `ruff check`, type check on push/PR - - Verify: CI passes on empty codebase - -- [ ] **3. CLI entrypoint skeleton** - - Implement `leadforge/cli/main.py` with a minimal app (typer or click) - - Implement stub commands: `list-recipes`, `generate`, `inspect`, `validate` (each prints "not yet implemented") - - Add `leadforge/cli/commands/` directory with one file per command - - Verify: `leadforge --help` lists all four commands - - Verify: `leadforge list-recipes` exits cleanly with a stub message - -- [ ] **4. Core primitives scaffold** - - `leadforge/core/enums.py` — `ExposureMode` enum (`student_public`, `research_instructor`), `DifficultyProfile` enum (`intro`, `intermediate`, `advanced`) - - `leadforge/core/exceptions.py` — `LeadforgeError` base + `InvalidRecipeError`, `InvalidConfigError`, `SimulationError`, `RenderError`, `ValidationError` - - `leadforge/core/models.py` — empty `GenerationConfig`, `WorldSpec`, `WorldBundle` dataclass stubs - - Add pytest smoke tests for enum values and exception hierarchy - -- [ ] **5. Initial recipe directory** - - Create `leadforge/recipes/b2b_saas_procurement_v1/recipe.yaml` with minimal metadata (id, title, primary_task, vertical, supported_modes, supported_difficulty) - - Create `leadforge/recipes/registry.py` with a `list_recipes()` function that reads from the recipes directory - - Wire `list-recipes` CLI command to `list_recipes()` - - Verify: `leadforge list-recipes` outputs at least the `b2b_saas_procurement_v1` entry +## Active Task Breakdown — Milestone 1: Canonical Config, Recipe & Model Objects (v0.2.0) + +Goal: Establish the typed configuration and recipe system that all simulation work will depend on. + +- [ ] **1. Core models + RNG utilities** + - Implement `leadforge/core/rng.py`: seeded RNG root + deterministic named substreams + - Flesh out `GenerationConfig` with full validation (config precedence rules) + - Implement `leadforge/core/hashing.py`: deterministic config hashing for manifest identity + +- [ ] **2. Recipe registry and loading** + - Implement full `Recipe` typed model (dataclass) in `leadforge/api/recipes.py` + - Implement config precedence: CLI flags > override file > recipe defaults > package defaults + - Implement `leadforge/core/serialization.py`: JSON/YAML read-write helpers + +- [ ] **3. Recipe assets + validation tests** + - Add `narrative.yaml`, `difficulty_profiles.yaml` to `b2b_saas_procurement_v1/` + - Implement `Generator.from_recipe(...)` skeleton (no simulation yet) + - Tests: recipe validation, config precedence, RNG determinism, `from_recipe` round-trip --- ## Context Pointers -- Full milestone scope for Phase 1 (Milestone 0): `docs/leadforge_implementation_plan.md` §5 "Milestone 0" -- Next phase (Milestone 1 — config, recipe, model objects): `docs/leadforge_implementation_plan.md` §5 "Milestone 1" -- Dependency graph across all milestones: `docs/leadforge_implementation_plan.md` §6 -- Canonical package layout: `docs/leadforge_architecture_spec.md` §4 +- Milestone 1 scope: `docs/leadforge_implementation_plan.md` §5 "Milestone 1" +- Full milestone dependency graph: `docs/leadforge_implementation_plan.md` §6 - Public API contract: `docs/leadforge_architecture_spec.md` §6 -- CLI command spec: `docs/leadforge_architecture_spec.md` §7 -- Tech stack decisions: `CLAUDE.md` "Tech Stack" section +- Config precedence rules: `docs/leadforge_architecture_spec.md` §24 --- ## Completed Phases -_None yet._ +### Milestone 0 — Project Foundation ✓ (v0.1.0) +- `pyproject.toml`, `README.md`, `LICENSE`, `.pre-commit-config.yaml` +- Full package skeleton with `__init__.py` stubs for all submodules +- `leadforge/core/`: `enums.py`, `exceptions.py`, `models.py`, `rng.py` (stub), `ids.py` (stub) +- `leadforge/cli/`: `main.py` + four commands (`list-recipes` implemented, others stubbed) +- `leadforge/recipes/`: registry + `b2b_saas_procurement_v1/recipe.yaml` +- `.github/workflows/ci.yml`: lint, typecheck, test matrix (3.11 + 3.12) with coverage upload +- 20 tests passing; ruff + mypy clean --- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4d1f81e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint & format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install ruff + - run: ruff check . + - run: ruff format --check . + + typecheck: + name: Type check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e ".[dev]" + - run: mypy leadforge/ + + test: + name: Tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + env: + COVERAGE_FILE: .coverage.${{ matrix.python-version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: pip install -e ".[dev]" pytest-cov + - run: pytest --cov=leadforge --cov-report=term-missing + - name: Upload coverage artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: pr-agent-context-coverage-py${{ matrix.python-version }} + path: .coverage.${{ matrix.python-version }} + include-hidden-files: true + if-no-files-found: ignore diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..37495e0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.5 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-merge-conflict diff --git a/README.md b/README.md index 674e4f3..1288a43 100644 --- a/README.md +++ b/README.md @@ -1 +1,74 @@ -# leadforge \ No newline at end of file +# leadforge + +**Opinionated framework for generating synthetic CRM and GTM datasets from simulated commercial worlds.** + +`leadforge` generates narrative-grounded synthetic revenue datasets starting with lead scoring, designed to support teaching, portfolio projects, and research. Rather than sampling rows from a distribution, it simulates a commercial world — a specific company, selling a specific product, to a specific kind of buyer — and renders realistic CRM-style outputs from that world. + +--- + +## Installation + +```bash +pip install leadforge +``` + +For development: + +```bash +git clone https://github.com/leadforge-dev/leadforge.git +cd leadforge +pip install -e ".[dev]" +pre-commit install +``` + +--- + +## Quickstart + +```bash +# List available recipes +leadforge list-recipes + +# Generate a dataset bundle +leadforge generate \ + --recipe b2b_saas_procurement_v1 \ + --seed 42 \ + --mode student_public \ + --difficulty intermediate \ + --n-leads 5000 \ + --out ./out/demo_bundle + +# Inspect a generated bundle +leadforge inspect ./out/demo_bundle + +# Validate a generated bundle +leadforge validate ./out/demo_bundle +``` + +**Python API:** + +```python +from leadforge.api import Generator + +gen = Generator.from_recipe( + "b2b_saas_procurement_v1", + seed=42, + exposure_mode="student_public", +) +bundle = gen.generate(n_leads=5000, difficulty="intermediate") +bundle.save("./out/demo_bundle") +``` + +--- + +## Documentation + +- [Design document](docs/leadforge_design_doc.md) +- [Architecture spec](docs/leadforge_architecture_spec.md) +- [Implementation plan](docs/leadforge_implementation_plan.md) + +--- + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/leadforge/__init__.py b/leadforge/__init__.py new file mode 100644 index 0000000..4da8d10 --- /dev/null +++ b/leadforge/__init__.py @@ -0,0 +1,5 @@ +"""leadforge — synthetic CRM and GTM dataset generation.""" + +from leadforge.version import __version__ + +__all__ = ["__version__"] diff --git a/leadforge/api/__init__.py b/leadforge/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/cli/__init__.py b/leadforge/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/cli/commands/__init__.py b/leadforge/cli/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/cli/commands/generate.py b/leadforge/cli/commands/generate.py new file mode 100644 index 0000000..18bdddd --- /dev/null +++ b/leadforge/cli/commands/generate.py @@ -0,0 +1,35 @@ +"""leadforge generate command.""" + +import typer + + +def generate( + recipe: str = typer.Option(..., "--recipe", "-r", help="Recipe ID to use."), + seed: int = typer.Option(..., "--seed", help="Random seed for deterministic generation."), + mode: str = typer.Option( + ..., + "--mode", + help="Exposure mode: student_public or research_instructor.", + ), + out: str = typer.Option(..., "--out", help="Output directory for the generated bundle."), + difficulty: str = typer.Option( + "intermediate", + "--difficulty", + help="Difficulty profile: intro, intermediate, or advanced.", + ), + n_accounts: int | None = typer.Option(None, "--n-accounts", help="Number of accounts."), + n_contacts: int | None = typer.Option(None, "--n-contacts", help="Number of contacts."), + n_leads: int | None = typer.Option(None, "--n-leads", help="Number of leads."), + horizon_days: int | None = typer.Option( + None, "--horizon-days", help="Simulation horizon in days." + ), + override: str | None = typer.Option( + None, "--override", help="Path to a YAML config override file." + ), +) -> None: + """Generate a synthetic CRM dataset bundle from a recipe.""" + typer.echo( + "The 'generate' command is not yet implemented. Coming in v0.2.0.", + err=True, + ) + raise typer.Exit(1) diff --git a/leadforge/cli/commands/inspect.py b/leadforge/cli/commands/inspect.py new file mode 100644 index 0000000..36d6bf9 --- /dev/null +++ b/leadforge/cli/commands/inspect.py @@ -0,0 +1,14 @@ +"""leadforge inspect command.""" + +import typer + + +def inspect( + bundle_path: str = typer.Argument(..., help="Path to a generated bundle directory."), +) -> None: + """Inspect a generated dataset bundle and print a summary.""" + typer.echo( + "The 'inspect' command is not yet implemented. Coming in v0.4.0.", + err=True, + ) + raise typer.Exit(1) diff --git a/leadforge/cli/commands/list_recipes.py b/leadforge/cli/commands/list_recipes.py new file mode 100644 index 0000000..c9034a4 --- /dev/null +++ b/leadforge/cli/commands/list_recipes.py @@ -0,0 +1,34 @@ +"""leadforge list-recipes command.""" + +import typer +from rich.console import Console +from rich.table import Table + +from leadforge.recipes.registry import list_recipes + + +def list_recipes_cmd() -> None: + """List all available generation recipes.""" + recipes = list_recipes() + if not recipes: + typer.echo("No recipes found.") + raise typer.Exit() + + console = Console() + table = Table(title="Available Recipes", show_header=True, header_style="bold cyan") + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Title") + table.add_column("Primary Task", style="green") + table.add_column("Modes") + table.add_column("Difficulty") + + for r in recipes: + table.add_row( + r.get("id", ""), + r.get("title", ""), + r.get("primary_task", ""), + ", ".join(r.get("supported_modes", [])), + ", ".join(r.get("supported_difficulty", [])), + ) + + console.print(table) diff --git a/leadforge/cli/commands/validate.py b/leadforge/cli/commands/validate.py new file mode 100644 index 0000000..461db4a --- /dev/null +++ b/leadforge/cli/commands/validate.py @@ -0,0 +1,14 @@ +"""leadforge validate command.""" + +import typer + + +def validate( + bundle_path: str = typer.Argument(..., help="Path to a generated bundle directory."), +) -> None: + """Run schema and artifact validation on a generated bundle.""" + typer.echo( + "The 'validate' command is not yet implemented. Coming in v0.5.0.", + err=True, + ) + raise typer.Exit(1) diff --git a/leadforge/cli/main.py b/leadforge/cli/main.py new file mode 100644 index 0000000..c1b8070 --- /dev/null +++ b/leadforge/cli/main.py @@ -0,0 +1,42 @@ +"""leadforge CLI entrypoint.""" + +import typer + +from leadforge.cli.commands.generate import generate +from leadforge.cli.commands.inspect import inspect +from leadforge.cli.commands.list_recipes import list_recipes_cmd +from leadforge.cli.commands.validate import validate +from leadforge.version import __version__ + +app = typer.Typer( + name="leadforge", + help="Generate synthetic CRM and GTM datasets from simulated commercial worlds.", + no_args_is_help=True, + pretty_exceptions_enable=False, +) + + +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"leadforge {__version__}") + raise typer.Exit() + + +@app.callback() +def main( + version: bool = typer.Option( # noqa: FBT001 + False, + "--version", + "-V", + callback=_version_callback, + is_eager=True, + help="Show version and exit.", + ), +) -> None: + pass + + +app.command("list-recipes")(list_recipes_cmd) +app.command("generate")(generate) +app.command("inspect")(inspect) +app.command("validate")(validate) diff --git a/leadforge/core/__init__.py b/leadforge/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/core/enums.py b/leadforge/core/enums.py new file mode 100644 index 0000000..1e3d50c --- /dev/null +++ b/leadforge/core/enums.py @@ -0,0 +1,16 @@ +from enum import StrEnum + + +class ExposureMode(StrEnum): + """Controls how much hidden world truth is published in a bundle.""" + + student_public = "student_public" + research_instructor = "research_instructor" + + +class DifficultyProfile(StrEnum): + """Named difficulty preset for a generation run.""" + + intro = "intro" + intermediate = "intermediate" + advanced = "advanced" diff --git a/leadforge/core/exceptions.py b/leadforge/core/exceptions.py new file mode 100644 index 0000000..1ed7264 --- /dev/null +++ b/leadforge/core/exceptions.py @@ -0,0 +1,26 @@ +class LeadforgeError(Exception): + """Base exception for all leadforge errors.""" + + +class InvalidRecipeError(LeadforgeError): + """Raised when a recipe identifier is unknown or its files are malformed.""" + + +class InvalidConfigError(LeadforgeError): + """Raised when a GenerationConfig fails validation.""" + + +class GraphConstructionError(LeadforgeError): + """Raised when the hidden world graph cannot be constructed or validated.""" + + +class SimulationError(LeadforgeError): + """Raised when world simulation fails.""" + + +class RenderError(LeadforgeError): + """Raised when bundle rendering fails.""" + + +class ValidationError(LeadforgeError): + """Raised when bundle artifact validation fails.""" diff --git a/leadforge/core/ids.py b/leadforge/core/ids.py new file mode 100644 index 0000000..ba1cad7 --- /dev/null +++ b/leadforge/core/ids.py @@ -0,0 +1,16 @@ +"""Entity ID generation. + +Implemented in Milestone 3. All IDs must be stable, opaque, namespace-unique, +and deterministic for a given run. + +Canonical prefixes: + acct_ — Account + cnt_ — Contact + lead_ — Lead + touch_ — Touch + sess_ — Session + act_ — SalesActivity + opp_ — Opportunity + cust_ — Customer + sub_ — Subscription +""" diff --git a/leadforge/core/models.py b/leadforge/core/models.py new file mode 100644 index 0000000..57be139 --- /dev/null +++ b/leadforge/core/models.py @@ -0,0 +1,43 @@ +"""Top-level typed configuration and result models. + +WorldSpec and WorldBundle are stubs in M0; they will be populated in M1+. +""" + +from dataclasses import dataclass, field + +from leadforge.core.enums import DifficultyProfile, ExposureMode + + +@dataclass +class GenerationConfig: + """Fully resolved configuration for a single generation run.""" + + recipe_id: str = "b2b_saas_procurement_v1" + seed: int = 42 + exposure_mode: ExposureMode = ExposureMode.student_public + difficulty: DifficultyProfile = DifficultyProfile.intermediate + n_accounts: int = 1500 + n_contacts: int = 4200 + n_leads: int = 5000 + horizon_days: int = 90 + output_path: str = "./out" + + +@dataclass +class WorldSpec: + """Fully instantiated hidden world specification (post-sampling, pre-simulation). + + Populated in Milestone 1 (config/recipe) through Milestone 6 (mechanisms). + """ + + config: GenerationConfig = field(default_factory=GenerationConfig) + + +@dataclass +class WorldBundle: + """In-memory result of one complete generation run. + + Populated in Milestone 7+ (simulation and rendering). + """ + + spec: WorldSpec = field(default_factory=WorldSpec) diff --git a/leadforge/core/rng.py b/leadforge/core/rng.py new file mode 100644 index 0000000..879b3de --- /dev/null +++ b/leadforge/core/rng.py @@ -0,0 +1,6 @@ +"""Seeded RNG root and deterministic substream utilities. + +Implemented in Milestone 1. Every stochastic component in leadforge must +derive its RNG from a single seeded root so that (recipe, config, seed, +version) fully determines all outputs. +""" diff --git a/leadforge/examples/configs/.gitkeep b/leadforge/examples/configs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/examples/notebooks/.gitkeep b/leadforge/examples/notebooks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/exposure/__init__.py b/leadforge/exposure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/mechanisms/__init__.py b/leadforge/mechanisms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/narrative/__init__.py b/leadforge/narrative/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/recipes/__init__.py b/leadforge/recipes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/recipes/b2b_saas_procurement_v1/__init__.py b/leadforge/recipes/b2b_saas_procurement_v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/recipes/b2b_saas_procurement_v1/recipe.yaml b/leadforge/recipes/b2b_saas_procurement_v1/recipe.yaml new file mode 100644 index 0000000..44b48ac --- /dev/null +++ b/leadforge/recipes/b2b_saas_procurement_v1/recipe.yaml @@ -0,0 +1,20 @@ +id: b2b_saas_procurement_v1 +title: "Mid-market B2B SaaS — Procurement & AP Automation" +vertical: mid_market_b2b_saas +description: > + A mid-market B2B SaaS company selling procurement and AP workflow + automation software to 200–2,000 employee firms in the US and UK, + through a mixed inbound, SDR-assisted, and partner-driven GTM motion. +primary_task: converted_within_90_days +supported_modes: + - student_public + - research_instructor +supported_difficulty: + - intro + - intermediate + - advanced +default_population: + n_accounts: 1500 + n_contacts: 4200 + n_leads: 5000 +horizon_days: 90 diff --git a/leadforge/recipes/registry.py b/leadforge/recipes/registry.py new file mode 100644 index 0000000..f395724 --- /dev/null +++ b/leadforge/recipes/registry.py @@ -0,0 +1,37 @@ +"""Recipe registry: discovery and loading of generation recipes.""" + +from pathlib import Path +from typing import Any + +import yaml + +from leadforge.core.exceptions import InvalidRecipeError + +_RECIPES_DIR = Path(__file__).parent + + +def list_recipes() -> list[dict[str, Any]]: + """Return metadata for all available recipes, sorted by ID.""" + recipes = [] + for entry in sorted(_RECIPES_DIR.iterdir()): + recipe_file = entry / "recipe.yaml" + if entry.is_dir() and recipe_file.exists(): + with recipe_file.open() as fh: + recipes.append(yaml.safe_load(fh)) + return recipes + + +def load_recipe(recipe_id: str) -> dict[str, Any]: + """Load and return a recipe by ID. + + Raises: + InvalidRecipeError: if the recipe does not exist. + """ + recipe_file = _RECIPES_DIR / recipe_id / "recipe.yaml" + if not recipe_file.exists(): + raise InvalidRecipeError( + f"Recipe '{recipe_id}' not found. " + f"Run 'leadforge list-recipes' to see available recipes." + ) + with recipe_file.open() as fh: + return yaml.safe_load(fh) # type: ignore[no-any-return] diff --git a/leadforge/render/__init__.py b/leadforge/render/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/sample_data/instructor/.gitkeep b/leadforge/sample_data/instructor/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/sample_data/public/.gitkeep b/leadforge/sample_data/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/schema/__init__.py b/leadforge/schema/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/simulation/__init__.py b/leadforge/simulation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/structure/__init__.py b/leadforge/structure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/validation/__init__.py b/leadforge/validation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/version.py b/leadforge/version.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/leadforge/version.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4475f21 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,69 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "leadforge" +version = "0.1.0" +description = "Opinionated framework for generating synthetic CRM and GTM datasets from simulated commercial worlds" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.11" +authors = [{ name = "leadforge contributors" }] +keywords = ["synthetic data", "CRM", "lead scoring", "machine learning", "simulation"] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "Intended Audience :: Education", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] +dependencies = [ + "typer[all]>=0.12", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "ruff>=0.4", + "mypy>=1.10", + "pre-commit>=3.7", + "types-pyyaml>=6.0", +] + +[project.scripts] +leadforge = "leadforge.cli.main:app" + +[tool.setuptools.packages.find] +where = ["."] +include = ["leadforge*"] + +[tool.setuptools.package-data] +"leadforge" = ["**/*.yaml", "**/*.yml"] + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "PT"] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101"] + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +no_implicit_optional = true + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/test_enums.py b/tests/core/test_enums.py new file mode 100644 index 0000000..ce508dd --- /dev/null +++ b/tests/core/test_enums.py @@ -0,0 +1,23 @@ +"""Tests for core enums.""" + +from leadforge.core.enums import DifficultyProfile, ExposureMode + + +def test_exposure_mode_values() -> None: + assert ExposureMode.student_public.value == "student_public" + assert ExposureMode.research_instructor.value == "research_instructor" + + +def test_exposure_mode_from_string() -> None: + assert ExposureMode("student_public") is ExposureMode.student_public + assert ExposureMode("research_instructor") is ExposureMode.research_instructor + + +def test_difficulty_profile_values() -> None: + assert DifficultyProfile.intro.value == "intro" + assert DifficultyProfile.intermediate.value == "intermediate" + assert DifficultyProfile.advanced.value == "advanced" + + +def test_difficulty_profile_from_string() -> None: + assert DifficultyProfile("intermediate") is DifficultyProfile.intermediate diff --git a/tests/core/test_exceptions.py b/tests/core/test_exceptions.py new file mode 100644 index 0000000..af777c4 --- /dev/null +++ b/tests/core/test_exceptions.py @@ -0,0 +1,36 @@ +"""Tests for the exception hierarchy.""" + +import pytest + +from leadforge.core.exceptions import ( + GraphConstructionError, + InvalidConfigError, + InvalidRecipeError, + LeadforgeError, + RenderError, + SimulationError, + ValidationError, +) + + +def test_all_exceptions_are_leadforge_errors() -> None: + for exc_class in ( + InvalidRecipeError, + InvalidConfigError, + GraphConstructionError, + SimulationError, + RenderError, + ValidationError, + ): + assert issubclass(exc_class, LeadforgeError) + + +def test_exceptions_are_catchable_as_base() -> None: + with pytest.raises(LeadforgeError): + raise InvalidRecipeError("unknown-recipe") + + +def test_exception_message_preserved() -> None: + msg = "recipe 'foo' not found" + exc = InvalidRecipeError(msg) + assert str(exc) == msg diff --git a/tests/recipes/__init__.py b/tests/recipes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/recipes/test_registry.py b/tests/recipes/test_registry.py new file mode 100644 index 0000000..e3a4877 --- /dev/null +++ b/tests/recipes/test_registry.py @@ -0,0 +1,39 @@ +"""Tests for the recipe registry.""" + +import pytest + +from leadforge.core.exceptions import InvalidRecipeError +from leadforge.recipes.registry import list_recipes, load_recipe + + +def test_list_recipes_returns_list() -> None: + recipes = list_recipes() + assert isinstance(recipes, list) + assert len(recipes) >= 1 + + +def test_list_recipes_contains_v1() -> None: + ids = [r["id"] for r in list_recipes()] + assert "b2b_saas_procurement_v1" in ids + + +def test_v1_recipe_has_required_fields() -> None: + recipe = load_recipe("b2b_saas_procurement_v1") + for field in ("id", "title", "primary_task", "supported_modes", "supported_difficulty"): + assert field in recipe, f"Missing field: {field}" + + +def test_v1_recipe_primary_task() -> None: + recipe = load_recipe("b2b_saas_procurement_v1") + assert recipe["primary_task"] == "converted_within_90_days" + + +def test_v1_recipe_supported_modes() -> None: + recipe = load_recipe("b2b_saas_procurement_v1") + assert "student_public" in recipe["supported_modes"] + assert "research_instructor" in recipe["supported_modes"] + + +def test_load_unknown_recipe_raises() -> None: + with pytest.raises(InvalidRecipeError, match="not found"): + load_recipe("nonexistent_recipe_xyz") diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..b97c095 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,45 @@ +"""CLI smoke tests.""" + +from typer.testing import CliRunner + +from leadforge.cli.main import app + +runner = CliRunner() + + +def test_help_exits_clean() -> None: + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + + +def test_version() -> None: + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert "leadforge" in result.output + + +def test_list_recipes_exits_clean() -> None: + result = runner.invoke(app, ["list-recipes"]) + assert result.exit_code == 0 + + +def test_list_recipes_shows_v1_recipe() -> None: + result = runner.invoke(app, ["list-recipes"]) + assert "b2b_saas_procurement_v1" in result.output + + +def test_generate_stub_exits_nonzero() -> None: + result = runner.invoke( + app, ["generate", "--recipe", "x", "--seed", "1", "--mode", "y", "--out", "/tmp"] + ) + assert result.exit_code != 0 + + +def test_inspect_stub_exits_nonzero() -> None: + result = runner.invoke(app, ["inspect", "/nonexistent"]) + assert result.exit_code != 0 + + +def test_validate_stub_exits_nonzero() -> None: + result = runner.invoke(app, ["validate", "/nonexistent"]) + assert result.exit_code != 0 From 5173f9290d142d2dc6fb631916b33d1e3d581943 Mon Sep 17 00:00:00 2001 From: Shay Palachy Date: Sat, 18 Apr 2026 13:32:58 +0300 Subject: [PATCH 2/4] fix: address Copilot review comments on Milestone 0 PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit COPILOT-1 — Add Generator stub to leadforge/api - leadforge/api/generator.py: Generator class with from_recipe() and generate() raising NotImplementedError with "coming in v0.2.0" messages - leadforge/api/__init__.py: export Generator so `from leadforge.api import Generator` resolves correctly COPILOT-2 — Sort list_recipes() by recipe id field, not path - return sorted(recipes, key=lambda r: r["id"]) instead of relying on filesystem iteration order COPILOT-3 — Validate yaml.safe_load() result in registry - Extract _parse_and_validate() helper; raises InvalidRecipeError if the parsed value is not a dict or is missing the required 'id' key; used by both list_recipes() and load_recipe() COPILOT-4 — Guard load_recipe() against path traversal - Resolve the candidate path and verify it stays within _RECIPES_DIR before checking existence or opening; raises InvalidRecipeError for any recipe_id that would escape the recipes directory COPILOT-5 — Comment out unimplemented CLI commands in README - Quickstart now shows generate/inspect/validate as commented-out examples with "Coming in v0.x.0" labels; only `list-recipes` is shown as immediately runnable - Python API snippet annotated with "(coming in v0.2.0)" Co-Authored-By: Claude Sonnet 4.6 --- README.md | 30 ++++++++-------- leadforge/api/__init__.py | 5 +++ leadforge/api/generator.py | 65 +++++++++++++++++++++++++++++++++++ leadforge/recipes/registry.py | 31 ++++++++++++----- 4 files changed, 108 insertions(+), 23 deletions(-) create mode 100644 leadforge/api/generator.py diff --git a/README.md b/README.md index 1288a43..8582db3 100644 --- a/README.md +++ b/README.md @@ -29,23 +29,23 @@ pre-commit install # List available recipes leadforge list-recipes -# Generate a dataset bundle -leadforge generate \ - --recipe b2b_saas_procurement_v1 \ - --seed 42 \ - --mode student_public \ - --difficulty intermediate \ - --n-leads 5000 \ - --out ./out/demo_bundle - -# Inspect a generated bundle -leadforge inspect ./out/demo_bundle - -# Validate a generated bundle -leadforge validate ./out/demo_bundle +# Coming in v0.2.0: generate a dataset bundle +# leadforge generate \ +# --recipe b2b_saas_procurement_v1 \ +# --seed 42 \ +# --mode student_public \ +# --difficulty intermediate \ +# --n-leads 5000 \ +# --out ./out/demo_bundle + +# Coming in v0.4.0: inspect a generated bundle +# leadforge inspect ./out/demo_bundle + +# Coming in v0.5.0: validate a generated bundle +# leadforge validate ./out/demo_bundle ``` -**Python API:** +**Python API** (coming in v0.2.0): ```python from leadforge.api import Generator diff --git a/leadforge/api/__init__.py b/leadforge/api/__init__.py index e69de29..47e54c9 100644 --- a/leadforge/api/__init__.py +++ b/leadforge/api/__init__.py @@ -0,0 +1,5 @@ +"""leadforge public Python API.""" + +from leadforge.api.generator import Generator + +__all__ = ["Generator"] diff --git a/leadforge/api/generator.py b/leadforge/api/generator.py new file mode 100644 index 0000000..77f7af5 --- /dev/null +++ b/leadforge/api/generator.py @@ -0,0 +1,65 @@ +"""Public Generator API — stub for Milestone 1. + +The Generator class is the primary entry point for programmatic dataset +generation. It is fully specified in the architecture doc (§6) and will +be implemented across Milestones 1–9. +""" + +from __future__ import annotations + +from typing import Any + +from leadforge.core.enums import DifficultyProfile, ExposureMode +from leadforge.core.models import GenerationConfig, WorldBundle + + +class Generator: + """High-level entry point for generating a synthetic CRM dataset bundle. + + Usage (once implemented):: + + gen = Generator.from_recipe( + "b2b_saas_procurement_v1", + seed=42, + exposure_mode="student_public", + ) + bundle = gen.generate(n_leads=5000, difficulty="intermediate") + bundle.save("./out/demo_bundle") + + Implemented in Milestone 1 (config/recipe) through Milestone 9 (rendering). + """ + + def __init__(self, config: GenerationConfig) -> None: + self._config = config + + @classmethod + def from_recipe( + cls, + recipe_id: str, + *, + seed: int = 42, + exposure_mode: str | ExposureMode = ExposureMode.student_public, + **kwargs: Any, + ) -> Generator: + """Create a Generator from a recipe ID. + + Not yet implemented — available in v0.2.0. + """ + raise NotImplementedError( + "Generator.from_recipe() is not yet implemented. Coming in v0.2.0." + ) + + def generate( + self, + *, + n_accounts: int | None = None, + n_contacts: int | None = None, + n_leads: int | None = None, + difficulty: str | DifficultyProfile = DifficultyProfile.intermediate, + **kwargs: Any, + ) -> WorldBundle: + """Run the world simulation and return a bundle. + + Not yet implemented — available in v0.2.0. + """ + raise NotImplementedError("Generator.generate() is not yet implemented. Coming in v0.2.0.") diff --git a/leadforge/recipes/registry.py b/leadforge/recipes/registry.py index f395724..ad17aba 100644 --- a/leadforge/recipes/registry.py +++ b/leadforge/recipes/registry.py @@ -10,28 +10,43 @@ _RECIPES_DIR = Path(__file__).parent +def _parse_and_validate(path: Path) -> dict[str, Any]: + """Parse a recipe YAML file and validate it is a well-formed dict.""" + with path.open() as fh: + data = yaml.safe_load(fh) + if not isinstance(data, dict) or "id" not in data: + raise InvalidRecipeError( + f"Recipe file '{path}' is malformed: expected a YAML mapping with an 'id' key." + ) + return data # type: ignore[return-value] + + def list_recipes() -> list[dict[str, Any]]: """Return metadata for all available recipes, sorted by ID.""" recipes = [] - for entry in sorted(_RECIPES_DIR.iterdir()): + for entry in _RECIPES_DIR.iterdir(): recipe_file = entry / "recipe.yaml" if entry.is_dir() and recipe_file.exists(): - with recipe_file.open() as fh: - recipes.append(yaml.safe_load(fh)) - return recipes + recipes.append(_parse_and_validate(recipe_file)) + return sorted(recipes, key=lambda recipe: recipe["id"]) def load_recipe(recipe_id: str) -> dict[str, Any]: """Load and return a recipe by ID. Raises: - InvalidRecipeError: if the recipe does not exist. + InvalidRecipeError: if the recipe does not exist, the ID is invalid, + or the recipe file is malformed. """ - recipe_file = _RECIPES_DIR / recipe_id / "recipe.yaml" + # Guard against path traversal (e.g. recipe_id = "../secret") + recipe_dir = (_RECIPES_DIR / recipe_id).resolve() + if not str(recipe_dir).startswith(str(_RECIPES_DIR.resolve())): + raise InvalidRecipeError(f"Recipe ID '{recipe_id}' is invalid.") + + recipe_file = recipe_dir / "recipe.yaml" if not recipe_file.exists(): raise InvalidRecipeError( f"Recipe '{recipe_id}' not found. " f"Run 'leadforge list-recipes' to see available recipes." ) - with recipe_file.open() as fh: - return yaml.safe_load(fh) # type: ignore[no-any-return] + return _parse_and_validate(recipe_file) From 946668726746ae88472a32e066d7e0e0ad03285c Mon Sep 17 00:00:00 2001 From: Shay Palachy Date: Sat, 18 Apr 2026 14:13:59 +0300 Subject: [PATCH 3/4] chore: add AGENTS.md with rule to resolve PR review threads after commits Documents the mandatory step of resolving GitHub review threads via GraphQL after addressing PR comments, so the omission from PR #3 does not recur. Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0827d08 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,27 @@ +# AGENTS.md — leadforge + +Agent-specific conventions layered on top of CLAUDE.md. + +--- + +## PR Review Comment Workflow + +When addressing PR review comments (Copilot, human reviewers, or otherwise): + +1. Triage each comment — recommend one of: resolve as irrelevant, accept and implement, open a separate issue and resolve as out-of-scope, accept a different solution, or resolve as already treated. +2. After the user confirms decisions, implement accepted changes and push **all changes in a single commit** to the PR branch. +3. **After the commit lands, resolve the corresponding GitHub review threads** using the GraphQL API: + +```bash +gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "PRRT_..."}) { thread { isResolved } } }' +``` + +Resolve every addressed thread — whether the action was "implement", "already treated", or "irrelevant/out-of-scope". Unresolved threads indicate open work; resolved threads mean the discussion is closed. + +Do **not** leave threads unresolved after the commit is pushed. + +--- + +## Branch & PR Conventions + +See CLAUDE.md for the full mandatory branch/PR workflow (branch → commit → update `.agent-plan.md` → open PR). From ac22ad52c0c25ff581cb8af14d2a97de9b664c63 Mon Sep 17 00:00:00 2001 From: Shay Palachy Date: Sat, 18 Apr 2026 17:07:18 +0300 Subject: [PATCH 4/4] fix: address Copilot review comments on PR #3 (round 2) - registry.py: replace string-prefix path traversal guard with Path.is_relative_to() (Python 3.11+), closing the prefix-collision bypass (e.g. recipes_evil alongside recipes) - pyproject.toml: add "S" (bandit) ruleset to ruff select so security checks are active on non-test code; widen per-file-ignores glob from tests/* to tests/**/* to cover subdirectories; add S108 to test ignores to suppress the /tmp false-positive in test CLI invocations Co-Authored-By: Claude Sonnet 4.6 --- leadforge/recipes/registry.py | 5 +++-- pyproject.toml | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/leadforge/recipes/registry.py b/leadforge/recipes/registry.py index ad17aba..4f47dff 100644 --- a/leadforge/recipes/registry.py +++ b/leadforge/recipes/registry.py @@ -39,8 +39,9 @@ def load_recipe(recipe_id: str) -> dict[str, Any]: or the recipe file is malformed. """ # Guard against path traversal (e.g. recipe_id = "../secret") - recipe_dir = (_RECIPES_DIR / recipe_id).resolve() - if not str(recipe_dir).startswith(str(_RECIPES_DIR.resolve())): + base_dir = _RECIPES_DIR.resolve() + recipe_dir = (base_dir / recipe_id).resolve() + if not recipe_dir.is_relative_to(base_dir): raise InvalidRecipeError(f"Recipe ID '{recipe_id}' is invalid.") recipe_file = recipe_dir / "recipe.yaml" diff --git a/pyproject.toml b/pyproject.toml index 4475f21..1a4d6b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,10 +51,10 @@ target-version = "py311" line-length = 100 [tool.ruff.lint] -select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "PT"] +select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "PT", "S"] [tool.ruff.lint.per-file-ignores] -"tests/*" = ["S101"] +"tests/**/*" = ["S101", "S108"] [tool.mypy] python_version = "3.11"