"""文書の分類・参照・完了条件の回帰検査。標準ライブラリだけで実行する。""" import contextlib import importlib.util import io import tempfile import unittest from pathlib import Path from unittest.mock import patch spec = importlib.util.spec_from_file_location("doc_lint", Path(__file__).with_name("doc-lint.py")) lint = importlib.util.module_from_spec(spec) spec.loader.exec_module(lint) def backlog_item(pid="PHY-P-14"): return ( f"### {pid} 原点の吸着\n\n" "- 関連仕様: [PHY](../PHY.md)\n" "- 未決事項: 吸着が必要か\n" "- 保留できる理由: 数値入力で編集できる\n" "- 再開条件: 原点移動を試作するとき\n" "- 完了条件: 吸着の要否を決める\n" ) class DocLintTests(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) self.root = Path(temporary.name).resolve() root_patch = patch.object(lint, "ROOT", self.root) root_patch.start() self.addCleanup(root_patch.stop) self.write("PHY.md", "# PHY\n") def write(self, relative, content): path = self.root / relative path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") return path def run_lint(self, *paths): output = io.StringIO() with contextlib.redirect_stdout(output): status = lint.main(["doc-lint.py", *(str(path) for path in paths)]) return status, output.getvalue() def test_default_scan_includes_backlog_and_accepts_fixed_links(self): self.write("PHY.md", "# PHY\n\n## 保留課題\n\n- [PHYの保留課題](backlog/README.md#phy)\n") self.write("backlog/README.md", "# 保留課題\n\n## PHY\n\n" + backlog_item()) self.write("journal/2026-09-22-decision.md", "# 決定\n\n## 結論\n\n採用した。\n") status, output = self.run_lint() self.assertEqual(status, 0, output) self.assertIn("3 files / ERROR 0", output) def test_backlog_fields_must_have_values(self): for field in lint.BACKLOG_FIELDS: with self.subTest(field=field): text = backlog_item() text = "\n".join(f"- {field}: " if line.startswith(f"- {field}:") else line for line in text.splitlines()) path = self.write("backlog/README.md", "## PHY\n" + text) status, output = self.run_lint(path) self.assertEqual(status, 1) self.assertIn(f"- {field}:", output) def test_duplicate_backlog_ids_across_nested_files(self): self.write("backlog/one.md", "## PHY\n" + backlog_item()) self.write("backlog/sub/two.md", "## PHY\n" + backlog_item().replace("../PHY.md", "../../PHY.md")) status, output = self.run_lint() self.assertEqual(status, 1) self.assertIn("保留課題のID PHY-P-14 が重複", output) def test_incomplete_last_item_cannot_borrow_fields_from_next_section(self): self.write("backlog/README.md", "## PHY\n### PHY-P-14 吸着\n\n## ELC\n" + backlog_item("ELC-P-1")) status, output = self.run_lint() self.assertEqual(status, 1) self.assertIn("PHY-P-14 に「- 完了条件:」", output) def test_invalid_backlog_heading_and_missing_reference(self): path = self.write("backlog/README.md", "## PHY\n### P-14 吸着\n" + backlog_item().replace("../PHY.md", "../missing.md")) status, output = self.run_lint(path) self.assertEqual(status, 1) self.assertIn("保留課題の見出し", output) self.assertIn("参照先ファイルがない", output) def test_spec_backlog_section_rejects_prose_empty_and_broken_targets(self): self.write("backlog/README.md", "## PHY\n" + backlog_item()) cases = { "内容をここにも要約する。": "だけを書く", "": "空の ## 保留課題", "- [PHYの保留課題](backlog/missing.md#phy)": "参照先ファイルがない", "- [PHYの保留課題](backlog/README.md#missing)": "参照先見出しがない", "- [PHYの保留課題](backlog/README.md)": "見出しへリンク", "- [PHYの保留課題](PHY.md#phy)": "backlog内", } for content, expected in cases.items(): with self.subTest(content=content): path = self.write("PHY.md", "# PHY\n\n## 保留課題\n\n" + content + "\n") status, output = self.run_lint(path) self.assertEqual(status, 1) self.assertIn(expected, output) def test_management_sections_have_one_order(self): path = self.write("PHY.md", "## 未確認事項\n\n(なし)\n\n## 検討済み・非採用\n\n## 未確認事項\n") status, output = self.run_lint(path) self.assertEqual(status, 1) self.assertIn("順に置く", output) self.assertIn("## 未確認事項 が重複", output) def test_decisions_accept_completion_but_tagged_assumptions_require_evidence(self): text = "## 未確認事項\n\n### P-1 操作方式\n- 完了条件: 操作方式を決める\n" path = self.write("PHY.md", text) self.assertEqual(self.run_lint(path)[0], 0) path.write_text("本文 [推測:P-1]\n\n" + text, encoding="utf-8") status, output = self.run_lint(path) self.assertEqual(status, 1) self.assertIn("格上げ条件", output) path.write_text("本文 [推測:P-1]\n\n" + text.replace("完了条件", "格上げ条件"), encoding="utf-8") self.assertEqual(self.run_lint(path)[0], 0) def test_absolute_journal_paths_keep_journal_checks(self): path = self.write("journal/bad.md", "# 調査\n\n[仕様](../PHY.md)\n") status, output = self.run_lint(path) self.assertEqual(status, 1) self.assertIn("journal のファイル名", output) self.assertIn("journal から他ファイルへのリンク", output) def test_backlog_is_not_prose_linted_but_cannot_link_to_journal(self): self.write("journal/2026-09-22-decision.md", "## 結論\n") path = self.write("backlog/README.md", "## PHY\n" + backlog_item() + "\n候補はA案。\n[履歴](../journal/2026-09-22-decision.md)\n") status, output = self.run_lint(path) self.assertEqual(status, 1) self.assertIn("journal へのリンク", output) self.assertIn("WARN 0", output) def test_code_examples_do_not_create_real_sections_or_tags(self): path = self.write("PHY.md", "```markdown\n## 保留課題\n[推測:P-9]\n```\n") status, output = self.run_lint(path) self.assertEqual(status, 0, output) def test_missing_explicit_file_is_an_error(self): status, output = self.run_lint(self.root / "missing.md") self.assertEqual(status, 1) self.assertIn("対象ファイルが見つからない", output) if __name__ == "__main__": unittest.main()