#!/usr/bin/env python3
"""从唯一JSON原始资料生成Markdown总表、词表及文集;默认只检查,不评级或写登记。
运行:make sync-gongfa-catalog / make check-gongfa-catalog。
依赖:.github/requirements-gongfa.txt;复用原登记校验器与成熟Markdown解析器。
"""
import argparse
from collections import Counter, defaultdict
from hashlib import sha256
from html import escape
import importlib.util
import json
from pathlib import Path
import re
import sys
import tempfile
ROOT = Path(__file__).resolve().parents[1]
# 共用模块与校验器直接平铺于.github,不保留旧包副本。
sys.path.insert(0, str(ROOT / '.github'))
import source_archive
import book_views
import tool_explanations
spec = importlib.util.spec_from_file_location('gongfa_protocol', ROOT / '.github/check-gongfa.py')
protocol = importlib.util.module_from_spec(spec)
spec.loader.exec_module(protocol)
MARKER = ''
GRADES = [f'{tier}-{level}' for tier in ('huang', 'xuan', 'di', 'tian')
for level in ('initial', 'intermediate', 'advanced')]
GRADE_LABELS = {f'{tier}-{level}': tier_label + level_label
for tier, tier_label in [('huang', '黄阶'), ('xuan', '玄阶'), ('di', '地阶'), ('tian', '天阶')]
for level, level_label in [('initial', '初级'), ('intermediate', '中级'), ('advanced', '高级')]}
TYPE_LABELS = {'explanatory-content': '解释型', 'normative-allocation-content': '准则型',
'method-specification': '方法型', 'dao-content': '复合(保留上位)', None: '待分类'}
BATCH_LABELS = {'registry': '登记库'}
def require(condition, message):
if not condition:
raise ValueError(message)
def digest(text):
return sha256(text.encode('utf-8')).hexdigest()
def as_text(value):
return '' if value is None else value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False)
def unique(items, key):
result = {}
for item in items:
identity = key(item)
require(identity not in result, '来源对象、内容版本或初评意见重复')
result[identity] = item
return result
def load_inputs(root):
root = root.resolve(strict=True)
registry_path = protocol.safe_path(root, '.github/metadata/gongfa/registry.json')
registry_sha = sha256(protocol.bounded_read(registry_path, 32 * 1024 * 1024)).hexdigest()
registry, registry_summary = protocol.validate(root, registry_path)
require(sha256(protocol.bounded_read(registry_path, 32 * 1024 * 1024)).hexdigest() == registry_sha, 'registry.json读取期间变化,请重跑')
index_path = protocol.safe_path(root, '.github/metadata/gongfa/catalog-sources.json')
index = protocol.load_json(index_path, 1024 * 1024)
require(set(index) == {'schema_version', 'kind', 'snapshot', 'candidate_counts'} and index['schema_version'] == '1.0.0'
and index['kind'] == 'catalog_candidate_source_index_not_registry', 'catalog-sources.json协议形态不符')
reference = index['snapshot']
require(set(reference) == {'path', 'sha256'} and re.fullmatch(r'source-[a-z0-9-]+\.json', reference['path']), '来源快照须为本目录source-*.json')
require(not (root / '.github/metadata/gongfa' / reference['path']).is_symlink(), '来源快照不能是软链接')
snapshot_path = protocol.safe_path(root, '.github/metadata/gongfa/' + reference['path'])
raw = protocol.bounded_read(snapshot_path, 32 * 1024 * 1024)
require(sha256(raw).hexdigest() == reference['sha256'], '来源快照SHA-256不符')
snapshot = protocol.load_json(snapshot_path)
require(sha256(protocol.bounded_read(snapshot_path, 32 * 1024 * 1024)).hexdigest() == reference['sha256'], '来源快照读取期间变化')
require(set(snapshot) == {'schema_version', 'kind', 'captured_at', 'scope', 'source_input_bindings',
'independent_semantic_approval', 'source_books_and_licenses_verified', 'formal_assessments_created', 'documents'}
and snapshot['schema_version'] == '1.0.0' and snapshot['kind'] == 'frozen_classified_candidate_source_not_registry', '来源快照协议形态不符')
require(snapshot['independent_semantic_approval'] is False and snapshot['source_books_and_licenses_verified'] is False
and snapshot['formal_assessments_created'] == 0, '历史来源快照的审批、来源核验或正式评级字段不符')
protocol.timestamp(snapshot['captured_at'], 'candidate_snapshot/captured_at')
documents = snapshot['documents']
require(set(documents) == {'html211_candidates', 'html211_grades', 'html96_candidates', 'html96_grades', 'solve24_analysis', 'solve_raw'}, '来源快照文档集合不符')
counts = {'html211': len(documents['html211_candidates']['candidates']), 'html96': len(documents['html96_candidates']['candidates']),
'solve24': len(documents['solve24_analysis']['items'])}
require(counts == index['candidate_counts'], '来源批次范围与索引不一致')
reviewed = {item['source_id'] for item in documents['solve24_analysis']['items']}
require({item['source_id'] for item in documents['solve_raw']['items']} == reviewed, 'Solve原条目须仅含已审范围')
archive = source_archive.bundles(root).get('gongfa')
require(archive is not None and 'explanations_v1' in archive['data'], '功法data.json缺少v1说明载荷')
binding = {'registry_sha256': registry_sha, 'candidate_sha256': reference['sha256'], 'candidate_snapshot': reference['path'],
'candidate_counts': counts, 'source_captured_at': snapshot['captured_at'],
'registry_assessments': registry_summary['assessments'],
'archive_sha256': {str(path.relative_to(root)): digest for path, digest in source_archive.bundle_bindings(root).items()
if path.parent.name == 'gongfa'}}
return {**documents, 'registry': registry, 'explanations_v1': archive['data']['explanations_v1']}, binding
def merge_rows(documents):
rows = []
registry = documents['registry']
sources = unique(registry['sources'], lambda item: item['id'])
batches = unique(registry['grade_proposals'], lambda batch: batch['id'])
links = {batch['id']: batch['supersedes'] for batch in batches.values()}
protocol.check_chains(links, 'grade_proposals/supersedes', reject_forks=True)
superseded = {value for value in links.values() if value is not None}
opinions = defaultdict(list)
for batch in batches.values():
if batch['id'] not in superseded:
unique(batch['items'], lambda item: (item['gongfa_id'], item['content_version']))
for item in batch['items']:
opinions[item['gongfa_id'], item['content_version']].append((item, batch))
entries = unique(registry['gongfa'], lambda item: (item['id'], item['content_version']))
candidate_source_id = 'source-classified-candidates-20261007'
candidate_source = sources.get(candidate_source_id)
require(candidate_source is not None and candidate_source['kind'] == 'classified_candidate_archive',
'候选来源未绑定冻结登记源')
promoted_by_anchor = {
item['source_ref']['heading_id']: item for item in entries.values()
if item['source_ref']['source_id'] == candidate_source_id
}
require(len(promoted_by_anchor) == len(candidate_source['included_anchors'])
and set(promoted_by_anchor) == set(candidate_source['included_anchors']),
'候选登记范围与冻结来源锚点不一致')
checked_candidate_anchors = set()
def verify_promoted_candidate(namespace, candidate_id, name, ontology_type, original_text,
content_version, grade_id, grading_status, task_family,
classification_reason, reason, confidence, falsifier, assessed_at):
anchor = namespace + ':' + candidate_id
entry = promoted_by_anchor.get(anchor)
require(entry is not None, '来源记录未逐条登记:' + anchor)
require(entry['id'] == protocol.candidate_registry_id(namespace, candidate_id)
and entry['name'] == name and entry['ontology_type'] == ontology_type
and entry['content_version'] == content_version and entry['entry_status'] == 'registered'
and entry['content']['original_text'] == original_text
and entry['content']['text_sha256'] == digest(original_text)
and entry['source_ref']['selection'] == 'candidate-record',
'候选登记身份、正文或来源不一致:' + anchor)
proposals = opinions[entry['id'], content_version]
require(len(proposals) == 1, '候选原初评必须且只能迁移一次:' + anchor)
proposal, batch = proposals[0]
require((proposal['grade_id'], proposal.get('grading_status', 'provisional'),
proposal['ontology_type'], proposal['task_family'], proposal['classification_reason'],
proposal['reason'], proposal['confidence'], proposal['falsifier'],
proposal['content_sha256'], batch['assessed_at']) ==
(grade_id, grading_status, ontology_type, task_family, classification_reason,
reason, confidence, falsifier, digest(original_text), assessed_at),
'候选既有初评或正文摘要迁移不一致:' + anchor)
checked_candidate_anchors.add(anchor)
return entry
for item in entries.values():
content = item['content']
require(content['text_sha256'] == digest(content['original_text']), '登记正文SHA-256不符')
proposals = opinions[item['id'], item['content_version']]
for grade, _batch in proposals:
require(grade['content_sha256'] == content['text_sha256'], '登记初评正文绑定不符')
grade = proposals[0][0] if len(proposals) == 1 else {}
batch = proposals[0][1] if len(proposals) == 1 else {}
status = ('undetermined' if proposals[0][0].get('grading_status') == 'undetermined' else 'provisional') if len(proposals) == 1 else 'multiple-contexts' if proposals else 'unrated'
detail = as_text([{'batch_id': b['id'], 'application_context': b['application_context'],
'baseline_assumption': b['baseline_assumption'], 'assessed_at': b['assessed_at'], 'item': g} for g, b in proposals])
reference = item['source_ref']
boundary = '选区说明:' + content['selection_note']
profile = content['profile']
explanation_parts = [profile['summary'], '问题:' + (profile['problem'] or '待核'),
'机制:' + (profile['mechanism'] or '待核')]
for field, label in [('conditions', '条件'), ('limitations', '限制'), ('expected_outcomes', '预期结果')]:
if profile[field]:
explanation_parts.append(label + ':' + ';'.join(profile[field]))
explanation = ' '.join(explanation_parts)
for field, label in [('conditions', '条件'), ('limitations', '限制')]:
if content['profile'][field]:
boundary += '\n' + label + ':' + as_text(content['profile'][field])
source_record = sources[reference['source_id']]
source_summary = {key: source_record[key] for key in
('id', 'kind', 'path', 'captured_at', 'snapshot_path', 'snapshot_sha256', 'text_normalization')
if key in source_record}
rows.append({'namespace': 'registry', 'original_id': item['id'], 'name': item['name'], 'ontology_type': item['ontology_type'],
'grade_id': grade.get('grade_id'), 'grading_status': status,
'entry_status': '已登记' if item['entry_status'] == 'registered' else '登记库候选',
'task_family': grade.get('task_family', ''), 'explanation': explanation,
'original_text': content['original_text'], 'boundary': boundary,
'reason': grade.get('reason', detail if proposals else '当前内容版本未初评;不继承旧版本意见。'),
'falsifier': grade.get('falsifier', ''), 'confidence': grade.get('confidence', ''), 'content_version': item['content_version'],
'source': as_text({'source': source_summary, 'selection': reference, 'original_proposal_context': detail}),
'content_sha256': content['text_sha256'], 'relations': '', 'assessed_at': batch.get('assessed_at', '')})
for namespace in ('html211', 'html96'):
candidates = unique(documents[namespace + '_candidates']['candidates'], lambda item: item['candidate_id'])
grade_document = documents[namespace + '_grades']
grades = unique(grade_document['items'], lambda item: item['candidate_id'])
require(set(candidates) == set(grades), 'HTML候选与原初评范围不符')
for item in candidates.values():
grade = grades[item['candidate_id']]
require(grade['content_sha256'] == item['text_sha256'] == digest(item['original_text']), 'HTML限定正文绑定不符')
require(grade['source_selection'] == item['source_selection'] and grade['ontology_type'] == item['ontology_type']
and grade['name'] == item['name'], 'HTML选区、类型或名称与原意见不符')
require(grade['grading_status'] in {'provisional', 'undetermined'}
and (grade['provisional_grade_id'] is None) == (grade['grading_status'] == 'undetermined'), '空品级状态不符')
verify_promoted_candidate(
namespace, item['candidate_id'], item['name'], item['ontology_type'], item['original_text'], 'r1',
grade['provisional_grade_id'], grade['grading_status'], grade['task_family'],
item['classification_reason'], grade['reason'], grade['confidence'], grade['falsifier'],
grade_document['created_at'])
analyzed = unique(documents['solve24_analysis']['items'], lambda item: item['source_id'])
raw_items = unique(documents['solve_raw']['items'], lambda item: item['source_id'])
require(set(analyzed) == set(raw_items), 'Solve来源记录与原文范围不符')
for item in analyzed.values():
raw, grade = raw_items[item['source_id']], item['editorial_analysis']
require(raw['source_version'] == item['source_version'] and raw['source_selection'] == item['source_selection']
and grade['source_id'] == item['source_id'], 'Solve版本、选区或意见外键不符')
text = as_text(raw['original_entry'])
verify_promoted_candidate(
'solve24', item['source_id'], raw['name'], grade['ontology_type'], text, item['source_version'],
grade['grade_id'], 'provisional', grade['task_family'], grade['classification_reason'],
grade['reason'], grade['confidence'], grade['falsifier'], documents['solve24_analysis']['assessed_at'])
require(checked_candidate_anchors == set(promoted_by_anchor), '来源记录存在未核验登记或额外身份')
names = defaultdict(list)
for row in rows:
row['key'] = row['namespace'] + ':' + row['original_id'] + ('@' + row['content_version'] if row['namespace'] == 'registry' else '')
require(row['ontology_type'] in TYPE_LABELS and (row['grade_id'] is None or row['grade_id'] in GRADES), '未知类型或词表外品级')
require(row['confidence'] in {'', 'low', 'medium'}, '未知置信标签')
names[row['name']].append(row['key'])
unique(rows, lambda row: row['key'])
for row in rows:
others = [key for key in names[row['name']] if key != row['key']]
if others:
row['relations'] += '\n同名限定内容未自动合并:' + ';'.join(others)
rank = {grade: i for i, grade in enumerate(GRADES)}
payload = documents.get('explanations_v1', {})
presentation_order = {
item['object_key']: position
for position, item in enumerate(payload.get('items', []))
if isinstance(item, dict) and isinstance(item.get('object_key'), str)
}
source_order = {row['key']: position for position, row in enumerate(rows)}
rows.sort(key=lambda row: (
-rank.get(row['grade_id'], -1),
presentation_order.get(row['key'], len(presentation_order) + source_order[row['key']]),
))
explanations = {item.get('object_key'): item.get('fields') for item in payload.get('items', [])
if isinstance(item, dict)} if isinstance(payload, dict) else {}
for row in rows:
row['explanation_fields'] = explanations.get(row['key'])
return rows
# 章节编号保留在冻结来源和登记名中;阅读视图只显示标题正文。
CHAPTER_NUMBER_PREFIX = re.compile(r'^\d+(?:\.\d+)+[ \t]+')
def display_name(name):
return CHAPTER_NUMBER_PREFIX.sub('', name, count=1)
def navigation_labels(rows):
visible_names = [display_name(row['name']) for row in rows]
counts = Counter(visible_names)
return {
row['key']: name if counts[name] == 1 else f"{name}({row['key']})"
for row, name in zip(rows, visible_names)
}
def render_explanation_object(row):
fields = [dict(field) for field in row['explanation_fields']]
fields[0]['content'] = display_name(fields[0]['content'])
return tool_explanations.render_object(row['key'], display_name(row['name']), fields)
def grade_label(row):
return GRADE_LABELS[row['grade_id']] if row['grade_id'] else {'undetermined': '暂不可判', 'unrated': '未初评', 'multiple-contexts': '多情境意见'}[row['grading_status']]
def grid(rows):
result = []
for number, row in enumerate(rows, 1):
values = [number, grade_label(row), row['name'], TYPE_LABELS[row['ontology_type']], row['entry_status'], BATCH_LABELS[row['namespace']],
row['task_family'], row['original_text'], row['boundary'], row['reason'], row['falsifier'], row['confidence'], row['original_id'],
row['content_version'], row['source'], row['content_sha256'], row['relations'], row['assessed_at']]
for value in values:
if isinstance(value, str):
require(len(value.encode('utf-16-le')) // 2 <= 32767, 'Excel单元格超过32767 UTF-16单位,不能静默截断')
require(not re.search(r'[\x00-\x08\x0b-\x0c\x0e-\x1f\ufffe\uffff]', value), 'Excel原文含不支持的字符,不能静默清洗')
result.append(values)
return result
def notes(rows, binding):
counts = Counter(row['namespace'] for row in rows)
return ['全部功法总表(保留原初评,不重评)',
f"合计{len(rows)}条;" + ';'.join(f'{BATCH_LABELS[ns]} {counts[ns]}' for ns in BATCH_LABELS)
+ ';输入:' + digest(binding['registry_sha256'] + binding['candidate_sha256']),
f"品级是编者前瞻;历史来源快照保留原状态;当前正式评级记录数:{binding['registry_assessments']}。同名未合并。"]
TIERS = (('tian', '天阶'), ('di', '地阶'), ('xuan', '玄阶'), ('huang', '黄阶'))
VIEW_NAMES = ('README.md', *(tier + '.md' for tier, _label in TIERS))
# v1全量说明视图约4.2MiB;保留增长余量,同时限制读取与生成预算。
MAX_VIEW_BYTES = 5 * 1024 * 1024
def source_navigation(root):
keys = book_views.source_keys(root, 'gongfa')
toc = book_views.source_toc(root, 'gongfa') if keys else []
anchors = []
for line in toc:
match = re.search(r'\]\(#([^)]+)\)', line)
if match and match.group(1) not in anchors:
anchors.append(match.group(1))
document_anchors = {book_views.chapter_id(key) for key in keys}
lines = ['## 原文文集', '',
'原始文档、字节摘要和选择器保存在唯一资料源[data.json](data.json)的`documents`中;本页只保留历史入口,不重复展开原文。', '',
'原文档中的候选/复核状态只表示记录时点;当前登记和说明审阅状态以本总表及对象页为准。', '',
'### 原始文档', '']
for key in keys:
anchor = book_views.chapter_id(key)
lines.extend((f'',
f'- `{escape(key)}`:原始字节与摘要见[data.json](data.json)'))
legacy = [anchor for anchor in anchors if anchor not in document_anchors]
if legacy:
lines.extend(('', ''))
lines.extend(f'' for anchor in legacy)
return lines
def validate_rows(rows):
keys = [row['key'] for row in rows]
require(len(set(keys)) == len(keys), '功法对象身份重复')
for row in rows:
fields = row.get('explanation_fields')
require(isinstance(fields, list) and len(fields) == 18, '对象缺少完整v1说明:' + row['key'])
require([field.get('name') for field in fields] == list(tool_explanations.FIELD_NAMES), '对象v1栏目不符:' + row['key'])
require(fields[0]['content'] == row['name'], '对象v1名称与登记身份不符:' + row['key'])
for value in row.values():
if isinstance(value, str):
require(len(value.encode('utf-8')) <= MAX_VIEW_BYTES, '字段超过阅读视图预算')
require(not re.search(r'[\x00-\x08\x0b-\x0c\x0e-\x1f]', value), '阅读视图不接受控制字符')
def render_markdown(rows, binding, root=None):
labels = navigation_labels(rows)
counts = Counter(row['entry_status'] for row in rows)
provisional = sum(row['grading_status'] == 'provisional' for row in rows)
undetermined = sum(row['grading_status'] == 'undetermined' for row in rows)
by_tier = {tier: [row for row in rows if row['grade_id'] and row['grade_id'].startswith(tier + '-')]
for tier, _label in TIERS}
pending = [row for row in rows if row['grade_id'] is None]
lines = [MARKER, '# 功法总表', '', '## 字多不看', '',
f"- 共**{len(rows)}条内容记录**;已登记{counts['已登记']}条,待登记{counts['登记库候选']}条,未登记来源条目{counts['未入登记库']}条。不是独立功法去重后的总数。",
f'- 原前瞻初评{provisional}条,暂不可判{undetermined}条;不是正式效果评级,亦不把缺测默认补成黄阶。',
f"- {sum(binding['candidate_counts'].values())}条来源迁入说明曾由用户确认正式通过审阅;后续内容更新不自动继承该确认。历史来源快照保留原样,书目/许可核验、实际效果与正式评级仍分别维护。",
'- [data.json](data.json)是唯一原始数据源,原始文档、历史来源选区、意见、来源和附件均保留其中。', '',
'输入绑定:`' + digest(binding['registry_sha256'] + binding['candidate_sha256']) + '`。', '',
'## 快速导航', '']
for tier, label in TIERS:
lines.append(f'- [{label}({len(by_tier[tier])}条)]({tier}.md)')
lines += [f'- [未明确品级({len(pending)}条;不归属任何阶)](huang.md#未明确品级不归属黄阶)',
'- [全部记录导航](#全部记录)', '- [原文文集](#原文文集)', '- [品级词表](#品级词表)', '',
'', '完整细粒度目录(点击展开/收起)
', '',
'- [全部记录](#全部记录)', '- [原文文集](#原文文集)', '- [品级词表](#品级词表)', '- [数据与边界](#数据与边界)',
'', '', ' ', '', '## 使用方式', '',
'按阶打开对应文档;对象身份、字段说明与表格都由data.json和已登记模板生成。修改资料后运行make sync-gongfa-catalog,不手改阅读视图。同名条目在导航中附稳定对象ID,不表示身份合并。', '',
'## 正文', '', '## 全部记录', '']
for tier, label in TIERS:
lines.extend((f'### {label}', ''))
for row in by_tier[tier]:
anchor = tool_explanations.anchor_for(row['key'])
lines.extend((f'',
tool_explanations.render_file_link(row['key'], labels[row['key']], tier + '.md') + f' — {grade_label(row)}'))
if tier == 'huang' and pending:
lines.extend(('', '### 未明确品级(不归属任何阶)', ''))
for row in pending:
anchor = tool_explanations.anchor_for(row['key'])
lines.extend((f'',
tool_explanations.render_file_link(row['key'], labels[row['key']], 'huang.md') + f' — {grade_label(row)}'))
lines.append('')
if root is not None:
lines += source_navigation(root)
registry, _summary = protocol.validate(root, root / '.github/metadata/gongfa/registry.json') if root and source_archive.bundles(root) else (None, None)
grades = protocol.render_grades(registry) if registry else ''
lines += ['## 品级词表', '', grades, '', '## 数据与边界', '',
'本工具只维护data.json原始资料和分阶阅读视图。用户已确认这批说明正式通过审阅;原始来源快照与历史意见保持原样。审阅通过不替代原著/许可核验、实际效果验证或正式评级;历史附件保留为JSON原始载荷,不再维护第二个活动视图。', '',
'## 更新与核对', '', '```bash', 'make sync-gongfa-catalog', 'make check-gongfa-catalog', 'make test-gongfa-catalog', '```', '',
'内容新版本不继承旧初评;同对象不同情境的活动意见并列保留,不按最高档或最新时间裁决。',
'本视图不执行方法,也不证明原著、许可证或真实效果;未知内容仍逐项标待核。', '']
text = '\n'.join(lines)
source_toc = book_views.source_toc(root, 'gongfa') if root is not None else []
return text.replace('', '\n'.join(source_toc))
def render_grade_markdown(rows, tier):
labels = navigation_labels(rows)
label = dict(TIERS)[tier]
tier_rows = [row for row in rows if row['grade_id'] and row['grade_id'].startswith(tier + '-')]
pending = [row for row in rows if row['grade_id'] is None] if tier == 'huang' else []
lines = [MARKER, f'# 功法总表:{label}', '',
f'本页包含{len(tier_rows)}条{label}记录。品级沿用已有前瞻初评,不是正式效果评级。',
'- [返回总索引](README.md);其他阶:[天阶](tian.md)、[地阶](di.md)、[玄阶](xuan.md)、[黄阶](huang.md)。', '',
f'## {label}记录', '']
for row in tier_rows:
lines.append(tool_explanations.render_index_link(row['key'], labels[row['key']]))
lines.append('')
for row in tier_rows:
lines.append(render_explanation_object(row))
if pending:
lines.extend(('## 未明确品级(不归属黄阶)', '',
f'以下{len(pending)}条没有可沿用的明确品级;仅为减少文件数暂置本页附录,不视为黄阶。', ''))
for row in pending:
lines.append(tool_explanations.render_index_link(row['key'], labels[row['key']]))
lines.append('')
for row in pending:
lines.append(render_explanation_object(row))
return '\n'.join(lines)
def render_views(root, rows, binding, explanations=None):
if explanations is not None:
names = {row['key']: row['name'] for row in rows}
fields_by_key = tool_explanations.validate_payload(root, explanations, names)
for row in rows:
row['explanation_fields'] = fields_by_key[row['key']]
validate_rows(rows)
views = {'README.md': render_markdown(rows, binding, root)}
views.update({tier + '.md': render_grade_markdown(rows, tier) for tier, _label in TIERS})
require(sum(len(text.encode('utf-8')) for text in views.values()) <= MAX_VIEW_BYTES,
'分阶阅读视图合计超过4MiB预算')
return views
def view_path(root, name='README.md'):
require(name in VIEW_NAMES, '未知功法阅读视图文件名')
nominal = root.resolve() / '.github/context/gongfa' / name
require(not nominal.is_symlink(), '生成目标不能是软链接')
path = protocol.safe_path(root.resolve(), '.github/context/gongfa/' + name)
require(path == nominal, '生成目录不能通过软链接重定向')
return path
def output_bytes(root, rows, binding, explanations=None):
views = render_views(root, rows, binding, explanations)
return {view_path(root, name): text.encode('utf-8') for name, text in views.items()}
def check_views(root, rows, binding, explanations=None):
for path, expected in output_bytes(root, rows, binding, explanations).items():
require(protocol.bounded_read(path, MAX_VIEW_BYTES) == expected,
path.name + '陈旧或手改;运行make sync-gongfa-catalog')
def write_views(root, rows, binding, explanations=None):
outputs = output_bytes(root, rows, binding, explanations)
before = {}
for path in outputs:
current = protocol.bounded_read(path, MAX_VIEW_BYTES) if path.exists() else None
require(current is None or MARKER.encode() in current[:200], '拒绝覆盖非本工具生成的Markdown:' + path.name)
before[path] = current
source_archive.check_unchanged(root)
if all(before[path] == raw for path, raw in outputs.items()):
return
parent = next(iter(outputs)).parent
parent.mkdir(parents=True, exist_ok=True)
temporaries = {}
for path, raw in outputs.items():
if before[path] == raw:
continue
with tempfile.NamedTemporaryFile(prefix='.gongfa-', suffix='.tmp', dir=parent, delete=False) as stream:
stream.write(raw)
temporaries[path] = Path(stream.name)
source_archive.check_unchanged(root)
for path, temporary in temporaries.items():
require(not path.is_symlink() and
(protocol.bounded_read(path, MAX_VIEW_BYTES) if path.exists() else None) == before[path],
'提交视图前目标被并行修改;保留临时文件,不覆盖目标:' + path.name)
temporary.replace(path)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--root', type=Path, default=ROOT)
mode = parser.add_mutually_exclusive_group()
mode.add_argument('--write', action='store_true')
mode.add_argument('--check', action='store_true')
args = parser.parse_args()
try:
root = args.root.resolve(strict=True)
documents, binding = load_inputs(root)
rows = merge_rows(documents)
explanations = documents['explanations_v1']
if args.write:
write_views(root, rows, binding, explanations)
check_views(root, rows, binding, explanations)
_documents, fresh_binding = load_inputs(root)
require(fresh_binding == binding, '输入在生成/检查期间变化;视图不宣称最新,请重跑')
print(json.dumps({'records': len(rows), 'batches': dict(Counter(row['namespace'] for row in rows)),
'provisional': sum(row['grading_status'] == 'provisional' for row in rows),
'undetermined': sum(row['grading_status'] == 'undetermined' for row in rows),
'registered': sum(row['entry_status'] == '已登记' for row in rows),
'formal_assessments': binding['registry_assessments'], 'mode': 'write' if args.write else 'check'}, ensure_ascii=False))
return 0
except Exception as error:
# 第三方异常可能包含输入值;只报告已控的位置/规则或异常类别。
detail = str(error) if type(error) in (ValueError, protocol.ContractError, source_archive.ArchiveError) and not isinstance(error, json.JSONDecodeError) else type(error).__name__
print('GONGFA_CATALOG_ERRORS\n' + detail, file=sys.stderr)
return 1
if __name__ == '__main__':
sys.exit(main())