sdd_selfcheck.sh 11.2 KB
#!/usr/bin/env bash
set -euo pipefail

# SDD 一键闭环自检脚本(支持:py_compile + 4 场景重放 + schema/约束兼容 + 产物审计)
# 用法:
#   1) 全链路闭环(推荐):bash scripts/sdd_selfcheck.sh [OUT_DIR]
#      - 支持环境变量:PRD_ROOT / PROJECT_NAME / VERSION / OWNER / PYTHON_BIN
#   2) 仅审计现有目录:bash scripts/sdd_selfcheck.sh check [OUT_DIR]
#
# 默认场景:
#   - default_graph:默认图模式
#   - disable_graph:--disable-graph
#   - custom_null:外部 manifest + constraints: null
#   - custom_bad:外部 manifest + constraints: bad 类型(字符串)

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"

PYTHON_BIN="${PYTHON_BIN:-python3}"
GENERATOR="$SCRIPT_DIR/sdd_mvcp_generator.py"
GRAPH_SCRIPT="$SCRIPT_DIR/sdd_exec_graph.py"
TEMPLATE_MANIFEST="$REPO_ROOT/templates/sdd_exec_graph_manifest.json"
SCHEMA_FILE="$REPO_ROOT/references/sdd_mvcp_contract_schema.json"

if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
  echo "❌ missing python executable: $PYTHON_BIN"
  exit 1
fi

OUT_DIR="${OUT_DIR:-./SDD}"
MODE="full"

if [[ ${1:-} == "check" ]]; then
  MODE="check"
  shift
  OUT_DIR="${1:-$OUT_DIR}"
else
  # 全链路模式可选通过参数覆盖输出基目录
  OUT_DIR="${1:-$OUT_DIR}"
fi

PRD_ROOT="${PRD_ROOT:-/tmp/sdd_100_a}"
PROJECT_NAME="${PROJECT_NAME:-DemoProj}"
VERSION="${VERSION:-v1.0.0}"
OWNER="${OWNER:-ops}"

if [[ "$MODE" == "full" && ! -d "$PRD_ROOT" ]]; then
  echo "❌ PRD 根目录不存在:$PRD_ROOT"
  echo "请在 env 中设置 PRD_ROOT(例如 /tmp/sdd_100_a),或传入显式 PRD_ROOT="
  exit 1
fi

mkdir -p "$OUT_DIR"
OUT_DIR="$(cd "$OUT_DIR" && pwd)"

RUN_ROOT="$OUT_DIR/.sdd_selfcheck_runs"
mkdir -p "$RUN_ROOT"
LOG_DIR="$RUN_ROOT/logs"
mkdir -p "$LOG_DIR"

SC1="$RUN_ROOT/default_graph"
SC2="$RUN_ROOT/disable_graph"
SC3="$RUN_ROOT/custom_manifest_null"
SC4="$RUN_ROOT/custom_manifest_bad"

MAN_NULL="$RUN_ROOT/custom_manifest_null.json"
MAN_BAD="$RUN_ROOT/custom_manifest_bad.json"

failures=0

log() {
  echo "[$(date +%Y-%m-%dT%H:%M:%S)] $*"
}

assert_cmd() {
  local cmd="$1"
  if ! eval "$cmd"; then
    echo "❌ 失败:$cmd"
    return 1
  fi
}

run_py_compile() {
  log "步骤1/4:py_compile"
  "$PYTHON_BIN" -m py_compile "$GENERATOR" "$GRAPH_SCRIPT"
}

build_custom_manifest() {
  local src="$1"
  local dst="$2"
  local constraints_mode="$3"

  "$PYTHON_BIN" - "$src" "$dst" "$constraints_mode" "$PROJECT_NAME" "$VERSION" "$OWNER" <<'PY'
import json
import sys
from pathlib import Path

src, dst, constraints_mode, project, version, owner = sys.argv[1:7]
obj = json.loads(Path(src).read_text(encoding="utf-8"))

meta = obj.get("meta", {})
if not isinstance(meta, dict):
    meta = {}

meta.update(
    {
        "project": project,
        "version": version,
        "out_dir": str(Path(dst).parent),
        "requirements_count": 1,
        "parallel_mode": "e2e_check",
        "derived_rules": {
            "strict_contract_mode": False,
            "source": "selfcheck",
        },
        "generated_at": "1970-01-01T00:00:00Z",
    }
)
obj["meta"] = meta

if constraints_mode == "null":
    obj["constraints"] = None
elif constraints_mode == "bad":
    obj["constraints"] = "invalid-constraints-type"

obj.setdefault("agent_pool", ["agent-1", "agent-2", "agent-3", "agent-4", "agent-5"])
obj.setdefault("loop_rules", [])
obj.setdefault("nodes", [])

# 保证 owner/owner_pool 不影响测试过程
Path(dst).write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8")
PY
}

