diff --git a/src/adm_cli/model.py b/src/adm_cli/model.py new file mode 100644 index 0000000..a5b10a7 --- /dev/null +++ b/src/adm_cli/model.py @@ -0,0 +1,131 @@ +"""Spec Kit handoff — seed a feature directory from a ratcheted contract.""" + +from __future__ import annotations + +import json +from datetime import date +from pathlib import Path + + +def generate_feature_json( + domain: str, + contract_version: str, + contract_path: Path, + schema_path: Path, +) -> dict: + """Generate feature.json metadata for Spec Kit consumption.""" + return { + "name": f"{domain}-engine", + "source": "adm-kit", + "domain": domain, + "contract_version": contract_version, + "contract_path": str(contract_path), + "schema_path": str(schema_path), + "generated": date.today().isoformat(), + } + + +def generate_spec_seed( + domain: str, + contract_version: str, + schema: dict, + invariants: list[dict] | None = None, +) -> str: + """Generate a Spec Kit-compatible spec.md seed from a contract schema.""" + properties = schema.get("properties", {}) + required = set(schema.get("required", [])) + + lines = [ + f"# Feature Specification: {domain.title()} Engine", + "", + f"**Source**: ADM Kit ratcheted contract v{contract_version}", + f"**Generated**: {date.today().isoformat()}", + "", + "## Context", + "", + f"This specification was generated from the validated and ratcheted ADM contract for the `{domain}` data domain. " + "All requirements trace back to discovered data invariants and validated schema fields.", + "", + "## Key Entities", + "", + f"### {domain.title()}Record", + "", + "| Field | Type | Required | Description |", + "|-------|------|----------|-------------|", + ] + + for field_name, field_def in properties.items(): + field_type = field_def.get("type", "any") + is_required = "Yes" if field_name in required else "No" + description = field_def.get("description", "—") + lines.append(f"| {field_name} | {field_type} | {is_required} | {description} |") + + lines.extend([ + "", + "## Functional Requirements", + "", + ]) + + for i, (field_name, field_def) in enumerate(properties.items(), start=1): + constraint = "" + if "minimum" in field_def: + constraint = f" (minimum: {field_def['minimum']})" + elif "pattern" in field_def: + constraint = f" (pattern: `{field_def['pattern']}`)" + lines.append(f"- **FR-{i:03d}**: System MUST store and validate `{field_name}`{constraint}") + + if invariants: + lines.extend([ + "", + "## Acceptance Criteria (from Invariants)", + "", + ]) + for inv in invariants: + inv_id = inv.get("id", "INV-???") + description = inv.get("description", inv.get("title", "")) + lines.append(f"- **{inv_id}**: {description}") + + lines.extend([ + "", + "## Traceability", + "", + "| Requirement | Source |", + "|-------------|--------|", + ]) + for i, field_name in enumerate(properties.keys(), start=1): + lines.append(f"| FR-{i:03d} | contract.{field_name} |") + + lines.append("") + return "\n".join(lines) + + +def seed_feature_directory( + domain: str, + contract_version: str, + contract_path: Path, + schema_path: Path, + invariants_path: Path | None, + specs_dir: Path, +) -> Path: + """Create a Spec Kit feature directory seeded from the ADM contract. + + Returns the path to the created feature directory. + """ + existing = sorted(specs_dir.glob("[0-9]*")) if specs_dir.exists() else [] + next_num = len(existing) + 1 + feature_dir = specs_dir / f"{next_num:03d}-{domain}-engine" + feature_dir.mkdir(parents=True, exist_ok=True) + + schema = json.loads(schema_path.read_text()) + + feature_meta = generate_feature_json(domain, contract_version, contract_path, schema_path) + (feature_dir / "feature.json").write_text(json.dumps(feature_meta, indent=2) + "\n") + + invariants = None + if invariants_path and invariants_path.exists(): + invariants = json.loads(invariants_path.read_text()).get("invariants", []) + + spec_content = generate_spec_seed(domain, contract_version, schema, invariants) + (feature_dir / "spec.md").write_text(spec_content) + + return feature_dir diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..140c4b2 --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,120 @@ +"""Tests for Spec Kit handoff (/adm.model).""" + +import json +from pathlib import Path + +from adm_cli.model import generate_feature_json, generate_spec_seed, seed_feature_directory + + +class TestGenerateFeatureJson: + def test_structure(self): + result = generate_feature_json( + domain="holdings", + contract_version="1.0.0", + contract_path=Path("artefacts/holdings/6-contracts/v1.0.0/contract.py"), + schema_path=Path("artefacts/holdings/6-contracts/v1.0.0/contract.schema.json"), + ) + assert result["name"] == "holdings-engine" + assert result["source"] == "adm-kit" + assert result["domain"] == "holdings" + assert result["contract_version"] == "1.0.0" + assert "generated" in result + + +class TestGenerateSpecSeed: + def test_includes_fields_as_requirements(self): + schema = { + "properties": { + "account_id": {"type": "string", "pattern": "^ACC-\\d{4}$"}, + "balance": {"type": "number", "minimum": 0}, + }, + "required": ["account_id", "balance"], + } + result = generate_spec_seed("holdings", "1.0.0", schema) + assert "FR-001" in result + assert "account_id" in result + assert "FR-002" in result + assert "balance" in result + assert "pattern" in result + assert "minimum" in result + + def test_includes_invariants_as_ac(self): + schema = {"properties": {"name": {"type": "string"}}, "required": ["name"]} + invariants = [ + {"id": "INV-001", "description": "name is never null"}, + {"id": "INV-002", "description": "name is unique"}, + ] + result = generate_spec_seed("holdings", "1.0.0", schema, invariants) + assert "INV-001" in result + assert "name is never null" in result + assert "INV-002" in result + + def test_traceability_table(self): + schema = {"properties": {"x": {"type": "string"}, "y": {"type": "integer"}}, "required": []} + result = generate_spec_seed("test", "1.0.0", schema) + assert "| FR-001 | contract.x |" in result + assert "| FR-002 | contract.y |" in result + + +class TestSeedFeatureDirectory: + def test_creates_directory_and_files(self, tmp_path: Path): + schema_path = tmp_path / "contract.schema.json" + schema_path.write_text(json.dumps({ + "properties": {"name": {"type": "string"}}, + "required": ["name"], + })) + contract_path = tmp_path / "contract.py" + contract_path.write_text("# contract\n") + specs_dir = tmp_path / "specs" + + result = seed_feature_directory( + domain="holdings", + contract_version="1.0.0", + contract_path=contract_path, + schema_path=schema_path, + invariants_path=None, + specs_dir=specs_dir, + ) + assert result.name == "001-holdings-engine" + assert (result / "feature.json").exists() + assert (result / "spec.md").exists() + + def test_sequential_numbering(self, tmp_path: Path): + specs_dir = tmp_path / "specs" + (specs_dir / "001-existing").mkdir(parents=True) + + schema_path = tmp_path / "schema.json" + schema_path.write_text(json.dumps({"properties": {}, "required": []})) + contract_path = tmp_path / "contract.py" + contract_path.write_text("") + + result = seed_feature_directory( + domain="cashflows", + contract_version="1.0.0", + contract_path=contract_path, + schema_path=schema_path, + invariants_path=None, + specs_dir=specs_dir, + ) + assert result.name == "002-cashflows-engine" + + def test_includes_invariants(self, tmp_path: Path): + schema_path = tmp_path / "schema.json" + schema_path.write_text(json.dumps({"properties": {"x": {"type": "string"}}, "required": []})) + contract_path = tmp_path / "contract.py" + contract_path.write_text("") + inv_path = tmp_path / "invariants.json" + inv_path.write_text(json.dumps({"invariants": [{"id": "INV-001", "description": "x is unique"}]})) + specs_dir = tmp_path / "specs" + + result = seed_feature_directory( + domain="test", + contract_version="2.0.0", + contract_path=contract_path, + schema_path=schema_path, + invariants_path=inv_path, + specs_dir=specs_dir, + ) + spec_content = (result / "spec.md").read_text() + assert "INV-001" in spec_content + assert "x is unique" in spec_content