"""Generate and validate Scooling Lab dependency inventory evidence.""" from __future__ import annotations import argparse import sys import time import tomllib from pathlib import Path from scooling_lab.license_policy import ( BomEntry, LicensePolicyError, validate_entries, validate_source_path, ) SKIP_AUDIT_DIRS: frozenset[str] = frozenset( { ".git", ".muse/cache", ".muse/code", ".muse/objects", ".muse/snapshots", "node_modules", ".pnpm-store", "build", "dist", "coverage", "playwright-report", "test-results", "__pycache__", } ) def project_entry(pyproject_path: Path) -> BomEntry: """Read project metadata from pyproject.toml into the first BOM row.""" data = tomllib.loads(pyproject_path.read_text(encoding="utf-8")) project = data.get("project") if not isinstance(project, dict): raise LicensePolicyError("pyproject.toml must contain [project]") name = project.get("name") version = project.get("version") license_data = project.get("license") license_id = "" if isinstance(license_data, dict): text = license_data.get("text") if isinstance(text, str): license_id = text elif isinstance(license_data, str): license_id = license_data if not isinstance(name, str) or not isinstance(version, str): raise LicensePolicyError("project name and version are required") return BomEntry( name=name, version=version, license=license_id, source_path="pyproject.toml; src/scooling_lab", evidence="[project] metadata and committed source tree", ) def dependency_entries(lockfile_path: Path) -> list[BomEntry]: """Read locked dependency evidence rows from requirements.lock. The lockfile format is intentionally small for T0: ``name==version # license=MIT source=third_party/name evidence=license-file``. Empty and comment-only lockfiles are valid while Scooling Lab has no runtime dependencies. """ if not lockfile_path.exists(): raise LicensePolicyError("requirements.lock is required for BOM validation") entries: list[BomEntry] = [] for raw_line in lockfile_path.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() if not line or line.startswith("#"): continue requirement, _, metadata = line.partition("#") name, separator, version = requirement.strip().partition("==") if separator != "==": raise LicensePolicyError("locked dependencies must use name==version") fields = parse_lock_metadata(metadata) entries.append( BomEntry( name=name.strip(), version=version.strip(), license=fields["license"], source_path=fields["source"], evidence=fields["evidence"], ) ) return entries def parse_lock_metadata(metadata: str) -> dict[str, str]: """Parse required license, source, and evidence metadata from a lock row.""" fields: dict[str, str] = {} for token in metadata.strip().split(): key, separator, value = token.partition("=") if separator == "=": fields[key] = value required = {"license", "source", "evidence"} if set(fields) != required: raise LicensePolicyError("lock rows require license, source, and evidence") return fields def collect_entries(root: Path) -> list[BomEntry]: """Collect and validate project plus locked dependency rows.""" entries = [project_entry(root / "pyproject.toml")] entries.extend(dependency_entries(root / "requirements.lock")) validate_entries(entries) return entries def audit_repository_paths(root: Path) -> None: """Fail when committed source paths enter blocked AGPL directory names.""" for path in root.rglob("*"): relative = path.relative_to(root).as_posix() if should_skip_path(relative): continue validate_source_path(relative) def should_skip_path(relative_path: str) -> bool: """Return true for generated or local-only directories outside BOM scope.""" parts = relative_path.split("/") for index in range(len(parts)): prefix = "/".join(parts[: index + 1]) if prefix in SKIP_AUDIT_DIRS: return True return False def render_markdown(entries: list[BomEntry]) -> str: """Render dependency evidence as committed Markdown.""" lines = [ "# Scooling Lab Dependency Inventory", "", "Generated by `python -m scooling_lab.bom`. CI fails when this file is stale, when a", "license is not allowlisted, or when a blocked source path such as `studio/` or", "`unsloth_cli/` appears in the audited tree.", "", "| Name | Version | License | Source path evidence | License evidence |", "| --- | --- | --- | --- | --- |", ] for entry in sorted(entries, key=lambda item: item.name): lines.append( f"| {entry.name} | {entry.version} | {entry.license} | " f"{entry.source_path} | {entry.evidence} |" ) lines.extend( [ "", "No Unsloth package is installed or locked in this phase. The pinned Unsloth", "candidate is recorded as evidence only in `docs/UNSLOTH-LICENSE-EVIDENCE.md`.", "", ] ) return "\n".join(lines) def build_parser() -> argparse.ArgumentParser: """Build the BOM CLI argument parser.""" parser = argparse.ArgumentParser(description="Generate Scooling Lab BOM") parser.add_argument("--root", type=Path, default=Path.cwd()) parser.add_argument("--output", type=Path, default=Path("DEPENDENCIES.md")) parser.add_argument("--check", action="store_true") parser.add_argument("--budget-seconds", type=float, default=30.0) return parser def main(argv: list[str] | None = None) -> int: """Run BOM generation, policy validation, and optional stale-file check.""" start = time.perf_counter() args = build_parser().parse_args(argv) root = args.root.resolve() entries = collect_entries(root) audit_repository_paths(root) markdown = render_markdown(entries) output_path = args.output if not output_path.is_absolute(): output_path = root / output_path if args.check: existing = output_path.read_text(encoding="utf-8") if existing != markdown: raise LicensePolicyError("DEPENDENCIES.md is stale") else: output_path.write_text(markdown, encoding="utf-8") elapsed = time.perf_counter() - start if elapsed > args.budget_seconds: raise LicensePolicyError("BOM generation exceeded CI time budget") return 0 if __name__ == "__main__": try: raise SystemExit(main()) except LicensePolicyError as exc: print(f"BOM policy failure: {exc}", file=sys.stderr) raise SystemExit(1) from exc