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-commitframework, 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.
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-verifyis still caught server-side.
Gotchas and Edge Cases
- Hooks are skippable.
git commit --no-verifybypasses 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
filesfilter. That is acceptable — the CI gate covers code changes; the hook covers the high-frequency case of data edits.
Related
- Food-Cost Diff Checks in CI/CD — the CI backstop this hook front-runs.
- Failing a Build on Margin-Regression Thresholds — the shared threshold logic the scoped diff reuses.
- Menu Schema Normalization — the schema the pre-commit check validates against.
- Reporting, CI/CD & Delivery Pipelines — the wider delivery domain.
For framework specifics, see the official pre-commit documentation and Pydantic documentation.