@ikon85/agent-workflow-kit 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/ask-matt/SKILL.md +139 -0
- package/.agents/skills/ask-matt/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/audit-skills/SKILL.md +145 -0
- package/.agents/skills/board-to-waves/SKILL.md +135 -0
- package/.agents/skills/code-review/SKILL.md +64 -0
- package/.agents/skills/codebase-design/DEEPENING.md +37 -0
- package/.agents/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
- package/.agents/skills/codebase-design/SKILL.md +114 -0
- package/.agents/skills/codebase-design/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/codex-adapter-sync/SKILL.md +151 -0
- package/.agents/skills/decision-gate/SKILL.md +67 -0
- package/.agents/skills/diagnose/SKILL.md +136 -0
- package/.agents/skills/diagnose/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
- package/.agents/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/.agents/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/.agents/skills/domain-modeling/SKILL.md +76 -0
- package/.agents/skills/domain-modeling/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/git-worktree-recover/SKILL.md +176 -0
- package/.agents/skills/grill-me/SKILL.md +54 -0
- package/.agents/skills/grill-me/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/grill-with-docs/ADR-FORMAT.md +47 -0
- package/.agents/skills/grill-with-docs/CONTEXT-FORMAT.md +60 -0
- package/.agents/skills/grill-with-docs/SKILL.md +137 -0
- package/.agents/skills/grill-with-docs/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/implement/SKILL.md +15 -0
- package/.agents/skills/implement/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/improve-codebase-architecture/ADR-FORMAT.md +47 -0
- package/.agents/skills/improve-codebase-architecture/CONTEXT-FORMAT.md +60 -0
- package/.agents/skills/improve-codebase-architecture/DEEPENING.md +37 -0
- package/.agents/skills/improve-codebase-architecture/HTML-REPORT.md +123 -0
- package/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +46 -0
- package/.agents/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
- package/.agents/skills/improve-codebase-architecture/SKILL.md +88 -0
- package/.agents/skills/improve-codebase-architecture/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/kit-release/SKILL.md +56 -0
- package/.agents/skills/local-ci/SKILL.md +94 -0
- package/.agents/skills/orchestrate-wave/SKILL.md +261 -0
- package/.agents/skills/orchestrate-wave/references/builder-contract.md +67 -0
- package/.agents/skills/prototype/LOGIC.md +79 -0
- package/.agents/skills/prototype/SKILL.md +30 -0
- package/.agents/skills/prototype/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/prototype/UI.md +112 -0
- package/.agents/skills/research/SKILL.md +12 -0
- package/.agents/skills/research/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/resolving-merge-conflicts/SKILL.md +14 -0
- package/.agents/skills/resolving-merge-conflicts/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/retro/SKILL.md +209 -0
- package/.agents/skills/scale-check/SKILL.md +141 -0
- package/.agents/skills/security-audit/SKILL.md +67 -0
- package/.agents/skills/setup-workflow/SKILL.md +187 -0
- package/.agents/skills/setup-workflow/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/setup-workflow/board-sync.md +111 -0
- package/.agents/skills/setup-workflow/code-review.md +26 -0
- package/.agents/skills/setup-workflow/domain.md +51 -0
- package/.agents/skills/setup-workflow/issue-tracker-github.md +35 -0
- package/.agents/skills/setup-workflow/issue-tracker-gitlab.md +34 -0
- package/.agents/skills/setup-workflow/issue-tracker-local.md +30 -0
- package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +44 -0
- package/.agents/skills/setup-workflow/spec-completeness-seed.md +44 -0
- package/.agents/skills/setup-workflow/spec-self-critique-seed.md +43 -0
- package/.agents/skills/setup-workflow/triage-labels.md +18 -0
- package/.agents/skills/setup-workflow/workflow-overview.md +33 -0
- package/.agents/skills/spec-self-critique/SKILL.md +152 -0
- package/.agents/skills/spec-self-critique/scenarios.md +82 -0
- package/.agents/skills/tdd/SKILL.md +120 -0
- package/.agents/skills/tdd/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/tdd/deep-modules.md +33 -0
- package/.agents/skills/tdd/interface-design.md +31 -0
- package/.agents/skills/tdd/mocking.md +59 -0
- package/.agents/skills/tdd/refactoring.md +10 -0
- package/.agents/skills/tdd/tests.md +61 -0
- package/.agents/skills/to-issues/SKILL.md +325 -0
- package/.agents/skills/to-issues/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/to-prd/PROGRAM-PRD-FORMAT.md +162 -0
- package/.agents/skills/to-prd/SKILL.md +180 -0
- package/.agents/skills/to-prd/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/to-waves/SKILL.md +260 -0
- package/.agents/skills/to-waves/SLICE-METADATA-FORMAT.md +74 -0
- package/.agents/skills/triage/AGENT-BRIEF.md +168 -0
- package/.agents/skills/triage/OUT-OF-SCOPE.md +101 -0
- package/.agents/skills/triage/SKILL.md +122 -0
- package/.agents/skills/triage/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/verify-spike/SKILL.md +39 -0
- package/.agents/skills/wayfinder/SKILL.md +127 -0
- package/.agents/skills/wayfinder/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/wrapup/SKILL.md +71 -0
- package/.agents/skills/wrapup/SOURCES.txt +6 -0
- package/.agents/skills/zoom-out/SKILL.md +7 -0
- package/.agents/skills/zoom-out/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/hooks/_hook_utils.py +184 -0
- package/.claude/hooks/drift-guard.py +176 -0
- package/.claude/hooks/skill-drift-hint.py +122 -0
- package/.claude/hooks/sync-board-status.py +191 -0
- package/.claude/skills/ask-matt/SKILL.md +139 -0
- package/.claude/skills/ask-matt/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/audit-skills/SKILL.md +145 -0
- package/.claude/skills/board-to-waves/SKILL.md +148 -0
- package/.claude/skills/code-review/SKILL.md +64 -0
- package/.claude/skills/codebase-design/DEEPENING.md +37 -0
- package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
- package/.claude/skills/codebase-design/SKILL.md +114 -0
- package/.claude/skills/codebase-design/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/codex-build/SKILL.md +126 -0
- package/.claude/skills/codex-build/THIRD-PARTY-NOTICES.md +35 -0
- package/.claude/skills/codex-review/SKILL.md +145 -0
- package/.claude/skills/codex-review/THIRD-PARTY-NOTICES.md +31 -0
- package/.claude/skills/decision-gate/SKILL.md +67 -0
- package/.claude/skills/diagnose/SKILL.md +136 -0
- package/.claude/skills/diagnose/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
- package/.claude/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/.claude/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/.claude/skills/domain-modeling/SKILL.md +76 -0
- package/.claude/skills/domain-modeling/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/git-guardrails-claude-code/SKILL.md +96 -0
- package/.claude/skills/git-guardrails-claude-code/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/git-guardrails-claude-code/scripts/block-dangerous-git.sh +28 -0
- package/.claude/skills/git-worktree-recover/SKILL.md +182 -0
- package/.claude/skills/grill-me/SKILL.md +54 -0
- package/.claude/skills/grill-me/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/grill-me-codex/SKILL.md +158 -0
- package/.claude/skills/grill-me-codex/THIRD-PARTY-NOTICES.md +32 -0
- package/.claude/skills/grill-with-docs/ADR-FORMAT.md +47 -0
- package/.claude/skills/grill-with-docs/CONTEXT-FORMAT.md +60 -0
- package/.claude/skills/grill-with-docs/SKILL.md +137 -0
- package/.claude/skills/grill-with-docs/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/grill-with-docs-codex/ADR-FORMAT.md +47 -0
- package/.claude/skills/grill-with-docs-codex/CONTEXT-FORMAT.md +60 -0
- package/.claude/skills/grill-with-docs-codex/SKILL.md +233 -0
- package/.claude/skills/grill-with-docs-codex/THIRD-PARTY-NOTICES.md +33 -0
- package/.claude/skills/implement/SKILL.md +15 -0
- package/.claude/skills/implement/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/improve-codebase-architecture/ADR-FORMAT.md +47 -0
- package/.claude/skills/improve-codebase-architecture/CONTEXT-FORMAT.md +60 -0
- package/.claude/skills/improve-codebase-architecture/DEEPENING.md +37 -0
- package/.claude/skills/improve-codebase-architecture/HTML-REPORT.md +123 -0
- package/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +46 -0
- package/.claude/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
- package/.claude/skills/improve-codebase-architecture/SKILL.md +88 -0
- package/.claude/skills/improve-codebase-architecture/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/kit-release/SKILL.md +56 -0
- package/.claude/skills/local-ci/SKILL.md +94 -0
- package/.claude/skills/orchestrate-wave/SKILL.md +269 -0
- package/.claude/skills/orchestrate-wave/references/builder-contract.md +67 -0
- package/.claude/skills/prototype/LOGIC.md +79 -0
- package/.claude/skills/prototype/SKILL.md +30 -0
- package/.claude/skills/prototype/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/prototype/UI.md +112 -0
- package/.claude/skills/research/SKILL.md +12 -0
- package/.claude/skills/research/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/resolving-merge-conflicts/SKILL.md +14 -0
- package/.claude/skills/resolving-merge-conflicts/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/retro/SKILL.md +209 -0
- package/.claude/skills/scale-check/SKILL.md +141 -0
- package/.claude/skills/security-audit/SKILL.md +67 -0
- package/.claude/skills/setup-pre-commit/SKILL.md +104 -0
- package/.claude/skills/setup-pre-commit/scripts/pre-commit.template.sh +35 -0
- package/.claude/skills/setup-workflow/SKILL.md +187 -0
- package/.claude/skills/setup-workflow/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/setup-workflow/board-sync.md +111 -0
- package/.claude/skills/setup-workflow/code-review.md +26 -0
- package/.claude/skills/setup-workflow/domain.md +51 -0
- package/.claude/skills/setup-workflow/issue-tracker-github.md +35 -0
- package/.claude/skills/setup-workflow/issue-tracker-gitlab.md +34 -0
- package/.claude/skills/setup-workflow/issue-tracker-local.md +30 -0
- package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +44 -0
- package/.claude/skills/setup-workflow/spec-completeness-seed.md +44 -0
- package/.claude/skills/setup-workflow/spec-self-critique-seed.md +43 -0
- package/.claude/skills/setup-workflow/triage-labels.md +18 -0
- package/.claude/skills/setup-workflow/workflow-overview.md +33 -0
- package/.claude/skills/skill-manifest.json +389 -0
- package/.claude/skills/spec-self-critique/SKILL.md +152 -0
- package/.claude/skills/spec-self-critique/scenarios.md +82 -0
- package/.claude/skills/tdd/SKILL.md +120 -0
- package/.claude/skills/tdd/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/tdd/deep-modules.md +33 -0
- package/.claude/skills/tdd/interface-design.md +31 -0
- package/.claude/skills/tdd/mocking.md +59 -0
- package/.claude/skills/tdd/refactoring.md +10 -0
- package/.claude/skills/tdd/tests.md +61 -0
- package/.claude/skills/to-issues/SKILL.md +325 -0
- package/.claude/skills/to-issues/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/to-prd/PROGRAM-PRD-FORMAT.md +162 -0
- package/.claude/skills/to-prd/SKILL.md +180 -0
- package/.claude/skills/to-prd/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/to-waves/SKILL.md +260 -0
- package/.claude/skills/to-waves/SLICE-METADATA-FORMAT.md +74 -0
- package/.claude/skills/triage/AGENT-BRIEF.md +168 -0
- package/.claude/skills/triage/OUT-OF-SCOPE.md +101 -0
- package/.claude/skills/triage/SKILL.md +122 -0
- package/.claude/skills/triage/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/verify-spike/SKILL.md +39 -0
- package/.claude/skills/wayfinder/SKILL.md +127 -0
- package/.claude/skills/wayfinder/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/wrapup/SKILL.md +81 -0
- package/.claude/skills/wrapup/SOURCES.txt +6 -0
- package/.claude/skills/write-a-skill/GLOSSARY.md +201 -0
- package/.claude/skills/write-a-skill/SKILL.md +88 -0
- package/.claude/skills/write-a-skill/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/zoom-out/SKILL.md +7 -0
- package/.claude/skills/zoom-out/THIRD-PARTY-NOTICES.md +29 -0
- package/LICENSE +28 -0
- package/PROVENANCE.md +39 -0
- package/README.md +619 -0
- package/agent-workflow-kit.package.json +1939 -0
- package/docs/agents/board-sync.md +71 -0
- package/docs/agents/code-review.md +14 -0
- package/docs/agents/domain.md +7 -0
- package/docs/agents/issue-tracker.md +12 -0
- package/docs/agents/security-audit-runbook-template.md +205 -0
- package/docs/agents/skills/orchestrate-wave.md +13 -0
- package/docs/agents/skills/spec-self-critique.md +18 -0
- package/docs/agents/triage-labels.md +10 -0
- package/docs/agents/wave-anchor-template.md +88 -0
- package/docs/conventions/spec-completeness.md +23 -0
- package/docs/index.html +56 -0
- package/docs/methodology.html +488 -0
- package/docs/methodology.svg +93 -0
- package/docs/workflow.png +0 -0
- package/package.json +40 -0
- package/scripts/anchor_table.py +343 -0
- package/scripts/board-sync.py +1232 -0
- package/scripts/board_config.py +147 -0
- package/scripts/board_fields.py +192 -0
- package/scripts/build-kit.mjs +116 -0
- package/scripts/build-kit.test.mjs +59 -0
- package/scripts/check-kit-staleness.mjs +45 -0
- package/scripts/check-kit-staleness.test.mjs +19 -0
- package/scripts/execute-ready-check.py +516 -0
- package/scripts/issue_deps.py +79 -0
- package/scripts/kit-release.mjs +100 -0
- package/scripts/kit-release.test.mjs +67 -0
- package/scripts/lib/audit-refs.mjs +146 -0
- package/scripts/lib/scrub.mjs +113 -0
- package/scripts/lib/scrub.test.mjs +187 -0
- package/scripts/loc_offender_core.py +196 -0
- package/scripts/loc_offender_gate.py +182 -0
- package/scripts/node_kind.py +68 -0
- package/scripts/portability_profile_scan.py +53 -0
- package/scripts/pr-body-check.py +263 -0
- package/scripts/program_graph.py +125 -0
- package/scripts/program_graph_parse.py +275 -0
- package/scripts/program_graph_validate.py +256 -0
- package/scripts/program_sync.py +160 -0
- package/scripts/release-delta-guard.mjs +96 -0
- package/scripts/release-delta-guard.test.mjs +53 -0
- package/scripts/test_board_sync_create_idempotency.py +104 -0
- package/scripts/test_dist_kit_smoke.py +199 -0
- package/scripts/test_kit_docs_language_census.py +178 -0
- package/scripts/test_skill_code_review_seed.py +89 -0
- package/scripts/test_skill_frontmatter_lint.py +226 -0
- package/scripts/test_skill_gh_lint.py +124 -0
- package/scripts/test_skill_language_census.py +254 -0
- package/scripts/test_skill_portability_lint.py +773 -0
- package/scripts/test_skill_publish_audit.py +300 -0
- package/scripts/test_skill_selfcontainment_lint.py +255 -0
- package/scripts/test_skill_setup_workflow_seeds.py +200 -0
- package/scripts/test_skill_stale_name_lint.py +126 -0
- package/scripts/test_skill_surface_refs.py +300 -0
- package/scripts/test_skill_trailing_artifact_lint.py +71 -0
- package/scripts/wrapup-land.py +573 -0
- package/src/cli.mjs +70 -0
- package/src/commands/diff.mjs +6 -0
- package/src/commands/init.mjs +59 -0
- package/src/commands/uninstall.mjs +43 -0
- package/src/commands/update.mjs +95 -0
- package/src/lib/atomicWrite.mjs +49 -0
- package/src/lib/bundle.mjs +133 -0
- package/src/lib/hash.mjs +13 -0
- package/src/lib/manifest.mjs +58 -0
- package/src/lib/sentinel.mjs +20 -0
- package/src/lib/settings.mjs +23 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
pr-body-check.py — mechanical guard for the PR-body conventions that were until
|
|
4
|
+
now prose-only.
|
|
5
|
+
|
|
6
|
+
Called by `wrapup` Step 0c AFTER the PR has been created/reused, BEFORE the
|
|
7
|
+
merge gate. Turns three instruction-only rules into a check that actually fires:
|
|
8
|
+
|
|
9
|
+
1. Anker-Slice (Issue HAS a parent) → body MUST contain `Part of #<parent>`
|
|
10
|
+
and MUST NOT contain a close-keyword on the **parent anchor** number
|
|
11
|
+
(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved, case-insens.)
|
|
12
|
+
— the incident: `closes #<anker>` on a slice-PR closed the wave anchor.
|
|
13
|
+
A `closes #<foreign-leaf>` (any number ≠ parent) stays ALLOWED.
|
|
14
|
+
2. Atomar Leaf (Issue has NO parent / FREI) → body MUST contain an ACTIVE
|
|
15
|
+
`closes #<issue>` — not inside a code span / backticks (else GitHub's
|
|
16
|
+
auto-close never fires, wrapup Step 5b lesson).
|
|
17
|
+
3. `**Retro:**`-Pflichtzeile present in one of the Slice-7 forms
|
|
18
|
+
(`gefahren …` | `übersprungen …`), with a space after the marker.
|
|
19
|
+
|
|
20
|
+
Scope: this script checks ONLY the closes/Part-of anchor rule and the
|
|
21
|
+
`**Retro:**` line. It does NOT parse or validate `annahme-drift` markers
|
|
22
|
+
(those are prose-/judgment-driven in wrapup Step 0c + 5e, deliberately not
|
|
23
|
+
mechanised — R2-F6). The annahme-drift block therefore runs BEFORE this
|
|
24
|
+
check in wrapup Step 0c so the body the script sees is final.
|
|
25
|
+
|
|
26
|
+
Exit codes:
|
|
27
|
+
0 — green (no violations)
|
|
28
|
+
1 — violation(s) found → wrapup STOPs, fix body via `gh pr edit --body-file`
|
|
29
|
+
2 — not checkable (no issue-number derivable from the branch, or PR body
|
|
30
|
+
unavailable) → warn, do NOT block (fail-open, like drift-guard).
|
|
31
|
+
|
|
32
|
+
The pure functions below carry the logic + the unit tests; gh/git access is a
|
|
33
|
+
thin shell. NOT a hook — `wrapup` invokes it (Design).
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
pr-body-check.py [--branch <name>] [--issue <n>] [--parent <n>|FREI]
|
|
37
|
+
[--body-file <path>]
|
|
38
|
+
All flags optional; unset → derived from the current branch + live PR via gh.
|
|
39
|
+
|
|
40
|
+
Audit log: .claude/logs/pr-body-check.log
|
|
41
|
+
"""
|
|
42
|
+
import argparse
|
|
43
|
+
import re
|
|
44
|
+
import subprocess
|
|
45
|
+
import sys
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
49
|
+
from board_config import ConfigError, load_board_config # noqa: E402
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
_CFG = load_board_config()
|
|
53
|
+
except ConfigError as exc:
|
|
54
|
+
print(f"[FAIL] pr-body-check: Board-Profil nicht verfügbar — {exc}", file=sys.stderr)
|
|
55
|
+
sys.exit(1)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _spaced(marker: str) -> str:
|
|
59
|
+
"""Escape a marker, letting any run of whitespace inside it match `\\s+`."""
|
|
60
|
+
return r"\s+".join(re.escape(tok) for tok in marker.split())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
LOG_DIR = Path(".claude/logs")
|
|
64
|
+
LOG_NAME = "pr-body-check"
|
|
65
|
+
|
|
66
|
+
# Branch prefixes + the `Part of` / `**Retro:**` markers are PROJECT conventions
|
|
67
|
+
# → read from the board profile. GitHub's auto-close keywords are a PLATFORM
|
|
68
|
+
# constant (GitHub only auto-closes on exactly this set), so they stay hardcoded
|
|
69
|
+
# here — configuring them would be a footgun, not a portability win.
|
|
70
|
+
BRANCH_ISSUE_RE = re.compile(
|
|
71
|
+
r"^(?:" + "|".join(re.escape(p) for p in _CFG["branchPrefixes"]) + r")/(\d+)-")
|
|
72
|
+
CLOSE_KEYWORDS = r"close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved"
|
|
73
|
+
RETRO_RE = re.compile(
|
|
74
|
+
re.escape(_CFG["prMarkers"]["retroMarker"]) + r"\s+("
|
|
75
|
+
+ "|".join(re.escape(v) for v in _CFG["prMarkers"]["retroValues"]) + r")\b",
|
|
76
|
+
re.IGNORECASE)
|
|
77
|
+
PART_OF_RE_SRC = _spaced(_CFG["prMarkers"]["partOf"])
|
|
78
|
+
INLINE_CODE_RE = re.compile(r"`[^`]*`")
|
|
79
|
+
FENCED_CODE_RE = re.compile(r"```.*?```", re.DOTALL)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def log(msg: str) -> None:
|
|
83
|
+
try:
|
|
84
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
from datetime import datetime
|
|
86
|
+
with (LOG_DIR / f"{LOG_NAME}.log").open("a", encoding="utf-8") as f:
|
|
87
|
+
f.write(f"{datetime.now().isoformat(timespec='seconds')} {msg}\n")
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --- pure parsers / checks --------------------------------------------------
|
|
93
|
+
def parse_issue_from_branch(branch: str):
|
|
94
|
+
"""Issue number from `feat/<n>-slug` (also fix/chore/docs), or None."""
|
|
95
|
+
m = BRANCH_ISSUE_RE.match(branch or "")
|
|
96
|
+
return int(m.group(1)) if m else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def strip_code(text: str) -> str:
|
|
100
|
+
"""Remove fenced + inline code spans so a keyword that only lives inside
|
|
101
|
+
backticks does not count as active (GitHub ignores it for auto-close)."""
|
|
102
|
+
return INLINE_CODE_RE.sub(" ", FENCED_CODE_RE.sub(" ", text or ""))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _close_on(body: str, n: int) -> bool:
|
|
106
|
+
"""True if an ACTIVE close-keyword targets #n (outside code spans).
|
|
107
|
+
|
|
108
|
+
Matches `closes #n`, the colon form `closes: #n` (GitHub auto-closes on both
|
|
109
|
+
— the colon form was the R2-F3 bypass on the anchor guard), AND the
|
|
110
|
+
full-URL form `closes https://github.com/<owner>/<repo>/issues/<n>` (GitHub
|
|
111
|
+
auto-closes on this too — exactly the class bypass this script exists
|
|
112
|
+
to catch).
|
|
113
|
+
"""
|
|
114
|
+
target = rf"(?:#0*{n}(?!\d)|https?://\S+/issues/0*{n}(?!\d))"
|
|
115
|
+
pat = re.compile(
|
|
116
|
+
rf"\b(?:{CLOSE_KEYWORDS})(?::\s*|\s+){target}", re.IGNORECASE)
|
|
117
|
+
return bool(pat.search(strip_code(body)))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _part_of(body: str, n: int) -> bool:
|
|
121
|
+
return bool(re.search(
|
|
122
|
+
rf"\b{PART_OF_RE_SRC}(?::\s*|\s+)#0*{n}(?!\d)", body or "", re.IGNORECASE))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def check_pr_body(body: str, issue: int, parent, is_anchor: bool = False):
|
|
126
|
+
"""Return a list of violation strings ([] = green).
|
|
127
|
+
|
|
128
|
+
parent is the anchor issue number, or None for an atomar Leaf.
|
|
129
|
+
is_anchor: the branch issue IS itself a Wellen-Anker (type:cluster) —
|
|
130
|
+
Wave-PR-Fall: ein PR auf `feat/<anker#>-…` closed die Leaf-Issues,
|
|
131
|
+
nie den Anker selbst.
|
|
132
|
+
"""
|
|
133
|
+
violations = []
|
|
134
|
+
body = body or ""
|
|
135
|
+
|
|
136
|
+
if parent is not None:
|
|
137
|
+
# Anker-Slice
|
|
138
|
+
if not _part_of(body, parent):
|
|
139
|
+
violations.append(
|
|
140
|
+
f"Anker-Slice: Body braucht `Part of #{parent}` (fehlt).")
|
|
141
|
+
if _close_on(body, parent):
|
|
142
|
+
violations.append(
|
|
143
|
+
f"Anker-Slice: Body enthält ein close-Keyword auf den Anker "
|
|
144
|
+
f"#{parent} — das schließt den Wellen-Anker beim Merge verfrüht "
|
|
145
|
+
f". `Part of #{parent}` nutzen, kein closes.")
|
|
146
|
+
elif is_anchor:
|
|
147
|
+
# Wave-PR auf dem Anker-Branch selbst (Branch trägt die Anker-Nummer)
|
|
148
|
+
if not _part_of(body, issue):
|
|
149
|
+
violations.append(
|
|
150
|
+
f"Wave-PR auf Anker-Branch: Body braucht `Part of #{issue}` (fehlt).")
|
|
151
|
+
if _close_on(body, issue):
|
|
152
|
+
violations.append(
|
|
153
|
+
f"Wave-PR: Body enthält ein close-Keyword auf den Anker #{issue} "
|
|
154
|
+
f"— das schließt den Anker beim Merge. Entfernen; die "
|
|
155
|
+
f"Leaf-Issues per `closes #<leaf>` schließen.")
|
|
156
|
+
else:
|
|
157
|
+
# Atomar Leaf
|
|
158
|
+
if not _close_on(body, issue):
|
|
159
|
+
violations.append(
|
|
160
|
+
f"Leaf-PR: aktives `closes #{issue}` fehlt (oder steht in "
|
|
161
|
+
f"Backticks → GitHub-Auto-Close greift dann nicht).")
|
|
162
|
+
|
|
163
|
+
if not RETRO_RE.search(body):
|
|
164
|
+
violations.append(
|
|
165
|
+
"Pflichtzeile `**Retro:** gefahren — …` oder "
|
|
166
|
+
"`**Retro:** übersprungen — <Grund>` fehlt.")
|
|
167
|
+
|
|
168
|
+
return violations
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# --- thin git/gh shell ------------------------------------------------------
|
|
172
|
+
def _run(cmd, timeout=15):
|
|
173
|
+
try:
|
|
174
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
175
|
+
return r.returncode, r.stdout.strip()
|
|
176
|
+
except Exception:
|
|
177
|
+
return -1, ""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def current_branch():
|
|
181
|
+
rc, out = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"])
|
|
182
|
+
return out if rc == 0 and out else None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def fetch_parent(number: int):
|
|
186
|
+
"""Parent issue number via board-sync.py, or None (FREI = atomar)."""
|
|
187
|
+
rc, out = _run(["python3", "scripts/board-sync.py", "parent-of", str(number)])
|
|
188
|
+
if rc != 0 or not out or out.strip() == "FREI":
|
|
189
|
+
return None
|
|
190
|
+
try:
|
|
191
|
+
return int(out.strip())
|
|
192
|
+
except ValueError:
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def fetch_pr_body(branch: str):
|
|
197
|
+
rc, out = _run(["gh", "pr", "view", branch, "--json", "body", "-q", ".body"])
|
|
198
|
+
return out if rc == 0 else None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def fetch_is_anchor(number: int) -> bool:
|
|
202
|
+
"""True wenn das Issue selbst ein Wellen-Anker ist (Label type:cluster)."""
|
|
203
|
+
rc, out = _run(["gh", "issue", "view", str(number),
|
|
204
|
+
"--json", "labels", "-q", ".labels[].name"])
|
|
205
|
+
if rc != 0:
|
|
206
|
+
return False # fail-open in den Leaf-Pfad (bisheriges Verhalten)
|
|
207
|
+
return "type:cluster" in out.splitlines()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def main() -> int:
|
|
211
|
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
|
|
212
|
+
ap.add_argument("--branch", help="default: current git branch")
|
|
213
|
+
ap.add_argument("--issue", type=int, help="override branch-derived issue #")
|
|
214
|
+
ap.add_argument("--parent", help="override parent (number or FREI)")
|
|
215
|
+
ap.add_argument("--body-file", help="read PR body from file instead of gh")
|
|
216
|
+
args = ap.parse_args()
|
|
217
|
+
|
|
218
|
+
branch = args.branch or current_branch()
|
|
219
|
+
issue = args.issue if args.issue is not None else (
|
|
220
|
+
parse_issue_from_branch(branch) if branch else None)
|
|
221
|
+
if issue is None:
|
|
222
|
+
print(f"[WARN] pr-body-check: keine Issue-Nummer aus Branch '{branch}' "
|
|
223
|
+
f"ableitbar — übersprungen (kein Block).")
|
|
224
|
+
log(f"branch={branch} no-issue → exit 2")
|
|
225
|
+
return 2
|
|
226
|
+
|
|
227
|
+
if args.body_file:
|
|
228
|
+
try:
|
|
229
|
+
body = Path(args.body_file).read_text(encoding="utf-8")
|
|
230
|
+
except Exception as e:
|
|
231
|
+
print(f"[WARN] pr-body-check: --body-file unlesbar ({e}) — übersprungen.")
|
|
232
|
+
return 2
|
|
233
|
+
else:
|
|
234
|
+
body = fetch_pr_body(branch)
|
|
235
|
+
if body is None:
|
|
236
|
+
print(f"[WARN] pr-body-check: PR-Body für '{branch}' nicht abrufbar "
|
|
237
|
+
f"(kein PR? gh/Netz?) — übersprungen (kein Block).")
|
|
238
|
+
log(f"branch={branch} issue={issue} no-pr-body → exit 2")
|
|
239
|
+
return 2
|
|
240
|
+
|
|
241
|
+
if args.parent is not None:
|
|
242
|
+
parent = None if args.parent.strip().upper() == "FREI" else int(args.parent)
|
|
243
|
+
else:
|
|
244
|
+
parent = fetch_parent(issue)
|
|
245
|
+
|
|
246
|
+
is_anchor = fetch_is_anchor(issue) if parent is None else False
|
|
247
|
+
violations = check_pr_body(body, issue, parent, is_anchor=is_anchor)
|
|
248
|
+
kind = ("Anker-Slice" if parent is not None
|
|
249
|
+
else "Wave-PR" if is_anchor else "Leaf")
|
|
250
|
+
log(f"branch={branch} issue={issue} parent={parent} kind={kind} "
|
|
251
|
+
f"violations={len(violations)}")
|
|
252
|
+
|
|
253
|
+
if violations:
|
|
254
|
+
print(f"[FAIL] pr-body-check #{issue} ({kind}, parent={parent}):")
|
|
255
|
+
for v in violations:
|
|
256
|
+
print(f" - {v}")
|
|
257
|
+
return 1
|
|
258
|
+
print(f"[OK] pr-body-check #{issue} ({kind}) — Body-Konventionen erfüllt.")
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
if __name__ == "__main__":
|
|
263
|
+
sys.exit(main())
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""program_graph.py — public assembly + report layer for the Programm-Graph.
|
|
3
|
+
|
|
4
|
+
board-sync.py's `validate-graph` command imports from this one module. The
|
|
5
|
+
implementation is split by concern across three cohesive files (a single file
|
|
6
|
+
would exceed the 300-line size gate):
|
|
7
|
+
|
|
8
|
+
- program_graph_parse.py — parse the Program-PRD body into dataclasses +
|
|
9
|
+
the Wellenplan-table / Slice-block renderers
|
|
10
|
+
(parser↔renderer roundtrip pairs).
|
|
11
|
+
- program_graph_validate.py — the 8 pure validation axes over the parsed graph.
|
|
12
|
+
- this file — the `GraphReport` model, the `validate_program_graph`
|
|
13
|
+
orchestrator wiring the axes together, the counted
|
|
14
|
+
`render_report`, plus a flat re-export of the
|
|
15
|
+
parse/validate symbols consumers use.
|
|
16
|
+
|
|
17
|
+
PURE — no gh / no I/O / no board_config (mirrors anchor_table.py's purity
|
|
18
|
+
contract). The CLI handler in board-sync.py reads `phase_options`
|
|
19
|
+
defensively from the board profile and passes it in (CR#1).
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
from program_graph_parse import ( # noqa: F401
|
|
27
|
+
ProgramGraph,
|
|
28
|
+
SliceBlock,
|
|
29
|
+
WaveRow,
|
|
30
|
+
parse_phase_gates,
|
|
31
|
+
parse_plan_revision,
|
|
32
|
+
parse_program_prd,
|
|
33
|
+
parse_scope_items,
|
|
34
|
+
parse_slice_blocks,
|
|
35
|
+
parse_wellenplan_table,
|
|
36
|
+
render_slice_block,
|
|
37
|
+
render_wellenplan_table,
|
|
38
|
+
)
|
|
39
|
+
from program_graph_validate import ( # noqa: F401
|
|
40
|
+
GATE_TYPES,
|
|
41
|
+
check_capacity,
|
|
42
|
+
check_phase_options,
|
|
43
|
+
check_revision_coherence,
|
|
44
|
+
check_rollup,
|
|
45
|
+
check_scope_coverage,
|
|
46
|
+
find_backward_refs,
|
|
47
|
+
find_cycles,
|
|
48
|
+
find_duplicate_slice_ids,
|
|
49
|
+
find_gate_dependents_outside_wave,
|
|
50
|
+
find_unknown_refs,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class GraphReport:
|
|
56
|
+
scope_total: int
|
|
57
|
+
scope_covered: int
|
|
58
|
+
scope_gaps: list[str]
|
|
59
|
+
rollup_gaps: list[str]
|
|
60
|
+
cycles: list[list[str]]
|
|
61
|
+
backward_refs: list[str]
|
|
62
|
+
gate_warnings: list[str]
|
|
63
|
+
capacity_errors: list[str]
|
|
64
|
+
phase_errors: list[str]
|
|
65
|
+
revision_mismatches: list[str]
|
|
66
|
+
duplicate_id_errors: list[str]
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def cycle_messages(self) -> list[str]:
|
|
70
|
+
return [f"Zyklus: {' → '.join(c)}" for c in self.cycles]
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def errors(self) -> list[str]:
|
|
74
|
+
"""Blocking findings — Vorschau-Gate publish must not proceed."""
|
|
75
|
+
return [*self.cycle_messages, *self.backward_refs, *self.capacity_errors,
|
|
76
|
+
*self.phase_errors, *self.revision_mismatches, *self.duplicate_id_errors]
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def warnings(self) -> list[str]:
|
|
80
|
+
"""Advisory findings — shown + counted, but a human decides."""
|
|
81
|
+
return [*self.gate_warnings, *self.rollup_gaps, *self.scope_gaps]
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def blocking(self) -> bool:
|
|
85
|
+
return bool(self.errors)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def validate_program_graph(prd_body: str, *, phase_options: Optional[list[str]] = None,
|
|
89
|
+
max_children: int = 100,
|
|
90
|
+
stub_revisions: Optional[list[dict]] = None) -> GraphReport:
|
|
91
|
+
"""The single entry point: parse a Program-PRD body and run all 8 axes."""
|
|
92
|
+
graph = parse_program_prd(prd_body)
|
|
93
|
+
covered, total, scope_gaps = check_scope_coverage(graph)
|
|
94
|
+
return GraphReport(
|
|
95
|
+
scope_total=total,
|
|
96
|
+
scope_covered=covered,
|
|
97
|
+
scope_gaps=scope_gaps,
|
|
98
|
+
rollup_gaps=check_rollup(graph),
|
|
99
|
+
cycles=find_cycles(graph.slices),
|
|
100
|
+
backward_refs=[*find_backward_refs(graph.slices), *find_unknown_refs(graph.slices)],
|
|
101
|
+
gate_warnings=find_gate_dependents_outside_wave(graph.slices),
|
|
102
|
+
capacity_errors=check_capacity(graph.waves, max_children),
|
|
103
|
+
phase_errors=check_phase_options(graph, phase_options),
|
|
104
|
+
revision_mismatches=check_revision_coherence(graph.plan_revision, stub_revisions),
|
|
105
|
+
duplicate_id_errors=find_duplicate_slice_ids(graph.slices),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def render_report(report: GraphReport) -> str:
|
|
110
|
+
"""The counted Vorschau-Gate summary line + detail lines (AC1)."""
|
|
111
|
+
summary = (
|
|
112
|
+
f"Scope-Abdeckung {report.scope_covered} von {report.scope_total} · "
|
|
113
|
+
f"Rollup-Kette {'✓' if not report.rollup_gaps else 'Lücken'} · "
|
|
114
|
+
f"zyklenfrei {'✓' if not report.cycles else '✗'} · "
|
|
115
|
+
f"Kapazität {'✓' if not report.capacity_errors else '✗'} · "
|
|
116
|
+
f"Phasen-Optionen {'✓' if not report.phase_errors else '✗'}"
|
|
117
|
+
)
|
|
118
|
+
lines = [summary]
|
|
119
|
+
for msg in report.errors:
|
|
120
|
+
lines.append(f" FEHLER: {msg}")
|
|
121
|
+
for msg in report.warnings:
|
|
122
|
+
lines.append(f" WARNUNG: {msg}")
|
|
123
|
+
if not report.errors and not report.warnings:
|
|
124
|
+
lines.append(" keine weiteren Befunde")
|
|
125
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""program_graph_parse.py — pure parser for the Programm-PRD grammar.
|
|
3
|
+
|
|
4
|
+
Parses a Program-PRD issue body (per `.claude/skills/to-prd/PROGRAM-PRD-FORMAT.md`)
|
|
5
|
+
into structured dataclasses: plan_revision, Scope-Items (S1, S2, …), the Wellenplan
|
|
6
|
+
table (Welle/Status/Name/Phase/Slices/Gate/covers), the Phasen-Gates checklist,
|
|
7
|
+
and the per-Slice metadata blocks (per
|
|
8
|
+
`.claude/skills/to-waves/SLICE-METADATA-FORMAT.md`).
|
|
9
|
+
|
|
10
|
+
PURE — no gh / no I/O / no board_config. Reuses the generic pipe-table primitives
|
|
11
|
+
from anchor_table.py (a different table GRAMMAR — different columns, different
|
|
12
|
+
node kinds — but the same markdown mechanics, so the parse/render primitives are
|
|
13
|
+
shared rather than re-implemented design decision).
|
|
14
|
+
|
|
15
|
+
The Wellenplan-table renderer lives here too (parser↔renderer roundtrip pair,
|
|
16
|
+
consumed later by `program-sync`).
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from anchor_table import col_index, parse_pipe_table, render_pipe_table
|
|
25
|
+
|
|
26
|
+
PLAN_REV_RE = re.compile(r"\*\*plan_revision:\*\*\s*r(\d+)\b")
|
|
27
|
+
_H2_RE = re.compile(r"^##\s+(.+?)\s*$")
|
|
28
|
+
SCOPE_ITEM_RE = re.compile(r"^-\s+\*\*(S\d+):\*\*\s*(.+)$")
|
|
29
|
+
PHASE_GATE_RE = re.compile(r"^-\s+\[([ xX])\]\s*(P\d+):\s*(.+)$")
|
|
30
|
+
|
|
31
|
+
WELLENPLAN_START = "<!-- wellenplan:start -->"
|
|
32
|
+
WELLENPLAN_END = "<!-- wellenplan:end -->"
|
|
33
|
+
|
|
34
|
+
WELLENPLAN_HEADERS = ["Welle", "Status", "Issue", "Name", "Phase", "Slices", "Gate", "covers"]
|
|
35
|
+
_ENABLER = "enabler"
|
|
36
|
+
# Tokens that mean "no value" in an optional cell — shared by the Wellenplan phase
|
|
37
|
+
# and covers cells and the Slice-block phase/blocked_by fields so `—` normalizes
|
|
38
|
+
# consistently everywhere.
|
|
39
|
+
_NONE_TOKENS = {"none", "", "—", "-"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _none_if_blank(value: str) -> Optional[str]:
|
|
43
|
+
"""The trimmed value, or None if it is one of the `_NONE_TOKENS` sentinels."""
|
|
44
|
+
v = (value or "").strip()
|
|
45
|
+
return None if v.lower() in _NONE_TOKENS else v
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class WaveRow:
|
|
50
|
+
number: int
|
|
51
|
+
name: str
|
|
52
|
+
phase: Optional[str]
|
|
53
|
+
slice_ids: list[str]
|
|
54
|
+
gate: str
|
|
55
|
+
covers: list[str]
|
|
56
|
+
is_enabler: bool = False
|
|
57
|
+
# Volatile, board-derived — ⬜ (not started) at authoring, regenerated
|
|
58
|
+
# monotonically by `program-sync` (see program_sync.refresh_wave_status).
|
|
59
|
+
# Not a validate-graph input (no axis reads it) and — unlike the semantic
|
|
60
|
+
# columns above — TOLERANT-optional on parse (docs/adr/0054 Folge-Arbeit:
|
|
61
|
+
# one Wellenplan grammar, this is a column within it, not a second table).
|
|
62
|
+
status: str = "⬜"
|
|
63
|
+
# The promoted Wave-Anchor issue number (`#<n>` cell) — the navigation link
|
|
64
|
+
# from the program table down to the wave. Machine-written (to-waves
|
|
65
|
+
# publish / program-sync), `—` pre-publish; TOLERANT-optional like status,
|
|
66
|
+
# and identity once set (program-sync fills but never overwrites it).
|
|
67
|
+
issue: Optional[int] = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class SliceBlock:
|
|
72
|
+
local_id: str
|
|
73
|
+
title: str
|
|
74
|
+
wave: Optional[int]
|
|
75
|
+
phase: Optional[str]
|
|
76
|
+
area: Optional[str]
|
|
77
|
+
gate: str
|
|
78
|
+
blocked_by: list[str] = field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class ProgramGraph:
|
|
83
|
+
plan_revision: Optional[int]
|
|
84
|
+
scope_items: dict[str, str]
|
|
85
|
+
waves: list[WaveRow]
|
|
86
|
+
slices: list[SliceBlock]
|
|
87
|
+
phase_gates: dict[str, str]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_plan_revision(body: str) -> Optional[int]:
|
|
91
|
+
"""The PRD's top-level `**plan_revision:** rN`, or None if absent/malformed."""
|
|
92
|
+
m = PLAN_REV_RE.search(body or "")
|
|
93
|
+
return int(m.group(1)) if m else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _section_lines(body: str, heading: str) -> list[str]:
|
|
97
|
+
"""Lines of a `## <heading>` section, up to (excl.) the next `##` heading."""
|
|
98
|
+
lines = (body or "").splitlines()
|
|
99
|
+
start = None
|
|
100
|
+
for i, ln in enumerate(lines):
|
|
101
|
+
m = _H2_RE.match(ln)
|
|
102
|
+
if m and m.group(1).strip() == heading:
|
|
103
|
+
start = i + 1
|
|
104
|
+
break
|
|
105
|
+
if start is None:
|
|
106
|
+
return []
|
|
107
|
+
end = len(lines)
|
|
108
|
+
for j in range(start, len(lines)):
|
|
109
|
+
if _H2_RE.match(lines[j]):
|
|
110
|
+
end = j
|
|
111
|
+
break
|
|
112
|
+
return lines[start:end]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def parse_scope_items(body: str) -> dict[str, str]:
|
|
116
|
+
"""`{S1: "description", ...}` from the `## Scope` chapter's `- **S1:** …` list."""
|
|
117
|
+
items: dict[str, str] = {}
|
|
118
|
+
for ln in _section_lines(body, "Scope"):
|
|
119
|
+
m = SCOPE_ITEM_RE.match(ln.strip())
|
|
120
|
+
if m:
|
|
121
|
+
items[m.group(1)] = m.group(2).strip()
|
|
122
|
+
return items
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def parse_phase_gates(body: str) -> dict[str, str]:
|
|
126
|
+
"""`{P1: "criterion text", ...}` from the `## Phasen-Gates` checklist."""
|
|
127
|
+
gates: dict[str, str] = {}
|
|
128
|
+
for ln in _section_lines(body, "Phasen-Gates"):
|
|
129
|
+
m = PHASE_GATE_RE.match(ln.strip())
|
|
130
|
+
if m:
|
|
131
|
+
gates[m.group(2)] = m.group(3).strip()
|
|
132
|
+
return gates
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _split_csv(cell: str) -> list[str]:
|
|
136
|
+
return [t.strip() for t in (cell or "").split(",") if t.strip()]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_covers_cell(cell: str) -> tuple[list[str], bool]:
|
|
140
|
+
tokens = _split_csv(cell)
|
|
141
|
+
if len(tokens) == 1 and tokens[0].lower() == _ENABLER:
|
|
142
|
+
return [], True
|
|
143
|
+
# `—`/`-`/`none` tokens are the same "no value" sentinels as the phase and
|
|
144
|
+
# blocked_by cells — an all-sentinel covers cell must normalize to [], not
|
|
145
|
+
# a phantom covers=['—'] that then fools check_scope_coverage (Bug 1).
|
|
146
|
+
tokens = [t for t in tokens if t.lower() not in _NONE_TOKENS]
|
|
147
|
+
return tokens, False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def parse_wellenplan_table(body: str) -> list[WaveRow]:
|
|
151
|
+
"""The Wellenplan table between the `wellenplan:start/end` markers. `Phase` is an
|
|
152
|
+
optional column (Phasen sind optional) — absent column ⇒ every row's phase=None.
|
|
153
|
+
`Status` is likewise optional, but for a different reason: it is volatile,
|
|
154
|
+
board-derived state (never authored by hand), so an absent column or blank
|
|
155
|
+
cell ⇒ every row's status defaults to `⬜` rather than raising — same
|
|
156
|
+
tolerant treatment as Phase, distinct from the required semantic columns."""
|
|
157
|
+
if WELLENPLAN_START not in body or WELLENPLAN_END not in body:
|
|
158
|
+
return []
|
|
159
|
+
inner = body.split(WELLENPLAN_START, 1)[1].split(WELLENPLAN_END, 1)[0]
|
|
160
|
+
headers, rows = parse_pipe_table(inner.splitlines())
|
|
161
|
+
if not headers:
|
|
162
|
+
return []
|
|
163
|
+
idx = {name: col_index(headers, name) for name in WELLENPLAN_HEADERS}
|
|
164
|
+
i_phase = idx["Phase"] # optional column — may be None
|
|
165
|
+
i_status = idx["Status"] # optional column — may be None, defaults to ⬜
|
|
166
|
+
i_issue = idx["Issue"] # optional column — may be None, defaults to no link
|
|
167
|
+
missing = [name for name in WELLENPLAN_HEADERS
|
|
168
|
+
if name not in ("Phase", "Status", "Issue") and idx[name] is None]
|
|
169
|
+
if missing:
|
|
170
|
+
raise ValueError(
|
|
171
|
+
f"Wellenplan table missing column(s) {', '.join(missing)} "
|
|
172
|
+
f"(headers: {headers})")
|
|
173
|
+
out: list[WaveRow] = []
|
|
174
|
+
for row in rows:
|
|
175
|
+
cell = lambda i: row[i].strip() if i is not None and i < len(row) else ""
|
|
176
|
+
covers, is_enabler = _parse_covers_cell(cell(idx["covers"]))
|
|
177
|
+
issue_m = re.search(r"#(\d+)", cell(i_issue))
|
|
178
|
+
out.append(WaveRow(
|
|
179
|
+
number=int(cell(idx["Welle"])),
|
|
180
|
+
name=cell(idx["Name"]),
|
|
181
|
+
phase=_none_if_blank(cell(i_phase)),
|
|
182
|
+
slice_ids=_split_csv(cell(idx["Slices"])),
|
|
183
|
+
gate=cell(idx["Gate"]) or "—",
|
|
184
|
+
covers=covers,
|
|
185
|
+
is_enabler=is_enabler,
|
|
186
|
+
status=cell(i_status) or "⬜",
|
|
187
|
+
issue=int(issue_m.group(1)) if issue_m else None,
|
|
188
|
+
))
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def render_wellenplan_table(waves: list[WaveRow], *, include_phase: bool = True) -> str:
|
|
193
|
+
"""Render the Wellenplan table (markers + pipe table) — inverse of
|
|
194
|
+
`parse_wellenplan_table` (roundtrip pair). `Status` and `Issue` are always
|
|
195
|
+
emitted (both always present, unlike `Phase` which is toggled by
|
|
196
|
+
`include_phase`) — the two machine-written columns `program-sync` rewrites
|
|
197
|
+
(: `Issue` is the navigation link down to the promoted Wave-Anchor)."""
|
|
198
|
+
headers = [h for h in WELLENPLAN_HEADERS if include_phase or h != "Phase"]
|
|
199
|
+
rows = []
|
|
200
|
+
for w in waves:
|
|
201
|
+
covers_cell = _ENABLER if w.is_enabler else ", ".join(w.covers)
|
|
202
|
+
cells = {
|
|
203
|
+
"Welle": str(w.number),
|
|
204
|
+
"Status": w.status,
|
|
205
|
+
"Issue": f"#{w.issue}" if w.issue is not None else "—",
|
|
206
|
+
"Name": w.name,
|
|
207
|
+
"Phase": w.phase or "",
|
|
208
|
+
"Slices": ", ".join(w.slice_ids),
|
|
209
|
+
"Gate": w.gate,
|
|
210
|
+
"covers": covers_cell,
|
|
211
|
+
}
|
|
212
|
+
rows.append([cells[h] for h in headers])
|
|
213
|
+
table = render_pipe_table(headers, rows)
|
|
214
|
+
return f"{WELLENPLAN_START}\n{table}\n{WELLENPLAN_END}"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
SLICE_HEADING_RE = re.compile(r"^####\s+(\S+)\s+—\s+(.+)$")
|
|
218
|
+
META_FIELD_RE = re.compile(r"^<!--\s*(wave|phase|area|gate|blocked_by):\s*(.*?)\s*-->$")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def parse_slice_blocks(body: str) -> list[SliceBlock]:
|
|
222
|
+
"""The `#### <local-id> — <Title>` sections of `## Slices`, each carrying a
|
|
223
|
+
5-field HTML-comment metadata block (SLICE-METADATA-FORMAT.md)."""
|
|
224
|
+
lines = (body or "").splitlines()
|
|
225
|
+
heads = []
|
|
226
|
+
for i, ln in enumerate(lines):
|
|
227
|
+
m = SLICE_HEADING_RE.match(ln.strip())
|
|
228
|
+
if m:
|
|
229
|
+
heads.append((i, m.group(1), m.group(2).strip()))
|
|
230
|
+
blocks: list[SliceBlock] = []
|
|
231
|
+
for idx, (start, local_id, title) in enumerate(heads):
|
|
232
|
+
end = heads[idx + 1][0] if idx + 1 < len(heads) else len(lines)
|
|
233
|
+
fields: dict[str, str] = {}
|
|
234
|
+
for ln in lines[start + 1:end]:
|
|
235
|
+
m = META_FIELD_RE.match(ln.strip())
|
|
236
|
+
if m:
|
|
237
|
+
fields[m.group(1)] = m.group(2)
|
|
238
|
+
wave_raw = fields.get("wave", "").strip()
|
|
239
|
+
blocked_raw = fields.get("blocked_by", "none").strip()
|
|
240
|
+
blocked_by = [] if blocked_raw.lower() in _NONE_TOKENS else _split_csv(blocked_raw)
|
|
241
|
+
blocks.append(SliceBlock(
|
|
242
|
+
local_id=local_id,
|
|
243
|
+
title=title,
|
|
244
|
+
wave=int(wave_raw) if wave_raw.isdigit() else None,
|
|
245
|
+
phase=_none_if_blank(fields.get("phase", "")),
|
|
246
|
+
area=(fields.get("area") or "").strip() or None,
|
|
247
|
+
gate=(fields.get("gate") or "").strip() or "—",
|
|
248
|
+
blocked_by=blocked_by,
|
|
249
|
+
))
|
|
250
|
+
return blocks
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def render_slice_block(s: SliceBlock) -> str:
|
|
254
|
+
"""Render one Slice section — inverse of the per-block parse in
|
|
255
|
+
`parse_slice_blocks` (roundtrip pair)."""
|
|
256
|
+
blocked = "none" if not s.blocked_by else ", ".join(s.blocked_by)
|
|
257
|
+
return (
|
|
258
|
+
f"#### {s.local_id} — {s.title}\n"
|
|
259
|
+
f"<!-- wave: {s.wave if s.wave is not None else ''} -->\n"
|
|
260
|
+
f"<!-- phase: {s.phase or '—'} -->\n"
|
|
261
|
+
f"<!-- area: {s.area or ''} -->\n"
|
|
262
|
+
f"<!-- gate: {s.gate} -->\n"
|
|
263
|
+
f"<!-- blocked_by: {blocked} -->\n"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def parse_program_prd(body: str) -> ProgramGraph:
|
|
268
|
+
"""The full ProgramGraph parsed from a Program-PRD issue body."""
|
|
269
|
+
return ProgramGraph(
|
|
270
|
+
plan_revision=parse_plan_revision(body),
|
|
271
|
+
scope_items=parse_scope_items(body),
|
|
272
|
+
waves=parse_wellenplan_table(body),
|
|
273
|
+
slices=parse_slice_blocks(body),
|
|
274
|
+
phase_gates=parse_phase_gates(body),
|
|
275
|
+
)
|