243 lines
11 KiB
Python
243 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
交付物阻断式核查脚本(对齐软件改造部署外包交付规范)。
|
||
|
||
检查项:
|
||
A. 7 项交付物:源码 / 当前产品截图 / 全功能录屏 / 网盘归档 / README / Docker 部署 / 依赖与注意事项
|
||
B. README 12 项必备内容(按标题关键词启发式匹配)
|
||
C. 痕迹/来源清理:对用户提供的商业标识与来源关键词做全文检索(交付物中不得命中)
|
||
D. 交付清单 .Codex/delivery/manifest.md 存在且 7 项条目齐全
|
||
|
||
退出码:0 全部通过;1 存在阻断项。
|
||
用法见 --help。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
README_KEYWORDS = {
|
||
"一句话简介": ["简介", "项目介绍"],
|
||
"详细功能描述": ["功能", "功能特性", "功能特点"],
|
||
"行业痛点": ["痛点"],
|
||
"技术优势": ["技术优势", "核心优势"],
|
||
"应用场景": ["应用场景", "使用场景", "场景"],
|
||
"目标行业与人群": ["目标行业", "适用行业", "适配行业", "目标用户", "适用人群"],
|
||
"商业应用价值": ["商业价值", "应用价值"],
|
||
"技术架构(含拓扑图)": ["技术架构", "架构"],
|
||
"环境前置依赖": ["环境要求", "前置依赖", "依赖要求", "环境依赖"],
|
||
"快速开始": ["快速开始", "快速上手", "quick start"],
|
||
"安装部署教程": ["部署", "安装"],
|
||
"版权与使用说明": ["版权", "使用说明"],
|
||
}
|
||
|
||
SCAN_SUFFIXES = {
|
||
".ts", ".tsx", ".js", ".jsx", ".vue", ".java", ".py", ".go", ".json",
|
||
".md", ".html", ".yml", ".yaml", ".less", ".css", ".scss", ".xml",
|
||
}
|
||
|
||
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"}
|
||
VIDEO_SUFFIXES = {".mp4", ".mkv", ".mov", ".avi", ".webm"}
|
||
|
||
SKIP_DIRS = {
|
||
"node_modules", ".git", "dist", "build", "target", ".next", ".umi",
|
||
"__pycache__", ".venv", "venv",
|
||
}
|
||
|
||
|
||
def iter_files(root: Path):
|
||
# os.walk 自顶向下并在目录层剪枝,避免遍历 node_modules/.git 等巨型目录
|
||
for dirpath, dirnames, filenames in os.walk(root):
|
||
dirnames[:] = [
|
||
d for d in dirnames
|
||
if d not in SKIP_DIRS and not d.lower().startswith(".codex")
|
||
]
|
||
for name in filenames:
|
||
yield Path(dirpath) / name
|
||
|
||
|
||
def find_files(root: Path, suffixes: set[str]) -> list[Path]:
|
||
return [p for p in iter_files(root) if p.suffix.lower() in suffixes]
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description="开源改造项目交付物阻断式核查")
|
||
ap.add_argument("--project-dir", required=True, help="项目根目录")
|
||
ap.add_argument("--readme", default="README.md", help="README 相对路径")
|
||
ap.add_argument("--delivery-dir", default=".Codex/delivery", help="交付目录(相对项目)")
|
||
ap.add_argument("--assets-dir", default=".Codex/delivery/assets",
|
||
help="截图/录屏素材目录(相对项目)")
|
||
ap.add_argument("--trace-keywords", default="",
|
||
help="逗号分隔的商业标识与来源宣传关键词(原公司名、外部仓库、项目代号等)")
|
||
ap.add_argument("--allow-no-docker", action="store_true",
|
||
help="项目确实不适配 Docker 时,要求存在书面说明而非 Dockerfile")
|
||
ap.add_argument("--netdisk-link", default="",
|
||
help="网盘归档链接(manifest 中也可提供,二选一)")
|
||
ap.add_argument("--out", default=".Codex/reports/deliverables-check.json")
|
||
args = ap.parse_args()
|
||
|
||
root = Path(args.project_dir).resolve()
|
||
violations: list[str] = []
|
||
warnings: list[str] = []
|
||
evidence: dict = {}
|
||
|
||
# ---------- A1. 源码可构建产物线索 ----------
|
||
# 多模块项目(如 frontend/backend 分离)清单可能在子目录,递归查找
|
||
manifest_names = {
|
||
"package.json", "pom.xml", "build.gradle", "go.mod",
|
||
"requirements.txt", "pyproject.toml", "Cargo.toml",
|
||
}
|
||
manifest_hits = [p for p in iter_files(root) if p.name in manifest_names]
|
||
if not manifest_hits:
|
||
violations.append("未发现项目清单文件(package.json/pom.xml/go.mod 等),无法确认源码完整性")
|
||
else:
|
||
evidence["manifests"] = sorted(str(p.relative_to(root)) for p in manifest_hits)
|
||
|
||
# ---------- A2/A3. 截图与录屏 ----------
|
||
assets_dir = root / args.assets_dir
|
||
alt_dirs = [root / args.delivery_dir / "screenshots", root / args.delivery_dir / "recordings"]
|
||
images: list[Path] = []
|
||
videos: list[Path] = []
|
||
search_dirs = [d for d in [assets_dir, *alt_dirs] if d.exists()]
|
||
for d in search_dirs:
|
||
images.extend(find_files(d, IMAGE_SUFFIXES))
|
||
videos.extend(find_files(d, VIDEO_SUFFIXES))
|
||
# 对外只提供当前产品界面截图;交付素材目录不得含改造前/before 旧界面
|
||
leaked_before = [p for p in images if re.search(r"(before|改造前|原版)", p.name, re.I)]
|
||
if leaked_before:
|
||
violations.append("交付素材中包含改造前/原版截图(不应随包交付):%s"
|
||
% "、".join(p.name for p in leaked_before[:8]))
|
||
if not images:
|
||
violations.append("缺少产品界面截图(应放在 %s 或交付目录)" % args.assets_dir)
|
||
else:
|
||
evidence["screenshots"] = len(images)
|
||
if not videos:
|
||
violations.append("缺少全功能操作演示录屏(mp4/mov 等)")
|
||
else:
|
||
evidence["recordings"] = len(videos)
|
||
|
||
# ---------- A5. README ----------
|
||
readme_path = root / args.readme
|
||
readme_text = ""
|
||
if not readme_path.exists():
|
||
violations.append("缺少 README 文档:%s" % args.readme)
|
||
else:
|
||
readme_text = readme_path.read_text(encoding="utf-8", errors="ignore")
|
||
missing = []
|
||
low = readme_text.lower()
|
||
for item, kws in README_KEYWORDS.items():
|
||
if not any(k.lower() in low for k in kws):
|
||
missing.append(item)
|
||
if missing:
|
||
violations.append("README 缺少必备内容:%s" % "、".join(missing))
|
||
# 架构图线索:mermaid 或图片引用
|
||
if not re.search(r"```mermaid|!\[.*\]\(.*\)|架构图|拓扑图", readme_text):
|
||
warnings.append("README 未发现架构/拓扑图(mermaid 或图片)")
|
||
# 截图图文对应
|
||
if not re.search(r"!\[.*\]\(.*\)", readme_text):
|
||
warnings.append("README 无图片引用,合同要求功能逐条对应截图")
|
||
|
||
# ---------- A6. Docker / 书面说明 ----------
|
||
docker_files = [p for p in [root / "Dockerfile", root / "docker-compose.yml", root / "docker-compose.yaml"] if p.exists()]
|
||
if not docker_files:
|
||
if args.allow_no_docker:
|
||
note = root / args.delivery_dir / "no-docker说明.md"
|
||
if not note.exists():
|
||
violations.append("已声明不适配 Docker,但缺少书面说明(%s)" % note.relative_to(root))
|
||
else:
|
||
violations.append("缺少 Dockerfile / docker-compose(不适配 Docker 请加 --allow-no-docker 并提供书面说明)")
|
||
|
||
# ---------- A7. 依赖清单与部署注意事项 ----------
|
||
dep_docs = list((root / args.delivery_dir).glob("*依赖*")) + list((root / args.delivery_dir).glob("*注意事项*"))
|
||
dep_docs += [root / "依赖清单.md", root / "部署注意事项.md"]
|
||
dep_docs = [p for p in dep_docs if p.exists()]
|
||
if not dep_docs:
|
||
# 若 README 已含环境依赖/注意事项章节,降级为警告
|
||
if re.search(r"环境(要求|依赖|前置)|注意事项", readme_text):
|
||
warnings.append("未单独提供依赖清单/部署注意事项文档(README 中有相关章节,建议单独输出)")
|
||
else:
|
||
violations.append("缺少环境依赖清单与部署注意事项说明文档")
|
||
|
||
# ---------- A4. 网盘链接 + D. manifest ----------
|
||
manifest_path = root / args.delivery_dir / "manifest.md"
|
||
manifest_text = manifest_path.read_text(encoding="utf-8", errors="ignore") if manifest_path.exists() else ""
|
||
netdisk = args.netdisk_link or (re.search(r"https?://\S*(?:pan\.baidu|aliyundrive|alipan|123pan|cloud\.189)\S*", manifest_text) or [""])[0]
|
||
if not netdisk:
|
||
if not manifest_text:
|
||
violations.append("缺少网盘归档链接,且未提供交付清单 manifest.md")
|
||
else:
|
||
violations.append("未提供网盘素材归档链接(命令行 --netdisk-link 或写入 manifest)")
|
||
if not manifest_path.exists():
|
||
violations.append("缺少交付清单:%s" % manifest_path.relative_to(root))
|
||
else:
|
||
required_items = ["源码", "截图", "录屏", "网盘", "README", "Docker", "依赖"]
|
||
miss_items = [w for w in required_items if w not in manifest_text]
|
||
if miss_items:
|
||
violations.append("manifest.md 未覆盖交付物条目:%s" % "、".join(miss_items))
|
||
|
||
# ---------- C. 痕迹清理检索 ----------
|
||
kws = [k.strip() for k in args.trace_keywords.split(",") if k.strip()]
|
||
traces: list[str] = []
|
||
if kws:
|
||
# before/after 需要按独立词识别,避免 beforeEach、::before、::after 等代码语法误报。
|
||
exact_keywords = [k for k in kws if k.lower() not in {"before", "after"}]
|
||
exact_pat = (
|
||
re.compile("|".join(re.escape(k) for k in exact_keywords), re.I)
|
||
if exact_keywords else None
|
||
)
|
||
boundary_pats = {
|
||
"before": re.compile(r"(?<![A-Za-z_:-])before(?![A-Za-z_:-])", re.I),
|
||
"after": re.compile(r"(?<![A-Za-z_:-])after(?![A-Za-z_:-])", re.I),
|
||
}
|
||
active_boundary_pats = [
|
||
boundary_pats[k.lower()] for k in kws if k.lower() in boundary_pats
|
||
]
|
||
for file_path in iter_files(root):
|
||
if file_path.suffix.lower() not in SCAN_SUFFIXES:
|
||
continue
|
||
rel = str(file_path.relative_to(root))
|
||
if re.search(r"license|copying", rel, re.I):
|
||
continue
|
||
try:
|
||
file_text = file_path.read_text(encoding="utf-8", errors="ignore")
|
||
except OSError:
|
||
continue
|
||
matched = bool(exact_pat and exact_pat.search(file_text))
|
||
if not matched:
|
||
matched = any(pat.search(file_text) for pat in active_boundary_pats)
|
||
if matched:
|
||
traces.append(rel)
|
||
if traces:
|
||
violations.append("痕迹清理不彻底,以下文件命中原标识关键词:%s" % "、".join(sorted(set(traces))[:20]))
|
||
|
||
# ---------- 输出 ----------
|
||
result = {
|
||
"timestamp": datetime.now().isoformat(),
|
||
"project_dir": str(root),
|
||
"violations": violations,
|
||
"warnings": warnings,
|
||
"evidence": evidence,
|
||
"passed": not violations,
|
||
}
|
||
out_path = root / args.out
|
||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||
out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
print("交付物核查:%d 个阻断项,%d 个警告" % (len(violations), len(warnings)))
|
||
for v in violations:
|
||
print(" [FAIL]", v)
|
||
for w in warnings:
|
||
print(" [WARN]", w)
|
||
print("report=%s" % out_path)
|
||
return 0 if not violations else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|