2026-09-15 18:47:13 +08:00
|
|
|
|
#!/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` 对账。
|
|
|
|
|
|
|
|
|
|
|
|
用法
|
2026-09-24 07:25:16 +08:00
|
|
|
|
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 <路径子串> # 合法重构白名单(不报警)
|
2026-09-15 18:47:13 +08:00
|
|
|
|
退出码: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:]))
|