docs: replace tracked document catalogs (#3482)

This commit is contained in:
Carlos Florêncio
2026-09-12 17:23:20 +01:00
committed by GitHub
parent 01afaf05b3
commit 49df3794bc
48 changed files with 2048 additions and 1499 deletions
+44 -147
View File
@@ -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]]]:
+589
View File
@@ -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())
+420
View File
@@ -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()
+194
View File
@@ -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