run_generator() {
  local label="$1"
  local out="$2"
  local disable_graph="$3"
  local manifest="$4"

  local log_file="$LOG_DIR/${label}.log"
  rm -rf "$out"
  mkdir -p "$out"

  local cmd=("$PYTHON_BIN" "$GENERATOR" --project-name "$PROJECT_NAME" --version "$VERSION" --owner "$OWNER" --prd-root "$PRD_ROOT" --out "$out")

  if [[ "$disable_graph" == "1" ]]; then
    cmd+=("--disable-graph")
  fi
  if [[ -n "$manifest" ]]; then
    cmd+=("--manifest" "$manifest")
  fi

  log "运行场景 ${label}:${cmd[*]}"
  if ! ("${cmd[@]}" >"$log_file" 2>&1); then
    echo "❌ 场景 ${label} 生成失败,日志:$log_file"
    cat "$log_file"
    return 1
  fi

  log "场景 ${label} 生成完成,日志:$log_file"
}

validate_artifacts() {
  local out_dir="$1"
  local expect_graph="$2"  # 1/0
  local expect_manifest="$3"  # 可选,空串时不强制
  local scenario="$4"

  "$PYTHON_BIN" - "$out_dir" "$expect_graph" "$expect_manifest" "$SCHEMA_FILE" "$scenario" <<'PY'
import json
import os
import sys
from pathlib import Path

out = Path(sys.argv[1])
expect_graph = sys.argv[2] == "1"
expected_manifest = sys.argv[3].strip()
schema_path = Path(sys.argv[4])
scenario = sys.argv[5]

ok = True


def check(cond, msg):
    global ok
    if cond:
        print(f"✅ {msg}")
    else:
        print(f"❌ {msg}")
        ok = False


def check_schema(contract_path: Path) -> None:
    global ok
    if not schema_path.exists():
        print(f"ℹ️ schema 文件不存在,跳过 jsonschema 校验: {schema_path}")
        return

    try:
        import jsonschema
    except Exception:
        print("ℹ️ 未安装 jsonschema,跳过 jsonschema 校验")
        return

    schema = json.loads(schema_path.read_text(encoding="utf-8"))
    contract = json.loads(contract_path.read_text(encoding="utf-8"))
    try:
        jsonschema.validate(contract, schema)
    except Exception as exc:
        print(f"❌ jsonschema 校验失败: {exc}")
        ok = False
    else:
        print("✅ jsonschema 校验通过")


# contract
contract_path = out / "sdd-mvcp-contract.json"
check(contract_path.exists(), f"[{scenario}] 合约文件存在")
if contract_path.exists():
    contract = json.loads(contract_path.read_text(encoding="utf-8"))
    for k in ["model_contract", "view_contract", "controller_contract", "process_contract"]:
        check(isinstance(contract.get(k), list) and len(contract[k]) > 0, f"[{scenario}] contract[{k}] 非空")

    check(isinstance(contract.get("acceptance_matrix"), list) and len(contract.get("acceptance_matrix", [])) > 0, f"[{scenario}] acceptance_matrix 非空")
    check(isinstance(contract.get("assumptions"), list) and len(contract.get("assumptions", [])) > 0, f"[{scenario}] assumptions 非空")
    check(isinstance(contract.get("risks"), list) and len(contract.get("risks", [])) > 0, f"[{scenario}] risks 非空")
    check(isinstance(contract.get("non_goals"), list) and len(contract.get("non_goals", [])) > 0, f"[{scenario}] non_goals 非空")

    check_schema(contract_path)

runbook_path = out / "sdd-graph.runbook.json"
if expect_graph:
    check(runbook_path.exists(), f"[{scenario}] runbook 存在(图场景)")
    if runbook_path.exists():
        runbook = json.loads(runbook_path.read_text(encoding="utf-8"))
        check(isinstance(runbook.get("source_manifest"), str) and bool(runbook.get("source_manifest")), f"[{scenario}] runbook.source_manifest 存在")
        check(bool(runbook.get("sdd_dir")), f"[{scenario}] runbook.sdd_dir 存在")
        check(isinstance(runbook.get("manifest_meta"), dict), f"[{scenario}] runbook.manifest_meta 为 dict")
        check(isinstance(runbook.get("constraints"), dict), f"[{scenario}] runbook.constraints 为 dict")
        manifest_meta = runbook.get("manifest_meta") or {}
        check(isinstance(manifest_meta.get("derived_rules"), dict), f"[{scenario}] runbook.manifest_meta.derived_rules 为 dict")

        outcome = runbook.get("outcome") or {}
        waves = runbook.get("waves", [])
        check(isinstance(outcome, dict), f"[{scenario}] runbook.outcome 存在")
        if isinstance(outcome, dict):
            check(outcome.get("total_waves") == len(waves), f"[{scenario}] runbook.outcome.total_waves 与 waves 长度一致")
            check(isinstance(outcome.get("total_nodes"), int), f"[{scenario}] outcome.total_nodes 存在")

        um = out / "unit_matrix.json"
        hb = out / "handoff_bundle.md"
        check(um.exists(), f"[{scenario}] unit_matrix 存在")
        if um.exists():
            try:
                um_content = json.loads(um.read_text(encoding="utf-8"))
                check(bool(um_content.get("units")), f"[{scenario}] unit_matrix.units 非空")
            except Exception:
                check(False, f"[{scenario}] unit_matrix 可解析")
        check(hb.exists() and hb.stat().st_size > 0, f"[{scenario}] handoff_bundle.md 存在且非空")

        check("manifest_meta_keys" in (out / "00-SDD-生成记录.md").read_text(encoding="utf-8"), f"[{scenario}] 生成记录包含 manifest_meta_keys")
        check("manifest_constraint_keys" in (out / "00-SDD-生成记录.md").read_text(encoding="utf-8"), f"[{scenario}] 生成记录包含 manifest_constraint_keys")

        if expected_manifest:
            # 与旧版本兼容:优先保证写入的是可落盘 manifest 文件路径即可
            check(os.path.basename(str(runbook.get("source_manifest", "")) or "").endswith("sdd_exec_graph_manifest.json"), f"[{scenario}] source_manifest 可落盘")
else:
    check(not runbook_path.exists(), f"[{scenario}] disable-graph 场景下 runbook 不应存在")
    # 非图场景不要求 unit_matrix/handoff

if not ok:
    sys.exit(1)
PY
}

