@@ -218,10 +218,13 @@ def scoped_decision_references(sdd: str, errors: list[str]) -> set[str]:
218218
219219
220220def validate_registries (root : Path , sdd : str , errors : list [str ]) -> tuple [int , int ]:
221- requirements = strip_markdown_noncontract (
222- read_text (root / ".planning/REQUIREMENTS.md" )
223- )
224- decisions = strip_markdown_noncontract (read_text (root / ".planning/DECISIONS.md" ))
221+ requirements_path = root / ".planning/REQUIREMENTS.md"
222+ decisions_path = root / ".planning/DECISIONS.md"
223+ if not requirements_path .is_file () or not decisions_path .is_file ():
224+ return 0 , 0
225+
226+ requirements = strip_markdown_noncontract (read_text (requirements_path ))
227+ decisions = strip_markdown_noncontract (read_text (decisions_path ))
225228
226229 requirement_definitions = REQ_DEFINITION .findall (requirements )
227230 decision_definitions = DECISION_DEFINITION .findall (decisions )
@@ -533,9 +536,17 @@ def validate_artifacts(
533536 )
534537
535538
536- def validate_markdown (root : Path , errors : list [str ]) -> None :
537- markdown_paths = [root / path for path in EXPECTED_WP0_ARTIFACTS if path .endswith (".md" )]
538- for path in markdown_paths :
539+ def validate_markdown (
540+ root : Path ,
541+ errors : list [str ],
542+ relative_paths : tuple [str , ...] | None = None ,
543+ ) -> None :
544+ if relative_paths is None :
545+ relative_paths = tuple (
546+ path for path in EXPECTED_WP0_ARTIFACTS if path .endswith (".md" )
547+ )
548+ for relative_path in relative_paths :
549+ path = root / relative_path
539550 if not path .is_file ():
540551 continue
541552 text = read_text (path )
@@ -569,7 +580,10 @@ def validate_governance_terms(root: Path, errors: list[str]) -> None:
569580 if not path .is_file ():
570581 continue
571582 text = read_text (path )
572- if "Part of" in text :
583+ if re .search (
584+ r"(?mi)\bPart of\b\s*:?\s*(?:\[#\d+\]\(|#\d+|https://github\.com/)" ,
585+ text ,
586+ ):
573587 errors .append (f"deprecated partial-Issue keyword remains in { relative } " )
574588
575589 milestone_pattern = re .compile (
@@ -598,6 +612,7 @@ def validate(
598612 * ,
599613 check_git_diff : bool = True ,
600614 check_markdown : bool = True ,
615+ markdown_paths : tuple [str , ...] | None = None ,
601616) -> tuple [list [str ], dict [str , object ]]:
602617 errors : list [str ] = []
603618 sdd_path = root / ".planning/SDD.md"
@@ -616,7 +631,7 @@ def validate(
616631 invariant_count = validate_invariants (sdd , errors )
617632 validate_artifacts (root , sdd , fields , errors , check_git_diff )
618633 if check_markdown :
619- validate_markdown (root , errors )
634+ validate_markdown (root , errors , markdown_paths )
620635 validate_governance_terms (root , errors )
621636 summary : dict [str , object ] = {
622637 "authority" : fields .get ("authority" ),
@@ -644,12 +659,24 @@ def copy_contract(root: Path, destination: Path) -> None:
644659 shutil .copy2 (source , target )
645660
646661
647- def expect_failure (root : Path , mutation , expected_fragment : str ) -> None :
662+ def expect_failure (
663+ root : Path ,
664+ mutation ,
665+ expected_fragment : str ,
666+ * ,
667+ check_markdown : bool = False ,
668+ markdown_paths : tuple [str , ...] | None = None ,
669+ ) -> None :
648670 with tempfile .TemporaryDirectory (prefix = "kiwi-sdd-test-" ) as temporary :
649671 candidate = Path (temporary )
650672 copy_contract (root , candidate )
651673 mutation (candidate )
652- errors , _ = validate (candidate , check_git_diff = False , check_markdown = False )
674+ errors , _ = validate (
675+ candidate ,
676+ check_git_diff = False ,
677+ check_markdown = check_markdown ,
678+ markdown_paths = markdown_paths ,
679+ )
653680 if not any (expected_fragment in error for error in errors ):
654681 raise AssertionError (
655682 f"mutation did not fail for { expected_fragment !r} ; errors={ errors } "
@@ -661,6 +688,16 @@ def run_self_tests(root: Path) -> None:
661688 if errors :
662689 raise AssertionError (f"baseline contract must pass before mutations: { errors } " )
663690
691+ for relative in (
692+ ".planning/REQUIREMENTS.md" ,
693+ ".planning/DECISIONS.md" ,
694+ ):
695+ expect_failure (
696+ root ,
697+ lambda candidate , path = relative : (candidate / path ).unlink (),
698+ f"missing WP0 artifact: { relative } " ,
699+ )
700+
664701 expect_failure (
665702 root ,
666703 lambda candidate : (candidate / ".planning/SDD.md" ).write_text (
@@ -684,6 +721,43 @@ def duplicate_requirement(candidate: Path) -> None:
684721
685722 expect_failure (root , duplicate_requirement , "duplicate requirement definitions" )
686723
724+ def add_deprecated_issue_keyword (candidate : Path ) -> None :
725+ path = candidate / "docs/prd.md"
726+ path .write_text (
727+ read_text (path ) + "\n Part of: #413\n " ,
728+ encoding = "utf-8" ,
729+ )
730+
731+ expect_failure (
732+ root ,
733+ add_deprecated_issue_keyword ,
734+ "deprecated partial-Issue keyword remains" ,
735+ )
736+
737+ with tempfile .TemporaryDirectory (prefix = "kiwi-sdd-prose-" ) as temporary :
738+ candidate = Path (temporary )
739+ copy_contract (root , candidate )
740+ path = candidate / "docs/prd.md"
741+ path .write_text (
742+ read_text (path ) + "\n Part of the request path remains synchronous.\n " ,
743+ encoding = "utf-8" ,
744+ )
745+ prose_errors , _ = validate (
746+ candidate ,
747+ check_git_diff = False ,
748+ check_markdown = False ,
749+ )
750+ deprecated_errors = [
751+ error
752+ for error in prose_errors
753+ if "deprecated partial-Issue keyword" in error
754+ ]
755+ if deprecated_errors :
756+ raise AssertionError (
757+ "ordinary prose must not be treated as an Issue relationship: "
758+ f"{ deprecated_errors } "
759+ )
760+
687761 def indent_requirement_definition (candidate : Path ) -> None :
688762 path = candidate / ".planning/REQUIREMENTS.md"
689763 text = read_text (path )
@@ -787,13 +861,45 @@ def remove_scoped_artifact(candidate: Path) -> None:
787861
788862 expect_failure (root , remove_scoped_artifact , "WP0 SDD scope differs" )
789863
864+ def add_broken_relative_link (candidate : Path ) -> None :
865+ path = candidate / ".planning/KANBAN.md"
866+ path .write_text (
867+ read_text (path ) + "\n [broken](missing-contract.md)\n " ,
868+ encoding = "utf-8" ,
869+ )
870+
871+ expect_failure (
872+ root ,
873+ add_broken_relative_link ,
874+ "broken relative link in .planning/KANBAN.md" ,
875+ check_markdown = True ,
876+ markdown_paths = (".planning/KANBAN.md" ,),
877+ )
878+
879+ def add_unpaired_fence (candidate : Path ) -> None :
880+ path = candidate / ".planning/KANBAN.md"
881+ path .write_text (read_text (path ) + "\n ```text\n " , encoding = "utf-8" )
882+
883+ expect_failure (
884+ root ,
885+ add_unpaired_fence ,
886+ "unpaired ``` fence in .planning/KANBAN.md" ,
887+ check_markdown = True ,
888+ markdown_paths = (".planning/KANBAN.md" ,),
889+ )
890+
790891 def break_baseline (candidate : Path ) -> None :
791892 path = candidate / ".planning/SDD.md"
792- text = read_text ( path ). replace (
793- " baseline_ref: 0c4795ec716299598686fc7c5e0fac03a30e044d " ,
893+ text , replacements = re . subn (
894+ r"(?m)^ baseline_ref: [0-9a-f]{40}$ " ,
794895 "baseline_ref: deadbeef" ,
795- 1 ,
896+ read_text (path ),
897+ count = 1 ,
796898 )
899+ if replacements != 1 :
900+ raise AssertionError (
901+ "baseline mutation requires exactly one full baseline_ref SHA"
902+ )
797903 path .write_text (text , encoding = "utf-8" )
798904
799905 expect_failure (root , break_baseline , "baseline_ref must be a full" )
@@ -874,7 +980,10 @@ def remove_live_gate(candidate: Path) -> None:
874980 if lifecycle_errors :
875981 raise AssertionError (f"implemented lifecycle state must remain valid: { lifecycle_errors } " )
876982
877- print ("SDD validator self-tests passed (20 failure-path mutations, 1 lifecycle transition)" )
983+ print (
984+ "SDD validator self-tests passed "
985+ "(25 failure-path mutations, 1 lifecycle transition, 1 prose guard)"
986+ )
878987
879988
880989def main () -> int :
0 commit comments