"""文書の分類・参照・完了条件の回帰検査。標準ライブラリだけで実行する。""" import contextlib import importlib.util import io import tempfile import unittest from pathlib import Path from urllib.parse import quote 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" ) def issue_section(): return ( "## 未確認事項\n\n" "### P-1 動作の範囲\n\n- 完了条件: 範囲を決める\n\n" "### P-3 対応・状態管理\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) def test_issue_links_accept_same_file_cross_file_and_encoded_anchors(self): self.write("MAPレイヤー.md", "# MAP\n\n" + issue_section()) encoded = quote("MAPレイヤー.md") + "#" + quote("p-3-対応状態管理") path = self.write( "PHY.md", "# PHY\n\n範囲は未決。\n[P-1](#p-1-動作の範囲)で扱う。\n\n" "[MAPのP-3](MAPレイヤー.md#p-3-対応状態管理)へ。\n\n" f"[MAPのP-3]({encoded})へ。\n\n" + issue_section(), ) status, output = self.run_lint(path) self.assertEqual(status, 0, output) self.assertIn("ERROR 0 / WARN 0", output) def test_issue_links_reject_missing_file_heading_and_fragment(self): self.write("MAP.md", "# MAP\n\n" + issue_section()) for target, expected in ( ("missing.md#p-3-対応状態管理", "参照先ファイルがない"), ("MAP.md#p-3-古い題名", "参照先見出しがない"), ("MAP.md", "参照先見出しがない"), ): with self.subTest(target=target): path = self.write("PHY.md", f"[P-3]({target})\n") status, output = self.run_lint(path) self.assertEqual(status, 1, output) self.assertIn(expected, output) def test_issue_label_must_match_real_entry_in_unconfirmed_section(self): self.write("MAP.md", "# MAP\n\n### P-2 本文の見出し\n\n" + issue_section()) for link, expected in ( ("[P-3](MAP.md#p-1-動作の範囲)", "課題番号と参照先が一致しない"), ("[MAPのP-3](MAP.md#p-1-動作の範囲)", "課題番号と参照先が一致しない"), ("[P-2](MAP.md#p-2-本文の見出し)", "個別項目を指していない"), ("[P-3](MAP.md#未確認事項)", "個別項目を指していない"), ): with self.subTest(link=link): path = self.write("PHY.md", link + "\n") status, output = self.run_lint(path) self.assertEqual(status, 1, output) self.assertIn(expected, output) def test_duplicate_heading_slug_resolves_actual_target(self): self.write("MAP.md", "# MAP\n\n### P-1 動作の範囲\n\n" + issue_section()) path = self.write("PHY.md", "[P-1](MAP.md#p-1-動作の範囲-1)\n") self.assertEqual(self.run_lint(path)[0], 0) path.write_text("[P-1](MAP.md#p-1-動作の範囲)\n", encoding="utf-8") status, output = self.run_lint(path) self.assertEqual(status, 1, output) self.assertIn("個別項目を指していない", output) def test_unresolved_prose_warns_without_failing_or_modifying_documents(self): text = "# PHY\n\n方式は未定。P-3で検討する。\n\n" + issue_section() path = self.write("PHY.md", text) before = path.read_bytes() status, output = self.run_lint(path) self.assertEqual(status, 0, output) self.assertIn("リンクのない課題番号", output) self.assertIn("未決・未定に課題への参照がない", output) self.assertEqual(path.read_bytes(), before) def test_another_issue_link_does_not_hide_bare_id(self): path = self.write( "PHY.md", "P-3と[P-1](#p-1-動作の範囲)を参照。\n\n" + issue_section() ) status, output = self.run_lint(path) self.assertEqual(status, 0, output) self.assertIn("リンクのない課題番号がある(要確認): P-3", output) def test_unrelated_links_and_section_links_do_not_hide_unresolved_prose(self): for link in ("[仕様](PHY.md)", "[一覧](#未確認事項)", "[外部](https://example.com/)"): with self.subTest(link=link): path = self.write("PHY.md", f"範囲は未決。{link}\n\n" + issue_section()) status, output = self.run_lint(path) self.assertEqual(status, 0, output) self.assertIn("未決・未定に課題への参照がない", output) def test_broken_link_does_not_count_as_resolved_reference(self): path = self.write("PHY.md", "範囲は未決。\n[P-1](#p-1-missing)\n") status, output = self.run_lint(path) self.assertEqual(status, 1, output) self.assertIn("参照先見出しがない", output) self.assertIn("未決・未定に課題への参照がない", output) def test_paragraph_and_list_boundaries_do_not_borrow_links(self): for body in ( "範囲は未決。\n\n[P-1](#p-1-動作の範囲)\n", "- 範囲は未決。\n- [P-1](#p-1-動作の範囲)\n", "1. 範囲は未決。\n2. [P-1](#p-1-動作の範囲)\n", "範囲は未決。\n## 次の話\n[P-1](#p-1-動作の範囲)\n", ): with self.subTest(body=body): path = self.write("PHY.md", body + "\n" + issue_section()) status, output = self.run_lint(path) self.assertEqual(status, 0, output) self.assertIn("未決・未定に課題への参照がない", output) path = self.write( "PHY.md", "- 範囲は未決。\n [P-1](#p-1-動作の範囲)\n\n" + issue_section() ) self.assertIn("ERROR 0 / WARN 0", self.run_lint(path)[1]) def test_code_examples_external_links_and_assumptions_are_not_prose_warnings(self): path = self.write( "PHY.md", "\x60未決 P-8 [P-8](#missing)\x60\n\n" "\x60\x60\x60markdown\n未定 P-8 [P-8](#missing)\n\x60\x60\x60\n\n" "~~~markdown\n未定 P-8 [P-8](#missing)\n~~~\n\n" "[P-99](https://example.com/#p-99)\n\n" "範囲は未定 [推測:P-1]。\n\n" "## 未確認事項\n\n### P-1 調査\n\n- 格上げ条件: 検証する\n", ) status, output = self.run_lint(path) self.assertEqual(status, 0, output) self.assertIn("ERROR 0 / WARN 0", output) def test_management_sections_and_non_spec_files_are_exempt_from_prose_warnings(self): self.write("CLAUDE.md", "未決 P-8 [P-8](#missing)\n") self.write("journal/2026-09-22-decision.md", "## 結論\n\n未定 P-8\n") self.write("backlog/README.md", "## PHY\n\n" + backlog_item() + "\n未定 P-8\n") self.write("PHY.md", "# PHY\n\n" + issue_section() + "\n未定 P-8\n") status, output = self.run_lint() self.assertEqual(status, 0, output) self.assertIn("WARN 0", output) def test_links_inside_unconfirmed_items_are_still_checked(self): path = self.write("PHY.md", issue_section() + "\n[P-8](#missing)\n") status, output = self.run_lint(path) self.assertEqual(status, 1, output) self.assertIn("参照先見出しがない", output) def test_rejected_count_warning_does_not_select_entries_for_deletion(self): path = self.write( "PHY.md", "## 検討済み・非採用\n\n" + "".join(f"- 案{i} (journal/2026-09-22-decision.md)\n" for i in range(6)) ) before = path.read_bytes() status, output = self.run_lint(path) self.assertEqual(status, 0, output) self.assertIn("6 件(目安5件)", output) self.assertIn("再提案を防ぐ必要性", output) self.assertNotIn("古いものから落とす", output) self.assertEqual(path.read_bytes(), before) if __name__ == "__main__": unittest.main()