Files
dcalc/scripts/migrate_generated_characters.py
T
2026-10-21 15:07:19 +08:00

675 lines
29 KiB
Python

"""将生成数据合并到已有职业实现。
优先按职业 ref、其次 UUID 匹配;缺少稳定身份时才兼容旧中文名称。
保留运行时类编号、名称、关联规则和手写方法;旧技能缺少 ref 时补齐绑定及导入。
生成器独有技能保持注释,运行时独有技能原样保留;职业变体按 prefix 查找来源。
先执行 backend/openapi/scripts/generate.py 同步 refs 和生成数据,再运行本脚本。
--check 只检查合并及语法,不写职业文件;--generated-root/--character-root 可指定验证目录。
"""
from __future__ import annotations
import argparse
import ast
from dataclasses import dataclass
import os
from pathlib import Path
import re
import time
ROOT = Path(__file__).resolve().parents[1]
GENERATED_ROOT = ROOT / "backend" / "openapi" / "generate"
CHARACTER_ROOT = ROOT / "backend" / "core" / "character" / "GF"
@dataclass(frozen=True)
class SkillBlock:
class_name: str
skill_name: str
start: int
end: int
position: str | None
cell_pos: str | None
ref: str | None
ref_key: tuple[str, str, str] | None
uuid: str | None
def _offsets(source: str) -> list[int]:
result = [0]
for line in source.splitlines(keepends=True):
result.append(result[-1] + len(line))
return result
def _skills(source: str, path: Path) -> list[SkillBlock]:
tree = ast.parse(source, filename=str(path))
offsets = _offsets(source)
imports = {
item.asname or item.name: (node.module, item.name)
for node in tree.body if isinstance(node, ast.ImportFrom)
and (node.module or '').startswith('core.character.refs.')
for item in node.names
}
result: list[SkillBlock] = []
for node in tree.body:
if not isinstance(node, ast.ClassDef) or not re.fullmatch(r"Skill\d+", node.name):
continue
name = None
position = None
cell_pos = None
ref = ref_key = uuid = None
for statement in node.body:
if not isinstance(statement, (ast.Assign, ast.AnnAssign)):
continue
targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target]
if len(targets) != 1 or not isinstance(targets[0], ast.Name):
continue
if targets[0].id == 'uuid' and isinstance(statement.value, ast.Constant):
uuid = statement.value.value
if targets[0].id == 'ref':
value = statement.value
if not isinstance(value, ast.Attribute) or not isinstance(value.value, ast.Name) or value.value.id not in imports:
raise ValueError(f'{path}: 技能 ref 必须引用已导入的职业 Ref 成员')
ref = ast.unparse(value)
ref_key = (*imports[value.value.id], value.attr)
if targets[0].id == "name" and isinstance(statement.value, ast.Constant):
name = statement.value.value
elif targets[0].id == "position":
position = ast.get_source_segment(source, statement.value)
elif targets[0].id == "cellPos":
cell_pos = ast.get_source_segment(source, statement.value)
if not isinstance(name, str) or node.end_lineno is None:
raise ValueError(f"{path}:{node.lineno}: invalid {node.name}")
result.append(
SkillBlock(
node.name,
name,
offsets[node.lineno - 1],
offsets[node.end_lineno],
position,
cell_pos,
ref, ref_key, uuid,
)
)
return result
def _module_skill_bounds(source: str, path: Path) -> tuple[int, int]:
"""Return the code-only header end and profession-footer start offsets."""
tree = ast.parse(source, filename=str(path))
offsets = _offsets(source)
indexes = [
index
for index, node in enumerate(tree.body)
if isinstance(node, ast.ClassDef) and re.fullmatch(r"Skill\d+", node.name)
]
if not indexes:
raise ValueError(f"missing skills: {path}")
first_index, last_index = indexes[0], indexes[-1]
header_end = offsets[tree.body[first_index - 1].end_lineno] if first_index else 0
footer_start = (
offsets[tree.body[last_index + 1].lineno - 1]
if last_index + 1 < len(tree.body)
else len(source)
)
return header_end, footer_start
def _set_generated_field(block: str, field: str, value: str | None) -> str:
if value is None:
return block
assignment = re.compile(rf"(?m)^(\s*{re.escape(field)}\s*(?::[^=\n]+)?=\s*).*$")
if assignment.search(block):
return assignment.sub(rf"\g<1>{value}", block, count=1)
return re.sub(r"(?m)^(\s*name\s*=.*)$", rf"\g<1>\n {field} = {value}", block, count=1)
def _statement_key(statement: ast.stmt) -> str | None:
if isinstance(statement, (ast.Assign, ast.AnnAssign)):
targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target]
if len(targets) == 1 and isinstance(targets[0], ast.Name):
return targets[0].id
return None
def _is_data_key(key: str | None) -> bool:
return bool(key and re.fullmatch(r"data\d+", key))
def _sync_generated_assignment(
runtime_block: str, generated_block: str, field: str
) -> str:
"""Replace all runtime definitions of one field with the generated one."""
runtime_class = ast.parse(runtime_block).body[0]
generated_class = ast.parse(generated_block).body[0]
if not isinstance(runtime_class, ast.ClassDef) or not isinstance(generated_class, ast.ClassDef):
return runtime_block
generated_statement = next(
(statement for statement in generated_class.body if _statement_key(statement) == field),
None,
)
if generated_statement is None:
return runtime_block
runtime_statements = [
statement for statement in runtime_class.body if _statement_key(statement) == field
]
runtime_lines = runtime_block.splitlines(keepends=True)
generated_lines = generated_block.splitlines(keepends=True)
generated_source = generated_lines[
generated_statement.lineno - 1:generated_statement.end_lineno
]
if runtime_statements:
insertion_line = runtime_statements[0].lineno - 1
for statement in sorted(runtime_statements, key=lambda item: item.lineno, reverse=True):
runtime_lines[statement.lineno - 1:statement.end_lineno] = []
else:
anchor = next(
(
statement
for key in ("hasUP", "hasVP", "uuid")
for statement in reversed(runtime_class.body)
if _statement_key(statement) == key
),
None,
)
insertion_line = anchor.end_lineno if anchor is not None else 1
generated_source = ["\n", *generated_source]
runtime_lines[insertion_line:insertion_line] = generated_source
return "".join(runtime_lines)
def _sync_generated_data(runtime_block: str, generated_block: str) -> str:
"""Rebuild dataN/hitN in numeric order using generated data definitions."""
runtime_class = ast.parse(runtime_block).body[0]
generated_class = ast.parse(generated_block).body[0]
if not isinstance(runtime_class, ast.ClassDef) or not isinstance(generated_class, ast.ClassDef):
return runtime_block
runtime_lines = runtime_block.splitlines(keepends=True)
generated_lines = generated_block.splitlines(keepends=True)
runtime_items = {
_statement_key(statement): (index, statement)
for index, statement in enumerate(runtime_class.body)
if _statement_key(statement)
}
generated_items = {
_statement_key(statement): (index, statement)
for index, statement in enumerate(generated_class.body)
if _statement_key(statement)
}
selected_statements = [
(index, statement, _statement_key(statement))
for index, statement in enumerate(runtime_class.body)
if _is_data_key(_statement_key(statement))
or re.fullmatch(r"hit\d+", _statement_key(statement) or "")
]
generated_data = {key: value for key, value in generated_items.items() if _is_data_key(key)}
if not selected_statements and not generated_data:
return runtime_block
def numeric_key(key: str) -> int:
return int(re.search(r"\d+$", key).group())
def segment(
lines: list[str], class_node: ast.ClassDef, index: int, statement: ast.stmt
) -> list[str]:
previous_end = class_node.body[index - 1].end_lineno if index else class_node.lineno
return lines[previous_end:statement.end_lineno]
# Remove every old dataN/hitN statement together with its leading comment
# trivia. They are re-emitted below in deterministic numeric order.
removals: list[tuple[int, int]] = []
for index, statement, _ in selected_statements:
previous_end = runtime_class.body[index - 1].end_lineno if index else runtime_class.lineno
removals.append((previous_end, statement.end_lineno))
if removals:
insertion_line = min(start for start, _ in removals)
else:
insertion_anchor = next(
(
statement
for statement in runtime_class.body
if _statement_key(statement) == "associate"
or isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef))
),
None,
)
insertion_line = (
insertion_anchor.lineno - 1
if insertion_anchor is not None
else runtime_class.end_lineno
)
for start, end in sorted(removals, reverse=True):
runtime_lines[start:end] = []
data_keys = sorted(
set(generated_data) | {key for key in runtime_items if _is_data_key(key)},
key=numeric_key,
)
rebuilt: list[str] = []
emitted_hits: set[str] = set()
for data_key in data_keys:
source_items = generated_items if data_key in generated_data else runtime_items
source_lines = generated_lines if data_key in generated_data else runtime_block.splitlines(keepends=True)
source_class = generated_class if data_key in generated_data else runtime_class
index, statement = source_items[data_key]
rebuilt.extend(segment(source_lines, source_class, index, statement))
hit_key = f"hit{numeric_key(data_key)}"
if hit_key in runtime_items:
_, hit_statement = runtime_items[hit_key]
rebuilt.extend(runtime_block.splitlines(keepends=True)[hit_statement.lineno - 1:hit_statement.end_lineno])
emitted_hits.add(hit_key)
# Retain unusual hitN fields even when no matching dataN exists.
for hit_key in sorted(
(
key
for key in runtime_items
if re.fullmatch(r"hit\d+", key) and key not in emitted_hits
),
key=numeric_key,
):
_, hit_statement = runtime_items[hit_key]
rebuilt.extend(runtime_block.splitlines(keepends=True)[hit_statement.lineno - 1:hit_statement.end_lineno])
runtime_lines[insertion_line:insertion_line] = rebuilt
return "".join(runtime_lines)
def _sync_generated_comments(runtime_block: str, generated_block: str) -> str:
"""Sync generated docstrings and leading field comments into runtime code."""
runtime_class = ast.parse(runtime_block).body[0]
generated_class = ast.parse(generated_block).body[0]
if not isinstance(runtime_class, ast.ClassDef) or not isinstance(generated_class, ast.ClassDef):
return runtime_block
runtime_lines = runtime_block.splitlines(keepends=True)
generated_lines = generated_block.splitlines(keepends=True)
replacements: list[tuple[int, int, list[str]]] = []
# The generated API description is authoritative, but executable methods
# and assignments continue to come from the runtime implementation.
runtime_doc = runtime_class.body[0] if runtime_class.body and isinstance(runtime_class.body[0], ast.Expr) and isinstance(runtime_class.body[0].value, ast.Constant) and isinstance(runtime_class.body[0].value.value, str) else None
generated_doc = generated_class.body[0] if generated_class.body and isinstance(generated_class.body[0], ast.Expr) and isinstance(generated_class.body[0].value, ast.Constant) and isinstance(generated_class.body[0].value.value, str) else None
if runtime_doc is not None and generated_doc is not None:
replacements.append(
(runtime_doc.lineno - 1, runtime_doc.end_lineno, generated_lines[generated_doc.lineno - 1:generated_doc.end_lineno])
)
elif runtime_doc is None and generated_doc is not None:
# Insert the generated skill description immediately below the class
# declaration when the old runtime class had no docstring.
replacements.append(
(
runtime_class.lineno,
runtime_class.lineno,
generated_lines[generated_doc.lineno - 1:generated_doc.end_lineno],
)
)
generated_by_key = {_statement_key(statement): (index, statement) for index, statement in enumerate(generated_class.body) if _statement_key(statement)}
for runtime_index, runtime_statement in enumerate(runtime_class.body):
key = _statement_key(runtime_statement)
if key not in generated_by_key:
continue
generated_index, generated_statement = generated_by_key[key]
runtime_previous_end = runtime_class.body[runtime_index - 1].end_lineno if runtime_index else runtime_class.lineno
generated_previous_end = generated_class.body[generated_index - 1].end_lineno if generated_index else generated_class.lineno
generated_trivia = generated_lines[generated_previous_end:generated_statement.lineno - 1]
# Only replace trivia when it carries actual generated comments. Blank
# spacing alone should not disturb hand-formatted runtime code.
if any(line.lstrip().startswith("#") for line in generated_trivia):
replacements.append(
(runtime_previous_end, runtime_statement.lineno - 1, generated_trivia)
)
for start, end, replacement in sorted(replacements, reverse=True):
runtime_lines[start:end] = replacement
return "".join(runtime_lines)
def _append_missing_methods(
runtime_block: str, generated_block: str, method_names: set[str]
) -> str:
"""Append generated class methods that the runtime skill does not define."""
runtime_class = ast.parse(runtime_block).body[0]
generated_class = ast.parse(generated_block).body[0]
if not isinstance(runtime_class, ast.ClassDef) or not isinstance(generated_class, ast.ClassDef):
return runtime_block
existing = {
statement.name
for statement in runtime_class.body
if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef))
}
generated_lines = generated_block.splitlines(keepends=True)
additions: list[str] = []
for statement in generated_class.body:
if not isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
if statement.name not in method_names or statement.name in existing:
continue
additions.extend(generated_lines[statement.lineno - 1:statement.end_lineno])
existing.add(statement.name)
if not additions:
return runtime_block
return runtime_block.rstrip() + "\n\n" + "".join(additions).rstrip() + "\n"
def _sync_generated_method_docstrings(
runtime_block: str, generated_block: str, method_names: set[str]
) -> str:
"""Use generated method docstrings while preserving runtime method bodies."""
runtime_class = ast.parse(runtime_block).body[0]
generated_class = ast.parse(generated_block).body[0]
if not isinstance(runtime_class, ast.ClassDef) or not isinstance(generated_class, ast.ClassDef):
return runtime_block
runtime_methods = {
statement.name: statement
for statement in runtime_class.body
if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef))
}
generated_methods = {
statement.name: statement
for statement in generated_class.body
if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef))
}
runtime_lines = runtime_block.splitlines(keepends=True)
generated_lines = generated_block.splitlines(keepends=True)
replacements: list[tuple[int, int, list[str]]] = []
def docstring_statement(method: ast.FunctionDef | ast.AsyncFunctionDef) -> ast.Expr | None:
if not method.body:
return None
statement = method.body[0]
if (
isinstance(statement, ast.Expr)
and isinstance(statement.value, ast.Constant)
and isinstance(statement.value.value, str)
):
return statement
return None
for name in method_names:
runtime_method = runtime_methods.get(name)
generated_method = generated_methods.get(name)
if runtime_method is None or generated_method is None:
continue
generated_doc = docstring_statement(generated_method)
if generated_doc is None:
continue
generated_source = generated_lines[
generated_doc.lineno - 1:generated_doc.end_lineno
]
runtime_doc = docstring_statement(runtime_method)
if runtime_doc is not None:
replacements.append(
(runtime_doc.lineno - 1, runtime_doc.end_lineno, generated_source)
)
else:
# A method without a docstring starts directly with executable
# code. Insert the generated VP description before that code.
replacements.append(
(runtime_method.body[0].lineno - 1, runtime_method.body[0].lineno - 1, generated_source)
)
for start, end, replacement in sorted(replacements, reverse=True):
runtime_lines[start:end] = replacement
return "".join(runtime_lines)
def _ensure_get_data_import(source: str) -> str:
if not re.search(r"(?m)^\s*data\d+\s*=\s*get_data\(", source):
return source
pattern = re.compile(r"(?m)^(from\s+core\.basic\.skill\s+import\s+)([^\n]+)$")
match = pattern.search(source)
if match is None:
raise ValueError("generated data requires a core.basic.skill import")
imports = [item.strip() for item in match.group(2).split(",")]
if "get_data" in imports:
return source
imports.append("get_data")
return source[:match.start()] + match.group(1) + ", ".join(imports) + source[match.end():]
def _sync_module_assignment(
runtime_source: str, generated_source: str, field: str
) -> str:
"""Make a module-level assignment match the generated module."""
runtime_tree = ast.parse(runtime_source)
generated_tree = ast.parse(generated_source)
generated_statement = next(
(
statement
for statement in generated_tree.body
if _statement_key(statement) == field
),
None,
)
if generated_statement is None:
return runtime_source
runtime_statements = [
statement
for statement in runtime_tree.body
if _statement_key(statement) == field
]
runtime_lines = runtime_source.splitlines(keepends=True)
generated_lines = generated_source.splitlines(keepends=True)
assignment = generated_lines[
generated_statement.lineno - 1:generated_statement.end_lineno
]
if runtime_statements:
insertion_line = runtime_statements[0].lineno - 1
for statement in sorted(runtime_statements, key=lambda item: item.lineno, reverse=True):
runtime_lines[statement.lineno - 1:statement.end_lineno] = []
replacement = assignment
else:
imports = [
statement
for statement in runtime_tree.body
if isinstance(statement, (ast.Import, ast.ImportFrom))
]
insertion_line = imports[-1].end_lineno if imports else 0
replacement = [*assignment, "\n"]
runtime_lines[insertion_line:insertion_line] = replacement
return "".join(runtime_lines)
def _comment(block: str, name: str) -> str:
lines = [f"# MIGRATION: character 中没有匹配身份的技能“{name}”,暂不启用\n"]
lines.extend("# " + line.rstrip() + "\n" if line.strip() else "#\n" for line in block.splitlines())
return "".join(lines)
def _identity_indexes(skills, path):
"""校验稳定身份唯一性,禁止重复引用或 UUID 被字典静默覆盖。"""
indexes = {}
for field in ('ref_key', 'uuid', 'skill_name'):
index = {}
for skill in skills:
value = getattr(skill, field)
if value is None:
continue
if value in index:
raise ValueError(f'{path}: duplicate {field}: {value}')
index[value] = skill
indexes[field] = index
return indexes
def _match_skill(skill, indexes):
"""优先 ref、其次 UUID;仅在缺少可比较身份时兼容旧中文名称。"""
by_ref = indexes['ref_key'].get(skill.ref_key)
by_uuid = indexes['uuid'].get(skill.uuid)
if by_ref is not None and by_uuid is not None and by_ref is not by_uuid:
raise ValueError(f'技能 ref 与 UUID 指向不同目标: {skill.skill_name}')
candidate = by_ref or by_uuid or indexes['skill_name'].get(skill.skill_name)
if candidate is None:
return None
if skill.ref_key and candidate.ref_key and skill.ref_key != candidate.ref_key:
raise ValueError(f'技能引用冲突: {skill.skill_name}: {candidate.ref} -> {skill.ref}')
if by_ref is None and skill.uuid and candidate.uuid and skill.uuid != candidate.uuid:
raise ValueError(f'同名技能 UUID 冲突: {skill.skill_name}')
return candidate
def _ensure_ref_imports(source, generated):
"""补齐生成代码的职业 Ref 导入,保留原有别名,拒绝别名指向不同对象。"""
tree = ast.parse(source)
imports = {
item.asname or item.name: (node.module, item.name)
for node in tree.body if isinstance(node, ast.ImportFrom) for item in node.names
}
additions = []
for node in ast.parse(generated).body:
if not isinstance(node, ast.ImportFrom) or not (node.module or '').startswith('core.character.refs.'):
continue
for item in node.names:
alias = item.asname or item.name
if alias in imports:
if imports[alias] != (node.module, item.name):
raise ValueError(f'职业引用导入别名冲突: {alias}')
else:
additions.append(f'from {node.module} import {item.name}' + (f' as {item.asname}' if item.asname else '') + '\n')
imports[alias] = (node.module, item.name)
if not additions:
return source
position = max((node.end_lineno for node in tree.body if isinstance(node, (ast.Import, ast.ImportFrom))), default=0)
if position == 0:
return ''.join(additions) + '\n' + source.lstrip('\r\n')
lines = source.splitlines(keepends=True)
lines[position:position] = additions
return ''.join(lines)
def merge(generated_path: Path, character_path: Path) -> tuple[str, dict[str, int]]:
generated = generated_path.read_text(encoding="utf-8")
character = character_path.read_text(encoding="utf-8")
generated_skills = _skills(generated, generated_path)
character_skills = _skills(character, character_path)
if not generated_skills or not character_skills:
raise ValueError(f"missing skills: {generated_path} or {character_path}")
runtime = _identity_indexes(character_skills, character_path)
_identity_indexes(generated_skills, generated_path)
used = set()
character_header_end, character_footer_start = _module_skill_bounds(character, character_path)
generated_header_end, _ = _module_skill_bounds(generated, generated_path)
chunks = [character[:character_header_end].rstrip() + "\n\n"]
chunks.append(generated[generated_header_end:generated_skills[0].start])
cursor = generated_skills[0].start
matched = commented = 0
for index, generated_skill in enumerate(generated_skills):
if index:
chunks.append(generated[cursor:generated_skill.start])
runtime_skill = _match_skill(generated_skill, runtime)
if runtime_skill is None:
chunks.append(_comment(generated[generated_skill.start:generated_skill.end], generated_skill.skill_name))
commented += 1
else:
if runtime_skill.class_name in used:
raise ValueError(f'多个生成技能匹配同一运行时技能: {runtime_skill.class_name}')
used.add(runtime_skill.class_name)
block = character[runtime_skill.start:runtime_skill.end]
generated_block = generated[generated_skill.start:generated_skill.end]
if generated_skill.ref is not None and runtime_skill.ref is None:
block = _set_generated_field(block, 'ref', generated_skill.ref)
# 名称保留运行时值,防止破坏未迁移的名称关联;稳定 ref 匹配不要求名称相同。
if generated_skill.uuid is not None and generated_skill.ref_key == runtime_skill.ref_key and generated_skill.ref_key is not None:
block = _set_generated_field(block, 'uuid', repr(generated_skill.uuid))
block = _sync_generated_assignment(block, generated_block, "vps")
block = _sync_generated_data(block, generated_block)
block = _sync_generated_comments(block, generated_block)
block = _set_generated_field(block, "position", generated_skill.position)
block = _set_generated_field(block, "cellPos", generated_skill.cell_pos)
block = _append_missing_methods(block, generated_block, {"vp_1", "vp_2"})
block = _sync_generated_method_docstrings(
block, generated_block, {"vp_1", "vp_2"}
)
chunks.append(block.rstrip() + "\n")
matched += 1
cursor = generated_skill.end
# 未出现在新生成数据中的手写技能必须保留,不能随布局重建被删除。
for skill in character_skills:
if skill.class_name not in used:
chunks.append('\n' + character[skill.start:skill.end].rstrip() + '\n')
chunks.append("\n" + character[character_footer_start:].lstrip("\r\n"))
merged = "".join(chunks)
merged = "\n".join(line.rstrip() for line in merged.splitlines()) + "\n"
merged = _sync_module_assignment(merged, generated, "prefix")
merged = _ensure_get_data_import(merged)
merged = _ensure_ref_imports(merged, generated)
return merged, {
"generated": len(generated_skills), "runtime": len(character_skills),
"matched": matched, "commented": commented,
"runtime_only": len(character_skills) - matched,
}
def _atomic_write(path: Path, content: str) -> None:
"""Write beside the target and atomically replace it on Windows.
Writing an already-open Python source file directly can intermittently
produce ``OSError: [Errno 22]`` on Windows. A sibling temporary file also
prevents a failed write from leaving a partially truncated module.
"""
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
temporary.write_bytes(content.encode("utf-8"))
for attempt in range(3):
try:
os.replace(temporary, path)
return
except OSError:
if attempt == 2:
raise
time.sleep(0.1 * (attempt + 1))
except OSError as error:
raise OSError(
error.errno,
f"failed to replace {path} using temporary file {temporary}: {error.strerror}",
str(path),
) from error
finally:
temporary.unlink(missing_ok=True)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--check", action="store_true", help="validate without writing")
parser.add_argument("--generated-root", type=Path, default=GENERATED_ROOT)
parser.add_argument("--character-root", type=Path, default=CHARACTER_ROOT)
args = parser.parse_args()
totals = {key: 0 for key in ("files", "generated", "runtime", "matched", "commented", "runtime_only")}
for character_path in sorted(args.character_root.rglob("*.py")):
if character_path.name == '__init__.py':
continue
# 使用数据 prefix 找生成来源,输出圣骑士等变体也能复用来源职业。
module = ast.parse(character_path.read_text(encoding='utf-8'))
prefix = next((node.value.value for node in module.body
if _statement_key(node) == 'prefix' and isinstance(node.value, ast.Constant)), None)
relative = character_path.relative_to(args.character_root)
generated_relative = Path(*prefix.split('/')[:2]).with_suffix('.py') if isinstance(prefix, str) else relative
generated_path = args.generated_root / generated_relative
if not generated_path.exists():
print(f"SKIP {relative}: no generated profession")
continue
output, stats = merge(generated_path, character_path)
ast.parse(output, filename=str(character_path))
if not args.check:
_atomic_write(character_path, output)
totals["files"] += 1
for key, value in stats.items():
totals[key] += value
print(f"{'CHECK' if args.check else 'WRITE'} {relative}: "
f"matched={stats['matched']} commented={stats['commented']} runtime_only={stats['runtime_only']}")
print("TOTAL " + " ".join(f"{key}={value}" for key, value in totals.items()))
return 0
if __name__ == "__main__":
raise SystemExit(main())