Files

243 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())