run_full() {
  run_py_compile

  build_custom_manifest "$TEMPLATE_MANIFEST" "$MAN_NULL" null
  build_custom_manifest "$TEMPLATE_MANIFEST" "$MAN_BAD" bad

  log "步骤2/4:场景1 default_graph(默认图)"
  if ! run_generator "default_graph" "$SC1" "0" ""; then
    failures=$((failures + 1))
    return 1
  fi

  log "步骤3/4:场景2 disable_graph(禁图)"
  if ! run_generator "disable_graph" "$SC2" "1" ""; then
    failures=$((failures + 1))
    return 1
  fi

  log "步骤4/4:场景3 custom_manifest_null(外部 manifest + constraints null)"
  if ! run_generator "custom_manifest_null" "$SC3" "0" "$MAN_NULL"; then
    failures=$((failures + 1))
    return 1
  fi

  log "步骤4+:场景4 custom_manifest_bad(外部 manifest + constraints 非法类型)"
  if ! run_generator "custom_manifest_bad" "$SC4" "0" "$MAN_BAD"; then
    failures=$((failures + 1))
    return 1
  fi

  validate_artifacts "$SC1" 1 "" "default_graph" && validate_artifacts "$SC2" 0 "" "disable_graph" && validate_artifacts "$SC3" 1 "$MAN_NULL" "custom_manifest_null" && validate_artifacts "$SC4" 1 "$MAN_BAD" "custom_manifest_bad"

  if [[ $? -ne 0 ]]; then
    failures=$((failures + 1))
    return 1
  fi

  return 0
}

run_check() {
  if [[ ! -d "$OUT_DIR" ]]; then
    echo "❌ 检查目录不存在:$OUT_DIR"
    return 1
  fi

  if [[ -f "$OUT_DIR/sdd-graph.runbook.json" ]]; then
    validate_artifacts "$OUT_DIR" 1 "" "check_existing"
  else
    validate_artifacts "$OUT_DIR" 0 "" "check_existing"
  fi
}

if [[ "$MODE" == "check" ]]; then
  if ! run_check; then
    echo "❌ 仅校验模式失败:$OUT_DIR"
    exit 1
  fi
  echo "✅ sdd_selfcheck 一键闭环通过(仅产物校验)"
  echo "检查目录:$OUT_DIR"
  exit 0
else
  if ! run_full; then
    echo "❌ sdd_selfcheck 一键闭环失败,请查看日志:$LOG_DIR"
    echo "  - 失败次数:$failures"
    exit 1
  fi
fi

echo "✅ sdd_selfcheck 一键闭环通过"
echo "输出目录:"
echo "  default_graph:      $SC1"
echo "  disable_graph:      $SC2"
echo "  custom_manifest_n:  $SC3"
echo "  custom_manifest_b:  $SC4"