2026-10-10 18:50:57 +08:00
|
|
|
|
#!/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:
|
2026-10-10 19:16:41 +08:00
|
|
|
|
# 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:
|
2026-10-10 18:50:57 +08:00
|
|
|
|
continue
|
2026-10-10 19:16:41 +08:00
|
|
|
|
rel = str(file_path.relative_to(root))
|
2026-10-10 18:50:57 +08:00
|
|
|
|
if re.search(r"license|copying", rel, re.I):
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
2026-10-10 19:16:41 +08:00
|
|
|
|
file_text = file_path.read_text(encoding="utf-8", errors="ignore")
|
2026-10-10 18:50:57 +08:00
|
|
|
|
except OSError:
|
|
|
|
|
|
continue
|
2026-10-10 19:16:41 +08:00
|
|
|
|
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:
|
2026-10-10 18:50:57 +08:00
|
|
|
|
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())
|