195 lines
6.6 KiB
Python
195 lines
6.6 KiB
Python
"""Shared specification path, frontmatter, and status metadata rules."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
|
|
VALID_REQUIREMENT_STATUSES = {"draft", "active", "deprecated"}
|
|
VALID_DESIGN_STATUSES = {"draft", "current", "superseded"}
|
|
VALID_SYSTEM_STATUSES = {"draft", "active", "retired"}
|
|
VALID_MIGRATION_STATUSES = {"in_progress", "complete"}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FrontmatterResult:
|
|
"""The parsed frontmatter and the source location of any parse error."""
|
|
|
|
metadata: dict[str, object] | None
|
|
end: int
|
|
line: int
|
|
error: str | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MetadataIssue:
|
|
"""A specification metadata contract violation."""
|
|
|
|
code: str
|
|
message: str
|
|
|
|
|
|
def metadata_text(metadata: dict[str, object], key: str) -> str | None:
|
|
"""Return a scalar or list frontmatter value in catalog form."""
|
|
value = metadata.get(key)
|
|
if isinstance(value, str):
|
|
return value.strip()
|
|
if isinstance(value, list) and value:
|
|
return ", ".join(str(item) for item in value)
|
|
return None
|
|
|
|
|
|
def validate_metadata(
|
|
kind: str,
|
|
system: str | None,
|
|
metadata: dict[str, object],
|
|
has_frontmatter: bool = True,
|
|
) -> list[MetadataIssue]:
|
|
"""Validate the metadata contract shared by the catalog and spec linter."""
|
|
contract_kind = "system-index" if kind == "system" else kind
|
|
if contract_kind not in {"requirement", "system-design", "system-index"}:
|
|
return []
|
|
if not has_frontmatter:
|
|
return [
|
|
MetadataIssue(
|
|
"frontmatter", "new specification document must start with YAML frontmatter"
|
|
)
|
|
]
|
|
|
|
issues: list[MetadataIssue] = []
|
|
if system is not None and metadata_text(metadata, "system") != system:
|
|
issues.append(
|
|
MetadataIssue(
|
|
"system-owner",
|
|
f"frontmatter system must be `{system}`, found `{metadata_text(metadata, 'system') or ''}`",
|
|
)
|
|
)
|
|
|
|
status = metadata_text(metadata, "status")
|
|
if contract_kind == "requirement" and status not in VALID_REQUIREMENT_STATUSES:
|
|
issues.append(
|
|
MetadataIssue(
|
|
"requirement-status",
|
|
f"requirement status must be one of {sorted(VALID_REQUIREMENT_STATUSES)}, found `{status or ''}`",
|
|
)
|
|
)
|
|
elif contract_kind == "system-design" and status not in VALID_DESIGN_STATUSES:
|
|
issues.append(
|
|
MetadataIssue(
|
|
"system-design-status",
|
|
f"system-design status must be one of {sorted(VALID_DESIGN_STATUSES)}, found `{status or ''}`",
|
|
)
|
|
)
|
|
elif contract_kind == "system-index" and status not in VALID_SYSTEM_STATUSES:
|
|
issues.append(
|
|
MetadataIssue(
|
|
"system-index-status",
|
|
f"system status must be one of {sorted(VALID_SYSTEM_STATUSES)}",
|
|
)
|
|
)
|
|
|
|
if contract_kind == "system-index":
|
|
if metadata_text(metadata, "specification_version") != "1":
|
|
issues.append(
|
|
MetadataIssue(
|
|
"system-index-version", "system index must set `specification_version: 1`"
|
|
)
|
|
)
|
|
migration = metadata_text(metadata, "migration")
|
|
if migration not in VALID_MIGRATION_STATUSES:
|
|
issues.append(
|
|
MetadataIssue(
|
|
"system-index-migration",
|
|
f"migration must be one of {sorted(VALID_MIGRATION_STATUSES)}",
|
|
)
|
|
)
|
|
elif contract_kind == "system-design" and not isinstance(metadata.get("requirements"), list):
|
|
issues.append(
|
|
MetadataIssue(
|
|
"system-design-requirements",
|
|
"system design must declare `requirements` as a YAML list, including an explicit empty list",
|
|
)
|
|
)
|
|
return issues
|
|
|
|
|
|
def parse_frontmatter(text: str, *, require: bool = False) -> FrontmatterResult:
|
|
"""Parse the small YAML subset used by specification files."""
|
|
lines = text.splitlines()
|
|
if not lines or lines[0] != "---":
|
|
if require:
|
|
return FrontmatterResult(
|
|
None, 0, 1, "new specification document must start with YAML frontmatter"
|
|
)
|
|
return FrontmatterResult({}, 0, 1, None)
|
|
|
|
try:
|
|
end = lines.index("---", 1)
|
|
except ValueError:
|
|
return FrontmatterResult(None, 0, 1, "YAML frontmatter has no closing delimiter")
|
|
|
|
metadata: dict[str, object] = {}
|
|
current_list: str | None = None
|
|
for line_number, line in enumerate(lines[1:end], start=2):
|
|
if re.match(r"^\s+-\s+", line) and current_list:
|
|
value = re.sub(r"^\s+-\s+", "", line).strip().strip('"\'`')
|
|
current_value = metadata.setdefault(current_list, [])
|
|
if not isinstance(current_value, list):
|
|
return FrontmatterResult(
|
|
None,
|
|
end + 1,
|
|
line_number,
|
|
f"frontmatter field `{current_list}` mixes scalar and list values",
|
|
)
|
|
current_value.append(value)
|
|
continue
|
|
|
|
match = re.match(r"^(?P<key>[a-z][a-z0-9_-]*):(?:\s*(?P<value>.*))?$", line)
|
|
if not match:
|
|
if not line.strip():
|
|
continue
|
|
return FrontmatterResult(
|
|
None, end + 1, line_number, "frontmatter contains unsupported YAML"
|
|
)
|
|
|
|
key = match.group("key")
|
|
value = (match.group("value") or "").strip()
|
|
if value == "[]":
|
|
metadata[key] = []
|
|
current_list = None
|
|
elif value:
|
|
metadata[key] = value.strip('"\'`')
|
|
current_list = None
|
|
else:
|
|
metadata[key] = []
|
|
current_list = key
|
|
|
|
return FrontmatterResult(metadata, end + 1, 1, None)
|
|
|
|
|
|
def classify_path(relative: Path) -> tuple[str, str | None]:
|
|
"""Classify a specification path using the authoritative linter layout."""
|
|
parts = relative.parts
|
|
try:
|
|
spec_index = parts.index("specs")
|
|
except ValueError:
|
|
return "unknown", None
|
|
inside = parts[spec_index + 1 :]
|
|
if not inside:
|
|
return "unknown", None
|
|
if inside[0] == "guide":
|
|
return "guide", None
|
|
if inside[0] == "templates":
|
|
return "template", None
|
|
if inside[0] == "product":
|
|
return "product", None
|
|
if len(inside) >= 3 and inside[1] == "requirements":
|
|
return "requirement", inside[0]
|
|
if len(inside) >= 3 and inside[1] == "system-design":
|
|
return "system-design", inside[0]
|
|
if len(inside) == 2 and inside[1] in {"README.md", "glossary.md"}:
|
|
return "system-index", inside[0]
|
|
return "legacy", None
|