review_log.py

scripts/review_log.py(218 行)

review_log.py
# -*- coding: utf-8 -*-
"""レビューログ&frontmatterインデックス

全レビューMD(reviews/ と reviews/archive/)の冒頭 YAML frontmatter を読み、
reviews/review_log.md を再生成する(1レビュー=1行の時系列ログ・手編集禁止)。

「追記」ではなく毎回フル再生成する設計: 情報の正はレビューMD自身の frontmatter に
あるので、ログはいつでも作り直せる派生物。書き忘れ・ファイル移動(archive/退避)・
再取得のどれが起きてもログが壊れない。

frontmatter 形式(article-review スキル STEP 1 の規約):
---
reviewer: legal
date: 2026-01-15 10:30:15   # 秒まで(ファイル名のTSと同時刻。日付のみも許容)
articles:
  sample-article: 97
  another-article: null     # 点数未確定は null
---

使い方:
    python -X utf8 scripts/review_log.py [--root <プロジェクトルート>] [--config config.yaml]

フォルダは config の paths.reviews / paths.articles を使う(無ければ reviews / content/articles)。
PyYAML が無い環境では、上の形式だけを読める簡易パーサに切り替える。
"""
import os, re, sys, io, glob, datetime, argparse

try:
    import yaml
except ImportError:
    yaml = None

sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')

DEFAULT_REVIEWERS = {'legal', 'consistency', 'seo', 'cro', 'panel', 'confidentiality'}
IGNORE_BASENAMES = {'review_log.md'}

FM_RE = re.compile(r'^---\s*\n(.*?)\n---\s*(?:\n|$)', re.S)


def default_root():
    return os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))


def load_config(path):
    """config.yaml を読む。無い・PyYAML 無し・壊れている場合は {} を返し既定値で動く"""
    if not os.path.isfile(path):
        return {}
    if yaml is None:
        print('⚠️ PyYAML が無いため config を読めません(既定のフォルダ名で動きます): pip install pyyaml', file=sys.stderr)
        return {}
    try:
        with open(path, encoding='utf-8') as fh:
            data = yaml.safe_load(fh) or {}
    except Exception as e:
        print(f'⚠️ config を解釈できません(既定のフォルダ名で動きます): {e}', file=sys.stderr)
        return {}
    return data if isinstance(data, dict) else {}


def simple_frontmatter(block):
    """PyYAML が無いとき用の簡易パーサ(reviewer / date / articles の3キーだけ読む)"""
    data, arts, in_articles = {}, {}, False
    for line in block.splitlines():
        line = re.sub(r'\s+#.*$', '', line).rstrip()
        if not line.strip():
            continue
        if in_articles and line[:1] in (' ', '\t'):
            k, _, v = line.strip().partition(':')
            v = v.strip().strip('"').strip("'")
            arts[k.strip().strip('"').strip("'")] = None if v in ('', 'null', '~') else v
            continue
        in_articles = False
        k, _, v = line.partition(':')
        k, v = k.strip(), v.strip().strip('"').strip("'")
        if k == 'articles':
            in_articles = True
        elif k:
            data[k] = v
    if arts:
        data['articles'] = arts
    return data


def parse_frontmatter(path):
    """ファイル冒頭の --- ... --- ブロックを dict で返す。無ければ None。"""
    try:
        with open(path, encoding='utf-8', errors='ignore') as fh:
            head = fh.read(4000)
    except OSError:
        return None
    if not head.startswith('---'):
        return None
    m = FM_RE.match(head)
    if not m:
        return None
    if yaml is None:
        return simple_frontmatter(m.group(1))
    try:
        data = yaml.safe_load(m.group(1))
    except yaml.YAMLError:
        return None
    return data if isinstance(data, dict) else None


def known_reviewers(cfg):
    gate = cfg.get('gate') if isinstance(cfg.get('gate'), dict) else {}
    names = set(DEFAULT_REVIEWERS)
    for key in ('tier1', 'tier2'):
        names.update(str(n).strip() for n in (gate.get(key) or []) if str(n).strip())
    return names


