@michelj/context-guard 0.4.0 → 0.4.2
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/README.md +55 -24
- package/README.zh-CN.md +55 -24
- package/bin/context-guard-skill.js +5 -5
- package/bin/postinstall.js +28 -0
- package/package.json +2 -1
- package/skills/context-guard/README.md +55 -24
- package/skills/context-guard/README.zh-CN.md +55 -24
- package/skills/context-guard/SKILL.md +42 -3
- package/skills/context-guard/scripts/context_guard.py +305 -80
- package/skills/context-guard/scripts/context_guard_hook.py +59 -2
- package/skills/context-guard/tests/BC-20260702-096.sh +45 -0
|
@@ -4,18 +4,15 @@
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
|
-
import contextlib
|
|
8
7
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
9
8
|
import html
|
|
10
9
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
11
|
-
import io
|
|
12
10
|
import json
|
|
13
11
|
import os
|
|
14
12
|
import re
|
|
15
13
|
import shutil
|
|
16
14
|
import subprocess
|
|
17
15
|
import sys
|
|
18
|
-
import threading
|
|
19
16
|
import webbrowser
|
|
20
17
|
from datetime import datetime
|
|
21
18
|
from pathlib import Path
|
|
@@ -3630,6 +3627,10 @@ def test_registry_path(ctx: Path) -> Path:
|
|
|
3630
3627
|
return ctx / "test-hub" / "registry.json"
|
|
3631
3628
|
|
|
3632
3629
|
|
|
3630
|
+
def feature_chains_path(ctx: Path) -> Path:
|
|
3631
|
+
return ctx / "test-hub" / "feature-chains.json"
|
|
3632
|
+
|
|
3633
|
+
|
|
3633
3634
|
def default_test_registry() -> dict[str, object]:
|
|
3634
3635
|
return {
|
|
3635
3636
|
"version": 1,
|
|
@@ -3673,6 +3674,206 @@ def next_test_id(ctx: Path, title: str) -> str:
|
|
|
3673
3674
|
return f"TC-{today}-{(max(numbers) + 1 if numbers else 1):03d}-{normalize_test_slug(title)[:36]}"
|
|
3674
3675
|
|
|
3675
3676
|
|
|
3677
|
+
def default_feature_chain_registry() -> dict[str, object]:
|
|
3678
|
+
return {
|
|
3679
|
+
"version": 1,
|
|
3680
|
+
"description": "Feature-oriented test chains. One chain should cover a real workflow and multiple linked bad-case recurrence checks.",
|
|
3681
|
+
"chains": [],
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
|
|
3685
|
+
def load_feature_chains(ctx: Path) -> dict[str, object]:
|
|
3686
|
+
path = feature_chains_path(ctx)
|
|
3687
|
+
if not path.exists():
|
|
3688
|
+
return default_feature_chain_registry()
|
|
3689
|
+
try:
|
|
3690
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
3691
|
+
except json.JSONDecodeError:
|
|
3692
|
+
print(f"[context-guard] invalid feature-chain JSON: {path}", file=sys.stderr)
|
|
3693
|
+
return default_feature_chain_registry()
|
|
3694
|
+
if not isinstance(data, dict):
|
|
3695
|
+
return default_feature_chain_registry()
|
|
3696
|
+
chains = data.get("chains")
|
|
3697
|
+
if not isinstance(chains, list):
|
|
3698
|
+
data["chains"] = []
|
|
3699
|
+
data.setdefault("version", 1)
|
|
3700
|
+
data.setdefault("description", default_feature_chain_registry()["description"])
|
|
3701
|
+
return data
|
|
3702
|
+
|
|
3703
|
+
|
|
3704
|
+
def write_feature_chains(ctx: Path, registry: dict[str, object]) -> Path:
|
|
3705
|
+
path = feature_chains_path(ctx)
|
|
3706
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
3707
|
+
path.write_text(json.dumps(registry, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
3708
|
+
return path
|
|
3709
|
+
|
|
3710
|
+
|
|
3711
|
+
def next_feature_chain_id(ctx: Path) -> str:
|
|
3712
|
+
today = datetime.now().strftime("%Y%m%d")
|
|
3713
|
+
registry = load_feature_chains(ctx)
|
|
3714
|
+
existing = " ".join(
|
|
3715
|
+
str(chain.get("id", "")) for chain in registry.get("chains", []) if isinstance(chain, dict)
|
|
3716
|
+
)
|
|
3717
|
+
numbers = [int(match.group(1)) for match in re.finditer(rf"FC-{today}-(\d+)", existing)]
|
|
3718
|
+
return f"FC-{today}-{(max(numbers) + 1 if numbers else 1):03d}"
|
|
3719
|
+
|
|
3720
|
+
|
|
3721
|
+
def feature_chain_find(
|
|
3722
|
+
ctx: Path, chain_id: str
|
|
3723
|
+
) -> tuple[dict[str, object], list[dict[str, object]], dict[str, object]]:
|
|
3724
|
+
registry = load_feature_chains(ctx)
|
|
3725
|
+
chains = registry.setdefault("chains", [])
|
|
3726
|
+
if not isinstance(chains, list):
|
|
3727
|
+
chains = []
|
|
3728
|
+
registry["chains"] = chains
|
|
3729
|
+
matches = [item for item in chains if isinstance(item, dict) and str(item.get("id", "")) == chain_id]
|
|
3730
|
+
if not matches:
|
|
3731
|
+
raise ValueError(f"feature chain not found: {chain_id}")
|
|
3732
|
+
return matches[0], chains, registry
|
|
3733
|
+
|
|
3734
|
+
|
|
3735
|
+
def feature_chain_add(
|
|
3736
|
+
root: Path,
|
|
3737
|
+
title: str,
|
|
3738
|
+
entry: str,
|
|
3739
|
+
exit_check: str,
|
|
3740
|
+
command_text: str = "",
|
|
3741
|
+
run_policy: str = RUN_ALWAYS_POLICY,
|
|
3742
|
+
status: str = "proposed",
|
|
3743
|
+
timeout_seconds: int = 300,
|
|
3744
|
+
artifact_policy: str = "cleanup-on-pass",
|
|
3745
|
+
resource: str = "local",
|
|
3746
|
+
) -> Path:
|
|
3747
|
+
init_context(root)
|
|
3748
|
+
ctx = context_dir(root)
|
|
3749
|
+
if not title.strip():
|
|
3750
|
+
raise ValueError("feature-chain-add requires --title")
|
|
3751
|
+
if not entry.strip():
|
|
3752
|
+
raise ValueError("feature-chain-add requires --entry")
|
|
3753
|
+
if not exit_check.strip():
|
|
3754
|
+
raise ValueError("feature-chain-add requires --exit-check")
|
|
3755
|
+
registry = load_feature_chains(ctx)
|
|
3756
|
+
chains = registry.setdefault("chains", [])
|
|
3757
|
+
if not isinstance(chains, list):
|
|
3758
|
+
chains = []
|
|
3759
|
+
registry["chains"] = chains
|
|
3760
|
+
chain_id = next_feature_chain_id(ctx)
|
|
3761
|
+
now = datetime.now().isoformat(timespec="seconds")
|
|
3762
|
+
chains.append(
|
|
3763
|
+
{
|
|
3764
|
+
"id": chain_id,
|
|
3765
|
+
"title": title.strip(),
|
|
3766
|
+
"status": status.strip() or "proposed",
|
|
3767
|
+
"run_policy": run_policy.strip() or RUN_ALWAYS_POLICY,
|
|
3768
|
+
"entry": entry.strip(),
|
|
3769
|
+
"exit_check": exit_check.strip(),
|
|
3770
|
+
"type": "command" if command_text.strip() else "manual",
|
|
3771
|
+
"command": command_text.strip(),
|
|
3772
|
+
"cwd": ".",
|
|
3773
|
+
"resource": resource.strip() or "local",
|
|
3774
|
+
"timeout_seconds": int(timeout_seconds),
|
|
3775
|
+
"artifact_policy": artifact_policy.strip() or "cleanup-on-pass",
|
|
3776
|
+
"blocker_keywords": BLOCKER_PATTERNS,
|
|
3777
|
+
"nodes": [],
|
|
3778
|
+
"created_at": now,
|
|
3779
|
+
"updated_at": now,
|
|
3780
|
+
"source": "feature-chain",
|
|
3781
|
+
}
|
|
3782
|
+
)
|
|
3783
|
+
path = write_feature_chains(ctx, registry)
|
|
3784
|
+
print(f"[context-guard] feature chain: {chain_id}")
|
|
3785
|
+
print(f"[context-guard] registry: {path}")
|
|
3786
|
+
return path
|
|
3787
|
+
|
|
3788
|
+
|
|
3789
|
+
def feature_chain_attach_bc(
|
|
3790
|
+
root: Path,
|
|
3791
|
+
chain_id: str,
|
|
3792
|
+
node_title: str,
|
|
3793
|
+
bad_case: str,
|
|
3794
|
+
check: str = "",
|
|
3795
|
+
) -> Path:
|
|
3796
|
+
init_context(root)
|
|
3797
|
+
ctx = context_dir(root)
|
|
3798
|
+
if not chain_id.strip():
|
|
3799
|
+
raise ValueError("feature-chain-attach-bc requires --chain-id")
|
|
3800
|
+
if not node_title.strip():
|
|
3801
|
+
raise ValueError("feature-chain-attach-bc requires --node-title")
|
|
3802
|
+
if not bad_case.strip():
|
|
3803
|
+
raise ValueError("feature-chain-attach-bc requires --bad-case")
|
|
3804
|
+
chain, _chains, registry = feature_chain_find(ctx, chain_id.strip())
|
|
3805
|
+
nodes = chain.setdefault("nodes", [])
|
|
3806
|
+
if not isinstance(nodes, list):
|
|
3807
|
+
nodes = []
|
|
3808
|
+
chain["nodes"] = nodes
|
|
3809
|
+
node_slug = normalize_test_slug(node_title)
|
|
3810
|
+
node: dict[str, object] | None = None
|
|
3811
|
+
for item in nodes:
|
|
3812
|
+
if not isinstance(item, dict):
|
|
3813
|
+
continue
|
|
3814
|
+
if str(item.get("title", "")).strip() == node_title.strip() or str(item.get("id", "")) == node_slug:
|
|
3815
|
+
node = item
|
|
3816
|
+
break
|
|
3817
|
+
if node is None:
|
|
3818
|
+
node = {
|
|
3819
|
+
"id": node_slug,
|
|
3820
|
+
"title": node_title.strip(),
|
|
3821
|
+
"checks": [],
|
|
3822
|
+
"bad_cases": [],
|
|
3823
|
+
"created_at": datetime.now().isoformat(timespec="seconds"),
|
|
3824
|
+
}
|
|
3825
|
+
nodes.append(node)
|
|
3826
|
+
bad_cases = node.setdefault("bad_cases", [])
|
|
3827
|
+
if not isinstance(bad_cases, list):
|
|
3828
|
+
bad_cases = []
|
|
3829
|
+
node["bad_cases"] = bad_cases
|
|
3830
|
+
if bad_case.strip() not in [str(item) for item in bad_cases]:
|
|
3831
|
+
bad_cases.append(bad_case.strip())
|
|
3832
|
+
checks = node.setdefault("checks", [])
|
|
3833
|
+
if not isinstance(checks, list):
|
|
3834
|
+
checks = []
|
|
3835
|
+
node["checks"] = checks
|
|
3836
|
+
if check.strip() and check.strip() not in [str(item) for item in checks]:
|
|
3837
|
+
checks.append(check.strip())
|
|
3838
|
+
now = datetime.now().isoformat(timespec="seconds")
|
|
3839
|
+
node["updated_at"] = now
|
|
3840
|
+
chain["updated_at"] = now
|
|
3841
|
+
path = write_feature_chains(ctx, registry)
|
|
3842
|
+
print(f"[context-guard] feature chain updated: {chain_id.strip()}")
|
|
3843
|
+
print(f"[context-guard] node: {node_title.strip()}")
|
|
3844
|
+
print(f"[context-guard] bad case: {bad_case.strip()}")
|
|
3845
|
+
return path
|
|
3846
|
+
|
|
3847
|
+
|
|
3848
|
+
def feature_chain_list(root: Path) -> int:
|
|
3849
|
+
init_context(root)
|
|
3850
|
+
ctx = context_dir(root)
|
|
3851
|
+
registry = load_feature_chains(ctx)
|
|
3852
|
+
chains = [item for item in registry.get("chains", []) if isinstance(item, dict)]
|
|
3853
|
+
if not chains:
|
|
3854
|
+
print("[context-guard] feature chains: none.")
|
|
3855
|
+
print(f"[context-guard] registry: {feature_chains_path(ctx)}")
|
|
3856
|
+
return 0
|
|
3857
|
+
print("[context-guard] feature chains:")
|
|
3858
|
+
for chain in chains:
|
|
3859
|
+
chain_id = str(chain.get("id", "unknown"))
|
|
3860
|
+
title = str(chain.get("title", "untitled"))
|
|
3861
|
+
status = str(chain.get("status", "unknown"))
|
|
3862
|
+
run_policy = str(chain.get("run_policy", "unset"))
|
|
3863
|
+
nodes = [item for item in chain.get("nodes", []) if isinstance(item, dict)]
|
|
3864
|
+
covered = sorted(
|
|
3865
|
+
{
|
|
3866
|
+
str(bc)
|
|
3867
|
+
for node in nodes
|
|
3868
|
+
for bc in (node.get("bad_cases", []) if isinstance(node.get("bad_cases", []), list) else [])
|
|
3869
|
+
}
|
|
3870
|
+
)
|
|
3871
|
+
suffix = f" | covers {', '.join(covered)}" if covered else ""
|
|
3872
|
+
print(f"- {chain_id} | {status} | {run_policy} | {title} | {len(nodes)} node(s){suffix}")
|
|
3873
|
+
print(f"[context-guard] registry: {feature_chains_path(ctx)}")
|
|
3874
|
+
return 0
|
|
3875
|
+
|
|
3876
|
+
|
|
3676
3877
|
def test_hub_add(
|
|
3677
3878
|
root: Path,
|
|
3678
3879
|
title: str,
|
|
@@ -3740,7 +3941,8 @@ def test_hub_list(root: Path) -> int:
|
|
|
3740
3941
|
ctx = context_dir(root)
|
|
3741
3942
|
registry_tests = test_hub_registry_tests(ctx)
|
|
3742
3943
|
task_case_tests = discover_task_case_tests(ctx)
|
|
3743
|
-
|
|
3944
|
+
feature_chain_tests = approved_feature_chain_tests(ctx)
|
|
3945
|
+
if not registry_tests and not task_case_tests and not feature_chain_tests:
|
|
3744
3946
|
print("[context-guard] test hub: no registered tests.")
|
|
3745
3947
|
print(f"[context-guard] registry: {test_registry_path(ctx)}")
|
|
3746
3948
|
return 0
|
|
@@ -3761,6 +3963,17 @@ def test_hub_list(root: Path) -> int:
|
|
|
3761
3963
|
title = str(test.get("title", "untitled"))
|
|
3762
3964
|
source = str(test.get("source", "task-cases"))
|
|
3763
3965
|
print(f"- {test_id} | {test.get('status')} | {test.get('run_policy')} | {title} | {source}")
|
|
3966
|
+
if feature_chain_tests:
|
|
3967
|
+
print("[context-guard] approved feature-chain tests:")
|
|
3968
|
+
for test in feature_chain_tests:
|
|
3969
|
+
test_id = str(test.get("id", "unknown"))
|
|
3970
|
+
title = str(test.get("title", "untitled"))
|
|
3971
|
+
source = str(test.get("source", "feature-chain"))
|
|
3972
|
+
covered = test.get("covered_bad_cases", [])
|
|
3973
|
+
covered_suffix = ""
|
|
3974
|
+
if isinstance(covered, list) and covered:
|
|
3975
|
+
covered_suffix = f" | covers {', '.join(str(item) for item in covered)}"
|
|
3976
|
+
print(f"- {test_id} | {test.get('status')} | {test.get('run_policy')} | {title} | {source}{covered_suffix}")
|
|
3764
3977
|
print(f"[context-guard] registry: {test_registry_path(ctx)}")
|
|
3765
3978
|
return 0
|
|
3766
3979
|
|
|
@@ -3781,8 +3994,9 @@ def test_hub_state(root: Path) -> dict[str, object]:
|
|
|
3781
3994
|
ctx = context_dir(root)
|
|
3782
3995
|
registry_tests = test_hub_registry_tests(ctx)
|
|
3783
3996
|
task_case_tests = discover_task_case_tests(ctx)
|
|
3997
|
+
feature_chain_tests = approved_feature_chain_tests(ctx)
|
|
3784
3998
|
tests: list[dict[str, object]] = []
|
|
3785
|
-
for source, items in (("registry", registry_tests), ("task-case", task_case_tests)):
|
|
3999
|
+
for source, items in (("registry", registry_tests), ("task-case", task_case_tests), ("feature-chain", feature_chain_tests)):
|
|
3786
4000
|
for item in items:
|
|
3787
4001
|
status = str(item.get("status", "unknown")).strip() or "unknown"
|
|
3788
4002
|
run_policy = str(item.get("run_policy", item.get("run policy", "unset"))).strip() or "unset"
|
|
@@ -3954,20 +4168,6 @@ def render_test_hub_html(root: Path) -> str:
|
|
|
3954
4168
|
p {{ margin: 0; }}
|
|
3955
4169
|
.subtle, .muted {{ color: var(--muted); }}
|
|
3956
4170
|
.actions {{ display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }}
|
|
3957
|
-
button {{
|
|
3958
|
-
appearance: none;
|
|
3959
|
-
border: 1px solid var(--accent);
|
|
3960
|
-
border-radius: 999px;
|
|
3961
|
-
padding: 6px 13px;
|
|
3962
|
-
color: white;
|
|
3963
|
-
background: var(--accent);
|
|
3964
|
-
font: inherit;
|
|
3965
|
-
font-size: 12px;
|
|
3966
|
-
font-weight: 760;
|
|
3967
|
-
cursor: pointer;
|
|
3968
|
-
box-shadow: 0 0 0 4px var(--accent-soft);
|
|
3969
|
-
}}
|
|
3970
|
-
button:disabled {{ cursor: not-allowed; opacity: .45; }}
|
|
3971
4171
|
.hub-board {{
|
|
3972
4172
|
background: var(--panel);
|
|
3973
4173
|
border: 1px solid var(--line);
|
|
@@ -3989,7 +4189,7 @@ def render_test_hub_html(root: Path) -> str:
|
|
|
3989
4189
|
gap: 10px;
|
|
3990
4190
|
margin-bottom: 14px;
|
|
3991
4191
|
}}
|
|
3992
|
-
.metric, .test-card, .empty
|
|
4192
|
+
.metric, .test-card, .empty {{
|
|
3993
4193
|
background: var(--card);
|
|
3994
4194
|
border: 1px solid var(--line);
|
|
3995
4195
|
border-radius: var(--radius);
|
|
@@ -4051,8 +4251,6 @@ def render_test_hub_html(root: Path) -> str:
|
|
|
4051
4251
|
font-size: 12px;
|
|
4052
4252
|
}}
|
|
4053
4253
|
.empty {{ padding: 22px; text-align: center; }}
|
|
4054
|
-
.log {{ display: none; margin-top: 14px; padding: 12px; white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }}
|
|
4055
|
-
.log.visible {{ display: block; }}
|
|
4056
4254
|
@media (max-width: 760px) {{
|
|
4057
4255
|
header, .shell {{ padding-left: 16px; padding-right: 16px; }}
|
|
4058
4256
|
.header-row, .card-head, .board-head {{ flex-direction: column; align-items: flex-start; }}
|
|
@@ -4065,11 +4263,10 @@ def render_test_hub_html(root: Path) -> str:
|
|
|
4065
4263
|
<div class="header-row">
|
|
4066
4264
|
<div>
|
|
4067
4265
|
<h1>测试中台</h1>
|
|
4068
|
-
<p class="subtle"
|
|
4266
|
+
<p class="subtle">查看用户已确认的测试;开发结束时由 Context Guard 自动运行。</p>
|
|
4069
4267
|
</div>
|
|
4070
4268
|
<div class="actions">
|
|
4071
|
-
<
|
|
4072
|
-
<p id="mode" class="subtle"></p>
|
|
4269
|
+
<p class="subtle">只读状态页</p>
|
|
4073
4270
|
</div>
|
|
4074
4271
|
</div>
|
|
4075
4272
|
</header>
|
|
@@ -4090,39 +4287,10 @@ def render_test_hub_html(root: Path) -> str:
|
|
|
4090
4287
|
<section class="grid" aria-label="测试列表">
|
|
4091
4288
|
{''.join(rows)}
|
|
4092
4289
|
</section>
|
|
4093
|
-
<pre id="log" class="log"></pre>
|
|
4094
4290
|
</section>
|
|
4095
4291
|
</main>
|
|
4096
4292
|
<script>
|
|
4097
4293
|
window.__TEST_HUB_STATE__ = {state_json};
|
|
4098
|
-
const runButton = document.getElementById('run-all');
|
|
4099
|
-
const mode = document.getElementById('mode');
|
|
4100
|
-
const log = document.getElementById('log');
|
|
4101
|
-
const canRun = location.protocol === 'http:' || location.protocol === 'https:';
|
|
4102
|
-
if (!canRun) {{
|
|
4103
|
-
runButton.disabled = true;
|
|
4104
|
-
mode.textContent = '一键运行需要 serve-test-hub 启动 localhost 控制台';
|
|
4105
|
-
}} else {{
|
|
4106
|
-
mode.textContent = 'localhost 控制台已启用';
|
|
4107
|
-
}}
|
|
4108
|
-
runButton.addEventListener('click', async () => {{
|
|
4109
|
-
if (!canRun) return;
|
|
4110
|
-
runButton.disabled = true;
|
|
4111
|
-
runButton.textContent = '运行中...';
|
|
4112
|
-
log.classList.add('visible');
|
|
4113
|
-
log.textContent = '正在运行已确认测试...';
|
|
4114
|
-
try {{
|
|
4115
|
-
const response = await fetch('/api/run', {{ method: 'POST' }});
|
|
4116
|
-
const data = await response.json();
|
|
4117
|
-
log.textContent = data.output || JSON.stringify(data, null, 2);
|
|
4118
|
-
setTimeout(() => location.reload(), 900);
|
|
4119
|
-
}} catch (error) {{
|
|
4120
|
-
log.textContent = String(error);
|
|
4121
|
-
}} finally {{
|
|
4122
|
-
runButton.disabled = false;
|
|
4123
|
-
runButton.textContent = '运行所有测试';
|
|
4124
|
-
}}
|
|
4125
|
-
}});
|
|
4126
4294
|
</script>
|
|
4127
4295
|
</body>
|
|
4128
4296
|
</html>
|
|
@@ -4143,7 +4311,6 @@ def show_test_hub(root: Path, open_browser: bool = False) -> Path:
|
|
|
4143
4311
|
|
|
4144
4312
|
def serve_test_hub(root: Path, host: str, port: int, jobs: int, open_browser: bool = False) -> int:
|
|
4145
4313
|
init_context(root)
|
|
4146
|
-
run_lock = threading.Lock()
|
|
4147
4314
|
|
|
4148
4315
|
class TestHubHandler(BaseHTTPRequestHandler):
|
|
4149
4316
|
def log_message(self, format: str, *args: object) -> None:
|
|
@@ -4173,29 +4340,7 @@ def serve_test_hub(root: Path, host: str, port: int, jobs: int, open_browser: bo
|
|
|
4173
4340
|
self.send_json(404, {"error": "not found"})
|
|
4174
4341
|
|
|
4175
4342
|
def do_POST(self) -> None:
|
|
4176
|
-
|
|
4177
|
-
if path != "/api/run":
|
|
4178
|
-
self.send_json(404, {"error": "not found"})
|
|
4179
|
-
return
|
|
4180
|
-
if not run_lock.acquire(blocking=False):
|
|
4181
|
-
self.send_json(409, {"status": "busy", "output": "测试正在运行,请稍后。"})
|
|
4182
|
-
return
|
|
4183
|
-
stdout = io.StringIO()
|
|
4184
|
-
stderr = io.StringIO()
|
|
4185
|
-
try:
|
|
4186
|
-
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
|
4187
|
-
code = test_hub_dev_complete(root, jobs=jobs)
|
|
4188
|
-
output = stdout.getvalue() + stderr.getvalue()
|
|
4189
|
-
self.send_json(
|
|
4190
|
-
200 if code == 0 else 500,
|
|
4191
|
-
{
|
|
4192
|
-
"status": "passed" if code == 0 else "failed",
|
|
4193
|
-
"code": code,
|
|
4194
|
-
"output": output,
|
|
4195
|
-
},
|
|
4196
|
-
)
|
|
4197
|
-
finally:
|
|
4198
|
-
run_lock.release()
|
|
4343
|
+
self.send_json(405, {"error": "test hub page is read-only; tests run from the Stop hook or `dev-complete`."})
|
|
4199
4344
|
|
|
4200
4345
|
server = ThreadingHTTPServer((host, port), TestHubHandler)
|
|
4201
4346
|
actual_host, actual_port = server.server_address[:2]
|
|
@@ -4289,6 +4434,43 @@ def discover_task_case_tests(ctx: Path) -> list[dict[str, object]]:
|
|
|
4289
4434
|
return tests
|
|
4290
4435
|
|
|
4291
4436
|
|
|
4437
|
+
def approved_feature_chain_tests(ctx: Path) -> list[dict[str, object]]:
|
|
4438
|
+
registry = load_feature_chains(ctx)
|
|
4439
|
+
tests: list[dict[str, object]] = []
|
|
4440
|
+
for item in registry.get("chains", []):
|
|
4441
|
+
if not isinstance(item, dict):
|
|
4442
|
+
continue
|
|
4443
|
+
status = str(item.get("status", "")).strip().lower()
|
|
4444
|
+
run_policy = str(item.get("run_policy", item.get("run policy", ""))).strip()
|
|
4445
|
+
if status not in APPROVED_TEST_STATUSES or run_policy != RUN_ALWAYS_POLICY:
|
|
4446
|
+
continue
|
|
4447
|
+
command = str(item.get("command", "")).strip()
|
|
4448
|
+
nodes = [node for node in item.get("nodes", []) if isinstance(node, dict)]
|
|
4449
|
+
covered_bad_cases = sorted(
|
|
4450
|
+
{
|
|
4451
|
+
str(bc)
|
|
4452
|
+
for node in nodes
|
|
4453
|
+
for bc in (node.get("bad_cases", []) if isinstance(node.get("bad_cases", []), list) else [])
|
|
4454
|
+
if str(bc).strip()
|
|
4455
|
+
}
|
|
4456
|
+
)
|
|
4457
|
+
normalized = dict(item)
|
|
4458
|
+
normalized["id"] = str(item.get("id") or item.get("title") or "feature-chain")
|
|
4459
|
+
normalized["title"] = str(item.get("title") or normalized["id"])
|
|
4460
|
+
normalized["run_policy"] = run_policy
|
|
4461
|
+
normalized["type"] = "command" if command else "manual"
|
|
4462
|
+
normalized["command"] = command
|
|
4463
|
+
normalized.setdefault("cwd", ".")
|
|
4464
|
+
normalized.setdefault("timeout_seconds", 300)
|
|
4465
|
+
normalized.setdefault("artifact_policy", "cleanup-on-pass")
|
|
4466
|
+
normalized.setdefault("resource", "local")
|
|
4467
|
+
normalized.setdefault("blocker_keywords", BLOCKER_PATTERNS)
|
|
4468
|
+
normalized["source"] = "feature-chain"
|
|
4469
|
+
normalized["covered_bad_cases"] = covered_bad_cases
|
|
4470
|
+
tests.append(normalized)
|
|
4471
|
+
return tests
|
|
4472
|
+
|
|
4473
|
+
|
|
4292
4474
|
def approved_registry_tests(ctx: Path) -> list[dict[str, object]]:
|
|
4293
4475
|
registry = load_test_registry(ctx)
|
|
4294
4476
|
tests: list[dict[str, object]] = []
|
|
@@ -4311,7 +4493,7 @@ def approved_registry_tests(ctx: Path) -> list[dict[str, object]]:
|
|
|
4311
4493
|
|
|
4312
4494
|
def approved_dev_completion_tests(ctx: Path) -> list[dict[str, object]]:
|
|
4313
4495
|
by_id: dict[str, dict[str, object]] = {}
|
|
4314
|
-
for test in approved_registry_tests(ctx) + discover_task_case_tests(ctx):
|
|
4496
|
+
for test in approved_registry_tests(ctx) + discover_task_case_tests(ctx) + approved_feature_chain_tests(ctx):
|
|
4315
4497
|
test_id = str(test.get("id") or test.get("title") or "unnamed")
|
|
4316
4498
|
by_id[test_id] = test
|
|
4317
4499
|
return list(by_id.values())
|
|
@@ -4502,7 +4684,7 @@ def create_branch_task(root: Path, title: str, branch: str, parent_node: str = "
|
|
|
4502
4684
|
|
|
4503
4685
|
def main() -> int:
|
|
4504
4686
|
parser = argparse.ArgumentParser(description="Context Guard utilities")
|
|
4505
|
-
parser.add_argument("command", choices=["init", "set-language", "export-roadmap", "show-roadmap", "create-branch-task", "checkpoint-roadmap-node", "validate-bad-cases", "validate-roadmap-maintenance", "test-hub-add", "test-hub-list", "test-hub-enable", "test-hub-disable", "test-hub-set-policy", "test-hub-remove", "show-test-hub", "serve-test-hub", "dev-complete"])
|
|
4687
|
+
parser.add_argument("command", choices=["init", "set-language", "export-roadmap", "show-roadmap", "create-branch-task", "checkpoint-roadmap-node", "validate-bad-cases", "validate-roadmap-maintenance", "test-hub-add", "test-hub-list", "test-hub-enable", "test-hub-disable", "test-hub-set-policy", "test-hub-remove", "feature-chain-add", "feature-chain-attach-bc", "feature-chain-list", "show-test-hub", "serve-test-hub", "dev-complete"])
|
|
4506
4688
|
parser.add_argument("--format", choices=["html", "md"], default="html")
|
|
4507
4689
|
parser.add_argument("--language", default=None, help="Folder-scoped language for future context records.")
|
|
4508
4690
|
parser.add_argument("--title", default=None, help="Title for a branch task or roadmap checkpoint.")
|
|
@@ -4514,6 +4696,12 @@ def main() -> int:
|
|
|
4514
4696
|
parser.add_argument("--timeout-seconds", type=int, default=300, help="Timeout for a registered test command.")
|
|
4515
4697
|
parser.add_argument("--artifact-policy", default="cleanup-on-pass", help="Artifact policy for a registered test command.")
|
|
4516
4698
|
parser.add_argument("--resource", default="local", help="Resource/node label for a registered test command.")
|
|
4699
|
+
parser.add_argument("--entry", default="", help="Human-readable entry point for a feature-chain test.")
|
|
4700
|
+
parser.add_argument("--exit-check", default="", help="Human-readable success condition for a feature-chain test.")
|
|
4701
|
+
parser.add_argument("--chain-id", default="", help="Feature chain ID for feature-chain management commands.")
|
|
4702
|
+
parser.add_argument("--node-title", default="", help="Feature-chain node title for attaching bad-case coverage.")
|
|
4703
|
+
parser.add_argument("--bad-case", default="", help="Bad-case ID or title covered by a feature-chain node.")
|
|
4704
|
+
parser.add_argument("--check", default="", help="Checkpoint text for a feature-chain node.")
|
|
4517
4705
|
parser.add_argument("--jobs", type=int, default=1, help="Parallel test workers for dev-complete.")
|
|
4518
4706
|
parser.add_argument("--host", default="127.0.0.1", help="Host for serve-test-hub.")
|
|
4519
4707
|
parser.add_argument("--port", type=int, default=8772, help="Port for serve-test-hub.")
|
|
@@ -4604,6 +4792,43 @@ def main() -> int:
|
|
|
4604
4792
|
resource=args.resource,
|
|
4605
4793
|
)
|
|
4606
4794
|
return 0
|
|
4795
|
+
if args.command == "feature-chain-add":
|
|
4796
|
+
if not args.title:
|
|
4797
|
+
parser.error("feature-chain-add requires --title")
|
|
4798
|
+
if not args.entry:
|
|
4799
|
+
parser.error("feature-chain-add requires --entry")
|
|
4800
|
+
if not args.exit_check:
|
|
4801
|
+
parser.error("feature-chain-add requires --exit-check")
|
|
4802
|
+
feature_chain_add(
|
|
4803
|
+
root,
|
|
4804
|
+
title=args.title,
|
|
4805
|
+
entry=args.entry,
|
|
4806
|
+
exit_check=args.exit_check,
|
|
4807
|
+
command_text=args.command_text,
|
|
4808
|
+
run_policy=args.run_policy,
|
|
4809
|
+
status=args.test_status,
|
|
4810
|
+
timeout_seconds=args.timeout_seconds,
|
|
4811
|
+
artifact_policy=args.artifact_policy,
|
|
4812
|
+
resource=args.resource,
|
|
4813
|
+
)
|
|
4814
|
+
return 0
|
|
4815
|
+
if args.command == "feature-chain-attach-bc":
|
|
4816
|
+
if not args.chain_id:
|
|
4817
|
+
parser.error("feature-chain-attach-bc requires --chain-id")
|
|
4818
|
+
if not args.node_title:
|
|
4819
|
+
parser.error("feature-chain-attach-bc requires --node-title")
|
|
4820
|
+
if not args.bad_case:
|
|
4821
|
+
parser.error("feature-chain-attach-bc requires --bad-case")
|
|
4822
|
+
feature_chain_attach_bc(
|
|
4823
|
+
root,
|
|
4824
|
+
chain_id=args.chain_id,
|
|
4825
|
+
node_title=args.node_title,
|
|
4826
|
+
bad_case=args.bad_case,
|
|
4827
|
+
check=args.check,
|
|
4828
|
+
)
|
|
4829
|
+
return 0
|
|
4830
|
+
if args.command == "feature-chain-list":
|
|
4831
|
+
return feature_chain_list(root)
|
|
4607
4832
|
if args.command == "test-hub-list":
|
|
4608
4833
|
return test_hub_list(root)
|
|
4609
4834
|
if args.command == "test-hub-enable":
|
|
@@ -14,7 +14,7 @@ import subprocess
|
|
|
14
14
|
import sys
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
|
-
from context_guard import context_dir, init_context
|
|
17
|
+
from context_guard import approved_dev_completion_tests, context_dir, init_context
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
@@ -147,6 +147,41 @@ def hook_response(**payload: object) -> int:
|
|
|
147
147
|
return 0
|
|
148
148
|
|
|
149
149
|
|
|
150
|
+
def run_test_hub_completion(root: Path) -> tuple[int, str]:
|
|
151
|
+
ctx = context_dir(root)
|
|
152
|
+
tests = approved_dev_completion_tests(ctx)
|
|
153
|
+
if not tests:
|
|
154
|
+
return 0, "[context-guard] test hub: no approved every-dev-completion tests."
|
|
155
|
+
|
|
156
|
+
hook_log(f"[context-guard] test hub: running {len(tests)} approved every-dev-completion test(s).")
|
|
157
|
+
completed = subprocess.run(
|
|
158
|
+
[
|
|
159
|
+
sys.executable,
|
|
160
|
+
str(SKILL_ROOT / "scripts" / "context_guard.py"),
|
|
161
|
+
"dev-complete",
|
|
162
|
+
"--root",
|
|
163
|
+
str(root),
|
|
164
|
+
],
|
|
165
|
+
text=True,
|
|
166
|
+
capture_output=True,
|
|
167
|
+
timeout=900,
|
|
168
|
+
)
|
|
169
|
+
output = "\n".join(part for part in [completed.stdout.strip(), completed.stderr.strip()] if part)
|
|
170
|
+
return completed.returncode, output
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def completion_test_summary(output: str, code: int) -> str:
|
|
174
|
+
if "no approved every-dev-completion tests" in output:
|
|
175
|
+
return "no approved every-dev-completion tests"
|
|
176
|
+
for line in output.splitlines():
|
|
177
|
+
if "[context-guard] test hub:" in line and " passed," in line and " failed," in line and " blocked" in line:
|
|
178
|
+
summary = line.split("test hub:", 1)[1].strip().rstrip(".")
|
|
179
|
+
if code == 0:
|
|
180
|
+
return f"all approved tests passed ({summary})"
|
|
181
|
+
return f"approved tests are not all passing ({summary})"
|
|
182
|
+
return "test hub status unknown; inspect `.codex/context/test-hub/last-run.json`"
|
|
183
|
+
|
|
184
|
+
|
|
150
185
|
def prompt_text(raw: str) -> str:
|
|
151
186
|
if not raw.strip():
|
|
152
187
|
return ""
|
|
@@ -437,7 +472,7 @@ def main() -> int:
|
|
|
437
472
|
hook_log("[context-guard] COMPLETION RELIABILITY GATE: use existing user screenshots/logs/reproductions as red evidence when available; implement once the cause is clear, then run the smallest real post-fix check. Default budget is one primary check plus at most two highly relevant bad-case guards.")
|
|
438
473
|
hook_log("[context-guard] BAD-CASE GUARD GATE: newly checked resolved or recurred BC entries need Guard type, Red condition, Green condition, Expected failure reason, and a red-capable Guard / verification; run `context_guard.py validate-bad-cases` only after register/schema/renderer edits, or `--strict` when intentionally migrating/checking all resolved cases.")
|
|
439
474
|
hook_log("[context-guard] GUARD SELECTION GATE: do not run every historical guard and do not manufacture new red tests when credible evidence already exists. Select guards by changed files, feature area, route branch, tags, and original user-visible symptom; skip unrelated resolved cases.")
|
|
440
|
-
hook_log("[context-guard] TEST HUB GATE:
|
|
475
|
+
hook_log("[context-guard] TEST HUB GATE: Stop hook runs `context_guard.py dev-complete --root <project>` so the hub executes every human-approved `every-dev-completion` test, cleans success artifacts, and preserves failed/blocked evidence. Do not treat ordinary bad-case guards or roadmap Test chain notes as registered tests.")
|
|
441
476
|
hook_log("[context-guard] TASK-CASE GATE: when a workflow has multiple phases, prefer one relevant task case from `.codex/context/task-cases/` with phase/checkpoint logs over many isolated bug-level tests; report the failed phase/checkpoint if it breaks.")
|
|
442
477
|
hook_log("[context-guard] TEST BLOCKER GATE: if approved tests are blocked by credentials, external service outage, permission denial, hardware/resource limits, network, destructive-risk confirmation, or user-only judgment, stop and ask/warn the user with the exact blocker and evidence path.")
|
|
443
478
|
hook_log("[context-guard] TASK-CASE DESIGN GATE: before writing a new durable task-case script for a complex workflow, ask the user to confirm a short business-facing proposal: from what state to what state, main task, and major risk; keep technical details inside the task-case file, or keep it `proposed` if unavailable.")
|
|
@@ -452,6 +487,28 @@ def main() -> int:
|
|
|
452
487
|
hook_log(f"[context-guard] project root: {root}")
|
|
453
488
|
hook_log(f"[context-guard] context folder: {context_dir}")
|
|
454
489
|
hook_log(f"[context-guard] bad-case register: {bad_cases_path}")
|
|
490
|
+
try:
|
|
491
|
+
test_code, test_output = run_test_hub_completion(root)
|
|
492
|
+
for line in test_output.splitlines():
|
|
493
|
+
hook_log(line)
|
|
494
|
+
hook_log(
|
|
495
|
+
"[context-guard] final answer must include Test Hub summary: "
|
|
496
|
+
+ completion_test_summary(test_output, test_code)
|
|
497
|
+
)
|
|
498
|
+
if test_code != 0:
|
|
499
|
+
reason = (
|
|
500
|
+
"Context Guard Test Hub found failed or blocked approved tests. "
|
|
501
|
+
"Read `.codex/context/test-hub/last-run.json` and preserved run evidence, "
|
|
502
|
+
"then fix or report the blocker before finalizing."
|
|
503
|
+
)
|
|
504
|
+
hook_log(f"[context-guard] TEST HUB BLOCKER: {reason}")
|
|
505
|
+
return hook_response(decision="block", reason=reason)
|
|
506
|
+
except subprocess.TimeoutExpired:
|
|
507
|
+
reason = "Context Guard Test Hub timed out after 900s; report this blocker and evidence before finalizing."
|
|
508
|
+
hook_log(f"[context-guard] TEST HUB BLOCKER: {reason}")
|
|
509
|
+
return hook_response(decision="block", reason=reason)
|
|
510
|
+
except Exception as exc:
|
|
511
|
+
hook_log(f"[context-guard] test hub hook warning: {exc}")
|
|
455
512
|
open_cases = unresolved_bad_cases(bad_cases_path)
|
|
456
513
|
hook_log("[context-guard] final answer must include BC summary: archived/updated BC this turn, and current unresolved BC.")
|
|
457
514
|
hook_log(f"[context-guard] current unresolved BC: {format_unresolved_bad_cases(open_cases)}")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
SCRIPT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/scripts/context_guard.py}"
|
|
5
|
+
ROOT="$(mktemp -d "${TMPDIR:-/tmp}/context-guard-feature-chain-XXXXXX")"
|
|
6
|
+
trap 'rm -rf "$ROOT"' EXIT
|
|
7
|
+
|
|
8
|
+
python3 "$SCRIPT" init --root "$ROOT" >/dev/null
|
|
9
|
+
|
|
10
|
+
ADD_OUTPUT="$(python3 "$SCRIPT" feature-chain-add \
|
|
11
|
+
--root "$ROOT" \
|
|
12
|
+
--title "GPU 监控按钮" \
|
|
13
|
+
--entry "点击 GPU 监控按钮" \
|
|
14
|
+
--exit-check "打开包含有效 grafana_url 的监控页" \
|
|
15
|
+
--command-text "printf 'feature-chain-ok\n'" \
|
|
16
|
+
--test-status approved)"
|
|
17
|
+
|
|
18
|
+
CHAIN_ID="$(printf '%s\n' "$ADD_OUTPUT" | awk '/feature chain:/ {print $NF}')"
|
|
19
|
+
test -n "$CHAIN_ID"
|
|
20
|
+
|
|
21
|
+
python3 "$SCRIPT" feature-chain-attach-bc \
|
|
22
|
+
--root "$ROOT" \
|
|
23
|
+
--chain-id "$CHAIN_ID" \
|
|
24
|
+
--node-title "后端返回监控 URL" \
|
|
25
|
+
--bad-case "BC-20260702-000" \
|
|
26
|
+
--check "grafana_url 不为空,前端不会卡住" >/dev/null
|
|
27
|
+
|
|
28
|
+
python3 "$SCRIPT" feature-chain-list --root "$ROOT" | grep -q "BC-20260702-000"
|
|
29
|
+
|
|
30
|
+
python3 - "$ROOT/.codex/context/test-hub/feature-chains.json" "$CHAIN_ID" <<'PY'
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
import json
|
|
33
|
+
import sys
|
|
34
|
+
|
|
35
|
+
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
|
36
|
+
chain = next(item for item in data["chains"] if item["id"] == sys.argv[2])
|
|
37
|
+
assert chain["entry"] == "点击 GPU 监控按钮"
|
|
38
|
+
assert chain["exit_check"] == "打开包含有效 grafana_url 的监控页"
|
|
39
|
+
assert chain["nodes"][0]["bad_cases"] == ["BC-20260702-000"]
|
|
40
|
+
assert "grafana_url 不为空" in chain["nodes"][0]["checks"][0]
|
|
41
|
+
PY
|
|
42
|
+
|
|
43
|
+
RUN_OUTPUT="$(python3 "$SCRIPT" dev-complete --root "$ROOT")"
|
|
44
|
+
printf '%s\n' "$RUN_OUTPUT" | grep -q "1 passed, 0 failed, 0 blocked"
|
|
45
|
+
printf '%s\n' "$RUN_OUTPUT" | grep -q "success artifacts cleaned"
|