#!/usr/bin/env python3 # -*- coding: utf-8 -*- """docs-shrink-guard.py — 防「共享文件被整文件重写抹掉别人的行」 为什么需要(2026-09-14 实测) * `MEMORY.md` 被并行会话**整文件重写**,抹掉了本会话刚写进去的两行; * 本库的规矩是「共享文件只用 Edit 精确替换,禁整文件 Write」—— 但**只靠人守纪律**,没有机制兜底。 ⇒ 本脚本给"纪律"加一条**机械兜底**:记录每个文件的**行数快照**,下次运行时若某文件 **行数骤降**(默认 >30% 且绝对减少 >20 行),就把它当成"疑似被整文件重写"报警。 覆盖范围(两处共享热区): * 文档库 `dsh-server-docs/**/*.md` * 工作区记忆 `.workbuddy/memory/*.md` 快照落在**库外**(`<工作区>/.workbuddy/cache/docs-lines.json`)—— 放库内会污染 `docs-sync-check` 对账。 用法 python3 07-scripts/docs-shrink-guard.py # 比对并报告(不改快照) python3 07-scripts/docs-shrink-guard.py --write # 比对 + 刷新快照(改完文件后跑) python3 07-scripts/docs-shrink-guard.py --baseline # 只建档不比对(首次) python3 07-scripts/docs-shrink-guard.py --allow-shrink <路径子串> # 合法重构白名单(不报警) 退出码:0 = 无骤降;1 = 发现骤降(需人看一眼 diff);2 = 用法错误 """ import io import json import os import sys DOCS = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) WS = os.path.dirname(DOCS) SNAP = os.path.join(WS, '.workbuddy', 'cache', 'docs-lines.json') DROP_RATIO, DROP_MIN = 0.30, 20 def collect(): out = {} roots = [(DOCS, 'docs'), (os.path.join(WS, '.workbuddy', 'memory'), 'memory')] for root, tag in roots: if not os.path.isdir(root): continue for base, dirs, names in os.walk(root): if '.git' in dirs: dirs.remove('.git') if os.sep + 'cache' in base: continue for n in sorted(names): if not n.endswith('.md') or '.bak' in n: continue p = os.path.join(base, n) rel = os.path.relpath(p, WS).replace('\\', '/') try: out[rel] = sum(1 for _ in io.open(p, encoding='utf-8', errors='replace')) except OSError: continue return out def main(argv): now = collect() allow = [argv[i + 1] for i, a in enumerate(argv) if a == '--allow-shrink' and i + 1 < len(argv)] old = {} if os.path.exists(SNAP): try: old = json.loads(io.open(SNAP, encoding='utf-8').read()) except ValueError: print('⚠️ 快照损坏,按首次处理') if '--baseline' in argv or not old: if '--write' in argv or '--baseline' in argv: os.makedirs(os.path.dirname(SNAP), exist_ok=True) io.open(SNAP, 'w', encoding='utf-8', newline='\n').write( json.dumps(now, ensure_ascii=False, indent=1, sort_keys=True) + '\n') print('已建基线:%d 个文件 → %s' % (len(now), SNAP)) return 0 print('无快照(首次)。跑 `--baseline` 建档后再用。') return 2 shrunk, added, removed, grew = [], [], [], [] for rel, n in now.items(): if rel not in old: added.append(rel) elif n < old[rel]: if old[rel] - n > DROP_MIN and (old[rel] - n) / old[rel] > DROP_RATIO: if any(a in rel for a in allow): print(' ↳ 合法重构(白名单):%s %d → %d 行' % (rel, old[rel], n)) else: shrunk.append((rel, old[rel], n)) elif old[rel] - n > 0: grew.append((rel, old[rel], n, 'down')) for rel in old: if rel not in now: removed.append(rel) for rel, n in now.items(): if rel in old and n > old[rel]: grew.append((rel, old[rel], n, 'up')) print('文件 %d(新增 %d / 删除 %d)| 行数变化 %d' % (len(now), len(added), len(removed), len(grew))) for rel in added[:8]: print(' + %s' % rel) for rel in removed[:8]: print(' - %s(消失?)' % rel) for rel, o, n, d in sorted(grew, key=lambda x: -abs(x[1] - x[2]))[:6]: print(' %s %s %d → %d 行' % ('↑' if d == 'up' else '↓', rel, o, n)) bad = 0 if shrunk: bad = 1 print('\n⚠️ **疑似被整文件重写(行数骤降)** —— 请看一眼 `git diff` 或与上一位写入者核对:') for rel, o, n in shrunk: print(' %s %d → %d 行(-%.0f%%)' % (rel, o, n, (o - n) / o * 100)) print(' 本库规矩:共享文件只用 Edit 精确替换,禁整文件 Write(见 CODEBUDDY.md)') if '--write' in argv: os.makedirs(os.path.dirname(SNAP), exist_ok=True) io.open(SNAP, 'w', encoding='utf-8', newline='\n').write( json.dumps(now, ensure_ascii=False, indent=1, sort_keys=True) + '\n') print('已刷新快照。') elif bad: print('(未刷新快照:确认无误后跑 `--write`)') return bad if __name__ == '__main__': raise SystemExit(main(sys.argv[1:]))