docs: replace tracked document catalogs (#3482)
This commit is contained in:
+44
-147
@@ -9,6 +9,27 @@ import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from spec_metadata import (
|
||||
VALID_DESIGN_STATUSES,
|
||||
VALID_MIGRATION_STATUSES,
|
||||
VALID_REQUIREMENT_STATUSES,
|
||||
VALID_SYSTEM_STATUSES,
|
||||
classify_path,
|
||||
parse_frontmatter as parse_metadata_frontmatter,
|
||||
validate_metadata,
|
||||
)
|
||||
except ModuleNotFoundError:
|
||||
from scripts.spec_metadata import (
|
||||
VALID_DESIGN_STATUSES,
|
||||
VALID_MIGRATION_STATUSES,
|
||||
VALID_REQUIREMENT_STATUSES,
|
||||
VALID_SYSTEM_STATUSES,
|
||||
classify_path,
|
||||
parse_frontmatter as parse_metadata_frontmatter,
|
||||
validate_metadata,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Violation:
|
||||
@@ -18,10 +39,6 @@ class Violation:
|
||||
message: str
|
||||
|
||||
|
||||
VALID_REQUIREMENT_STATUSES = {"draft", "active", "deprecated"}
|
||||
VALID_DESIGN_STATUSES = {"draft", "current", "superseded"}
|
||||
VALID_SYSTEM_STATUSES = {"draft", "active", "retired"}
|
||||
VALID_MIGRATION_STATUSES = {"in_progress", "complete"}
|
||||
SIZE_EXCEPTIONS_PATH = "docs/specs/spec-lint-exceptions.tsv"
|
||||
REQ_ID_PATTERN = r"REQ-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}"
|
||||
AC_ID_PATTERN = r"AC-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}\.\d+"
|
||||
@@ -141,19 +158,8 @@ def lint_specs(
|
||||
continue
|
||||
|
||||
assert metadata is not None
|
||||
declared_system = metadata.get("system")
|
||||
if declared_system != system:
|
||||
violations.append(
|
||||
Violation(
|
||||
"system-owner",
|
||||
path,
|
||||
1,
|
||||
f"frontmatter system must be `{system}`, found `{declared_system or ''}`",
|
||||
)
|
||||
)
|
||||
|
||||
if kind == "requirement":
|
||||
violations.extend(check_requirement_metadata(path, metadata))
|
||||
violations.extend(check_requirement_metadata(path, metadata, system))
|
||||
reqs, criteria = collect_requirement_ids(path, text)
|
||||
if not reqs:
|
||||
violations.append(
|
||||
@@ -191,7 +197,7 @@ def lint_specs(
|
||||
)
|
||||
)
|
||||
else:
|
||||
violations.extend(check_design_metadata(path, metadata))
|
||||
violations.extend(check_design_metadata(path, metadata, system))
|
||||
references = metadata.get("requirements", [])
|
||||
if isinstance(references, list):
|
||||
for reference in references:
|
||||
@@ -406,30 +412,6 @@ def load_previous_size_exceptions(root: Path) -> dict[str, int] | None:
|
||||
return None
|
||||
|
||||
|
||||
def classify_path(relative: Path) -> tuple[str, str | None]:
|
||||
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
|
||||
|
||||
|
||||
def is_safe_regular_file(path: Path, root: Path) -> bool:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
return False
|
||||
@@ -575,120 +557,35 @@ def check_legacy_size_ratchet(
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> tuple[dict | None, int, str | None]:
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0] != "---":
|
||||
return None, 1, "new specification document must start with YAML frontmatter"
|
||||
try:
|
||||
end = lines.index("---", 1)
|
||||
except ValueError:
|
||||
return None, 1, "YAML frontmatter has no closing delimiter"
|
||||
|
||||
metadata: dict[str, str | list[str]] = {}
|
||||
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 None, 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 None, 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 metadata, 1, None
|
||||
parsed = parse_metadata_frontmatter(text, require=True)
|
||||
return parsed.metadata, parsed.line, parsed.error
|
||||
|
||||
|
||||
def check_requirement_metadata(path: Path, metadata: dict) -> list[Violation]:
|
||||
status = metadata.get("status")
|
||||
if status in VALID_REQUIREMENT_STATUSES:
|
||||
return []
|
||||
def metadata_violations(
|
||||
path: Path, kind: str, system: str | None, metadata: dict
|
||||
) -> list[Violation]:
|
||||
return [
|
||||
Violation(
|
||||
"requirement-status",
|
||||
path,
|
||||
1,
|
||||
f"requirement status must be one of {sorted(VALID_REQUIREMENT_STATUSES)}, found `{status or ''}`",
|
||||
)
|
||||
Violation(issue.code, path, 1, issue.message)
|
||||
for issue in validate_metadata(kind, system, metadata)
|
||||
]
|
||||
|
||||
|
||||
def check_system_index_metadata(path: Path, metadata: dict, system: str | None) -> list[Violation]:
|
||||
violations = []
|
||||
if metadata.get("status") not in VALID_SYSTEM_STATUSES:
|
||||
violations.append(
|
||||
Violation(
|
||||
"system-index-status",
|
||||
path,
|
||||
1,
|
||||
f"system status must be one of {sorted(VALID_SYSTEM_STATUSES)}",
|
||||
)
|
||||
)
|
||||
if metadata.get("system") != system:
|
||||
violations.append(
|
||||
Violation(
|
||||
"system-owner",
|
||||
path,
|
||||
1,
|
||||
f"frontmatter system must be `{system}`, found `{metadata.get('system', '')}`",
|
||||
)
|
||||
)
|
||||
if metadata.get("specification_version") != "1":
|
||||
violations.append(
|
||||
Violation(
|
||||
"system-index-version",
|
||||
path,
|
||||
1,
|
||||
"system index must set `specification_version: 1`",
|
||||
)
|
||||
)
|
||||
if metadata.get("migration") not in VALID_MIGRATION_STATUSES:
|
||||
violations.append(
|
||||
Violation(
|
||||
"system-index-migration",
|
||||
path,
|
||||
1,
|
||||
f"migration must be one of {sorted(VALID_MIGRATION_STATUSES)}",
|
||||
)
|
||||
)
|
||||
return violations
|
||||
def check_requirement_metadata(
|
||||
path: Path, metadata: dict, system: str | None = None
|
||||
) -> list[Violation]:
|
||||
return metadata_violations(path, "requirement", system, metadata)
|
||||
|
||||
|
||||
def check_design_metadata(path: Path, metadata: dict) -> list[Violation]:
|
||||
violations = []
|
||||
status = metadata.get("status")
|
||||
if status not in VALID_DESIGN_STATUSES:
|
||||
violations.append(
|
||||
Violation(
|
||||
"system-design-status",
|
||||
path,
|
||||
1,
|
||||
f"system-design status must be one of {sorted(VALID_DESIGN_STATUSES)}, found `{status or ''}`",
|
||||
)
|
||||
)
|
||||
if not isinstance(metadata.get("requirements"), list):
|
||||
violations.append(
|
||||
Violation(
|
||||
"system-design-requirements",
|
||||
path,
|
||||
1,
|
||||
"system design must declare `requirements` as a YAML list, including an explicit empty list",
|
||||
)
|
||||
)
|
||||
return violations
|
||||
def check_system_index_metadata(
|
||||
path: Path, metadata: dict, system: str | None
|
||||
) -> list[Violation]:
|
||||
return metadata_violations(path, "system-index", system, metadata)
|
||||
|
||||
|
||||
def check_design_metadata(
|
||||
path: Path, metadata: dict, system: str | None = None
|
||||
) -> list[Violation]:
|
||||
return metadata_violations(path, "system-design", system, metadata)
|
||||
|
||||
|
||||
def collect_requirement_ids(path: Path, text: str) -> tuple[list[tuple[str, int]], list[tuple[str, int]]]:
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
#!/usr/bin/env python3
|
||||
"""List and validate architecture decisions and specifications."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
try:
|
||||
from spec_metadata import (
|
||||
classify_path,
|
||||
metadata_text as spec_metadata_text,
|
||||
parse_frontmatter as parse_spec_frontmatter,
|
||||
validate_metadata,
|
||||
)
|
||||
except ModuleNotFoundError:
|
||||
from scripts.spec_metadata import (
|
||||
classify_path,
|
||||
metadata_text as spec_metadata_text,
|
||||
parse_frontmatter as parse_spec_frontmatter,
|
||||
validate_metadata,
|
||||
)
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
DECISION_METADATA_KEYS = {"status", "date", "area", "scope", "tags"}
|
||||
SPEC_KINDS = ("system", "glossary", "requirement", "system-design", "product", "legacy")
|
||||
SPEC_KIND_ORDER = {kind: index for index, kind in enumerate(SPEC_KINDS)}
|
||||
FIELD_LINE = re.compile(
|
||||
r"^\*{0,2}(?P<key>Status|Date|Area|Scope|Tags)"
|
||||
r"(?::\s*\*{0,2}\s*|\*{0,2}\s*:\s*)(?P<value>.*?)\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
HEADING_FIELD = re.compile(r"^##\s+(?P<key>Status|Date|Area|Scope|Tags)\s*$", re.IGNORECASE)
|
||||
FIRST_HEADING = re.compile(r"^#\s+(?P<title>\S.*)$")
|
||||
DECISION_ID_PREFIX = re.compile(
|
||||
r"^(?:ADR-)?(?:\d{4}|(?:\d{4}-\d{2}-\d{2}(?:-[a-z0-9-]+)?))\s*(?::|[—–])\s+",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class CatalogError(Exception):
|
||||
"""A source file cannot be represented in the catalog."""
|
||||
|
||||
|
||||
class SkipSource(Exception):
|
||||
"""A Markdown file is an authoring entry page, not a catalog source."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Document:
|
||||
type: str
|
||||
path: str
|
||||
title: str
|
||||
text: str
|
||||
id: str
|
||||
status: str | None = None
|
||||
area: str | None = None
|
||||
date: str | None = None
|
||||
system: str | None = None
|
||||
kind: str | None = None
|
||||
|
||||
|
||||
def repository_path(root: Path, path: Path) -> str:
|
||||
try:
|
||||
return path.resolve().relative_to(root.resolve()).as_posix()
|
||||
except ValueError as exc:
|
||||
raise CatalogError(f"{path}: path is outside the repository root") from exc
|
||||
|
||||
|
||||
def read_source(root: Path, path: Path) -> tuple[str, str]:
|
||||
relative = repository_path(root, path)
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise CatalogError(f"{relative}: source must be a regular file")
|
||||
try:
|
||||
return relative, path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
raise CatalogError(f"{relative}: cannot read source file: {exc}") from exc
|
||||
|
||||
|
||||
def first_heading(lines: list[str], start: int, relative: str) -> str:
|
||||
for line in lines[start:]:
|
||||
match = FIRST_HEADING.match(line)
|
||||
if match:
|
||||
return match.group("title").strip()
|
||||
raise CatalogError(f"{relative}: source must contain a level-one title heading")
|
||||
|
||||
|
||||
def decision_title(heading: str) -> str:
|
||||
return DECISION_ID_PREFIX.sub("", heading, count=1).strip()
|
||||
|
||||
|
||||
def normalize_field_value(value: str) -> str:
|
||||
return value.strip().strip("|").strip()
|
||||
|
||||
|
||||
def metadata_key(raw_key: str) -> str:
|
||||
key = raw_key.strip().strip("*").lower()
|
||||
return "area" if key in {"scope", "tags"} else key
|
||||
|
||||
|
||||
def add_metadata_value(
|
||||
fields: dict[str, str],
|
||||
raw_key: str,
|
||||
raw_value: str,
|
||||
relative: str,
|
||||
) -> None:
|
||||
key = metadata_key(raw_key)
|
||||
value = normalize_field_value(raw_value)
|
||||
if not value:
|
||||
return
|
||||
if key == "area" and raw_key.strip().lower() != "area" and "area" in fields:
|
||||
return
|
||||
if key in fields:
|
||||
raise CatalogError(f"{relative}: duplicate {key} metadata in the ADR header")
|
||||
fields[key] = value
|
||||
|
||||
|
||||
def parse_metadata_line(line: str) -> tuple[str, str] | None:
|
||||
stripped = line.strip()
|
||||
stripped = re.sub(r"^[-*]\s+", "", stripped)
|
||||
if stripped.startswith("|"):
|
||||
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
|
||||
if len(cells) >= 2:
|
||||
key = cells[0].strip().strip("*")
|
||||
if key.lower() in DECISION_METADATA_KEYS:
|
||||
return key, cells[1]
|
||||
return None
|
||||
match = FIELD_LINE.match(stripped)
|
||||
if match:
|
||||
return match.group("key"), match.group("value")
|
||||
return None
|
||||
|
||||
|
||||
def parse_decision_metadata(
|
||||
lines: list[str], start: int, frontmatter: dict[str, object], relative: str
|
||||
) -> dict[str, str]:
|
||||
fields: dict[str, str] = {}
|
||||
for key in ("status", "date", "area"):
|
||||
value = frontmatter.get(key)
|
||||
if isinstance(value, list):
|
||||
value = ", ".join(str(item) for item in value)
|
||||
if isinstance(value, str):
|
||||
add_metadata_value(fields, key, value, relative)
|
||||
|
||||
pending_key: str | None = None
|
||||
pending_value: list[str] = []
|
||||
|
||||
def flush_pending() -> None:
|
||||
nonlocal pending_key, pending_value
|
||||
if pending_key is not None:
|
||||
add_metadata_value(fields, pending_key, " ".join(pending_value), relative)
|
||||
pending_key = None
|
||||
pending_value = []
|
||||
|
||||
index = start
|
||||
while index < len(lines):
|
||||
line = lines[index]
|
||||
heading_match = HEADING_FIELD.match(line.strip())
|
||||
if heading_match:
|
||||
flush_pending()
|
||||
value_index = index + 1
|
||||
while value_index < len(lines) and not lines[value_index].strip():
|
||||
value_index += 1
|
||||
if value_index < len(lines) and not lines[value_index].lstrip().startswith("#"):
|
||||
pending_key = heading_match.group("key")
|
||||
pending_value = [lines[value_index].strip()]
|
||||
index = value_index + 1
|
||||
continue
|
||||
elif re.match(r"^##\s+", line):
|
||||
flush_pending()
|
||||
break
|
||||
|
||||
parsed = parse_metadata_line(line)
|
||||
if parsed:
|
||||
flush_pending()
|
||||
pending_key = parsed[0]
|
||||
pending_value = [parsed[1]]
|
||||
elif not line.strip():
|
||||
flush_pending()
|
||||
elif pending_key is not None:
|
||||
pending_value.append(line.strip())
|
||||
index += 1
|
||||
flush_pending()
|
||||
if not fields.get("status"):
|
||||
raise CatalogError(f"{relative}: ADR metadata must define a non-empty status")
|
||||
date = fields.get("date")
|
||||
if date and not re.match(r"^\d{4}-\d{2}-\d{2}\b", date):
|
||||
raise CatalogError(f"{relative}: ADR date must start with YYYY-MM-DD")
|
||||
return fields
|
||||
|
||||
|
||||
def parse_decision(root: Path, path: Path) -> Document:
|
||||
relative, text = read_source(root, path)
|
||||
lines = text.splitlines()
|
||||
parsed_frontmatter = parse_spec_frontmatter(text)
|
||||
if parsed_frontmatter.error:
|
||||
raise CatalogError(
|
||||
f"{relative}:{parsed_frontmatter.line}: {parsed_frontmatter.error}"
|
||||
)
|
||||
frontmatter = parsed_frontmatter.metadata or {}
|
||||
frontmatter_end = parsed_frontmatter.end
|
||||
heading = first_heading(lines, frontmatter_end, relative)
|
||||
fields = parse_decision_metadata(lines, frontmatter_end, frontmatter, relative)
|
||||
return Document(
|
||||
type="decision",
|
||||
path=relative,
|
||||
title=decision_title(heading),
|
||||
text=text,
|
||||
id=path.stem,
|
||||
status=fields.get("status"),
|
||||
area=fields.get("area"),
|
||||
date=fields.get("date"),
|
||||
)
|
||||
|
||||
|
||||
def decision_files(root: Path) -> Iterable[Path]:
|
||||
directory = root / "docs" / "decisions"
|
||||
if not directory.exists():
|
||||
return ()
|
||||
return (path for path in sorted(directory.rglob("*.md")) if path.name != "INDEX.md")
|
||||
|
||||
|
||||
def spec_files(root: Path) -> Iterable[Path]:
|
||||
directory = root / "docs" / "specs"
|
||||
if not directory.exists():
|
||||
return ()
|
||||
return (
|
||||
path
|
||||
for path in sorted(directory.rglob("*.md"))
|
||||
if path.name != "INDEX.md"
|
||||
and path != directory / "README.md"
|
||||
and "guide" not in path.relative_to(directory).parts
|
||||
and "templates" not in path.relative_to(directory).parts
|
||||
)
|
||||
|
||||
|
||||
def parse_spec_kind(
|
||||
relative: str, frontmatter: dict[str, object] | None = None
|
||||
) -> tuple[str, str | None]:
|
||||
del frontmatter
|
||||
inside = Path(relative).relative_to("docs/specs").parts
|
||||
if not inside:
|
||||
raise CatalogError(f"{relative}: specification path has no system directory")
|
||||
source_kind, source_system = classify_path(Path(relative))
|
||||
if source_kind in {"guide", "template"}:
|
||||
raise CatalogError(f"{relative}: authoring support files are not catalog sources")
|
||||
if source_kind == "system-index":
|
||||
if inside[-1] == "README.md":
|
||||
return "system", source_system
|
||||
return "glossary", source_system
|
||||
if source_kind in {"requirement", "system-design", "product", "legacy"}:
|
||||
return source_kind, source_system
|
||||
raise CatalogError(f"{relative}: unsupported specification source")
|
||||
|
||||
|
||||
def validate_spec_metadata(
|
||||
relative: str,
|
||||
kind: str,
|
||||
system: str | None,
|
||||
metadata: dict[str, object],
|
||||
has_frontmatter: bool,
|
||||
) -> None:
|
||||
issues = validate_metadata(kind, system, metadata, has_frontmatter)
|
||||
if issues:
|
||||
raise CatalogError(f"{relative}: {issues[0].message}")
|
||||
|
||||
|
||||
def parse_spec(root: Path, path: Path) -> Document:
|
||||
relative, text = read_source(root, path)
|
||||
if relative == "docs/specs/product/README.md":
|
||||
raise SkipSource
|
||||
parsed_frontmatter = parse_spec_frontmatter(text)
|
||||
if parsed_frontmatter.error:
|
||||
raise CatalogError(
|
||||
f"{relative}:{parsed_frontmatter.line}: {parsed_frontmatter.error}"
|
||||
)
|
||||
metadata = parsed_frontmatter.metadata or {}
|
||||
frontmatter_end = parsed_frontmatter.end
|
||||
kind, system = parse_spec_kind(relative, metadata)
|
||||
has_frontmatter = bool(text.splitlines() and text.splitlines()[0] == "---")
|
||||
validate_spec_metadata(relative, kind, system, metadata, has_frontmatter)
|
||||
title = spec_metadata_text(metadata, "title")
|
||||
if not title:
|
||||
try:
|
||||
title = first_heading(text.splitlines(), frontmatter_end, relative)
|
||||
except CatalogError:
|
||||
title = " ".join(
|
||||
word.capitalize()
|
||||
for word in re.split(r"[-_]+", Path(relative).stem)
|
||||
if word
|
||||
)
|
||||
identifier = spec_metadata_text(metadata, "id") or relative.removesuffix(".md")
|
||||
return Document(
|
||||
type="specification",
|
||||
path=relative,
|
||||
title=title,
|
||||
text=text,
|
||||
id=identifier,
|
||||
status=spec_metadata_text(metadata, "status"),
|
||||
system=system,
|
||||
kind=kind,
|
||||
)
|
||||
|
||||
|
||||
def load_documents(
|
||||
root: Path, files: Iterable[Path], parser
|
||||
) -> tuple[list[Document], list[str]]:
|
||||
documents: list[Document] = []
|
||||
errors: list[str] = []
|
||||
identities: dict[str, str] = {}
|
||||
for path in files:
|
||||
try:
|
||||
document = parser(root, path)
|
||||
except SkipSource:
|
||||
continue
|
||||
except CatalogError as exc:
|
||||
errors.append(str(exc))
|
||||
continue
|
||||
identity = document.id if document.type == "decision" else f"{document.type}:{document.id}"
|
||||
if identity in identities:
|
||||
errors.append(
|
||||
f"{document.path}: duplicate catalog identity {document.id} "
|
||||
f"(already used by {identities[identity]})"
|
||||
)
|
||||
continue
|
||||
identities[identity] = document.path
|
||||
documents.append(document)
|
||||
return documents, sorted(errors)
|
||||
|
||||
|
||||
def decision_sort_key(document: Document) -> tuple[int, int | str, str]:
|
||||
if re.match(r"^\d{4}-\d{2}-\d{2}-", document.id):
|
||||
return (1, document.id, document.path)
|
||||
numeric = re.match(r"^(\d+)(?:-|$)", document.id)
|
||||
if numeric:
|
||||
return (0, int(numeric.group(1)), document.path)
|
||||
return (2, document.id, document.path)
|
||||
|
||||
|
||||
def spec_sort_key(document: Document) -> tuple[str, int, str]:
|
||||
assert document.kind is not None
|
||||
return (document.system or "", SPEC_KIND_ORDER[document.kind], document.path)
|
||||
|
||||
|
||||
def filter_decisions(
|
||||
documents: Iterable[Document],
|
||||
status: str | None,
|
||||
area: str | None,
|
||||
text: str | None,
|
||||
) -> list[Document]:
|
||||
result = []
|
||||
for document in documents:
|
||||
if status and leading_status_class(document.status) != status.lower():
|
||||
continue
|
||||
if area and not area_matches(document.area, area):
|
||||
continue
|
||||
if text and text.lower() not in document.text.lower():
|
||||
continue
|
||||
result.append(document)
|
||||
return sorted(result, key=decision_sort_key)
|
||||
|
||||
|
||||
def leading_status_class(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
match = re.match(r"^\s*([a-z][a-z0-9_-]*)", value, re.IGNORECASE)
|
||||
return match.group(1).lower() if match else ""
|
||||
|
||||
|
||||
def area_matches(value: str | None, requested: str) -> bool:
|
||||
if not value:
|
||||
return False
|
||||
wanted = requested.strip().lower()
|
||||
if value.lower() == wanted:
|
||||
return True
|
||||
tokens = [token.strip().lower() for token in value.split(",") if token.strip()]
|
||||
if wanted in tokens:
|
||||
return True
|
||||
return wanted in {
|
||||
token.lower() for token in re.findall(r"[a-z0-9][a-z0-9_-]*", value)
|
||||
}
|
||||
|
||||
|
||||
def filter_specs(
|
||||
documents: Iterable[Document],
|
||||
system: str | None,
|
||||
kind: str | None,
|
||||
status: str | None,
|
||||
text: str | None,
|
||||
) -> list[Document]:
|
||||
result = []
|
||||
for document in documents:
|
||||
if system and (not document.system or document.system.lower() != system.lower()):
|
||||
continue
|
||||
if kind and document.kind != kind:
|
||||
continue
|
||||
if status and (not document.status or document.status.lower() != status.lower()):
|
||||
continue
|
||||
if text and text.lower() not in document.text.lower():
|
||||
continue
|
||||
result.append(document)
|
||||
return sorted(result, key=spec_sort_key)
|
||||
|
||||
|
||||
def markdown_escape(value: object) -> str:
|
||||
return (
|
||||
str(value if value is not None else "")
|
||||
.replace("\\", "\\\\")
|
||||
.replace("|", "\\|")
|
||||
.replace("\n", " ")
|
||||
)
|
||||
|
||||
|
||||
def markdown_table(documents: list[Document], document_type: str) -> str:
|
||||
if document_type == "decision":
|
||||
headers = ("ID", "Title", "Status", "Area", "Date", "Path")
|
||||
rows = [
|
||||
(
|
||||
document.id,
|
||||
document.title,
|
||||
document.status,
|
||||
document.area,
|
||||
document.date,
|
||||
document.path,
|
||||
)
|
||||
for document in documents
|
||||
]
|
||||
else:
|
||||
headers = ("System", "Kind", "Status", "Title", "Path")
|
||||
rows = [
|
||||
(
|
||||
document.system,
|
||||
document.kind,
|
||||
document.status,
|
||||
document.title,
|
||||
document.path,
|
||||
)
|
||||
for document in documents
|
||||
]
|
||||
lines = [
|
||||
"| " + " | ".join(headers) + " |",
|
||||
"| " + " | ".join("---" for _ in headers) + " |",
|
||||
]
|
||||
lines.extend(
|
||||
"| " + " | ".join(markdown_escape(value) for value in row) + " |"
|
||||
for row in rows
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def json_output(documents: list[Document]) -> str:
|
||||
items = []
|
||||
for document in documents:
|
||||
item = {
|
||||
"id": document.id,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"type": document.type,
|
||||
}
|
||||
if document.type == "decision":
|
||||
item.update(
|
||||
{
|
||||
"area": document.area,
|
||||
"date": document.date,
|
||||
"status": document.status,
|
||||
}
|
||||
)
|
||||
else:
|
||||
item.update(
|
||||
{
|
||||
"kind": document.kind,
|
||||
"status": document.status,
|
||||
"system": document.system,
|
||||
}
|
||||
)
|
||||
items.append(item)
|
||||
return (
|
||||
json.dumps(
|
||||
{"schema_version": SCHEMA_VERSION, "documents": items},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
||||
def format_documents(
|
||||
documents: list[Document], output_format: str, document_type: str
|
||||
) -> str:
|
||||
if output_format == "paths":
|
||||
return "".join(f"{document.path}\n" for document in documents)
|
||||
if output_format == "json":
|
||||
return json_output(documents)
|
||||
return markdown_table(documents, document_type)
|
||||
|
||||
|
||||
def add_common_arguments(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=("markdown", "paths", "json"),
|
||||
default="markdown",
|
||||
help="output format",
|
||||
)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="List and validate Kandev documentation sources."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
type=Path,
|
||||
default=Path.cwd(),
|
||||
help="repository root (defaults to the current directory)",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
decisions = subparsers.add_parser("decisions", help="list architecture decisions")
|
||||
decisions.add_argument("--status")
|
||||
decisions.add_argument("--area")
|
||||
decisions.add_argument("--text")
|
||||
add_common_arguments(decisions)
|
||||
|
||||
specs = subparsers.add_parser("specs", help="list specifications")
|
||||
specs.add_argument("--system")
|
||||
specs.add_argument("--kind", choices=SPEC_KINDS)
|
||||
specs.add_argument("--status")
|
||||
specs.add_argument("--text")
|
||||
add_common_arguments(specs)
|
||||
|
||||
subparsers.add_parser("validate", help="validate both documentation catalogs")
|
||||
return parser
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
root = args.root.resolve()
|
||||
if not root.is_dir():
|
||||
print(f"error: repository root does not exist: {root}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.command == "decisions":
|
||||
documents, errors = load_documents(root, decision_files(root), parse_decision)
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
selected = filter_decisions(documents, args.status, args.area, args.text)
|
||||
print(format_documents(selected, args.format, "decision"), end="")
|
||||
return 0
|
||||
|
||||
if args.command == "specs":
|
||||
documents, errors = load_documents(root, spec_files(root), parse_spec)
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
selected = filter_specs(documents, args.system, args.kind, args.status, args.text)
|
||||
print(format_documents(selected, args.format, "specification"), end="")
|
||||
return 0
|
||||
|
||||
decisions, decision_errors = load_documents(root, decision_files(root), parse_decision)
|
||||
specs, spec_errors = load_documents(root, spec_files(root), parse_spec)
|
||||
errors = sorted(decision_errors + spec_errors)
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"Validated {len(decisions)} decisions and {len(specs)} specifications.")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
try:
|
||||
args = parser.parse_args()
|
||||
return run(args)
|
||||
except CatalogError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,420 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "list-docs.py"
|
||||
|
||||
|
||||
class ListDocsTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
|
||||
def write(self, relative: str, content: str) -> Path:
|
||||
path = self.root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return path
|
||||
|
||||
def run_cli(self, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--root", str(self.root), *args],
|
||||
cwd=ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def add_decisions(self) -> None:
|
||||
self.write(
|
||||
"docs/decisions/0010-pipe.md",
|
||||
"""# 0010: Pipe | choice
|
||||
|
||||
**Status:** accepted (amended)
|
||||
**Date:** 2026-01-02 (amended)
|
||||
**Area:** backend, frontend
|
||||
|
||||
## Context
|
||||
|
||||
The searchable needle is here.
|
||||
""",
|
||||
)
|
||||
self.write(
|
||||
"docs/decisions/0002-bullet.md",
|
||||
"""# 0002 — Bullet metadata
|
||||
|
||||
- Status: Proposed
|
||||
- Date: 2025-12-01
|
||||
- Area: workflow, infra
|
||||
|
||||
## Context
|
||||
|
||||
This decision uses bullet metadata.
|
||||
""",
|
||||
)
|
||||
self.write(
|
||||
"docs/decisions/2026-01-03-frontmatter.md",
|
||||
"""---
|
||||
status: accepted
|
||||
date: 2026-01-03
|
||||
area: protocol
|
||||
---
|
||||
|
||||
# Frontmatter decision
|
||||
|
||||
## Context
|
||||
|
||||
This decision uses YAML metadata.
|
||||
""",
|
||||
)
|
||||
self.write(
|
||||
"docs/decisions/0019-status-section.md",
|
||||
"""# 0019: Status section
|
||||
|
||||
## Status
|
||||
|
||||
accepted
|
||||
|
||||
## Context
|
||||
|
||||
Older decisions can omit date and area metadata.
|
||||
""",
|
||||
)
|
||||
self.write(
|
||||
"docs/decisions/2026-01-04-table.md",
|
||||
"""# Table metadata
|
||||
|
||||
| Metadata | Value |
|
||||
| --- | --- |
|
||||
| Date | 2026-01-04 |
|
||||
| Status | superseded by another decision |
|
||||
| Tags | backend, security |
|
||||
|
||||
## Context
|
||||
|
||||
This decision uses a metadata table.
|
||||
""",
|
||||
)
|
||||
self.write(
|
||||
"docs/decisions/2026-01-05-wrapped-status.md",
|
||||
"""# Wrapped status metadata
|
||||
|
||||
**Status:** accepted (amended by
|
||||
2026-01-05)
|
||||
**Date:** 2026-01-05
|
||||
**Area:** workflow
|
||||
|
||||
## Context
|
||||
|
||||
This decision wraps one metadata value.
|
||||
""",
|
||||
)
|
||||
self.write(
|
||||
"docs/decisions/0028-status-punctuation.md",
|
||||
"""# 0028: Status punctuation
|
||||
|
||||
**Status:** accepted; browser-cache portion superseded
|
||||
**Date:** 2026-01-06
|
||||
**Area:** backend
|
||||
|
||||
## Context
|
||||
|
||||
This decision uses punctuation after the status class.
|
||||
""",
|
||||
)
|
||||
|
||||
def add_specs(self) -> None:
|
||||
self.write(
|
||||
"docs/specs/ui/README.md",
|
||||
"""---
|
||||
status: active
|
||||
system: ui
|
||||
specification_version: 1
|
||||
migration: complete
|
||||
---
|
||||
|
||||
# UI system
|
||||
""",
|
||||
)
|
||||
self.write(
|
||||
"docs/specs/ui/requirements/first.md",
|
||||
"""---
|
||||
id: ui-first
|
||||
title: Frontmatter requirement title
|
||||
status: `active`
|
||||
system: `ui`
|
||||
created: 2026-01-01
|
||||
---
|
||||
|
||||
# Heading requirement title
|
||||
|
||||
The specification body contains the Searchable Spec Term.
|
||||
""",
|
||||
)
|
||||
self.write(
|
||||
"docs/specs/ui/system-design/first.md",
|
||||
"""---
|
||||
id: ui-design
|
||||
status: current
|
||||
system: ui
|
||||
requirements: []
|
||||
---
|
||||
|
||||
# UI system design
|
||||
""",
|
||||
)
|
||||
self.write(
|
||||
"docs/specs/legacy/spec.md",
|
||||
"""# Legacy specification
|
||||
|
||||
This file has no frontmatter and remains in the legacy layout.
|
||||
""",
|
||||
)
|
||||
self.write(
|
||||
"docs/specs/product/overview.md",
|
||||
"""# Product overview
|
||||
|
||||
This document describes the product boundary.
|
||||
""",
|
||||
)
|
||||
self.write(
|
||||
"docs/specs/ui/glossary.md",
|
||||
"""# UI glossary
|
||||
|
||||
This document defines UI terms.
|
||||
""",
|
||||
)
|
||||
|
||||
def test_decisions_support_variants_filters_and_stable_sorting(self) -> None:
|
||||
self.add_decisions()
|
||||
|
||||
result = self.run_cli("decisions", "--status", "accepted", "--format", "paths")
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(
|
||||
result.stdout.splitlines(),
|
||||
[
|
||||
"docs/decisions/0010-pipe.md",
|
||||
"docs/decisions/0019-status-section.md",
|
||||
"docs/decisions/0028-status-punctuation.md",
|
||||
"docs/decisions/2026-01-03-frontmatter.md",
|
||||
"docs/decisions/2026-01-05-wrapped-status.md",
|
||||
],
|
||||
)
|
||||
|
||||
area_result = self.run_cli("decisions", "--area", "FRONTEND", "--format", "paths")
|
||||
self.assertEqual(area_result.returncode, 0, area_result.stderr)
|
||||
self.assertEqual(area_result.stdout.splitlines(), ["docs/decisions/0010-pipe.md"])
|
||||
|
||||
text_result = self.run_cli("decisions", "--text", "NEEDLE", "--format", "paths")
|
||||
self.assertEqual(text_result.returncode, 0, text_result.stderr)
|
||||
self.assertEqual(text_result.stdout.splitlines(), ["docs/decisions/0010-pipe.md"])
|
||||
|
||||
wrapped = self.run_cli("decisions", "--format", "json")
|
||||
self.assertEqual(wrapped.returncode, 0, wrapped.stderr)
|
||||
wrapped_document = next(
|
||||
document
|
||||
for document in json.loads(wrapped.stdout)["documents"]
|
||||
if document["id"] == "2026-01-05-wrapped-status"
|
||||
)
|
||||
self.assertEqual(wrapped_document["status"], "accepted (amended by 2026-01-05)")
|
||||
|
||||
punctuation = self.run_cli(
|
||||
"decisions", "--status", "accepted", "--format", "paths"
|
||||
)
|
||||
self.assertEqual(punctuation.returncode, 0, punctuation.stderr)
|
||||
self.assertIn(
|
||||
"docs/decisions/0028-status-punctuation.md", punctuation.stdout.splitlines()
|
||||
)
|
||||
|
||||
def test_decision_outputs_escape_markdown_and_preserve_metadata_in_json(self) -> None:
|
||||
self.add_decisions()
|
||||
|
||||
markdown = self.run_cli("decisions", "--format", "markdown")
|
||||
self.assertEqual(markdown.returncode, 0, markdown.stderr)
|
||||
self.assertIn("Pipe \\| choice", markdown.stdout)
|
||||
self.assertIn("| 0010-pipe |", markdown.stdout)
|
||||
|
||||
first = self.run_cli("decisions", "--format", "json")
|
||||
second = self.run_cli("decisions", "--format", "json")
|
||||
self.assertEqual(first.returncode, 0, first.stderr)
|
||||
self.assertEqual(first.stdout, second.stdout)
|
||||
payload = json.loads(first.stdout)
|
||||
self.assertEqual(payload["schema_version"], 1)
|
||||
self.assertEqual(payload["documents"][0]["id"], "0002-bullet")
|
||||
self.assertEqual(payload["documents"][0]["status"], "Proposed")
|
||||
self.assertEqual(payload["documents"][0]["area"], "workflow, infra")
|
||||
|
||||
def test_specs_support_kinds_filters_json_and_empty_results(self) -> None:
|
||||
self.add_specs()
|
||||
|
||||
result = self.run_cli(
|
||||
"specs",
|
||||
"--system",
|
||||
"ui",
|
||||
"--kind",
|
||||
"requirement",
|
||||
"--status",
|
||||
"active",
|
||||
"--text",
|
||||
"searchable spec term",
|
||||
"--format",
|
||||
"json",
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertEqual(len(payload["documents"]), 1)
|
||||
document = payload["documents"][0]
|
||||
self.assertEqual(document["kind"], "requirement")
|
||||
self.assertEqual(document["system"], "ui")
|
||||
self.assertEqual(document["title"], "Frontmatter requirement title")
|
||||
self.assertEqual(document["id"], "ui-first")
|
||||
|
||||
paths = self.run_cli("specs", "--kind", "legacy", "--format", "paths")
|
||||
self.assertEqual(paths.returncode, 0, paths.stderr)
|
||||
self.assertEqual(paths.stdout.splitlines(), ["docs/specs/legacy/spec.md"])
|
||||
|
||||
product_paths = self.run_cli("specs", "--kind", "product", "--format", "paths")
|
||||
self.assertEqual(product_paths.returncode, 0, product_paths.stderr)
|
||||
self.assertEqual(product_paths.stdout.splitlines(), ["docs/specs/product/overview.md"])
|
||||
|
||||
glossary_paths = self.run_cli("specs", "--kind", "glossary", "--format", "paths")
|
||||
self.assertEqual(glossary_paths.returncode, 0, glossary_paths.stderr)
|
||||
self.assertEqual(glossary_paths.stdout.splitlines(), ["docs/specs/ui/glossary.md"])
|
||||
|
||||
empty = self.run_cli("specs", "--system", "missing", "--format", "json")
|
||||
self.assertEqual(empty.returncode, 0, empty.stderr)
|
||||
self.assertEqual(json.loads(empty.stdout)["documents"], [])
|
||||
|
||||
def test_spec_kind_filters_cover_each_linter_source_category(self) -> None:
|
||||
self.add_specs()
|
||||
expected = {
|
||||
"system": "docs/specs/ui/README.md",
|
||||
"glossary": "docs/specs/ui/glossary.md",
|
||||
"requirement": "docs/specs/ui/requirements/first.md",
|
||||
"system-design": "docs/specs/ui/system-design/first.md",
|
||||
"product": "docs/specs/product/overview.md",
|
||||
"legacy": "docs/specs/legacy/spec.md",
|
||||
}
|
||||
|
||||
for kind, path in expected.items():
|
||||
with self.subTest(kind=kind):
|
||||
result = self.run_cli("specs", "--kind", kind, "--format", "paths")
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.splitlines(), [path])
|
||||
|
||||
def test_empty_markdown_keeps_the_catalog_type(self) -> None:
|
||||
self.add_decisions()
|
||||
result = self.run_cli("decisions", "--status", "missing", "--format", "markdown")
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("| ID | Title | Status | Area | Date | Path |", result.stdout)
|
||||
|
||||
def test_specs_skip_entry_readmes_and_derive_titles_without_level_one_headings(self) -> None:
|
||||
self.write("docs/specs/product/README.md", "# Product Specifications\n")
|
||||
self.write(
|
||||
"docs/specs/ui/README.md",
|
||||
"""---
|
||||
status: active
|
||||
system: ui
|
||||
specification_version: 1
|
||||
migration: complete
|
||||
---
|
||||
|
||||
# UI system
|
||||
""",
|
||||
)
|
||||
self.write(
|
||||
"docs/specs/ui/requirements/no-level-one.md",
|
||||
"""---
|
||||
status: active
|
||||
system: ui
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This requirement has no level-one heading.
|
||||
""",
|
||||
)
|
||||
|
||||
result = self.run_cli("specs", "--system", "ui", "--format", "json")
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
documents = json.loads(result.stdout)["documents"]
|
||||
requirement = next(
|
||||
document
|
||||
for document in documents
|
||||
if document["path"].endswith("no-level-one.md")
|
||||
)
|
||||
self.assertEqual(requirement["title"], "No Level One")
|
||||
self.assertNotIn("docs/specs/product/README.md", result.stdout)
|
||||
|
||||
def test_validate_rejects_malformed_metadata_and_duplicate_spec_ids(self) -> None:
|
||||
self.write(
|
||||
"docs/decisions/bad.md",
|
||||
"""# Bad decision
|
||||
|
||||
**Date:** 2026-01-01
|
||||
**Area:** backend
|
||||
""",
|
||||
)
|
||||
self.write(
|
||||
"docs/specs/ui/README.md",
|
||||
"""---
|
||||
status: active
|
||||
system: ui
|
||||
specification_version: 1
|
||||
migration: complete
|
||||
---
|
||||
|
||||
# UI system
|
||||
""",
|
||||
)
|
||||
requirement = """---
|
||||
id: duplicate
|
||||
status: active
|
||||
system: ui
|
||||
---
|
||||
|
||||
# Requirement
|
||||
"""
|
||||
self.write("docs/specs/ui/requirements/one.md", requirement)
|
||||
self.write("docs/specs/ui/requirements/two.md", requirement)
|
||||
|
||||
result = self.run_cli("validate")
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
combined = result.stdout + result.stderr
|
||||
self.assertIn("docs/decisions/bad.md", combined)
|
||||
self.assertIn("docs/specs/ui/requirements/two.md", combined)
|
||||
self.assertIn("duplicate", combined.lower())
|
||||
|
||||
def test_validate_rejects_system_readmes_without_valid_frontmatter(self) -> None:
|
||||
for content in ("# UI system\n", "---\nstatus: active\n---\n# UI system\n"):
|
||||
with self.subTest(content=content):
|
||||
self.write("docs/specs/ui/README.md", content)
|
||||
|
||||
result = self.run_cli("validate")
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
combined = result.stdout + result.stderr
|
||||
self.assertIn("docs/specs/ui/README.md", combined)
|
||||
|
||||
def test_invalid_kind_is_an_actionable_cli_error(self) -> None:
|
||||
result = self.run_cli("specs", "--kind", "unknown")
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("invalid choice", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,194 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user