docs: replace tracked document catalogs (#3482)
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user