Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions src/adm_cli/model.py
Original file line number Diff line number Diff line change
@@ -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
120 changes: 120 additions & 0 deletions tests/test_model.py
Original file line number Diff line number Diff line change
@@ -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
Loading