@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,256 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""program_graph_validate.py — the 8 Programm-Graph validation axes.
|
|
3
|
+
|
|
4
|
+
Structural (blocking): Zyklen, Rückwärts-Refs über Wellengrenzen, Kapazität,
|
|
5
|
+
Phasen-Optionen, Revisions-Kohärenz. Advisory (counted, shown, non-blocking):
|
|
6
|
+
Gate-Slice-Struktur-Verdacht, Rollup-Kette-Lücken, Scope-Abdeckung-Lücken — these
|
|
7
|
+
feed the counted Vorschau-Gate report, but a human decides whether an incomplete
|
|
8
|
+
draft proceeds; only structural errors are hard.
|
|
9
|
+
|
|
10
|
+
PURE — no gh / no I/O / no board_config. `phase_options` is injected as a plain
|
|
11
|
+
parameter (caller reads it defensively from the board profile CR#1).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from program_graph_parse import ProgramGraph, SliceBlock, WaveRow
|
|
18
|
+
|
|
19
|
+
# Gate-Legende (docs/agents/wave-anchor-template.md): "—" = AFK/no gate; the four
|
|
20
|
+
# non-AFK tags mark a Gate-Slice (Struktur-Verdacht check).
|
|
21
|
+
GATE_TYPES = {"🧭", "🔬", "📐", "📝"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def find_cycles(slices: list[SliceBlock]) -> list[list[str]]:
|
|
25
|
+
"""Cycles in the blocked_by graph, each as the ordered path incl. the repeated
|
|
26
|
+
closing id. Unknown targets are ignored here (see `find_unknown_refs`)."""
|
|
27
|
+
ids = {s.local_id for s in slices}
|
|
28
|
+
graph = {s.local_id: [b for b in s.blocked_by if b in ids] for s in slices}
|
|
29
|
+
WHITE, GRAY, BLACK = 0, 1, 2
|
|
30
|
+
color = {k: WHITE for k in graph}
|
|
31
|
+
cycles: list[list[str]] = []
|
|
32
|
+
path: list[str] = []
|
|
33
|
+
|
|
34
|
+
def dfs(node: str) -> None:
|
|
35
|
+
color[node] = GRAY
|
|
36
|
+
path.append(node)
|
|
37
|
+
for nxt in graph.get(node, []):
|
|
38
|
+
if color[nxt] == GRAY:
|
|
39
|
+
idx = path.index(nxt)
|
|
40
|
+
cycles.append(path[idx:] + [nxt])
|
|
41
|
+
elif color[nxt] == WHITE:
|
|
42
|
+
dfs(nxt)
|
|
43
|
+
path.pop()
|
|
44
|
+
color[node] = BLACK
|
|
45
|
+
|
|
46
|
+
for node in graph:
|
|
47
|
+
if color[node] == WHITE:
|
|
48
|
+
dfs(node)
|
|
49
|
+
return cycles
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def find_duplicate_slice_ids(slices: list[SliceBlock]) -> list[str]:
|
|
53
|
+
"""Duplicate `local_id`s make the slice graph ambiguous: find_cycles,
|
|
54
|
+
find_backward_refs and the rollup membership dict all key slices by
|
|
55
|
+
local_id (last-wins dict lookups), so a second `#### 1a` block silently
|
|
56
|
+
shadows the first block's blocked_by edges — a blocking cycle living in the
|
|
57
|
+
shadowed block would otherwise go undetected. Same structural error class
|
|
58
|
+
as a cycle (Bug 3)."""
|
|
59
|
+
seen: set[str] = set()
|
|
60
|
+
dupes: list[str] = []
|
|
61
|
+
for s in slices:
|
|
62
|
+
if s.local_id in seen and s.local_id not in dupes:
|
|
63
|
+
dupes.append(s.local_id)
|
|
64
|
+
seen.add(s.local_id)
|
|
65
|
+
return [f"doppelte Slice-ID '{lid}' im Wellenplan — IDs müssen eindeutig sein"
|
|
66
|
+
for lid in dupes]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def find_unknown_refs(slices: list[SliceBlock]) -> list[str]:
|
|
70
|
+
"""blocked_by targets that name no existing slice local-id."""
|
|
71
|
+
ids = {s.local_id for s in slices}
|
|
72
|
+
return [f"Slice {s.local_id}: blocked_by unbekannte Referenz '{b}'"
|
|
73
|
+
for s in slices for b in s.blocked_by if b not in ids]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def find_backward_refs(slices: list[SliceBlock]) -> list[str]:
|
|
77
|
+
"""blocked_by a LATER wave's slice = a backward ref across the wave boundary
|
|
78
|
+
(a slice may only depend on its own or an earlier wave). Blocking."""
|
|
79
|
+
by_id = {s.local_id: s for s in slices}
|
|
80
|
+
out = []
|
|
81
|
+
for s in slices:
|
|
82
|
+
if s.wave is None:
|
|
83
|
+
continue
|
|
84
|
+
for b in s.blocked_by:
|
|
85
|
+
target = by_id.get(b)
|
|
86
|
+
if target is None or target.wave is None:
|
|
87
|
+
continue
|
|
88
|
+
if target.wave > s.wave:
|
|
89
|
+
out.append(
|
|
90
|
+
f"Slice {s.local_id} (Welle {s.wave}) blockiert durch {b} "
|
|
91
|
+
f"(Welle {target.wave}) — Rückwärts-Ref über Wellengrenze")
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def find_gate_dependents_outside_wave(slices: list[SliceBlock]) -> list[str]:
|
|
96
|
+
"""A Gate-Slice (🧭/🔬/📐/📝) with dependents in a DIFFERENT wave is a
|
|
97
|
+
structural suspicion — gate-slices are meant to be wave-local (AFK-safe)."""
|
|
98
|
+
warnings = []
|
|
99
|
+
for s in slices:
|
|
100
|
+
if s.gate not in GATE_TYPES:
|
|
101
|
+
continue
|
|
102
|
+
outside = [o for o in slices if s.local_id in o.blocked_by and o.wave != s.wave]
|
|
103
|
+
if outside:
|
|
104
|
+
names = ", ".join(f"{o.local_id} (Welle {o.wave})" for o in outside)
|
|
105
|
+
warnings.append(
|
|
106
|
+
f"Gate-Slice {s.local_id} (Welle {s.wave}) hat wellen-fremde "
|
|
107
|
+
f"Abhängige: {names} — Struktur-Verdacht")
|
|
108
|
+
return warnings
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def check_capacity(waves: list[WaveRow], max_children: int = 100) -> list[str]:
|
|
112
|
+
"""GitHub's 100-children-per-parent limit, for the PRD→Welle + Welle→Slice fan-outs."""
|
|
113
|
+
errors = []
|
|
114
|
+
if len(waves) > max_children:
|
|
115
|
+
errors.append(f"{len(waves)} Wellen im PRD > {max_children} "
|
|
116
|
+
f"(GitHub Sub-Issue-Limit je Parent)")
|
|
117
|
+
for w in waves:
|
|
118
|
+
if len(w.slice_ids) > max_children:
|
|
119
|
+
errors.append(f"Welle {w.number}: {len(w.slice_ids)} Slices > "
|
|
120
|
+
f"{max_children} (GitHub Sub-Issue-Limit je Parent)")
|
|
121
|
+
return errors
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def check_phase_options(graph: ProgramGraph, phase_options: Optional[list[str]]) -> list[str]:
|
|
125
|
+
"""Every Phase value used (Wellenplan rows or Slice metadata) must be a
|
|
126
|
+
configured option of the board's Phase field. `phase_options=None` means the
|
|
127
|
+
profile has no `fields.phase` at all — a visible setup hint, never a crash
|
|
128
|
+
(CRITICAL RECONCILIATION #2), and only if the PRD uses phases at all
|
|
129
|
+
(a phase-less PRD is unaffected — Phasen sind optional)."""
|
|
130
|
+
used = {w.phase for w in graph.waves if w.phase}
|
|
131
|
+
used |= {s.phase for s in graph.slices if s.phase}
|
|
132
|
+
if not used:
|
|
133
|
+
return []
|
|
134
|
+
if phase_options is None:
|
|
135
|
+
return ["keine Phase-Feld-Konfiguration im Board-Profil gefunden — "
|
|
136
|
+
"Setup-Hinweis: `fields.phase` ergänzen (/setup-workflow)"]
|
|
137
|
+
unknown = sorted(p for p in used if p not in phase_options)
|
|
138
|
+
return [f"Phase '{p}' ist keine Option des Phase-Felds im Board-Profil "
|
|
139
|
+
f"— Setup-Hinweis: Option in /setup-workflow ergänzen" for p in unknown]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _check_slice_membership(graph: ProgramGraph) -> list[str]:
|
|
143
|
+
"""Each Slices-column id must have a Slice-block; each Slice-block must be
|
|
144
|
+
referenced by exactly one wave and belong to a wave that exists."""
|
|
145
|
+
gaps: list[str] = []
|
|
146
|
+
wave_by_number = {w.number: w for w in graph.waves}
|
|
147
|
+
membership: dict[str, int] = {s.local_id: 0 for s in graph.slices}
|
|
148
|
+
for w in graph.waves:
|
|
149
|
+
for sid in w.slice_ids:
|
|
150
|
+
if sid in membership:
|
|
151
|
+
membership[sid] += 1
|
|
152
|
+
else:
|
|
153
|
+
gaps.append(f"Welle {w.number}: Slice '{sid}' in der Slices-Spalte "
|
|
154
|
+
f"hat keinen Slice-Block")
|
|
155
|
+
for sid, count in membership.items():
|
|
156
|
+
if count == 0:
|
|
157
|
+
gaps.append(f"Slice {sid}: in keiner Wellenplan-Zeile referenziert")
|
|
158
|
+
elif count > 1:
|
|
159
|
+
gaps.append(f"Slice {sid}: in {count} Wellenplan-Zeilen referenziert "
|
|
160
|
+
f"(erwartet genau 1)")
|
|
161
|
+
for s in graph.slices:
|
|
162
|
+
if s.wave is None:
|
|
163
|
+
gaps.append(f"Slice {s.local_id}: kein wave-Feld gesetzt")
|
|
164
|
+
elif s.wave not in wave_by_number:
|
|
165
|
+
gaps.append(f"Slice {s.local_id}: Welle {s.wave} existiert nicht im Wellenplan")
|
|
166
|
+
return gaps
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _check_wave_completeness(graph: ProgramGraph) -> list[str]:
|
|
170
|
+
"""Every wave carries ≥1 slice + a Gate (+ a Phase if the PRD uses phases)."""
|
|
171
|
+
gaps: list[str] = []
|
|
172
|
+
phases_used = any(w.phase for w in graph.waves)
|
|
173
|
+
for w in graph.waves:
|
|
174
|
+
if not w.slice_ids:
|
|
175
|
+
gaps.append(f"Welle {w.number}: keine Slices")
|
|
176
|
+
if not w.gate.strip():
|
|
177
|
+
gaps.append(f"Welle {w.number}: kein Gate-Wert gesetzt")
|
|
178
|
+
if phases_used and not w.phase:
|
|
179
|
+
gaps.append(f"Welle {w.number}: keine Phase gesetzt, obwohl das "
|
|
180
|
+
f"Programm Phasen nutzt")
|
|
181
|
+
return gaps
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _check_phase_gate_pairing(graph: ProgramGraph) -> list[str]:
|
|
185
|
+
"""Every used Phase has a checklist Gate-Kriterium, and every checklist entry
|
|
186
|
+
names a Phase some wave uses."""
|
|
187
|
+
gaps: list[str] = []
|
|
188
|
+
phases_used = {w.phase for w in graph.waves if w.phase}
|
|
189
|
+
for phase in phases_used:
|
|
190
|
+
if phase not in graph.phase_gates:
|
|
191
|
+
gaps.append(f"Phase {phase}: kein Gate-Kriterium in der "
|
|
192
|
+
f"Phasen-Gates-Checkliste")
|
|
193
|
+
for phase in graph.phase_gates:
|
|
194
|
+
if phase not in phases_used:
|
|
195
|
+
gaps.append(f"Phase {phase}: keine Welle referenziert diese Phase "
|
|
196
|
+
f"(Checklisten-Waise)")
|
|
197
|
+
return gaps
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def check_rollup(graph: ProgramGraph) -> list[str]:
|
|
201
|
+
"""Vertical completeness: the Programm→Welle→Slice rollup chain is closed
|
|
202
|
+
(slice↔wave membership + per-wave completeness + phase↔gate pairing)."""
|
|
203
|
+
return [*_check_slice_membership(graph), *_check_wave_completeness(graph),
|
|
204
|
+
*_check_phase_gate_pairing(graph)]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def check_scope_coverage(graph: ProgramGraph) -> tuple[int, int, list[str]]:
|
|
208
|
+
"""Horizontal completeness: every Scope-Item is covered by ≥1 wave, every wave
|
|
209
|
+
covers ≥1 Scope-Item or is explicitly marked `enabler`, and every covers token
|
|
210
|
+
names a declared Scope-Item (an `S99` typo is a non-blocking gap of its own —
|
|
211
|
+
advisory, and doesn't affect the covered/total count below). Returns
|
|
212
|
+
(covered_count, total_count, gap_messages)."""
|
|
213
|
+
gaps: list[str] = []
|
|
214
|
+
covered_ids: set = set()
|
|
215
|
+
for w in graph.waves:
|
|
216
|
+
if not w.covers and not w.is_enabler:
|
|
217
|
+
gaps.append(f"Welle {w.number}: keine Scope-Abdeckung und nicht als "
|
|
218
|
+
f"Enabler markiert")
|
|
219
|
+
for token in w.covers:
|
|
220
|
+
if token not in graph.scope_items:
|
|
221
|
+
gaps.append(f"Welle {w.number}: covers unbekanntes Scope-Item "
|
|
222
|
+
f"'{token}'")
|
|
223
|
+
covered_ids.update(w.covers)
|
|
224
|
+
covered = 0
|
|
225
|
+
for sid in graph.scope_items:
|
|
226
|
+
if sid in covered_ids:
|
|
227
|
+
covered += 1
|
|
228
|
+
else:
|
|
229
|
+
gaps.append(f"Scope-Item {sid}: von keiner Welle abgedeckt")
|
|
230
|
+
return covered, len(graph.scope_items), gaps
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def check_revision_coherence(plan_revision: Optional[int],
|
|
234
|
+
stub_revisions: Optional[list[dict]]) -> list[str]:
|
|
235
|
+
"""A published Welle-Stub whose `<!-- program-revision: rN -->` marker (stamped
|
|
236
|
+
by `to-waves`) no longer matches the PRD's plan_revision is stale — it blocks
|
|
237
|
+
loudly instead of silently building from an outdated plan.
|
|
238
|
+
|
|
239
|
+
`stub_revisions` (`[{"label": str, "revision": int | None}, …]`) is pre-fetched
|
|
240
|
+
by the caller (this module stays I/O-free); the CLI passes `[]` when no stubs
|
|
241
|
+
are fetched pre-publish, and callers wire in the real fetch via the same
|
|
242
|
+
signature."""
|
|
243
|
+
if not stub_revisions:
|
|
244
|
+
return []
|
|
245
|
+
if plan_revision is None:
|
|
246
|
+
return ["PRD hat kein plan_revision — Revisions-Kohärenz nicht prüfbar"]
|
|
247
|
+
out = []
|
|
248
|
+
for stub in stub_revisions:
|
|
249
|
+
label = stub.get("label", "?")
|
|
250
|
+
rev = stub.get("revision")
|
|
251
|
+
if rev is None:
|
|
252
|
+
out.append(f"{label}: kein program-revision-Marker gefunden")
|
|
253
|
+
elif rev != plan_revision:
|
|
254
|
+
out.append(f"{label}: program-revision r{rev} != PRD plan_revision "
|
|
255
|
+
f"r{plan_revision} — stale")
|
|
256
|
+
return out
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""program_sync.py — Wellenplan Status-resync for `board-sync.py program-sync`
|
|
3
|
+
(plan step 9b(d); Status column amendment).
|
|
4
|
+
|
|
5
|
+
A Program-PRD's `## Wellenplan` table is its OWN grammar (Welle/Status/Name/
|
|
6
|
+
Slices/Gate/covers — parsed by `program_graph_parse.py`), one level above a
|
|
7
|
+
Welle-Anchor's own Slices-table. `anchor-sync` (`anchor_table.py`) already
|
|
8
|
+
regenerates an Anchor's Slice-row Status/Branch from its native sub-issues;
|
|
9
|
+
`program-sync` does the SAME KIND of thing one graph level up — a PRD's native
|
|
10
|
+
sub-issues are its promoted Wave-Anchor stubs — but deliberately as a SEPARATE
|
|
11
|
+
command over a SEPARATE grammar (plan 9b(d): "program-sync statt
|
|
12
|
+
anchor-sync-Überladung"), not a second table format bolted onto either module.
|
|
13
|
+
|
|
14
|
+
`WaveRow` carries a dedicated `status` field (docs/adr/0054 §Folge-Arbeit forbids
|
|
15
|
+
only a SECOND Wellenplan table format, not a Status COLUMN within the one
|
|
16
|
+
grammar). The volatile Status therefore lives in its own cell, refreshed
|
|
17
|
+
MONOTONICALLY (never ✅→🔄→⬜, mirroring `anchor_table.refresh_status_cell`'s
|
|
18
|
+
never-regress rule) — the `Name` cell is left entirely untouched by this sync:
|
|
19
|
+
hand annotations there always survive verbatim, and Status is the only column
|
|
20
|
+
`program-sync` ever rewrites.
|
|
21
|
+
|
|
22
|
+
PURE — no gh / no I/O / no board_config. Only imports `WaveRow` from
|
|
23
|
+
`program_graph`'s public facade (never re-implementing the Wellenplan
|
|
24
|
+
parser/renderer — `program_graph_parse.py` owns that grammar).
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
from dataclasses import replace
|
|
30
|
+
from typing import Optional
|
|
31
|
+
|
|
32
|
+
from program_graph import WaveRow
|
|
33
|
+
|
|
34
|
+
_WAVE_NUMBER_RE = re.compile(r"^\s*Welle\s+(\d+)\s*[—–-]", re.IGNORECASE)
|
|
35
|
+
_STATUS_RANK = {"⬜": 0, "🔄": 1, "✅": 2}
|
|
36
|
+
|
|
37
|
+
_WELLENPLAN_BLOCK_RE = re.compile(
|
|
38
|
+
r"<!--\s*wellenplan:start\s*-->.*?<!--\s*wellenplan:end\s*-->", re.DOTALL)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def extract_wave_number_from_title(title: str) -> Optional[int]:
|
|
42
|
+
"""The wave number out of a promoted Wave-Anchor's `Welle <N> — <Thema>`
|
|
43
|
+
title (the same convention `board-sync.py`'s `wave_title()` writes), or
|
|
44
|
+
None for a title that isn't wave-prefixed (not yet promoted / foreign)."""
|
|
45
|
+
m = _WAVE_NUMBER_RE.match(title or "")
|
|
46
|
+
return int(m.group(1)) if m else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def wave_status_token(entry: Optional[dict], roles: dict) -> str:
|
|
50
|
+
"""⬜ (no matching native child yet — not promoted) · 🔄 (an in-flight
|
|
51
|
+
board status) · ✅ (the done-role status). Deliberately NOT
|
|
52
|
+
`anchor_table.status_token_from_board` — a Wave-Anchor issue is never
|
|
53
|
+
`closes`-referenced by a PR (board-sync.md convention: `closes` never
|
|
54
|
+
targets an Anker), so a Wave's completion is read off its OWN board Status
|
|
55
|
+
field, not a merged-PR check.
|
|
56
|
+
|
|
57
|
+
Status NAMES come from the profile's role map (`roles["done"]`,
|
|
58
|
+
`roles["inProgress"]`/`roles["review"]`), passed as a plain dict so
|
|
59
|
+
this module stays pure. Empty roles → every status reads as ⬜ (the
|
|
60
|
+
monotone refresh never regresses a hand-set cell)."""
|
|
61
|
+
if entry is None:
|
|
62
|
+
return "⬜"
|
|
63
|
+
status = entry.get("status")
|
|
64
|
+
if status and status == roles.get("done"):
|
|
65
|
+
return "✅"
|
|
66
|
+
if status and status in ({roles.get("inProgress"), roles.get("review")} - {None}):
|
|
67
|
+
return "🔄"
|
|
68
|
+
return "⬜"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def refresh_wave_status(existing_status: str, token: str) -> str:
|
|
72
|
+
"""Refresh a Wellenplan row's Status cell from the board, MONOTONICALLY
|
|
73
|
+
(never regress ✅→🔄→⬜ — mirrors `anchor_table.refresh_status_cell`'s
|
|
74
|
+
never-regress rule). An existing cell that isn't a known token (unset,
|
|
75
|
+
blank, or hand-typed garbage) ranks below ⬜ (rank -1), so the first sync
|
|
76
|
+
always sets it."""
|
|
77
|
+
existing_rank = _STATUS_RANK.get(existing_status, -1)
|
|
78
|
+
token_rank = _STATUS_RANK.get(token, -1)
|
|
79
|
+
if token_rank < existing_rank:
|
|
80
|
+
return existing_status
|
|
81
|
+
return existing_status if existing_status == token else token
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def sync_wellenplan_status(waves: list[WaveRow], board: dict, roles: dict) -> list[WaveRow]:
|
|
85
|
+
"""A fresh `WaveRow` list with each row's `status` field refreshed from
|
|
86
|
+
`board` — the SAME `{issue: {title, status, prs}}` shape
|
|
87
|
+
`anchor_table.extract_anchor_board_data` already produces (no second query,
|
|
88
|
+
no second parse: the PRD's native sub-issues — its promoted Wave-Anchor
|
|
89
|
+
stubs — are fetched exactly like an Anchor fetches its Slice sub-issues).
|
|
90
|
+
The `issue` field (the navigation link to the wave's own issue) is
|
|
91
|
+
FILLED from the matching board child but never overwritten — once stamped
|
|
92
|
+
it is identity, a later title rename must not re-point it. Every other
|
|
93
|
+
WaveRow field (Name/Phase/Slices/Gate/covers/is_enabler) round-trips
|
|
94
|
+
untouched — those are stable Plan columns (or, for Name, hand-owned prose),
|
|
95
|
+
never board-derived."""
|
|
96
|
+
by_wave_number: dict[int, tuple[int, dict]] = {}
|
|
97
|
+
for issue_no, entry in board.items():
|
|
98
|
+
n = extract_wave_number_from_title(entry.get("title", ""))
|
|
99
|
+
if n is not None:
|
|
100
|
+
by_wave_number[n] = (issue_no, entry)
|
|
101
|
+
out: list[WaveRow] = []
|
|
102
|
+
for w in waves:
|
|
103
|
+
hit = by_wave_number.get(w.number)
|
|
104
|
+
entry = hit[1] if hit else None
|
|
105
|
+
issue = w.issue if w.issue is not None else (hit[0] if hit else None)
|
|
106
|
+
out.append(replace(w, issue=issue,
|
|
107
|
+
status=refresh_wave_status(w.status, wave_status_token(entry, roles))))
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_PHASE_GATES_HEADING_RE = re.compile(r"^##\s+Phasen-Gates\s*$")
|
|
112
|
+
_H2_LINE_RE = re.compile(r"^##\s+")
|
|
113
|
+
_UNCHECKED_GATE_RE = re.compile(r"^(\s*-\s+\[) (\]\s*(P\d+):.*)$")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def checkoff_phase_gates(body: str, waves: list[WaveRow], today: str) -> tuple[str, list[str]]:
|
|
117
|
+
"""Check off `## Phasen-Gates` entries whose phase is mechanically complete —
|
|
118
|
+
every Wellenplan wave carrying that phase has status ✅ (upward
|
|
119
|
+
propagation). Monotone + idempotent: only an UNCHECKED box whose phase just
|
|
120
|
+
completed is flipped (`[ ]` → `[x]`, with an `— alle Wellen ✅ (<today>)`
|
|
121
|
+
suffix stamped once); a checked box is never touched again, a phase no wave
|
|
122
|
+
uses is never checked (that is a Checklisten-Waise — validate-graph's
|
|
123
|
+
finding, not ours). Only lines inside the `## Phasen-Gates` section are
|
|
124
|
+
considered. Returns (new_body, newly_checked_phase_ids)."""
|
|
125
|
+
waves_by_phase: dict[str, list[WaveRow]] = {}
|
|
126
|
+
for w in waves:
|
|
127
|
+
if w.phase:
|
|
128
|
+
waves_by_phase.setdefault(w.phase, []).append(w)
|
|
129
|
+
done_phases = {p for p, ws in waves_by_phase.items()
|
|
130
|
+
if all(w.status == "✅" for w in ws)}
|
|
131
|
+
lines = body.splitlines()
|
|
132
|
+
in_section = False
|
|
133
|
+
checked: list[str] = []
|
|
134
|
+
for i, ln in enumerate(lines):
|
|
135
|
+
if _PHASE_GATES_HEADING_RE.match(ln):
|
|
136
|
+
in_section = True
|
|
137
|
+
continue
|
|
138
|
+
if in_section and _H2_LINE_RE.match(ln):
|
|
139
|
+
in_section = False
|
|
140
|
+
if not in_section:
|
|
141
|
+
continue
|
|
142
|
+
m = _UNCHECKED_GATE_RE.match(ln)
|
|
143
|
+
if m and m.group(3) in done_phases:
|
|
144
|
+
lines[i] = f"{m.group(1)}x{m.group(2)} — alle Wellen ✅ ({today})"
|
|
145
|
+
checked.append(m.group(3))
|
|
146
|
+
new_body = "\n".join(lines) + ("\n" if body.endswith("\n") else "")
|
|
147
|
+
return new_body, checked
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def splice_wellenplan_table(body: str, rendered_block: str) -> str:
|
|
151
|
+
"""Replace the `<!-- wellenplan:start/end -->` block with a freshly
|
|
152
|
+
rendered one (`program_graph.render_wellenplan_table`'s output already
|
|
153
|
+
carries its own markers). Mirrors `anchor_table.splice_slice_table`'s
|
|
154
|
+
marker-replace mechanic — kept HERE rather than shared with it, because
|
|
155
|
+
this is a different table grammar entirely (plan 9b(d))."""
|
|
156
|
+
if not _WELLENPLAN_BLOCK_RE.search(body):
|
|
157
|
+
raise ValueError(
|
|
158
|
+
"no Wellenplan table found in program body (needs "
|
|
159
|
+
"`<!-- wellenplan:start -->`/`<!-- wellenplan:end -->` markers)")
|
|
160
|
+
return _WELLENPLAN_BLOCK_RE.sub(lambda _m: rendered_block, body)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** Block shipped changes whose version or checked manifest does not match a fresh build. */
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { buildKit } from './build-kit.mjs';
|
|
8
|
+
|
|
9
|
+
const byPath = (manifest) => new Map(manifest.files.map((entry) => [entry.path, entry.sha256]));
|
|
10
|
+
|
|
11
|
+
export function manifestDelta(before, after) {
|
|
12
|
+
const old = byPath(before);
|
|
13
|
+
const fresh = byPath(after);
|
|
14
|
+
return {
|
|
15
|
+
added: [...fresh.keys()].filter((path) => !old.has(path)).sort(),
|
|
16
|
+
removed: [...old.keys()].filter((path) => !fresh.has(path)).sort(),
|
|
17
|
+
changed: [...fresh.keys()].filter((path) => old.has(path) && old.get(path) !== fresh.get(path)).sort(),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const hasDelta = (delta) => Object.values(delta).some((paths) => paths.length);
|
|
22
|
+
const describe = (delta) => ['added', 'removed', 'changed']
|
|
23
|
+
.filter((kind) => delta[kind].length)
|
|
24
|
+
.map((kind) => `${kind}: ${delta[kind].join(', ')}`).join('; ');
|
|
25
|
+
|
|
26
|
+
export function recommendBump(delta) {
|
|
27
|
+
if (delta.removed.length) return 'major';
|
|
28
|
+
if (delta.added.length) return 'minor';
|
|
29
|
+
return delta.changed.length ? 'patch' : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function bumpKind(before, after) {
|
|
33
|
+
const a = before.split('.').map(Number);
|
|
34
|
+
const b = after.split('.').map(Number);
|
|
35
|
+
if (b[0] > a[0]) return 'major';
|
|
36
|
+
if (b[0] === a[0] && b[1] > a[1]) return 'minor';
|
|
37
|
+
if (b[0] === a[0] && b[1] === a[1] && b[2] > a[2]) return 'patch';
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function assessRelease(input) {
|
|
42
|
+
const delta = manifestDelta(input.baseManifest, input.builtManifest);
|
|
43
|
+
const checkedDrift = manifestDelta(input.checkedManifest, input.builtManifest);
|
|
44
|
+
const errors = [];
|
|
45
|
+
if (hasDelta(delta) && input.currentVersion === input.baseVersion) {
|
|
46
|
+
errors.push(`shipped delta has no version bump (${describe(delta)}); version remains ${input.currentVersion}`);
|
|
47
|
+
}
|
|
48
|
+
if (input.checkedManifest.kitVersion !== input.currentVersion) {
|
|
49
|
+
errors.push(`checked manifest version ${input.checkedManifest.kitVersion} != package ${input.currentVersion}`);
|
|
50
|
+
}
|
|
51
|
+
for (const path of checkedDrift.removed) errors.push(`checked manifest dead entry: ${path}`);
|
|
52
|
+
if (checkedDrift.added.length || checkedDrift.changed.length) {
|
|
53
|
+
errors.push(`checked manifest is stale (${describe(checkedDrift)})`);
|
|
54
|
+
}
|
|
55
|
+
const recommendedBump = recommendBump(delta);
|
|
56
|
+
const actual = bumpKind(input.baseVersion, input.currentVersion);
|
|
57
|
+
const rank = { patch: 1, minor: 2, major: 3 };
|
|
58
|
+
if (hasDelta(delta) && input.currentVersion !== input.baseVersion && !actual) {
|
|
59
|
+
errors.push(`invalid version transition: ${input.baseVersion} -> ${input.currentVersion}`);
|
|
60
|
+
}
|
|
61
|
+
if (recommendedBump && actual && rank[actual] < rank[recommendedBump]) {
|
|
62
|
+
errors.push(`${actual} bump is smaller than recommended ${recommendedBump} (${describe(delta)})`);
|
|
63
|
+
}
|
|
64
|
+
return { ok: errors.length === 0, errors, delta, recommendedBump };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function gitShowJson(repoRoot, ref, path) {
|
|
68
|
+
return JSON.parse(execFileSync('git', ['show', `${ref}:${path}`], { cwd: repoRoot, encoding: 'utf8' }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function checkReleaseDelta({ repoRoot, baseRef = 'origin/main' } = {}) {
|
|
72
|
+
repoRoot ??= join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
73
|
+
const distDir = await mkdtemp(join(tmpdir(), 'awkit-release-guard-'));
|
|
74
|
+
try {
|
|
75
|
+
await buildKit({ repoRoot, distDir });
|
|
76
|
+
const currentPackage = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8'));
|
|
77
|
+
return assessRelease({
|
|
78
|
+
baseVersion: gitShowJson(repoRoot, baseRef, 'package.json').version,
|
|
79
|
+
currentVersion: currentPackage.version,
|
|
80
|
+
baseManifest: gitShowJson(repoRoot, baseRef, 'agent-workflow-kit.package.json'),
|
|
81
|
+
checkedManifest: JSON.parse(await readFile(join(repoRoot, 'agent-workflow-kit.package.json'), 'utf8')),
|
|
82
|
+
builtManifest: JSON.parse(await readFile(join(distDir, 'agent-workflow-kit.package.json'), 'utf8')),
|
|
83
|
+
});
|
|
84
|
+
} finally { await rm(distDir, { recursive: true, force: true }); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
88
|
+
const baseIndex = process.argv.indexOf('--base');
|
|
89
|
+
checkReleaseDelta({ baseRef: baseIndex < 0 ? undefined : process.argv[baseIndex + 1] })
|
|
90
|
+
.then((result) => {
|
|
91
|
+
if (result.ok) return console.log(`release:guard — OK${result.recommendedBump ? ` (${result.recommendedBump})` : ''}`);
|
|
92
|
+
console.error('release:guard — BLOCKED');
|
|
93
|
+
for (const error of result.errors) console.error(` ${error}`);
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
}).catch((error) => { console.error(error.message); process.exitCode = 1; });
|
|
96
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { assessRelease } from './release-delta-guard.mjs';
|
|
4
|
+
|
|
5
|
+
const file = (path, sha256) => ({ path, sha256 });
|
|
6
|
+
|
|
7
|
+
test('an unbumped shipped change is blocked with its concrete delta', () => {
|
|
8
|
+
const result = assessRelease({
|
|
9
|
+
baseVersion: '1.2.3', currentVersion: '1.2.3',
|
|
10
|
+
baseManifest: { kitVersion: '1.2.3', files: [file('skill.md', 'old')] },
|
|
11
|
+
builtManifest: { kitVersion: '1.2.3', files: [file('skill.md', 'new')] },
|
|
12
|
+
checkedManifest: { kitVersion: '1.2.3', files: [file('skill.md', 'old')] },
|
|
13
|
+
});
|
|
14
|
+
assert.equal(result.ok, false);
|
|
15
|
+
assert.deepEqual(result.delta.changed, ['skill.md']);
|
|
16
|
+
assert.match(result.errors.join('\n'), /skill\.md/);
|
|
17
|
+
assert.match(result.errors.join('\n'), /version remains 1\.2\.3/);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('dead checked-manifest entries are rejected', () => {
|
|
21
|
+
const result = assessRelease({
|
|
22
|
+
baseVersion: '1.2.3', currentVersion: '1.3.0',
|
|
23
|
+
baseManifest: { kitVersion: '1.2.3', files: [] },
|
|
24
|
+
builtManifest: { kitVersion: '1.3.0', files: [] },
|
|
25
|
+
checkedManifest: { kitVersion: '1.3.0', files: [file('gone.md', 'old')] },
|
|
26
|
+
});
|
|
27
|
+
assert.equal(result.ok, false);
|
|
28
|
+
assert.match(result.errors.join('\n'), /dead entry: gone\.md/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('a matching regenerated manifest and semantic bump pass', () => {
|
|
32
|
+
const current = { kitVersion: '1.3.0', files: [file('new.md', 'one')] };
|
|
33
|
+
const result = assessRelease({
|
|
34
|
+
baseVersion: '1.2.3', currentVersion: '1.3.0',
|
|
35
|
+
baseManifest: { kitVersion: '1.2.3', files: [] }, builtManifest: current,
|
|
36
|
+
checkedManifest: current,
|
|
37
|
+
});
|
|
38
|
+
assert.equal(result.ok, true);
|
|
39
|
+
assert.equal(result.recommendedBump, 'minor');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('shipped content rejects downgrade and malformed version transitions', () => {
|
|
43
|
+
for (const currentVersion of ['1.1.0', 'banana']) {
|
|
44
|
+
const current = { kitVersion: currentVersion, files: [file('skill.md', 'new')] };
|
|
45
|
+
const result = assessRelease({
|
|
46
|
+
baseVersion: '1.2.3', currentVersion,
|
|
47
|
+
baseManifest: { kitVersion: '1.2.3', files: [file('skill.md', 'old')] },
|
|
48
|
+
builtManifest: current, checkedManifest: current,
|
|
49
|
+
});
|
|
50
|
+
assert.equal(result.ok, false, currentVersion);
|
|
51
|
+
assert.match(result.errors.join('\n'), /invalid version transition: 1\.2\.3 ->/);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Regression test for marker-aware board issue creation."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import importlib.util
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
PROFILE = {
|
|
15
|
+
"repo": "example/kit",
|
|
16
|
+
"project": {"number": 1, "owner": "example", "nodeId": "PVT_test"},
|
|
17
|
+
"fields": {
|
|
18
|
+
"status": {"id": "STATUS", "options": {}},
|
|
19
|
+
"wave": "WAVE",
|
|
20
|
+
"cluster": "CLUSTER",
|
|
21
|
+
"specPath": "SPEC",
|
|
22
|
+
"planPath": "PLAN",
|
|
23
|
+
},
|
|
24
|
+
"labels": {
|
|
25
|
+
"readyForAgent": "ready-for-agent",
|
|
26
|
+
"typePrefix": "type:",
|
|
27
|
+
"clusterType": "type:cluster",
|
|
28
|
+
"waveStub": "wave-stub",
|
|
29
|
+
},
|
|
30
|
+
"branchPrefixes": ["feat", "fix"],
|
|
31
|
+
"prMarkers": {"partOf": "Part of", "retroMarker": "Retro", "retroValues": []},
|
|
32
|
+
"headings": {"vorBau": "Clarify Before Build"},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_board_sync():
|
|
37
|
+
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as profile:
|
|
38
|
+
profile.write("<!-- board-sync:profile -->\n```json\n")
|
|
39
|
+
json.dump(PROFILE, profile)
|
|
40
|
+
profile.write("\n```\n")
|
|
41
|
+
profile_path = profile.name
|
|
42
|
+
os.environ["BOARD_SYNC_PROFILE"] = profile_path
|
|
43
|
+
spec = importlib.util.spec_from_file_location(
|
|
44
|
+
"board_sync_create_test", Path(__file__).with_name("board-sync.py")
|
|
45
|
+
)
|
|
46
|
+
module = importlib.util.module_from_spec(spec)
|
|
47
|
+
assert spec.loader is not None
|
|
48
|
+
spec.loader.exec_module(module)
|
|
49
|
+
Path(profile_path).unlink()
|
|
50
|
+
return module
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
bs = load_board_sync()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class FakeGh:
|
|
57
|
+
def __init__(self, marker: str):
|
|
58
|
+
self.marker = marker
|
|
59
|
+
self.created = False
|
|
60
|
+
self.calls: list[list[str]] = []
|
|
61
|
+
|
|
62
|
+
def __call__(self, args: list[str]) -> str:
|
|
63
|
+
self.calls.append(args)
|
|
64
|
+
joined = " ".join(args)
|
|
65
|
+
if "issue list" in joined:
|
|
66
|
+
if not self.created:
|
|
67
|
+
return "[]"
|
|
68
|
+
return json.dumps([
|
|
69
|
+
{
|
|
70
|
+
"number": 101,
|
|
71
|
+
"url": "https://github.com/example/kit/issues/101",
|
|
72
|
+
"body": self.marker,
|
|
73
|
+
}
|
|
74
|
+
])
|
|
75
|
+
if "issue create" in joined:
|
|
76
|
+
self.created = True
|
|
77
|
+
return "https://github.com/example/kit/issues/101\n"
|
|
78
|
+
if "project item-add" in joined:
|
|
79
|
+
return '{"id":"PVTI_test"}'
|
|
80
|
+
return ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class MarkerAwareCreateTest(unittest.TestCase):
|
|
84
|
+
def test_identical_program_leaf_retry_reuses_existing_issue(self):
|
|
85
|
+
marker = "<!-- program-leaf-source: program-x/1a -->"
|
|
86
|
+
fake = FakeGh(marker)
|
|
87
|
+
original = bs._gh
|
|
88
|
+
bs._gh = fake
|
|
89
|
+
try:
|
|
90
|
+
with tempfile.NamedTemporaryFile("w", suffix=".md") as body:
|
|
91
|
+
body.write(marker + "\nOutcome: test\n")
|
|
92
|
+
body.flush()
|
|
93
|
+
argv = ["create", "--title", "Slice 1a", "--body-file", body.name]
|
|
94
|
+
self.assertEqual(bs.main(argv), 0)
|
|
95
|
+
self.assertEqual(bs.main(argv), 0)
|
|
96
|
+
finally:
|
|
97
|
+
bs._gh = original
|
|
98
|
+
|
|
99
|
+
creates = [call for call in fake.calls if "issue create" in " ".join(call)]
|
|
100
|
+
self.assertEqual(len(creates), 1)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
unittest.main()
|