Files
HEROLL/HEROLL_OUTGAME_DEV_v1.0/99-审计与证据/工具/build_dev_docs.py
T

265 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from data_source import *
from module_content import CONTENT
from collections import Counter, defaultdict
import shutil
audit=OUT/'99-审计与证据'
before=snapshot()
baseline=audit/'旧目录生成前快照.json'
if baseline.exists():
assert before==json.loads(baseline.read_text(encoding='utf-8')), '旧目录已与首次快照不一致,停止生成'
else: write(baseline,json.dumps(before,ensure_ascii=False,indent=2))
modules,tables,local,oldconstants=load()
assert {m['id'] for m in modules}==set(CONTENT)
table_notes=constants(OLD/'99-审计与证据/工具/organize_module_docs.py')['NOTES']
tn={line.split('|')[0]:line.split('|')[1:] for line in table_notes.strip().splitlines()}
fieldzh=oldconstants['FIELD_ZH']
def clean(s):
s=s.replace('<br>',' / ').replace('<br/>',' / ')
return re.sub(r'</?(?:b|i|color|size)(?:=[^>]*)?>','',s)
def loc(key): return clean(local.get(key,{}).get('zh',''))
def translated(r):
texts={h:loc(v) for h,v in r.items() if v and loc(v)}
names=[v for h,v in texts.items() if re.search('title',h,re.I)]
effects=[v for h,v in texts.items() if re.search('desc|challengeText',h,re.I)]
return (names[0] if names else r['StringId']), ' / '.join(effects), texts
# 重要参数用紧凑业务表展示,完整配置与中文文本目录同目录保留。
HIGHLIGHTS={
'01': [('newbieguide',['clearStage','rewardID','rewardCount'])],
'02': [('lobbyticket',['EAsset','ELobbyAsset','defaultValue','boostValue'])],
'03': [('equipmentdata',['grade','starCount','maxUpgradeLevel','materialLevel','materialCount','sameGradeAnyEquipmentCount']),('equipment',['slotNum','isSpecial','isMaterial','baseStatus'])],
'04': [('bonusstatus',['EMonoStatus','isPercent','upgradeValue','maxLevel']),('slotupgrade',['slotNum','level','needItemID','needCount','needGold'])],
'05': [('relicdata',['grade','starCount','materialLevel','materialCount','sameGradeAnyRelicCount']),('relic',['slotNum','baseStatus','statusValue','isPercent'])],
'06': [('pet',['grade','baseCoolTime','plusCoolTime','unlockPrice']),('petskill',['petId','skillUnlockLevel','EPetSkill','skillValue'])],
'07': [('mount',['grade','unlockPiece','unlockDia','refundItemId','refundItemCount']),('mountawaken',['grade','level','piece','isOwnEffect','baseStatus0','value0'])],
'08': [('destinystat',['isEnabled','EDestinyStatGrade','EMonoStatus','addValue','normalWeight','rareWeight'])],
'09': [('collection',['equipmentIds','equipmentLevels','equipmentGrades','EMonoStatus','value'])],
'10': [('skin',['EEquipmentSlot','additionalStatus','additionalValue','maxSetCount','setAdditionalValueList'])],
'11': [('outgametileresearch',['totalUpgradeCount','researchLevel','weaponPlusLevel','EMonoWeapon']),('outgametileafkconfig',['afkTime','maxPackCount','startAddPack','needMileage','mileageRewardId','mileageRewardCount'])],
'12': [('afkconfig',['checkTime','maxAfkTime','quickAfkTime','dailyMaxQuickAfkCount','quickAfkEnergy','dailyMaxQuickAfkAdCount']),('afkreward',['lastClearStage','goldPerHour','amountPerCheckTime','maxAmount','dropPercent'])],
'13': [('gachatype',['type','gachaCount','isFree','isAD','needItemID','needCount']),('gachaceildata',['type','ceilCount','isResetBeforeCeil','ceilRewardString'])],
'14': [('pigbank',['startValue','endValue','winUpValue','loseUpValue','purchaseAvailableMinutes','cooldownMinutes']),('relaypackagedata',['relayPackageId','order','isFree','freeItemId','freeItemCount'])],
'15': [('achievement',['conditionType','goalCount','isPerGameReset','rewardType','rewardId','rewardValue'])],
'16': [('comebackconfig',['comebackTime','buffTime','rewardBuffPercent','rewardBuffCount','playSpeedBuffCount']),('newbiebuffconfig',['buffTime','additionalSpeed','rewardBuffPercent','playSpeedBuffCount','rewardBuffCount'])],
'17': [('passdata',['dailyQuestCount','weeklyQuestCount','challengeQuestCount','playMaxXP','primiumPlayMaxXP','loopClearXP'])],
'18': [('starchallenge',['challengeText','value']),('starchallengereward',['starCount','rewardID1','value1','rewardID2','value2'])],
'19': [('idledungeonconfig',['ticketChargeTime','maxTicket','startTicket','maxInvSize']),('dayoftheweekdungeon',['day','difficulty','itemId','itemCount'])],
'20': [('infiniteconfig',['playAssetId','playAssetCount','excludeReward','maxGold','shopIncrease'])],
'21': [('raidconfig',['startTicketCount','maxTicketCount','ticketChargeTime','playAssetId','playAssetCount']),('raidseasonpetlist',['season','deckIndex','petId','trialLevel'])],
'22': [('potionbattleconfig',['targetSeason','searchMmrRange','searchRange','maxTicketCount','ticketChargeTime']),('rankbasedata',['gameMode','followingPassId','resetSeasonAdjustTier'])],
'23': [('rankingrewarddata',['rankingDataKey','minRank','maxRank','rewardId','rewardCount'])],
'24': [('superballschedule',['startReward','RewardPercent','countingTime','superBallStartDay','superBallEndDay'])],
'25': [('plinkolevel',['maxBallCount','chargeSecond','adKey','level']),('plinkoad',['maxADPerDay','buffSecond','values'])],
'26': [('deoksun',['likePoint','goldUP','diaUP','ingameDiscount']),('deokjashop',['isEnabled','probabilityWeight','baseAdditionalValue','additionalValue','maxLevel'])]
}
fieldzh.update({'dailyQuestCount':'每日任务','weeklyQuestCount':'每周任务','challengeQuestCount':'挑战任务','maxTicket':'门票上限','startTicket':'初始门票','maxInvSize':'未装备物容量','materialCount':'指定材料数','sameGradeAnyEquipmentCount':'任意同品质材料数','starCount':'星数','grade':'品质','level':'阶段/等级','playMaxXP':'普通每日游玩经验上限','primiumPlayMaxXP':'高级每日游玩经验上限','loopClearXP':'每层通关经验','isSpecial':'特殊装备','baseStatus':'主属性','startReward':'初始奖池','RewardPercent':'前三名比例','countingTime':'统计分钟数','challengeText':'挑战描述','goldUP':'金币收益参数','diaUP':'钻石概率参数','value1':'奖励1数量','value2':'奖励2数量','maxBallCount':'球容量','chargeSecond':'恢复秒数','buffSecond':'增益秒数','maxADPerDay':'每日广告次数'})
rules_index=[];manifest=[];coverage=[];localized_count=0
shared=OUT/'00-共享资源与配置'
for t in ['gameconfig','item','newtile','weaponstatus','skilleffect']:
savecsv(shared/(t+'.csv'),tables[t]['rows'],tables[t]['headers'])
manifest.append(dict(来源=SOURCE.joinpath(t+'.csv').relative_to(ROOT).as_posix(),SHA256=sha(SOURCE/(t+'.csv')),用途='共享原作配置'))
GUIDE='''# 局外原作开发文档编写规范
## 1. 采用的文档结构
参考局内v0.2.8“玩法总纲 → 分模块规则 → 权威CSV → 规则状态与误写项”的顺序。正文先回答玩家做什么、进度如何变化,再给公式、数值和配置关系。不能用字段目录代替玩法说明。
每个模块的《设计与规则》包含:功能定位、主要状态、逐项规则、操作流程、关键数值、中文内容、开发权威表、验收样例和待补充边界。Markdown和CSV放在同一模块文件夹。
## 2. 规则状态
| 状态 | 处理方式 |
| --- | --- |
| 原作配置确认 | 字段、数组、类型和原注释直接支持的值,按原值使用 |
| 原作文本确认 | 原作中文名称、效果或条件文本;占位符未映射时保留,不当作执行公式 |
| 原作部分确认 | 功能主体或配置关系明确,仍有执行顺序或参数含义缺口 |
| 条件推导 | 写明公式与前提,只用于检查,不充当确认的完整运行规则 |
| 开发映射 | 为理解和实现而拆分的概念状态,不宣称是原作字段名或存档格式 |
| 未知 | 写出具体缺口,不填写新的设计默认值 |
状态对应证据精度,而非功能是否收录。原作配置、原作文本和条件推导分列;文字与数值不一致时保留两者并标明,不能无依据覆盖。
## 3. 使用约定
- 原作版本为HEROLL 2.0.1。只借用v0.2.8文档组织方法,不借用其题材包装、裁剪清单或默认数值。
- 中文名称使用原作本地化文本,保留原字符。没有中文文本时显示StringId,不创造名称。
- 正文列出的操作流程是功能阅读顺序;精确扣除和结算时序未确认的地方不冻结成新玩法。
- 执行规则表、配置表和中文对照表职责分开。中文对照列仅帮助读懂数据,不改写原字段。
- 详细数值以同模块CSV为权威;正文大表注明节选范围。状态和待确认项写入规则表,不靠口头补充。
'''
write(OUT/'文档规范与阅读说明.md',GUIDE)
def section_body(m):
global localized_count
c=CONTENT[m['id']]; d=OUT/m['folder']; rule_rows=[];checks=[]
body=f"# {m['name']}:原作设计与规则\n\n版本:HEROLL 2.0.1 原作还原文档 v1.0。\n\n## 1. 功能定位\n\n{c['intro']}\n\n## 2. 主要状态\n\n下面是开发阅读所需的概念状态,属于开发映射,不要求与原作内部字段同名。\n\n"
states=[]
for x in c['states'].split(';'):
a,b=x.split(':',1);states.append(dict(状态=a,用途=b,规则状态='开发映射'))
body+=md(['状态','用途'],[(r['状态'],r['用途']) for r in states])
savecsv(d/'状态字段.csv',states)
body+='\n## 3. 功能规则\n\n'
for n,(title,text) in enumerate(c['rules'],1):
body+=f'### 3.{n} {title}\n\n{text}\n\n'
rule_rows.append(dict(规则ID=f"O{m['id']}-{n:02}",规则项目=title,执行说明=text,规则状态='原作部分确认',来源=';'.join(m['tables']),边界=c['boundary']))
body+='## 4. 操作流程\n\n以下顺序用于拆解功能;涉及未确认的结算时机,以本章末尾具体缺口为准。\n\n'
body+='\n'.join(f'{n}. {text}' for n,text in enumerate(c['flow'],1))+'\n\n'
body+='## 5. 关键数值与计算\n\n'
facts=[]
for t,key,desc,fields in m['facts']:
matches=[r for r in tables[t]['rows'] if r['StringId']==key];assert len(matches)==1,(t,key)
r=matches[0];v=';'.join(f'{h}={r[h]}' for h in fields.split())
facts.append((desc,v,'原作配置确认'))
rule_rows.append(dict(规则ID=f"O{m['id']}-N{len(facts):02}",规则项目=desc,执行说明=v,规则状态='原作配置确认',来源=f'{t}.csv/StringId={key}',边界='原字段值;复合公式以正文边界为准'))
checks.append(dict(用例ID=f"T{m['id']}-{len(checks)+1:02}",输入=f'读取{t}中StringId={key}',检查动作='查询本条配置并核对界面/配置输出',预期=v,验收范围='配置数值与显示;不覆盖未知执行分支'))
if facts: body+=md(['项目','数值','状态'],facts)+'\n'
else: body+='关键数值见下列表格。\n\n'
if m['id']=='12':
body+='### 5.A 时间换算与倍数检查\n\n1440÷60=24小时;360÷60=6小时。若所有倍数按给定权重参与,则`E[m]=(1×65+1.2×25+1.5×10)/100=1.1`。时间换算是算术确认,期望倍数仅是条件推导。\n\n'
if m['id']=='11': body+='### 5.A 挂机容量换算\n\n`30分钟/包 × 30包 = 900分钟 = 15小时`。此式只换算地块包容量,不能替代巡逻挂机的24小时上限。\n\n'
if m['id']=='13':
body+='### 5.A 权重检查公式\n\n对指定池、并假定该池所有记录均按权重参与时,`P(i)=weight(i)/Σweight(pool)`。保底和动态筛选不在此式内,见[条件概率检查](./抽卡条件概率.csv)。\n\n'
for t,cols in HIGHLIGHTS[m['id']]:
rows=tables[t]['rows'];assert set(cols)<=set(tables[t]['headers']),t
chosen=rows if len(rows)<=40 else rows[:5]+rows[-3:]
tag='全表' if len(rows)<=40 else f'节选前5条与末3条,完整{len(rows)}条见CSV'
body+=f"### {tn[t][0]}({tag})\n\n"
outrows=[]
for r in chosen:
name,desc,tx=translated(r)
outrows.append((r['Id'],name,*[loc(r[h]) if h=='challengeText' and loc(r[h]) else r[h] for h in cols]))
body+=md(['ID','名称/键']+[fieldzh.get(h,h) for h in cols],outrows)+'\n'
body+='## 6. 原作中文内容\n\n中文名称和效果使用原作文本;{0}、{B}等占位符未确认绑定时保留,文字不能代替参数公式。\n\n'
visible=[];references=[]
for t in m['tables']:
for r in tables[t]['rows']:
name,effect,texts=translated(r)
for h,text in texts.items():
references.append(dict(表=t,Id=r['Id'],StringId=r['StringId'],源字段=h,文本键=r[h],原作中文=text,来源='原作本地化/zh-cn'))
if name!=r['StringId'] or effect:
visible.append((t,r['Id'],name,effect))
if visible:
shown=visible if len(visible)<=30 else visible[:20]
body+=md(['来源表','ID','名称','效果/条件文本'],shown)+'\n'
if len(visible)>30: body+=f'本节展示前20条中文内容;完整{len(visible)}条有名称或效果的记录见对应中文目录及[中文文本对照](./中文文本对照.csv)。\n\n'
else: body+='本模块主要由数值和键值组成,原作文本对照见同目录CSV。\n\n'
savecsv(d/'中文文本对照.csv',references,['表','Id','StringId','源字段','文本键','原作中文','来源'])
localized_count+=len(references)
body+='## 7. 开发权威表\n\n'
global_config=[r for r in tables['gameconfig']['rows'] if re.search(oldconstants['GROUTES'][m['id']],r['StringId'],re.I)]
savecsv(d/'关联全局配置.csv',global_config,tables['gameconfig']['headers'])
rows=[]
for t in m['tables']:
desc=tn[t][1];records=tables[t]['rows']
savecsv(d/(t+'.csv'),records,tables[t]['headers'])
catalog=[]
for r in records:
name,effect,_=translated(r)
catalog.append(dict(原作中文名称=name,原作效果文本=effect,**r))
savecsv(d/(t+'-中文目录.csv'),catalog,['原作中文名称','原作效果文本']+tables[t]['headers'])
rows.append((tn[t][0],f'[{t}.csv](./{t}.csv)',len(records),f'[中文目录](./{t}-中文目录.csv)'))
manifest.append(dict(来源=(SOURCE/(t+'.csv')).relative_to(ROOT).as_posix(),SHA256=sha(SOURCE/(t+'.csv')),用途=m['folder']))
coverage.append(dict(模块=m['folder'],子功能=tn[t][0],表=t,行数=len(records),规则文档=m['folder']+'/设计与规则.md',数值表=m['folder']+'/'+t+'.csv',数值用途=desc))
body+=md(['配置用途','权威原字段表','记录数','开发阅读表'],rows)+'\n'
body+='[执行规则](./执行规则.csv) · [状态字段](./状态字段.csv) · [验收样例](./验收样例.csv) · [字段字典](./字段字典.csv) · [关联全局配置](./关联全局配置.csv)\n\n'
if global_config:
body+='全局键按模块名称关联收录;其具体含义优先以正文已确认的规则为准。\n\n'+md(['配置键','原值'],[(r['StringId'],r['Values']) for r in global_config])+'\n'
dictionary=[]
for t in m['tables']:
for i,h in enumerate(tables[t]['headers']):
dictionary.append(dict(表=t,字段=h,含义=fieldzh.get(h,'按本模块功能规则与原注释解释'),原类型=tables[t]['types'][i],原注释=tables[t]['comments'][i] if i<len(tables[t]['comments']) else ''))
savecsv(d/'字段字典.csv',dictionary)
body+='## 8. 开发验收与禁止误写\n\n'
if checks: body+=md(['输入','检查内容','预期'],[(r['输入'],r['检查动作'],r['预期']) for r in checks])+'\n'
body+='验收样例覆盖已经确认的值;流程中的未知分支不能以本表通过代替规则确认。数值表内的空值、0和-1分别保留;升级数组不默认使用玩家等级作为索引。\n\n'
body+=f"## 9. 当前待补充边界\n\n{c['boundary']}\n\n[返回总纲](../局外玩法设计文档_HEROLL_原作_v1.0.md) · [阅读规范](../文档规范与阅读说明.md)\n"
savecsv(d/'执行规则.csv',rule_rows)
if not checks:
t=m['tables'][0];r=tables[t]['rows'][0]
checks=[dict(用例ID=f"T{m['id']}-01",输入=f'{t}/Id={r["Id"]}',检查动作='按主键读取配置',预期=json.dumps(r,ensure_ascii=False),验收范围='配置读取')]
savecsv(d/'验收样例.csv',checks)
write(d/'设计与规则.md',body)
write(d/'README.md',f"# {m['folder']}\n\n{c['intro']}\n\n[设计与规则](./设计与规则.md)是本模块正文。数值和规则CSV与正文同目录,原作中文目录用于开发阅读。\n\n"+md(['配置用途','权威原字段表','记录数','开发阅读表'],rows))
rules_index.extend(dict(模块=m['folder'],**r) for r in rule_rows)
return body
# 条件概率作为明确标记的算术附表。
pool=defaultdict(list)
for r in tables['gacha']['rows']:pool[r['type']].append(r)
prob=[]
for key,rows in pool.items():
total=sum(float(r['prob']) for r in rows)
for r in rows:
prob.append(dict(池=key,Id=r['Id'],权重=r['prob'],池总权重=total,条件概率=float(r['prob'])/total if total else '',状态='条件推导',前提='同池所有行按权重参与,未计保底和动态筛选'))
savecsv(OUT/'13-抽卡与保底/抽卡条件概率.csv',prob)
overview='''# 局外玩法设计文档
版本:HEROLL 2.0.1 原作还原 v1.0(开发阅读版)
本文描述原作局外的资源、长期养成、任务、商业化入口和模式外围规则。组织方式参照局内v0.2.8:先定义玩家玩法和状态,再列操作规则、数值示例、权威配置和待确认边界。开发阅读以本总纲或对应模块《设计与规则》为入口。
## 0. 文档状态与采用原则
### 0.1 状态
原作配置确认用于表值及原注释,原作文本确认用于中文名称与效果,原作部分确认用于尚有执行细节缺口的规则。开发映射只解释状态拆分,条件推导只用于带前提的计算检查。缺少证据的精确规则标未知,不填写新设计默认值。
### 0.2 采用顺序
1. 已确认的原作执行证据用于确定实际流程。
2. 原作配置用于确定档位、消耗、奖励和参数。
3. 原作中文文本用于解释名称与玩家可见效果,不能独立确定内部计算。
4. 条件推导与开发状态拆分始终保留各自标识。
### 0.3 模块采用矩阵
'''
overview+=md(['模块','本版执行阅读结论','文档'],[(m['name'],CONTENT[m['id']]['intro'],f"[{m['folder']}](./{m['folder']}/设计与规则.md)") for m in modules])
overview+='\n### 0.4 整体玩法关系\n\n主线推进开放局外入口;资源通过奖励、巡逻、活动等来源获得;装备、遗物、宠物、坐骑、命运和地块提供长期成长;任务、通行证与活动为成长提供目标和收益。不同模式拥有自己的门票、奖励、赛季和配置,不能只用一套全局等级或计数器替代。\n\n'
overview+='[文档规范](./文档规范与阅读说明.md) · [规则完整度报告](./规则完整度报告.md) · [开发权威表索引](./开发权威表索引.csv)\n\n'
bodies=[]
for m in modules:
body=section_body(m)
# 总纲内展开全部模块正文,保留独立模块文件;将相对资源链接改到模块目录。
expanded=body.split('\n',1)[1]
expanded=re.sub(r'^### (?:\d+\.[\dA-Z]+ )?(.+)$',r'**\1**',expanded,flags=re.M)
expanded=re.sub(r'^## (\d+)\. (.+)$',lambda x:f"### {int(m['id'])}.{x[1]} {x[2]}",expanded,flags=re.M)
expanded=re.sub(r'\]\(\./([^)]+)\)',lambda x:f"](./{m['folder']}/{x[1]})",expanded)
expanded=expanded.replace('](../','](./')
bodies.append(f"\n## {int(m['id'])}. {m['name']}\n"+expanded)
write(OUT/'局外玩法设计文档_HEROLL_原作_v1.0.md',overview+''.join(bodies))
write(OUT/'README.md','# HEROLL 原作局外开发文档 v1.0\n\n先读[局外玩法设计文档](./局外玩法设计文档_HEROLL_原作_v1.0.md),或直接进入下列模块。正文采用局内v0.2.8的规则叙述、数值表、状态区分与权威CSV结构。\n\n'+md(['模块','模块正文'],[(m['folder'],f"[设计与规则](./{m['folder']}/设计与规则.md)") for m in modules])+'\n[文档规范](./文档规范与阅读说明.md) · [规则完整度报告](./规则完整度报告.md) · [权威表索引](./开发权威表索引.csv)\n')
savecsv(OUT/'开发权威表索引.csv',coverage)
savecsv(audit/'规则总索引.csv',rules_index)
savecsv(audit/'源文件清单.csv',manifest+[dict(来源=LOC.relative_to(ROOT).as_posix(),SHA256=sha(LOC),用途='原作中文文本')])
boundary=[dict(模块=m['folder'],状态='原作部分确认',可直接使用='原作配置、中文内容和已注明单位的参数',尚需补充=CONTENT[m['id']]['boundary']) for m in modules]
savecsv(audit/'待确认边界.csv',boundary)
write(OUT/'规则完整度报告.md','# 原作局外规则完整度报告\n\n本版完成26模块的开发阅读文档、原字段配置、中文内容和规则索引。文档组织不等于所有公式已经恢复,以下逐模块列出精确缺口。\n\n'+md(['模块','可直接使用','待确认边界'],[(x['模块'],x['可直接使用'],x['尚需补充']) for x in boundary])+'\n数据总量:132张模块配置表、11742条原记录。所有原行和空值保留。\n')
write(audit/'scope.md','# 范围\n\n原作HEROLL 2.0.1局外功能。以现有配置及原作中文文本生成开发文档;仅在新目录写入。旧局外目录及v0.2.8参考文档不作修改。\n')
write(audit/'timeline.md','# 生成记录\n\n2026-09-06:读取v0.2.8总纲及规则闭合报告,确定开发文档结构。\n2026-09-06:快照旧目录,以只读AST读取原模块划分,不运行旧生成器。\n2026-09-06:提取原作中文文本,重写26模块玩法、状态、规则、数值及样例。\n2026-09-06:核对配置逐单元格一致性、文档链接和旧目录保护结果。\n')
write(audit/'来源与复现.md','# 来源与复现\n\nEvidence:源文件清单提供每张原作表与中文文本资源的SHA256。\n\nFinding:规则总索引按原作配置确认、原作部分确认分类;中文目录直接关联原字段。\n\nPath:原配置与本地化文本 → 业务正文 → 同目录权威CSV → 验收查询。\n\n从项目根目录执行 `HEROLL_OUTGAME_DEV_v1.0/99-审计与证据/工具/build_dev_docs.py` 可重建本目录;使用Python3标准库,不运行旧目录生成器。\n')
# 独立读取写出的配置,检查源值及中文目录原字段一致。
cells=0
for m in modules:
for t in m['tables']:
for suffix in ['', '-中文目录']:
with (OUT/m['folder']/(t+suffix+'.csv')).open(encoding='utf-8-sig',newline='') as f: rows=list(csv.DictReader(f))
recovered=[{h:r[h] for h in tables[t]['headers']} for r in rows]
assert recovered==tables[t]['rows'],(m['folder'],t,suffix)
cells+=len(tables[t]['headers'])*len(tables[t]['rows'])
for p in OUT.rglob('*.md'):
for target in re.findall(r'\]\(([^)]+)\)',p.read_text(encoding='utf-8')):
if target.startswith(('https:','http:','#')):continue
assert (p.parent/target.split('#')[0]).exists(),(p,target)
after=snapshot();assert after==before, '旧目录发生变化'
result=dict(模块数=len(modules),模块配置表数=len(coverage),模块原记录=sum(x['行数'] for x in coverage),原作中文对照项=localized_count,校验原字段单元格数=cells,旧目录文件数=len(before),旧目录内容大小修改时间='全部保持不变',配置与中文目录原字段='逐单元格一致',文档相对链接='全部有效',完整规则状态='已整理开发文档;具体执行缺口见规则完整度报告')
write(audit/'校验结果.json',json.dumps(result,ensure_ascii=False,indent=2))
files=[dict(文件=p.relative_to(OUT).as_posix(),大小=p.stat().st_size,SHA256=sha(p)) for p in sorted(OUT.rglob('*')) if p.is_file() and p.name!='文件索引.csv']
savecsv(OUT/'文件索引.csv',files)
print(json.dumps(result,ensure_ascii=False,indent=2))