@ikon85/agent-workflow-kit 0.27.1 → 0.29.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 +2 -2
- package/.agents/skills/board-to-waves/SKILL.md +13 -3
- package/.agents/skills/grill-me/SKILL.md +1 -1
- package/.agents/skills/grill-with-docs/SKILL.md +1 -1
- package/.agents/skills/kit-update/SKILL.md +12 -0
- package/.agents/skills/orchestrate-wave/SKILL.md +68 -21
- package/.agents/skills/orchestrate-wave/references/builder-contract.md +6 -4
- package/.agents/skills/retro/SKILL.md +24 -1
- package/.agents/skills/scale-check/SKILL.md +2 -2
- package/.agents/skills/setup-workflow/SKILL.md +44 -1
- package/.agents/skills/setup-workflow/workflow-overview.md +5 -5
- package/.agents/skills/tdd/SKILL.md +68 -14
- package/.agents/skills/to-issues/SKILL.md +104 -29
- package/.agents/skills/to-prd/SKILL.md +13 -3
- package/.agents/skills/to-waves/SKILL.md +16 -4
- package/.agents/skills/verify-spike/SKILL.md +1 -1
- package/.agents/skills/wrapup/SKILL.md +14 -3
- package/.claude/hooks/drift-guard.py +5 -2
- package/.claude/hooks/kit-origin-edit-hint.py +64 -0
- package/.claude/skills/ask-matt/SKILL.md +2 -2
- package/.claude/skills/board-to-waves/SKILL.md +13 -3
- package/.claude/skills/codex-build/SKILL.md +69 -23
- package/.claude/skills/codex-review/SKILL.md +47 -37
- package/.claude/skills/grill-me/SKILL.md +1 -1
- package/.claude/skills/grill-me-codex/SKILL.md +45 -34
- package/.claude/skills/grill-with-docs/SKILL.md +1 -1
- package/.claude/skills/grill-with-docs-codex/SKILL.md +46 -34
- package/.claude/skills/kit-update/SKILL.md +12 -0
- package/.claude/skills/orchestrate-wave/SKILL.md +68 -21
- package/.claude/skills/orchestrate-wave/references/builder-contract.md +6 -4
- package/.claude/skills/retro/SKILL.md +23 -0
- package/.claude/skills/scale-check/SKILL.md +2 -2
- package/.claude/skills/setup-workflow/SKILL.md +44 -1
- package/.claude/skills/setup-workflow/workflow-overview.md +5 -5
- package/.claude/skills/skill-manifest.json +1 -1
- package/.claude/skills/tdd/SKILL.md +68 -14
- package/.claude/skills/to-issues/SKILL.md +104 -29
- package/.claude/skills/to-prd/SKILL.md +13 -3
- package/.claude/skills/to-waves/SKILL.md +16 -4
- package/.claude/skills/verify-spike/SKILL.md +1 -1
- package/.claude/skills/wrapup/SKILL.md +14 -3
- package/README.md +70 -7
- package/agent-workflow-kit.package.json +99 -43
- package/docs/adr/0001-consumer-divergence-policy.md +49 -0
- package/docs/agents/wave-anchor-template.md +1 -1
- package/docs/research/wave-152-consumer-acceptance.md +98 -0
- package/package.json +1 -1
- package/scripts/board-sync.py +187 -12
- package/scripts/build-kit.test.mjs +42 -1
- package/scripts/codex-exec-scenarios/fake-codex.mjs +152 -0
- package/scripts/codex-exec.sh +566 -0
- package/scripts/codex-exec.test.mjs +815 -0
- package/scripts/codex_proc.py +602 -0
- package/scripts/find-by-marker.py +68 -0
- package/scripts/marker_lib.py +88 -0
- package/scripts/pr-body-check.py +34 -6
- package/scripts/pr_body_e2e.py +83 -0
- package/scripts/render-anchor.py +111 -0
- package/scripts/test_board_sync.py +208 -0
- package/scripts/test_census_backstop.py +56 -3
- package/scripts/test_marker_lib.py +154 -0
- package/scripts/test_orchestrate_wave_contract.py +116 -0
- package/scripts/test_pr_body_check.py +245 -0
- package/scripts/test_render_anchor.py +267 -0
- package/scripts/test_retro_wrapup_contract.py +117 -0
- package/scripts/test_skill_codex_exec_lifecycle.py +123 -0
- package/scripts/test_tdd_contract.py +78 -0
- package/src/cli.mjs +44 -8
- package/src/commands/diff.mjs +5 -2
- package/src/commands/init.mjs +12 -0
- package/src/commands/own.mjs +16 -0
- package/src/commands/uninstall.mjs +8 -1
- package/src/commands/update.mjs +37 -9
- package/src/lib/bundle.mjs +14 -0
- package/src/lib/consumerPath.mjs +30 -0
- package/src/lib/manifest.mjs +18 -0
- package/src/lib/ownedDiff.mjs +88 -0
- package/src/lib/updateCandidate.mjs +14 -0
- package/src/lib/updateReconcile.mjs +45 -6
|
@@ -4,6 +4,7 @@ import os
|
|
|
4
4
|
import subprocess
|
|
5
5
|
import sys
|
|
6
6
|
import tempfile
|
|
7
|
+
import time
|
|
7
8
|
import unittest
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from unittest.mock import patch
|
|
@@ -45,6 +46,35 @@ class CensusBackstopTest(unittest.TestCase):
|
|
|
45
46
|
json.dumps(fresh) + "\n", encoding="utf-8"
|
|
46
47
|
)
|
|
47
48
|
|
|
49
|
+
def test_census_bridge_uses_production_scale_budgets_and_clamps_proofs(self):
|
|
50
|
+
temporary, root = self.make_repo()
|
|
51
|
+
self.addCleanup(temporary.cleanup)
|
|
52
|
+
self.enable(root)
|
|
53
|
+
calls = []
|
|
54
|
+
|
|
55
|
+
def complete_scan(*args, **kwargs):
|
|
56
|
+
calls.append(kwargs)
|
|
57
|
+
return subprocess.CompletedProcess(
|
|
58
|
+
args=args[0], returncode=0,
|
|
59
|
+
stdout=json.dumps({"state": "bootstrap"}), stderr="",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
with patch.object(DRIFT_GUARD.subprocess, "run", side_effect=complete_scan):
|
|
63
|
+
DRIFT_GUARD.scan_census_status(root)
|
|
64
|
+
DRIFT_GUARD.scan_census_status(root, proof_timeout_ms=10**9)
|
|
65
|
+
|
|
66
|
+
default_input = json.loads(calls[0]["input"])
|
|
67
|
+
clamped_input = json.loads(calls[1]["input"])
|
|
68
|
+
self.assertEqual(calls[0]["timeout"], 30)
|
|
69
|
+
self.assertEqual(default_input["proofTimeoutMs"], 12_000)
|
|
70
|
+
self.assertGreater(clamped_input["proofTimeoutMs"], 0)
|
|
71
|
+
self.assertLess(clamped_input["proofTimeoutMs"], calls[1]["timeout"] * 1_000)
|
|
72
|
+
|
|
73
|
+
for invalid in (False, 1.5, "12000"):
|
|
74
|
+
with self.subTest(invalid=invalid):
|
|
75
|
+
with self.assertRaisesRegex(ValueError, "proof timeout must be an integer"):
|
|
76
|
+
DRIFT_GUARD.scan_census_status(root, proof_timeout_ms=invalid)
|
|
77
|
+
|
|
48
78
|
def test_missing_disabled_bootstrap_and_offline_are_visible_but_fail_open(self):
|
|
49
79
|
temporary, root = self.make_repo()
|
|
50
80
|
self.addCleanup(temporary.cleanup)
|
|
@@ -322,15 +352,14 @@ class CensusBackstopTest(unittest.TestCase):
|
|
|
322
352
|
("refresh_required", True))
|
|
323
353
|
self.assertIn("proof:src", failed_test["reasons"])
|
|
324
354
|
|
|
325
|
-
def
|
|
355
|
+
def test_production_scale_proof_completes_and_hung_proofs_stay_bounded(self):
|
|
326
356
|
temporary, root = self.make_repo()
|
|
327
357
|
self.addCleanup(temporary.cleanup)
|
|
328
358
|
module = root / "scanner.mjs"
|
|
329
359
|
test = root / "scanner.test.mjs"
|
|
330
360
|
module.write_text("export function scanLocal() { return ['src']; }\n", encoding="utf-8")
|
|
331
361
|
test.write_text(
|
|
332
|
-
"import { test } from 'node:test'; "
|
|
333
|
-
"test('hang', async () => new Promise(resolve => setTimeout(resolve, 250)));\n",
|
|
362
|
+
"import { test } from 'node:test'; test('proof', () => {});\n",
|
|
334
363
|
encoding="utf-8",
|
|
335
364
|
)
|
|
336
365
|
subprocess.run(["git", "add", "scanner.mjs", "scanner.test.mjs"], cwd=root, check=True)
|
|
@@ -341,11 +370,32 @@ class CensusBackstopTest(unittest.TestCase):
|
|
|
341
370
|
self.enable(root, local_scanners=[scanner])
|
|
342
371
|
self.activate_current(root)
|
|
343
372
|
|
|
373
|
+
module.write_text(
|
|
374
|
+
"export async function scanLocal() { "
|
|
375
|
+
"await new Promise(resolve => setTimeout(resolve, 5200)); return ['src']; }\n",
|
|
376
|
+
encoding="utf-8",
|
|
377
|
+
)
|
|
378
|
+
valid_started = time.monotonic()
|
|
379
|
+
valid = DRIFT_GUARD.evaluate_census(root)
|
|
380
|
+
valid_elapsed = time.monotonic() - valid_started
|
|
381
|
+
self.assertEqual((valid["state"], valid["block_handoff"]), ("current", False))
|
|
382
|
+
self.assertGreater(valid_elapsed, 5)
|
|
383
|
+
self.assertLess(valid_elapsed, DRIFT_GUARD.CENSUS_BRIDGE_TIMEOUT_SECONDS)
|
|
384
|
+
|
|
385
|
+
test.write_text(
|
|
386
|
+
"import { test } from 'node:test'; "
|
|
387
|
+
"test('hang', async () => new Promise(resolve => setTimeout(resolve, 250)));\n",
|
|
388
|
+
encoding="utf-8",
|
|
389
|
+
)
|
|
390
|
+
module.write_text("export function scanLocal() { return ['src']; }\n", encoding="utf-8")
|
|
391
|
+
timeout_started = time.monotonic()
|
|
344
392
|
result = DRIFT_GUARD.evaluate_census(root, proof_timeout_ms=25)
|
|
393
|
+
timeout_elapsed = time.monotonic() - timeout_started
|
|
345
394
|
|
|
346
395
|
self.assertEqual((result["state"], result["block_handoff"]),
|
|
347
396
|
("refresh_required", True))
|
|
348
397
|
self.assertIn("proof:src", result["reasons"])
|
|
398
|
+
self.assertLess(timeout_elapsed, 2)
|
|
349
399
|
|
|
350
400
|
test.write_text("import { test } from 'node:test'; test('proof', () => {});\n",
|
|
351
401
|
encoding="utf-8")
|
|
@@ -353,10 +403,13 @@ class CensusBackstopTest(unittest.TestCase):
|
|
|
353
403
|
"export async function scanLocal() { return new Promise(() => {}); }\n",
|
|
354
404
|
encoding="utf-8",
|
|
355
405
|
)
|
|
406
|
+
scanner_timeout_started = time.monotonic()
|
|
356
407
|
scanner_timeout = DRIFT_GUARD.evaluate_census(root, proof_timeout_ms=25)
|
|
408
|
+
scanner_timeout_elapsed = time.monotonic() - scanner_timeout_started
|
|
357
409
|
self.assertEqual((scanner_timeout["state"], scanner_timeout["block_handoff"]),
|
|
358
410
|
("refresh_required", True))
|
|
359
411
|
self.assertIn("proof:src", scanner_timeout["reasons"])
|
|
412
|
+
self.assertLess(scanner_timeout_elapsed, 2)
|
|
360
413
|
|
|
361
414
|
def test_activated_census_fails_closed_when_bridge_is_unavailable(self):
|
|
362
415
|
temporary, root = self.make_repo()
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Behavior tests for marker identity lookup and reconciliation."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import importlib.util
|
|
8
|
+
import io
|
|
9
|
+
import subprocess
|
|
10
|
+
import unittest
|
|
11
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from unittest.mock import patch
|
|
14
|
+
|
|
15
|
+
from scripts.marker_lib import find_by_marker, reconcile_after_create
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
CLI_SPEC = importlib.util.spec_from_file_location(
|
|
19
|
+
"find_by_marker_cli_test", Path(__file__).with_name("find-by-marker.py"))
|
|
20
|
+
cli = importlib.util.module_from_spec(CLI_SPEC)
|
|
21
|
+
assert CLI_SPEC.loader is not None
|
|
22
|
+
CLI_SPEC.loader.exec_module(cli)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MarkerLookupTest(unittest.TestCase):
|
|
26
|
+
def test_pagination_joins_all_pages(self):
|
|
27
|
+
calls: list[list[str]] = []
|
|
28
|
+
|
|
29
|
+
def fake_gh(args: list[str]) -> str:
|
|
30
|
+
calls.append(args)
|
|
31
|
+
return json.dumps([
|
|
32
|
+
[{"number": 11, "state": "open", "body":
|
|
33
|
+
"<!-- prd-source-id: alpha -->"}],
|
|
34
|
+
[{"number": 12, "state": "closed", "body":
|
|
35
|
+
"<!-- prd-source-id: alpha -->"}],
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
result = find_by_marker("example/repo", "prd-source-id", "alpha", fake_gh)
|
|
39
|
+
|
|
40
|
+
self.assertEqual(result["count"], 2)
|
|
41
|
+
self.assertEqual(result["issues"], [
|
|
42
|
+
{"number": 11, "state": "open"},
|
|
43
|
+
{"number": 12, "state": "closed"},
|
|
44
|
+
])
|
|
45
|
+
self.assertIn("--paginate", calls[0])
|
|
46
|
+
self.assertIn("state=all", calls[0])
|
|
47
|
+
|
|
48
|
+
def test_pull_requests_are_discarded_before_matching(self):
|
|
49
|
+
marker = "<!-- wave-stub-source: same -->"
|
|
50
|
+
payload = [[
|
|
51
|
+
{"number": 21, "state": "open", "body": marker,
|
|
52
|
+
"pull_request": {"url": "https://api.example/pr/21"}},
|
|
53
|
+
{"number": 22, "state": "open", "body": marker},
|
|
54
|
+
]]
|
|
55
|
+
|
|
56
|
+
result = find_by_marker(
|
|
57
|
+
"example/repo", "wave-stub-source", "same",
|
|
58
|
+
lambda _args: json.dumps(payload),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
self.assertEqual(result["issues"], [{"number": 22, "state": "open"}])
|
|
62
|
+
|
|
63
|
+
def test_closed_only_match_requires_user_decision(self):
|
|
64
|
+
payload = [[{"number": 31, "state": "closed", "body":
|
|
65
|
+
"<!-- prd-source-id: retired -->"}]]
|
|
66
|
+
|
|
67
|
+
result = find_by_marker(
|
|
68
|
+
"example/repo", "prd-source-id", "retired",
|
|
69
|
+
lambda _args: json.dumps(payload),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
self.assertEqual(result["verdict"], "user-decision")
|
|
73
|
+
|
|
74
|
+
def test_marker_value_is_exact_not_a_prefix_match(self):
|
|
75
|
+
payload = [[
|
|
76
|
+
{"number": 41, "state": "open", "body":
|
|
77
|
+
"<!-- prd-source-id: alpha-long -->"},
|
|
78
|
+
{"number": 42, "state": "open", "body":
|
|
79
|
+
"<!-- prd-source-id: alpha -->"},
|
|
80
|
+
]]
|
|
81
|
+
|
|
82
|
+
result = find_by_marker(
|
|
83
|
+
"example/repo", "prd-source-id", "alpha",
|
|
84
|
+
lambda _args: json.dumps(payload),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
self.assertEqual(result["issues"], [{"number": 42, "state": "open"}])
|
|
88
|
+
self.assertEqual(result["verdict"], "update")
|
|
89
|
+
|
|
90
|
+
def test_post_create_rescan_stops_and_reports_both_duplicate_numbers(self):
|
|
91
|
+
marker = "<!-- program-leaf-source: program-x/1a -->"
|
|
92
|
+
responses = iter([
|
|
93
|
+
[[]],
|
|
94
|
+
[[
|
|
95
|
+
{"number": 101, "state": "open", "body": marker},
|
|
96
|
+
{"number": 102, "state": "open", "body": marker},
|
|
97
|
+
]],
|
|
98
|
+
])
|
|
99
|
+
calls: list[list[str]] = []
|
|
100
|
+
|
|
101
|
+
def fake_gh(args: list[str]) -> str:
|
|
102
|
+
calls.append(args)
|
|
103
|
+
return json.dumps(next(responses))
|
|
104
|
+
|
|
105
|
+
before = find_by_marker(
|
|
106
|
+
"example/repo", "program-leaf-source", "program-x/1a",
|
|
107
|
+
fake_gh,
|
|
108
|
+
)
|
|
109
|
+
after = reconcile_after_create(
|
|
110
|
+
"example/repo", "program-leaf-source", "program-x/1a", 101,
|
|
111
|
+
fake_gh,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
self.assertEqual(before["verdict"], "create")
|
|
115
|
+
self.assertEqual(after["verdict"], "STOP")
|
|
116
|
+
self.assertEqual([issue["number"] for issue in after["issues"]], [101, 102])
|
|
117
|
+
self.assertEqual(len(calls), 2)
|
|
118
|
+
|
|
119
|
+
def test_post_create_rescan_rejects_a_different_single_issue(self):
|
|
120
|
+
payload = [[{"number": 102, "state": "open", "body":
|
|
121
|
+
"<!-- prd-source-id: alpha -->"}]]
|
|
122
|
+
|
|
123
|
+
result = reconcile_after_create(
|
|
124
|
+
"example/repo", "prd-source-id", "alpha", 101,
|
|
125
|
+
lambda _args: json.dumps(payload),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
self.assertEqual(result["verdict"], "STOP")
|
|
129
|
+
self.assertEqual(result["issues"], [{"number": 102, "state": "open"}])
|
|
130
|
+
|
|
131
|
+
def test_unknown_marker_kind_has_distinct_cli_error(self):
|
|
132
|
+
out, err = io.StringIO(), io.StringIO()
|
|
133
|
+
with redirect_stdout(out), redirect_stderr(err):
|
|
134
|
+
code = cli.main(
|
|
135
|
+
["--kind", "unknown-source", "--slug", "x"],
|
|
136
|
+
repo="example/repo",
|
|
137
|
+
gh=lambda _args: self.fail("unknown kinds must fail before scanning"),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
self.assertEqual(code, 2)
|
|
141
|
+
self.assertEqual(out.getvalue(), "")
|
|
142
|
+
self.assertIn("unknown marker kind", err.getvalue())
|
|
143
|
+
|
|
144
|
+
def test_cli_bounds_a_hanging_github_call(self):
|
|
145
|
+
with patch.object(
|
|
146
|
+
cli.subprocess, "run",
|
|
147
|
+
side_effect=subprocess.TimeoutExpired("gh", 30),
|
|
148
|
+
):
|
|
149
|
+
with self.assertRaisesRegex(RuntimeError, "timed out after 30s"):
|
|
150
|
+
cli._gh(["api", "--paginate", "repos/example/repo/issues"])
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
unittest.main()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Reference-free behavioral contract for the portable orchestrate-wave skill.
|
|
3
|
+
|
|
4
|
+
The parity table names outcomes, not the consumer that first proved them. That
|
|
5
|
+
keeps the regression test useful in every repository that installs the kit.
|
|
6
|
+
|
|
7
|
+
Run: python3 scripts/test_orchestrate_wave_contract.py
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import unittest
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
16
|
+
CLAUDE_SKILL = REPO / ".claude/skills/orchestrate-wave/SKILL.md"
|
|
17
|
+
CODEX_SKILL = REPO / ".agents/skills/orchestrate-wave/SKILL.md"
|
|
18
|
+
CLAUDE_BUILDER = (
|
|
19
|
+
REPO / ".claude/skills/orchestrate-wave/references/builder-contract.md"
|
|
20
|
+
)
|
|
21
|
+
CODEX_BUILDER = (
|
|
22
|
+
REPO / ".agents/skills/orchestrate-wave/references/builder-contract.md"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Outcome -> fragments whose conjunction proves that portable behavior.
|
|
27
|
+
BEHAVIORAL_PARITY = {
|
|
28
|
+
"collision-safe claim": (
|
|
29
|
+
"wave-active/<anchor>",
|
|
30
|
+
"ahead",
|
|
31
|
+
"uncommitted changes",
|
|
32
|
+
"LOCAL annotated tag",
|
|
33
|
+
"never push",
|
|
34
|
+
),
|
|
35
|
+
"owner-safe abort cleanup": (
|
|
36
|
+
"this run planted",
|
|
37
|
+
"On ANY wave STOP/abort",
|
|
38
|
+
"Never delete a claim marker observed during a preflight collision",
|
|
39
|
+
),
|
|
40
|
+
"dependency-aware retirement": (
|
|
41
|
+
"topological",
|
|
42
|
+
"internal import graph",
|
|
43
|
+
"ONE atomic slice",
|
|
44
|
+
"cycle",
|
|
45
|
+
),
|
|
46
|
+
"safe stacked landing": (
|
|
47
|
+
"Do NOT rely on auto-retarget",
|
|
48
|
+
"FRESH PR",
|
|
49
|
+
"merge order",
|
|
50
|
+
"manual gate",
|
|
51
|
+
),
|
|
52
|
+
"completion propagation": (
|
|
53
|
+
"Closing Conditions",
|
|
54
|
+
"completion status",
|
|
55
|
+
"native parent",
|
|
56
|
+
"Program-PRD",
|
|
57
|
+
"program sync",
|
|
58
|
+
),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def markdown_body(text: str) -> str:
|
|
63
|
+
"""Ignore frontmatter representation differences between adapters."""
|
|
64
|
+
if not text.startswith("---\n"):
|
|
65
|
+
return text
|
|
66
|
+
return text.split("\n---\n", 1)[1]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class OrchestrateWaveContract(unittest.TestCase):
|
|
70
|
+
@classmethod
|
|
71
|
+
def setUpClass(cls):
|
|
72
|
+
cls.skill = CLAUDE_SKILL.read_text(encoding="utf-8")
|
|
73
|
+
cls.builder = CLAUDE_BUILDER.read_text(encoding="utf-8")
|
|
74
|
+
|
|
75
|
+
def test_reference_free_behavioral_parity_table(self):
|
|
76
|
+
prose = " ".join(self.skill.split())
|
|
77
|
+
for outcome, fragments in BEHAVIORAL_PARITY.items():
|
|
78
|
+
with self.subTest(outcome=outcome):
|
|
79
|
+
for fragment in fragments:
|
|
80
|
+
self.assertIn(" ".join(fragment.split()), prose)
|
|
81
|
+
|
|
82
|
+
self.assertNotRegex(self.skill, re.compile(r"/home/|#[0-9]{3,}"))
|
|
83
|
+
|
|
84
|
+
def test_builder_commands_finish_in_the_foreground(self):
|
|
85
|
+
prose = " ".join(self.builder.split())
|
|
86
|
+
for fragment in (
|
|
87
|
+
"IN THE FOREGROUND",
|
|
88
|
+
"never background a test/gate command",
|
|
89
|
+
"completed command results",
|
|
90
|
+
"exit status",
|
|
91
|
+
):
|
|
92
|
+
self.assertIn(" ".join(fragment.split()), prose)
|
|
93
|
+
|
|
94
|
+
def test_current_portable_contracts_survive_the_port(self):
|
|
95
|
+
for fragment in (
|
|
96
|
+
"project layer",
|
|
97
|
+
"Native blocking edges are the frontier authority",
|
|
98
|
+
"AFK heartbeat",
|
|
99
|
+
"Re-run your project's full CI/verify gate CENTRALLY yourself",
|
|
100
|
+
"already authenticated",
|
|
101
|
+
):
|
|
102
|
+
self.assertIn(fragment, self.skill)
|
|
103
|
+
|
|
104
|
+
def test_claude_and_codex_surfaces_match(self):
|
|
105
|
+
self.assertEqual(
|
|
106
|
+
markdown_body(self.skill),
|
|
107
|
+
markdown_body(CODEX_SKILL.read_text(encoding="utf-8")),
|
|
108
|
+
)
|
|
109
|
+
self.assertEqual(
|
|
110
|
+
self.builder,
|
|
111
|
+
CODEX_BUILDER.read_text(encoding="utf-8"),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
unittest.main()
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Behavior tests for the PR-body convention guard."""
|
|
3
|
+
import importlib.util
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from unittest import mock
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
|
|
13
|
+
PROFILE_PATH = Path(__file__).parent.parent / "docs/agents/board-sync.md"
|
|
14
|
+
TEST_ENV = os.environ.copy()
|
|
15
|
+
TEST_ENV["BOARD_SYNC_PROFILE"] = str(PROFILE_PATH)
|
|
16
|
+
_PREVIOUS_PROFILE = os.environ.get("BOARD_SYNC_PROFILE")
|
|
17
|
+
os.environ["BOARD_SYNC_PROFILE"] = str(PROFILE_PATH)
|
|
18
|
+
|
|
19
|
+
_SPEC = importlib.util.spec_from_file_location(
|
|
20
|
+
"pr_body_check", Path(__file__).parent / "pr-body-check.py")
|
|
21
|
+
pbc = importlib.util.module_from_spec(_SPEC)
|
|
22
|
+
_SPEC.loader.exec_module(pbc)
|
|
23
|
+
if _PREVIOUS_PROFILE is None:
|
|
24
|
+
os.environ.pop("BOARD_SYNC_PROFILE", None)
|
|
25
|
+
else:
|
|
26
|
+
os.environ["BOARD_SYNC_PROFILE"] = _PREVIOUS_PROFILE
|
|
27
|
+
import pr_body_e2e as e2e # noqa: E402
|
|
28
|
+
|
|
29
|
+
SCRIPT_PATH = Path(__file__).parent / "pr-body-check.py"
|
|
30
|
+
|
|
31
|
+
RETRO = "**Retro:** skipped — focused guard change"
|
|
32
|
+
VALID_LEAF_BODY = f"closes #149\n{RETRO}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class E2eNaBodyEvidence(unittest.TestCase):
|
|
36
|
+
def test_no_trailer_and_no_evidence_is_green(self):
|
|
37
|
+
self.assertEqual(pbc.check_pr_body(VALID_LEAF_BODY, 149, None), [])
|
|
38
|
+
|
|
39
|
+
def test_no_trailer_with_evidence_is_green(self):
|
|
40
|
+
body = VALID_LEAF_BODY + "\nE2E: n/a — harmless extra context"
|
|
41
|
+
self.assertEqual(pbc.check_pr_body(body, 149, None), [])
|
|
42
|
+
|
|
43
|
+
def test_valid_trailer_without_body_evidence_is_actionable(self):
|
|
44
|
+
violations = pbc.check_pr_body(
|
|
45
|
+
VALID_LEAF_BODY,
|
|
46
|
+
149,
|
|
47
|
+
None,
|
|
48
|
+
has_e2e_na_trailer=True,
|
|
49
|
+
)
|
|
50
|
+
self.assertTrue(any("E2E: n/a" in violation for violation in violations))
|
|
51
|
+
|
|
52
|
+
def test_valid_trailer_with_non_empty_evidence_is_green(self):
|
|
53
|
+
body = VALID_LEAF_BODY + "\nE2E: n/a — backend-only change"
|
|
54
|
+
self.assertEqual(
|
|
55
|
+
pbc.check_pr_body(body, 149, None, has_e2e_na_trailer=True), []
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def test_valid_trailer_with_empty_evidence_is_actionable(self):
|
|
59
|
+
body = VALID_LEAF_BODY + "\nE2E: n/a — "
|
|
60
|
+
violations = pbc.check_pr_body(
|
|
61
|
+
body, 149, None, has_e2e_na_trailer=True
|
|
62
|
+
)
|
|
63
|
+
self.assertTrue(any("E2E: n/a" in violation for violation in violations))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ExistingBodyRules(unittest.TestCase):
|
|
67
|
+
def test_anchor_slice_still_accepts_part_of_and_leaf_close(self):
|
|
68
|
+
body = f"Part of #130\ncloses #149\n{RETRO}"
|
|
69
|
+
self.assertEqual(pbc.check_pr_body(body, 149, 130), [])
|
|
70
|
+
|
|
71
|
+
def test_anchor_slice_still_rejects_close_on_anchor(self):
|
|
72
|
+
body = f"Part of #130\ncloses #130\n{RETRO}"
|
|
73
|
+
self.assertTrue(any("130" in item for item in pbc.check_pr_body(body, 149, 130)))
|
|
74
|
+
|
|
75
|
+
def test_leaf_still_requires_active_close(self):
|
|
76
|
+
body = f"`closes #149`\n{RETRO}"
|
|
77
|
+
self.assertTrue(any("closes #149" in item for item in pbc.check_pr_body(body, 149, None)))
|
|
78
|
+
|
|
79
|
+
def test_retro_line_still_required(self):
|
|
80
|
+
self.assertTrue(any("Retro" in item for item in pbc.check_pr_body("closes #149", 149, None)))
|
|
81
|
+
|
|
82
|
+
def test_wave_pr_still_requires_part_of_without_closing_anchor(self):
|
|
83
|
+
body = f"Part of #130\ncloses #149\n{RETRO}"
|
|
84
|
+
self.assertEqual(pbc.check_pr_body(body, 130, None, is_anchor=True), [])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ImmutableRangeTrailer(unittest.TestCase):
|
|
88
|
+
def test_two_commit_range_finds_one_valid_trailer(self):
|
|
89
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
90
|
+
repo = Path(temp_dir)
|
|
91
|
+
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
|
92
|
+
subprocess.run(
|
|
93
|
+
["git", "config", "user.email", "tests@example.invalid"],
|
|
94
|
+
cwd=repo,
|
|
95
|
+
check=True,
|
|
96
|
+
)
|
|
97
|
+
subprocess.run(
|
|
98
|
+
["git", "config", "user.name", "Test User"], cwd=repo, check=True
|
|
99
|
+
)
|
|
100
|
+
(repo / "change.txt").write_text("base\n", encoding="utf-8")
|
|
101
|
+
subprocess.run(["git", "add", "change.txt"], cwd=repo, check=True)
|
|
102
|
+
subprocess.run(["git", "commit", "-qm", "base"], cwd=repo, check=True)
|
|
103
|
+
base = subprocess.check_output(
|
|
104
|
+
["git", "rev-parse", "HEAD"], cwd=repo, text=True
|
|
105
|
+
).strip()
|
|
106
|
+
(repo / "change.txt").write_text("head\n", encoding="utf-8")
|
|
107
|
+
subprocess.run(["git", "add", "change.txt"], cwd=repo, check=True)
|
|
108
|
+
subprocess.run(
|
|
109
|
+
[
|
|
110
|
+
"git",
|
|
111
|
+
"commit",
|
|
112
|
+
"-qm",
|
|
113
|
+
"head\n\nE2E-NA: backend-only change",
|
|
114
|
+
],
|
|
115
|
+
cwd=repo,
|
|
116
|
+
check=True,
|
|
117
|
+
)
|
|
118
|
+
head = subprocess.check_output(
|
|
119
|
+
["git", "rev-parse", "HEAD"], cwd=repo, text=True
|
|
120
|
+
).strip()
|
|
121
|
+
|
|
122
|
+
self.assertTrue(e2e.fetch_has_e2e_na_trailer(base, head, cwd=repo))
|
|
123
|
+
|
|
124
|
+
def test_unreadable_range_fails_open(self):
|
|
125
|
+
self.assertFalse(e2e.fetch_has_e2e_na_trailer("missing-base", "missing-head"))
|
|
126
|
+
|
|
127
|
+
def test_empty_or_multiple_trailers_are_not_a_single_valid_trailer(self):
|
|
128
|
+
with mock.patch.object(
|
|
129
|
+
e2e, "_collect_e2e_na_trailers", side_effect=[[""], ["one", "two"]]
|
|
130
|
+
):
|
|
131
|
+
self.assertFalse(e2e.fetch_has_e2e_na_trailer("base", "head"))
|
|
132
|
+
self.assertFalse(e2e.fetch_has_e2e_na_trailer("base", "head"))
|
|
133
|
+
|
|
134
|
+
def test_pr_range_reads_immutable_base_and_head_oids(self):
|
|
135
|
+
payload = '{"baseRefOid":"base-sha","headRefOid":"head-sha"}'
|
|
136
|
+
with mock.patch.object(e2e, "_run", return_value=(0, payload)):
|
|
137
|
+
self.assertEqual(e2e.fetch_pr_range("feat/149-guard"), ("base-sha", "head-sha"))
|
|
138
|
+
|
|
139
|
+
def test_checker_defaults_to_pr_range_when_no_overrides_are_given(self):
|
|
140
|
+
args = SimpleNamespace(base_sha=None, head_sha=None)
|
|
141
|
+
with (
|
|
142
|
+
mock.patch.object(pbc, "fetch_pr_range", return_value=("base", "head")) as get_range,
|
|
143
|
+
mock.patch.object(pbc, "fetch_has_e2e_na_trailer", return_value=True) as has_trailer,
|
|
144
|
+
):
|
|
145
|
+
self.assertTrue(pbc.resolve_has_e2e_na(args, "feat/149-guard"))
|
|
146
|
+
get_range.assert_called_once_with("feat/149-guard")
|
|
147
|
+
has_trailer.assert_called_once_with("base", "head")
|
|
148
|
+
|
|
149
|
+
def test_unavailable_or_invalid_pr_range_fails_open(self):
|
|
150
|
+
with mock.patch.object(e2e, "_run", side_effect=[(1, ""), (0, "not-json")]):
|
|
151
|
+
self.assertEqual(e2e.fetch_pr_range("missing"), (None, None))
|
|
152
|
+
self.assertEqual(e2e.fetch_pr_range("invalid"), (None, None))
|
|
153
|
+
|
|
154
|
+
def test_explicit_range_requires_then_accepts_body_evidence(self):
|
|
155
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
156
|
+
repo = Path(temp_dir)
|
|
157
|
+
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
|
158
|
+
subprocess.run(
|
|
159
|
+
["git", "config", "user.email", "tests@example.invalid"],
|
|
160
|
+
cwd=repo,
|
|
161
|
+
check=True,
|
|
162
|
+
)
|
|
163
|
+
subprocess.run(
|
|
164
|
+
["git", "config", "user.name", "Test User"], cwd=repo, check=True
|
|
165
|
+
)
|
|
166
|
+
(repo / "change.txt").write_text("base\n", encoding="utf-8")
|
|
167
|
+
subprocess.run(["git", "add", "change.txt"], cwd=repo, check=True)
|
|
168
|
+
subprocess.run(["git", "commit", "-qm", "base"], cwd=repo, check=True)
|
|
169
|
+
base = subprocess.check_output(
|
|
170
|
+
["git", "rev-parse", "HEAD"], cwd=repo, text=True
|
|
171
|
+
).strip()
|
|
172
|
+
(repo / "change.txt").write_text("head\n", encoding="utf-8")
|
|
173
|
+
subprocess.run(["git", "add", "change.txt"], cwd=repo, check=True)
|
|
174
|
+
subprocess.run(
|
|
175
|
+
["git", "commit", "-qm", "head\n\nE2E-NA: backend-only change"],
|
|
176
|
+
cwd=repo,
|
|
177
|
+
check=True,
|
|
178
|
+
)
|
|
179
|
+
head = subprocess.check_output(
|
|
180
|
+
["git", "rev-parse", "HEAD"], cwd=repo, text=True
|
|
181
|
+
).strip()
|
|
182
|
+
body_path = repo / "body.md"
|
|
183
|
+
base_body = f"Part of #130\ncloses #149\n{RETRO}"
|
|
184
|
+
command = [
|
|
185
|
+
sys.executable,
|
|
186
|
+
str(SCRIPT_PATH),
|
|
187
|
+
"--issue",
|
|
188
|
+
"149",
|
|
189
|
+
"--parent",
|
|
190
|
+
"130",
|
|
191
|
+
"--body-file",
|
|
192
|
+
str(body_path),
|
|
193
|
+
"--base-sha",
|
|
194
|
+
base,
|
|
195
|
+
"--head-sha",
|
|
196
|
+
head,
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
body_path.write_text(base_body, encoding="utf-8")
|
|
200
|
+
missing = subprocess.run(
|
|
201
|
+
command, cwd=repo, capture_output=True, text=True, env=TEST_ENV
|
|
202
|
+
)
|
|
203
|
+
self.assertEqual(missing.returncode, 1, missing.stdout + missing.stderr)
|
|
204
|
+
self.assertIn("E2E: n/a", missing.stdout)
|
|
205
|
+
|
|
206
|
+
body_path.write_text(
|
|
207
|
+
base_body + "\nE2E: n/a — backend-only change\n", encoding="utf-8"
|
|
208
|
+
)
|
|
209
|
+
matching = subprocess.run(
|
|
210
|
+
command, cwd=repo, capture_output=True, text=True, env=TEST_ENV
|
|
211
|
+
)
|
|
212
|
+
self.assertEqual(matching.returncode, 0, matching.stdout + matching.stderr)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class ExistingExitCodes(unittest.TestCase):
|
|
216
|
+
def test_no_issue_is_still_exit_two(self):
|
|
217
|
+
result = subprocess.run(
|
|
218
|
+
[sys.executable, str(SCRIPT_PATH), "--branch", "main"],
|
|
219
|
+
capture_output=True,
|
|
220
|
+
text=True,
|
|
221
|
+
env=TEST_ENV,
|
|
222
|
+
)
|
|
223
|
+
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
|
|
224
|
+
|
|
225
|
+
def test_unreadable_body_file_is_still_exit_two(self):
|
|
226
|
+
result = subprocess.run(
|
|
227
|
+
[
|
|
228
|
+
sys.executable,
|
|
229
|
+
str(SCRIPT_PATH),
|
|
230
|
+
"--issue",
|
|
231
|
+
"149",
|
|
232
|
+
"--parent",
|
|
233
|
+
"130",
|
|
234
|
+
"--body-file",
|
|
235
|
+
"/definitely/missing/body.md",
|
|
236
|
+
],
|
|
237
|
+
capture_output=True,
|
|
238
|
+
text=True,
|
|
239
|
+
env=TEST_ENV,
|
|
240
|
+
)
|
|
241
|
+
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
if __name__ == "__main__":
|
|
245
|
+
unittest.main()
|