Reporting Cicd Delivery Pipelines

Pre-Commit Hooks for Recipe Cost Validation

This page shows a food-tech developer how to catch recipe-cost mistakes at the moment of commit — before they ever reach a pull request — using a local pre-commit hook. It is the shift-left companion to food-cost diff checks in CI/CD: the CI gate is the backstop, but a fast local check gives the author feedback in a second instead of after a push-and-wait cycle.

The hook does three cheap things the full gate does expensively: validate that changed recipe files parse against the schema, assert every referenced ingredient SKU has a price, and run a food-cost diff scoped to only the staged recipes. It never replaces the CI gate — a developer can skip a local hook — but it removes most regressions before they cost a CI run.

Prerequisites and Data Contract

  • Python 3.11+, the pre-commit framework, and the costing package installed locally.
  • Recipe files as YAML/JSON under recipes/, each conforming to a Pydantic model.
  • A local price table (or a cached snapshot) so the hook runs offline in well under a second.

The hook’s contract: operate only on staged files, exit non-zero with a clear message on any violation, and never mutate the working tree.

Recipe-cost pre-commit hook flow A git commit triggers the pre-commit hook, which receives only the staged recipe files. The hook runs three checks in sequence: schema validation, an unpriced-SKU check, and a cost diff scoped to the staged files. If all pass the commit proceeds; if any fails the commit is blocked with an explanatory message and a non-zero exit. git commit Schema check Pydantic parse Unpriced SKU every ref priced Scoped diff staged only commit

Step-by-Step Implementation

Step 1 — Register the hook

# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: recipe-cost-validate
        name: recipe cost validation
        entry: python scripts/precommit_recipe_check.py
        language: system
        files: ^recipes/.*\.(ya?ml|json)$
        pass_filenames: true

files restricts the hook to recipe files, and pass_filenames: true hands the hook exactly the staged paths, so it never scans the whole tree.

Step 2 — Validate schema on staged files

from __future__ import annotations

import sys
from pathlib import Path

import yaml
from pydantic import ValidationError

from costing.models import Recipe   # Pydantic v2 model


def validate_files(paths: list[str]) -> list[str]:
    errors: list[str] = []
    for p in paths:
        raw = yaml.safe_load(Path(p).read_text())
        try:
            Recipe.model_validate(raw)
        except ValidationError as exc:
            errors.append(f"{p}: {exc.error_count()} schema error(s)\n{exc}")
    return errors

Step 3 — Assert every referenced SKU is priced

An unpriced SKU is the classic silent under-cost: the roll-up treats a missing price as zero and the dish looks cheaper than it is.

from pathlib import Path

import yaml

from costing.prices import load_local_price_index


def unpriced_skus(paths: list[str]) -> dict[str, list[str]]:
    prices = load_local_price_index()          # set of priced SKUs
    missing: dict[str, list[str]] = {}
    for p in paths:
        recipe = yaml.safe_load(Path(p).read_text())
        refs = [line["ingredient_sku"] for line in recipe["components"]]
        gaps = [sku for sku in refs if sku not in prices]
        if gaps:
            missing[p] = gaps
    return missing

Step 4 — Run a scoped cost diff and assemble the verdict

Reuse the shared diff logic from the CI gate, but only for the menu items whose recipes are staged, so the hook stays sub-second.

import sys

from costing.diff import diff_staged_items


def main(paths: list[str]) -> int:
    problems = validate_files(paths)
    for path, gaps in unpriced_skus(paths).items():
        problems.append(f"{path}: unpriced SKUs {gaps}")

    regressions = diff_staged_items(paths)     # scoped, fast
    if not regressions.empty:
        problems.append("staged recipe cost regression:\n" + regressions.to_markdown(index=False))

    if problems:
        print("\n\n".join(problems), file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

Verification and Validation

  • Blocks a bad commit. Stage a recipe referencing an unpriced SKU and attempt to commit; the hook must exit non-zero and name the SKU, and the commit must not be created.
  • Scoped, not global. Stage one recipe and confirm the diff only recomputes that dish, keeping runtime under a second even in a large menu.
  • Clean commits pass. A valid, fully-priced recipe within tolerance commits without friction.
  • Backstop intact. Confirm the CI gate still runs on push, so a hook skipped with --no-verify is still caught server-side.

Gotchas and Edge Cases

  • Hooks are skippable. git commit --no-verify bypasses the hook, which is why the CI gate remains the authority. Treat the hook as fast feedback, never as the enforcement boundary.
  • Stale local price cache. If the offline price index drifts from production, the hook can pass a recipe CI later fails. Refresh the cache on a schedule and stamp it with a date the hook prints on mismatch.
  • Partial staging. A developer may stage a recipe but not the price file that prices its new SKU. The unpriced-SKU check catches this, which is exactly its purpose — commit both together.
  • Non-recipe cost changes. Editing the costing code itself won’t match the files filter. That is acceptable — the CI gate covers code changes; the hook covers the high-frequency case of data edits.

For framework specifics, see the official pre-commit documentation and Pydantic documentation.