#!/usr/bin/env python3
"""archive_reviews.py — reviews/ の旧版レビューMDを reviews/archive/ へ掃き出す
方針:
「reviews/ 直下には(レビュアー×記事)の最新版だけを置く。旧版は削除せず
reviews/archive/ へ移す(ループ履歴・差分レビューの参照元として残す)」
対象ファイル名(4形式とも解釈する):
⓪ {prefix}_{slug}_YYYYMMDD-HHMMSS.md … 標準(タイムスタンプ式)
① {prefix}_{slug}_vN.md … 版番号式(N=1,2,3…)
② {prefix}_{slug}_vYYYYMMDD.md … 日付版(末尾 a-z 付きも可)
③ {prefix}_{slug}.md … 版数なし
prefix = config の gate.tier1 / gate.tier2 のレビュアー名+confidentiality
(config が無ければ legal / consistency / seo / cro / panel / confidentiality)
※ ⓪のバッチ形 {prefix}_batch-{テーマ}_TS.md は slug='batch-{テーマ}' として同テーマ内で世代管理される
※ legal_audit_*(精密監査・日付名)と review_summary / review_log 等は対象外(触らない)
「最新」の判定: 同一(prefix, slug)グループ内で、ファイル名のタイムスタンプ
(⓪形式のみ・YYYYMMDD-HHMMSS)があればそれ、無ければ**ファイル更新時刻(mtime)を
同書式に変換した値**で比較して最新の1本(vN と v日付は番号では比較不能なため)。
使い方:
python -X utf8 scripts/archive_reviews.py # dry-run(移動予定を表示するだけ)
python -X utf8 scripts/archive_reviews.py --apply # 実際に移動する
python -X utf8 scripts/archive_reviews.py --root <プロジェクトルート> --config config.yaml
"""
import sys
import io
import re
import shutil
import argparse
from datetime import datetime
from pathlib import Path
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
DEFAULT_PREFIXES = ["legal", "consistency", "seo", "cro", "panel", "confidentiality"]
TS_RE = re.compile(r"_(\d{8}-\d{6})\.md$")
def load_config(path: Path) -> dict:
"""config.yaml を読む。無い・PyYAML 無し・壊れている場合は {} を返し既定値で動く"""
if not path.is_file():
return {}
try:
import yaml
except ImportError:
print("⚠️ PyYAML が無いため config を読めません(既定値で動きます): pip install pyyaml", file=sys.stderr)
return {}
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except Exception as e:
print(f"⚠️ config を解釈できません(既定値で動きます): {e}", file=sys.stderr)
return {}
return data if isinstance(data, dict) else {}
def prefixes_from(cfg: dict):
"""config のレビュアー名からファイル名プレフィックス一覧を作る(既定値も常に含める)"""
gate = cfg.get("gate") if isinstance(cfg.get("gate"), dict) else {}
names = list(DEFAULT_PREFIXES)
for key in ("tier1", "tier2"):
for n in gate.get(key) or []:
n = str(n).strip()
if re.fullmatch(r"[A-Za-z0-9-]+", n) and n not in names:
names.append(n)
return names
def name_re(prefixes):
alt = "|".join(re.escape(p) for p in sorted(prefixes, key=len, reverse=True))
return re.compile(rf"^({alt})_(.+?)(?:_v(\d+[a-z]?|\d{{8}}[a-z]?)|_(\d{{8}}-\d{{6}}))?\.md$")
def version_key(f: Path) -> str:
"""世代比較キー: ファイル名TS優先・無ければ mtime を同書式へ変換"""
m = TS_RE.search(f.name)
if m:
return m.group(1)
return datetime.fromtimestamp(f.stat().st_mtime).strftime("%Y%m%d-%H%M%S")
def collect(reviews_dir: Path, pattern):
"""(prefix, slug) → [Path, ...] にグループ化。対象外ファイルは無視"""
groups = {}
skipped = []
for f in sorted(reviews_dir.glob("*.md")):
if f.name.startswith("legal_audit_"):
skipped.append(f.name)
continue
m = pattern.match(f.name)
if not m:
skipped.append(f.name)
continue
groups.setdefault((m.group(1), m.group(2)), []).append(f)
return groups, skipped
def main():
ap = argparse.ArgumentParser(description="reviews/ の旧版レビューMDを reviews/archive/ へ移す(削除はしない)")
ap.add_argument("--apply", action="store_true", help="実際に移動する(省略時は dry-run)")
ap.add_argument("--root", default="", help="プロジェクトルート(省略時はこのスクリプトの1つ上のフォルダ)")
ap.add_argument("--config", default="", help="config.yaml のパス(省略時は <root>/config.yaml)")
args = ap.parse_args()
root = Path(args.root).resolve() if args.root else Path(__file__).resolve().parent.parent
cfg = load_config(Path(args.config) if args.config else root / "config.yaml")
paths = cfg.get("paths") if isinstance(cfg.get("paths"), dict) else {}
reviews_dir = root / str(paths.get("reviews") or "reviews")
if not reviews_dir.is_dir():
print(f"レビューフォルダがありません → 何もしません: {reviews_dir}")
return
archive_dir = reviews_dir / "archive"
groups, skipped = collect(reviews_dir, name_re(prefixes_from(cfg)))
moved = 0
kept = 0
for (prefix, slug), files in sorted(groups.items()):
files.sort(key=version_key) # 末尾が最新(ファイル名TS優先・mtimeフォールバック)
latest = files[-1]
kept += 1
for old in files[:-1]:
dest = archive_dir / old.name
if dest.exists():
print(f" ⚠️ 移動先に同名あり・スキップ: {old.name}")
continue
if args.apply:
archive_dir.mkdir(exist_ok=True)
shutil.move(str(old), str(dest))
moved += 1
if not args.apply:
print(f" [dry] {old.name} → archive/ (残す最新: {latest.name})")
mode = "適用" if args.apply else "dry-run"
print(f"{mode}: 移動 {moved}本 / 最新として残置 {kept}本 / 対象外 {len(skipped)}本"
+ ("" if args.apply else "(dry-run=まだ何も動かしていない。実行は --apply)"))
print("※旧版は削除しない(archive/ に温存)。最新版の鮮度照合・差分レビューには影響しない")
if __name__ == "__main__":
main()
MIT ライセンスで配布しています。