Skip to content

Commit e2bfc7d

Browse files
AlexStocksOmX
andcommitted
fix(ci): close validator review gaps
Disable checkout credential persistence and make the SDD validator fail closed without tracebacks when either registry artifact is absent. Tighten the deprecated Issue-keyword match, derive the baseline mutation from current front matter, and add scoped Markdown failure-path coverage. The self-test now exercises 25 negative mutations plus lifecycle and ordinary-prose positive paths. Constraint: Preserve the 20-artifact WP0 scope and all runtime behavior; change only the planning-doc CI job and validator robustness. Confidence: high Scope-risk: narrow Tested: Windows and WSL validator self-tests; Windows and WSL main validation; Python AST; GitHub Actions YAML parse; external PR #414 probe; staged diff check. Not-tested: Fresh GitHub CI and CodeRabbit rereview must run on the pushed Head. Co-authored-by: OmX <omx@oh-my-codex.dev> Signed-off-by: Xin.Zh <alexstocks@foxmail.com>
1 parent e6a931b commit e2bfc7d

2 files changed

Lines changed: 125 additions & 15 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ jobs:
4040
- uses: actions/checkout@v7
4141
with:
4242
fetch-depth: 0
43+
persist-credentials: false
4344

4445
- name: Validate failure paths
4546
run: python3 scripts/validate_sdd.py --self-test

scripts/validate_sdd.py

Lines changed: 124 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -218,10 +218,13 @@ def scoped_decision_references(sdd: str, errors: list[str]) -> set[str]:
218218

219219

220220
def 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) + "\nPart 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) + "\nPart 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

880989
def main() -> int:

0 commit comments

Comments
 (0)