generated from coulomb/repo-seed
Add validation indexes and generated views
This commit is contained in:
360
src/info_tech_canon/validation.py
Normal file
360
src/info_tech_canon/validation.py
Normal file
@@ -0,0 +1,360 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
REQUIRED_TOP_LEVEL_FILES = (
|
||||
"README.md",
|
||||
"INTENT.md",
|
||||
"SCOPE.md",
|
||||
"canon.yaml",
|
||||
"pyproject.toml",
|
||||
"workplans/index.yaml",
|
||||
"infospace/infospace.yaml",
|
||||
"infospace/artifacts/index.yaml",
|
||||
)
|
||||
|
||||
REQUIRED_INFOSPACE_DIRS = (
|
||||
"kernel",
|
||||
"models",
|
||||
"standards",
|
||||
"profiles",
|
||||
"patterns",
|
||||
"mappings",
|
||||
"assimilation",
|
||||
"schemas",
|
||||
"views",
|
||||
"agent",
|
||||
"examples",
|
||||
"validation",
|
||||
"indexes",
|
||||
)
|
||||
|
||||
OPTIONAL_COLLECTION_DIRS = (
|
||||
"profiles",
|
||||
"patterns",
|
||||
"mappings",
|
||||
"assimilation",
|
||||
"examples",
|
||||
)
|
||||
|
||||
REQUIRED_SCHEMAS = (
|
||||
"standard.schema.yaml",
|
||||
"concept.schema.yaml",
|
||||
"mapping.schema.yaml",
|
||||
"profile.schema.yaml",
|
||||
"assimilation.schema.yaml",
|
||||
"interface-card.schema.yaml",
|
||||
"agent-brief.schema.yaml",
|
||||
"workplan.schema.yaml",
|
||||
)
|
||||
|
||||
|
||||
def structural_checks(context: Any) -> dict[str, list[dict[str, Any]]]:
|
||||
errors: list[dict[str, Any]] = []
|
||||
warnings: list[dict[str, Any]] = []
|
||||
|
||||
_check_required_top_level_files(context.repo_root, errors)
|
||||
_check_required_infospace_dirs(context.infospace_root, errors)
|
||||
_check_required_schemas(context.infospace_root, errors)
|
||||
_check_canon_paths(context.repo_root, context.infospace_root, errors)
|
||||
_check_artifact_index(context.repo_root, context.infospace_root, errors)
|
||||
_check_optional_assets(context.infospace_root, warnings)
|
||||
|
||||
return {"errors": errors, "warnings": warnings}
|
||||
|
||||
|
||||
def _check_required_top_level_files(
|
||||
repo_root: Path,
|
||||
errors: list[dict[str, Any]],
|
||||
) -> None:
|
||||
for relative in REQUIRED_TOP_LEVEL_FILES:
|
||||
if not (repo_root / relative).is_file():
|
||||
errors.append(
|
||||
{
|
||||
"code": "missing_required_file",
|
||||
"path": relative,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _check_required_infospace_dirs(
|
||||
infospace_root: Path,
|
||||
errors: list[dict[str, Any]],
|
||||
) -> None:
|
||||
for relative in REQUIRED_INFOSPACE_DIRS:
|
||||
if not (infospace_root / relative).is_dir():
|
||||
errors.append(
|
||||
{
|
||||
"code": "missing_required_infospace_dir",
|
||||
"path": str(Path("infospace") / relative),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _check_required_schemas(
|
||||
infospace_root: Path,
|
||||
errors: list[dict[str, Any]],
|
||||
) -> None:
|
||||
for filename in REQUIRED_SCHEMAS:
|
||||
if not (infospace_root / "schemas" / filename).is_file():
|
||||
errors.append(
|
||||
{
|
||||
"code": "missing_schema",
|
||||
"path": str(Path("infospace") / "schemas" / filename),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _check_canon_paths(
|
||||
repo_root: Path,
|
||||
infospace_root: Path,
|
||||
errors: list[dict[str, Any]],
|
||||
) -> None:
|
||||
canon_path = repo_root / "canon.yaml"
|
||||
canon = _read_yaml(canon_path, errors)
|
||||
if not isinstance(canon, dict):
|
||||
return
|
||||
|
||||
indexed_paths = _artifact_paths_by_path(infospace_root, errors)
|
||||
for section in ("kernel", "models", "standards"):
|
||||
items = canon.get(section) or []
|
||||
if not isinstance(items, list):
|
||||
errors.append(
|
||||
{
|
||||
"code": "invalid_canon_section",
|
||||
"section": section,
|
||||
"message": "Expected a list.",
|
||||
}
|
||||
)
|
||||
continue
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
errors.append(
|
||||
{
|
||||
"code": "invalid_canon_entry",
|
||||
"section": section,
|
||||
"message": "Expected a mapping.",
|
||||
}
|
||||
)
|
||||
continue
|
||||
path = str(item.get("path") or "")
|
||||
if not path:
|
||||
errors.append(
|
||||
{
|
||||
"code": "missing_canon_path",
|
||||
"section": section,
|
||||
"id": item.get("id"),
|
||||
}
|
||||
)
|
||||
continue
|
||||
if not (repo_root / path).is_file():
|
||||
errors.append(
|
||||
{
|
||||
"code": "missing_canon_path_target",
|
||||
"section": section,
|
||||
"id": item.get("id"),
|
||||
"path": path,
|
||||
}
|
||||
)
|
||||
relative_infospace_path = _strip_infospace_prefix(path)
|
||||
if relative_infospace_path not in indexed_paths:
|
||||
errors.append(
|
||||
{
|
||||
"code": "canon_path_not_indexed",
|
||||
"section": section,
|
||||
"id": item.get("id"),
|
||||
"path": path,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _check_artifact_index(
|
||||
repo_root: Path,
|
||||
infospace_root: Path,
|
||||
errors: list[dict[str, Any]],
|
||||
) -> None:
|
||||
index_path = infospace_root / "artifacts" / "index.yaml"
|
||||
index = _read_yaml(index_path, errors)
|
||||
if not isinstance(index, dict):
|
||||
return
|
||||
artifacts = index.get("artifacts")
|
||||
if not isinstance(artifacts, list):
|
||||
errors.append(
|
||||
{
|
||||
"code": "invalid_artifact_index",
|
||||
"path": "infospace/artifacts/index.yaml",
|
||||
"message": "Expected artifacts list.",
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
ids: set[str] = set()
|
||||
for artifact in artifacts:
|
||||
if not isinstance(artifact, dict):
|
||||
errors.append(
|
||||
{
|
||||
"code": "invalid_artifact_entry",
|
||||
"message": "Expected artifact mapping.",
|
||||
}
|
||||
)
|
||||
continue
|
||||
artifact_id = str(artifact.get("id") or "")
|
||||
if not artifact_id:
|
||||
errors.append({"code": "missing_artifact_id"})
|
||||
elif artifact_id in ids:
|
||||
errors.append(
|
||||
{
|
||||
"code": "duplicate_artifact_id",
|
||||
"artifact_id": artifact_id,
|
||||
}
|
||||
)
|
||||
ids.add(artifact_id)
|
||||
|
||||
for field in ("path", "kind", "title"):
|
||||
if not artifact.get(field):
|
||||
errors.append(
|
||||
{
|
||||
"code": "missing_artifact_field",
|
||||
"artifact_id": artifact_id,
|
||||
"field": field,
|
||||
}
|
||||
)
|
||||
|
||||
relative_path = str(artifact.get("path") or "")
|
||||
if relative_path and not (infospace_root / relative_path).is_file():
|
||||
errors.append(
|
||||
{
|
||||
"code": "missing_artifact_path",
|
||||
"artifact_id": artifact_id,
|
||||
"path": relative_path,
|
||||
}
|
||||
)
|
||||
|
||||
provenance = artifact.get("provenance") or {}
|
||||
if isinstance(provenance, dict):
|
||||
source_path = provenance.get("source_path")
|
||||
if isinstance(source_path, str) and source_path:
|
||||
if not (repo_root / source_path).is_file():
|
||||
errors.append(
|
||||
{
|
||||
"code": "missing_provenance_source",
|
||||
"artifact_id": artifact_id,
|
||||
"source_path": source_path,
|
||||
}
|
||||
)
|
||||
|
||||
for artifact in artifacts:
|
||||
if not isinstance(artifact, dict):
|
||||
continue
|
||||
artifact_id = str(artifact.get("id") or "")
|
||||
relationships = artifact.get("relationships") or []
|
||||
if not isinstance(relationships, list):
|
||||
errors.append(
|
||||
{
|
||||
"code": "invalid_relationships",
|
||||
"artifact_id": artifact_id,
|
||||
"message": "Expected relationship list.",
|
||||
}
|
||||
)
|
||||
continue
|
||||
for relationship in relationships:
|
||||
if not isinstance(relationship, dict):
|
||||
errors.append(
|
||||
{
|
||||
"code": "invalid_relationship",
|
||||
"artifact_id": artifact_id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
target = relationship.get("target")
|
||||
if target not in ids:
|
||||
errors.append(
|
||||
{
|
||||
"code": "missing_relationship_target",
|
||||
"artifact_id": artifact_id,
|
||||
"target": target,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _check_optional_assets(
|
||||
infospace_root: Path,
|
||||
warnings: list[dict[str, Any]],
|
||||
) -> None:
|
||||
global_brief = infospace_root / "agent" / "global-agent-brief.md"
|
||||
if not global_brief.is_file():
|
||||
warnings.append(
|
||||
{
|
||||
"code": "missing_optional_agent_brief",
|
||||
"path": "infospace/agent/global-agent-brief.md",
|
||||
}
|
||||
)
|
||||
|
||||
concepts_dir = infospace_root / "concepts"
|
||||
if not concepts_dir.is_dir():
|
||||
warnings.append(
|
||||
{
|
||||
"code": "missing_optional_concepts_dir",
|
||||
"path": "infospace/concepts",
|
||||
}
|
||||
)
|
||||
|
||||
for relative in OPTIONAL_COLLECTION_DIRS:
|
||||
directory = infospace_root / relative
|
||||
if directory.is_dir() and not _has_substantive_files(directory):
|
||||
warnings.append(
|
||||
{
|
||||
"code": "empty_optional_collection",
|
||||
"path": str(Path("infospace") / relative),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _artifact_paths_by_path(
|
||||
infospace_root: Path,
|
||||
errors: list[dict[str, Any]],
|
||||
) -> set[str]:
|
||||
index = _read_yaml(infospace_root / "artifacts" / "index.yaml", errors)
|
||||
if not isinstance(index, dict):
|
||||
return set()
|
||||
artifacts = index.get("artifacts") or []
|
||||
if not isinstance(artifacts, list):
|
||||
return set()
|
||||
return {
|
||||
str(artifact.get("path"))
|
||||
for artifact in artifacts
|
||||
if isinstance(artifact, dict) and artifact.get("path")
|
||||
}
|
||||
|
||||
|
||||
def _read_yaml(path: Path, errors: list[dict[str, Any]]) -> Any:
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return yaml.safe_load(handle) or {}
|
||||
except FileNotFoundError:
|
||||
errors.append({"code": "missing_yaml", "path": str(path)})
|
||||
except yaml.YAMLError as exc:
|
||||
errors.append(
|
||||
{
|
||||
"code": "invalid_yaml",
|
||||
"path": str(path),
|
||||
"message": str(exc),
|
||||
}
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _strip_infospace_prefix(path: str) -> str:
|
||||
prefix = "infospace/"
|
||||
return path[len(prefix) :] if path.startswith(prefix) else path
|
||||
|
||||
|
||||
def _has_substantive_files(directory: Path) -> bool:
|
||||
for path in directory.rglob("*"):
|
||||
if path.is_file() and path.name != "README.md":
|
||||
return True
|
||||
return False
|
||||
Reference in New Issue
Block a user