def build_index(root, reviews_rel='reviews', articles_rel=os.path.join('content', 'articles'), reviewers=None):
    """reviews/(+archive/)の frontmatter を走査する。

    返り値:
      entries  : [{slug, reviewer, date, score, file, archived}] (ログ用・全件)
      files    : {basename: {'entries': [...], 'multi': bool}} (reviews/ 直下のみ)
      warnings : [str] (frontmatter はあるが内容が規約外のもの)
    """
    reviewers = reviewers or DEFAULT_REVIEWERS
    slugs = set()
    for ext in ('*.md', '*.html'):
        slugs.update(os.path.splitext(os.path.basename(p))[0]
                     for p in glob.glob(os.path.join(root, articles_rel, ext)))
    entries, files, warnings = [], {}, []
    for sub, archived in ((reviews_rel, False), (os.path.join(reviews_rel, 'archive'), True)):
        d = os.path.join(root, sub)
        if not os.path.isdir(d):
            continue
        for f in sorted(os.listdir(d)):
            if not f.endswith('.md') or f in IGNORE_BASENAMES:
                continue
            p = os.path.join(d, f)
            if not os.path.isfile(p):
                continue
            fm = parse_frontmatter(p)
            if fm is None:
                continue
            reviewer = str(fm.get('reviewer', '')).strip()
            date = fm.get('date')
            arts = fm.get('articles')
            # PyYAML は "2026-01-15 10:30:15" を datetime、"2026-01-15" を date に変換する
            if isinstance(date, datetime.datetime):
                date = date.strftime('%Y-%m-%d %H:%M:%S')
            elif isinstance(date, datetime.date):
                date = date.isoformat()
            date = str(date or '').strip().replace('T', ' ')
            if not re.fullmatch(r'\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?', date):
                warnings.append(f"{sub}/{f}: date が YYYY-MM-DD[ HH:MM:SS] でない({date!r})→ スキップ")
                continue
            if reviewer not in reviewers:
                warnings.append(f"{sub}/{f}: 未知の reviewer {reviewer!r}(続行はする)")
            if not isinstance(arts, dict) or not arts:
                warnings.append(f"{sub}/{f}: articles が空/辞書でない → スキップ")
                continue
            f_entries = []
            for slug, score in arts.items():
                slug = str(slug).strip()
                if slug not in slugs:
                    warnings.append(f"{sub}/{f}: 未知の記事slug {slug!r} → この行だけスキップ")
                    continue
                if score is not None:
                    try:
                        score = int(score)
                    except (TypeError, ValueError):
                        warnings.append(f"{sub}/{f}: {slug} の点数 {score!r} が整数でない → 点数なし扱い")
                        score = None
                e = {'slug': slug, 'reviewer': reviewer, 'date': date,
                     'score': score, 'file': f, 'archived': archived}
                entries.append(e)
                f_entries.append(e)
            if not archived and f_entries:
                files[f] = {'entries': f_entries, 'multi': len(f_entries) > 1}
    return entries, files, warnings


def write_log(root, entries, reviews_rel='reviews'):
    """reviews/review_log.md を再生成する。1レビュー=1行・日付昇順。"""
    out = os.path.join(root, reviews_rel, 'review_log.md')
    lines = [
        '# レビューログ(自動生成・手編集禁止)',
        '# scripts/review_log.py が各レビューMD冒頭の frontmatter から毎回フル再生成する。',
        '# 形式: 日時 | 記事slug | レビュアー | 点数 | 成果物ファイル(同一日時=同一巡)',
        '# 記事の履歴を見るには、このファイルを記事slugで検索する。',
        '',
    ]
    for e in sorted(entries, key=lambda e: (e['date'], e['slug'], e['reviewer'], e['file'])):
        score = str(e['score']) if e['score'] is not None else '—'
        lines.append(f"{e['date']} | {e['slug']} | {e['reviewer']} | {score} | {e['file']}")
    os.makedirs(os.path.dirname(out), exist_ok=True)
    with open(out, 'w', encoding='utf-8', newline='\n') as fh:
        fh.write('\n'.join(lines) + '\n')
    return out, len(entries)


def main():
    ap = argparse.ArgumentParser(description='レビューMDの frontmatter から reviews/review_log.md を再生成する')
    ap.add_argument('--root', default='', help='プロジェクトルート(省略時はこのスクリプトの1つ上のフォルダ)')
    ap.add_argument('--config', default='', help='config.yaml のパス(省略時は <root>/config.yaml)')
    args = ap.parse_args()

    root = os.path.abspath(args.root) if args.root else default_root()
    cfg = load_config(args.config or os.path.join(root, 'config.yaml'))
    paths = cfg.get('paths') if isinstance(cfg.get('paths'), dict) else {}
    reviews_rel = str(paths.get('reviews') or 'reviews')
    articles_rel = str(paths.get('articles') or os.path.join('content', 'articles'))

    entries, files, warnings = build_index(root, reviews_rel, articles_rel, known_reviewers(cfg))
    out, n = write_log(root, entries, reviews_rel)
    print(f"OK: {out}  entries={n}  frontmatter付きファイル={len(files)}(直下)")
    for w in warnings:
        print(f"⚠️ {w}")


if __name__ == '__main__':
    main()

MIT ライセンスで配布しています。