Files
EVEngine/scripts/generate_binding_contracts.py
西风逍遥游 31880f0021 feat(examples): gate examples in CI and make repository scripts UTF-8 on every host
`examples/<name>/` was a tested surface in name only. The source-quality job had
no examples check at all, and scripts/smoke_examples.sh accepted any process that
had not printed one of its error markers: a project that died during startup,
exited after a single frame, or crashed without a marker was reported as PASS.
Nothing stopped a malformed or unregistered example from reaching dev, which is
how 14 examples arrived without READMEs and 20 were never registered in the
overview table.

Source side
- scripts/check_examples.py classifies every directory under examples/ as either
  runnable (main.nut plus a config.nut that assigns the engine `config` table) or
  as a reviewed non-runnable sample listed in NON_RUNNABLE_EXAMPLES with a
  reason. Both kinds must own a README.md and appear in examples/README.md.
  Stale exemptions and overview links to missing directories fail as well.
- `make check/examples` and a new source-quality CI step run the gate.
- scripts/tests/test_check_examples.py covers it and pins each failure mode
  against fixtures.

Runtime side
- scripts/smoke_examples.sh now enforces liveness, not just marker absence: every
  runnable example must still be running after MIN_RUN_SECONDS (default 2s; CI
  passes 2s), and a process that dies inside that window fails whatever its exit
  code. A clean self-exit after the window passes with an explicit note instead
  of being silently accepted as a pass.
- The Linux CI step sets MIN_RUN_SECONDS=2 and keeps RUN_SECONDS=6, so the job's
  wall clock is unchanged (13.7 min of its 20 min budget on dev).

Windows / GBK
- scripts/utf8_stdio.py re-encodes stdout/stderr as UTF-8 and every scripts/*.py
  program calls it as the first statement of its __main__ block. On a Chinese
  Windows a redirected stream falls back to the ANSI code page (cp936), and
  printing a character that code page cannot encode (arrows, superscripts, bullet
  separators, emoji - all common in this repository) raised UnicodeEncodeError
  and turned a checker into a traceback.
- The mirror image is input: read_text() without encoding= decoded UTF-8
  repository text as cp936 and raised UnicodeDecodeError.
- scripts/tests/test_utf8_stdio.py enforces both conventions for every script and
  test module; text file I/O and child-process decoding now pass encoding="utf-8"
  explicitly.
- Unit-test invocations run with `python3 -X utf8` so the runner's own output
  never follows the host code page.

Violations the new gates found on top of dev, fixed here: 14 examples shipped
without a README (fluid3d, mesh-impact-lab, mesh-modifier-lab, spline-tube-lab on
the earlier dev tip, plus pcg-depth-of-field, pcg-detail-overwrite,
pcg-layer-culling, pcg-location-system, pcg-runtime-orchestration,
pcg-scene-player-culling, pcg-snow-wind, pcg-thunder-strike, pcg-ui-scaler and
pcg-underwater added by #397), 20 were missing from the overview table (those 14
plus archspace-editor, geometry-stroke-lab, interior-mapping, layered-animation,
triplanar-decal, terrain-stamping), and scripts/export_meshret_onnx.py had no
UTF-8 stdio guard.

Verified locally: `python -m ruff check scripts` (All checks passed);
scripts/check_test_manifest.py; scripts/check_quality_metadata.py;
scripts/check_architecture_contracts.py --base HEAD; scripts/check_nodiscard.py;
`make -n check/examples`; scripts/check_examples.py (125 examples: 118 runnable,
7 reviewed non-runnable); and `python -X utf8 -m unittest discover -s scripts/tests
-p "test_*.py"` (155 tests, OK). The runtime half of the contract needs a Linux
headless build, so it is verified by the CI examples smoke step rather than
locally.
2026-09-15 20:20:23 +08:00

582 lines
25 KiB
Python

#!/usr/bin/env python3
"""Generate EveScript Binding Contracts from SimpleSquirrel addFunc declarations.
The generator reads the same C++ declarations that bind functions through
SimpleSquirrel. Member-function parameter names come from their class
declarations; lambda parameters come from the binding expression itself. A
binding that cannot supply real parameter names is rejected instead of
silently publishing arg0/arg1 placeholders.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass, field
import json
from pathlib import Path
import re
import sys
import utf8_stdio
ROOT = Path(__file__).resolve().parents[1]
SOURCE_ROOTS = (ROOT / "src" / "engine", ROOT / "src" / "modules")
SOURCE_SUFFIXES = {".h", ".hpp", ".cpp"}
@dataclass
class Parameter:
name: str
cpp_type: str
script_type: str
nullable: bool = False
default: str = ""
unit: str = "None"
choices: list[str] = field(default_factory=list)
@dataclass
class Contract:
module: str
script_class: str
method: str
parameters: list[Parameter]
return_type: str
return_nullable: bool
ownership: str
thread_affinity: str
platforms: list[str]
documentation_id: str
source: str
@property
def key(self) -> str:
owner = f"{self.script_class}." if self.script_class else ""
return f"{self.module}/{owner}{self.method}"
def mask_comments(source: str) -> str:
output = list(source)
quote: str | None = None
line_comment = False
block_comment = False
i = 0
while i < len(source):
value = source[i]
nxt = source[i + 1] if i + 1 < len(source) else ""
if line_comment:
if value == "\n":
line_comment = False
else:
output[i] = " "
elif block_comment:
if value == "*" and nxt == "/":
output[i] = output[i + 1] = " "
i += 1
block_comment = False
elif value != "\n":
output[i] = " "
elif quote:
if value == "\\":
output[i] = " "
if i + 1 < len(output):
output[i + 1] = " "
i += 1
elif value == quote:
quote = None
elif value in {'"', "'"}:
quote = value
elif value == "/" and nxt == "/":
output[i] = output[i + 1] = " "
i += 1
line_comment = True
elif value == "/" and nxt == "*":
output[i] = output[i + 1] = " "
i += 1
block_comment = True
i += 1
return "".join(output)
def matching(source: str, opening: int, left: str = "(", right: str = ")") -> int | None:
depth = 0
quote: str | None = None
i = opening
while i < len(source):
value = source[i]
nxt = source[i + 1] if i + 1 < len(source) else ""
if quote:
if value == "\\":
i += 2
continue
if value == quote:
quote = None
elif value in {'"', "'"}:
quote = value
elif value == "/" and nxt == "/":
newline = source.find("\n", i + 2)
i = len(source) if newline < 0 else newline
continue
elif value == "/" and nxt == "*":
end = source.find("*/", i + 2)
i = len(source) if end < 0 else end + 2
continue
elif value == left:
depth += 1
elif value == right:
depth -= 1
if depth == 0:
return i
i += 1
return None
def split_top_level(value: str, delimiter: str = ",") -> list[str]:
result: list[str] = []
begin = 0
depths = {"(": 0, "[": 0, "{": 0, "<": 0}
pairs = {")": "(", "]": "[", "}": "{", ">": "<"}
quote: str | None = None
i = 0
while i < len(value):
current = value[i]
if quote:
if current == "\\":
i += 2
continue
if current == quote:
quote = None
elif current in {'"', "'"}:
quote = current
elif current in depths:
depths[current] += 1
elif current in pairs:
depths[pairs[current]] = max(0, depths[pairs[current]] - 1)
elif current == delimiter and not any(depths.values()):
result.append(value[begin:i].strip())
begin = i + 1
i += 1
result.append(value[begin:].strip())
return [item for item in result if item]
def script_type(cpp_type: str) -> tuple[str, bool]:
normalized = re.sub(r"\b(const|volatile|class|struct)\b", "", cpp_type)
normalized = re.sub(r"\s+", " ", normalized).strip()
nullable = "*" in normalized or "optional<" in normalized
plain = normalized.replace("&", "").replace("*", "").strip()
plain = re.sub(r"^(std::|ssq::|eve::)", "", plain)
if plain in {"void"}:
return "void", False
if plain in {"bool", "SQBool"}:
return "bool", nullable
if re.search(r"(^|::)(u?int\d*_t|size_t|int|long|short|unsigned|SQInteger)$", plain):
return "int", nullable
if plain in {"float", "double", "SQFloat"}:
return "float", nullable
if "string" in plain or plain in {"char", "SQChar"}:
return "string", nullable
if re.search(r"(^|::)(vector|array|Array)<", plain) or plain == "Array":
return "Array<dynamic>", nullable
if re.search(r"(^|::)(map|unordered_map|Table)<", plain) or plain == "Table":
return "Table<string, dynamic>", nullable
if plain in {"Object", "Function", "Class", "Instance"}:
return "dynamic", nullable
if re.fullmatch(r"[TUVW]", plain):
return "dynamic", nullable
match = re.search(r"([A-Za-z_]\w*)\s*(?:<.*>)?$", plain)
return (match.group(1) if match else "dynamic"), nullable
def parse_parameter(text: str, index: int) -> Parameter | None:
text = re.sub(r"\[\[[^]]*\]\]", "", text).strip()
if not text or text == "void":
return None
pieces = split_top_level(text, "=")
declaration = pieces[0].strip()
# A C++ default is not automatically a script default: the native wrapper
# still expects its full positional arity unless binding metadata says
# otherwise. Keep it out of the generated language contract.
declaration = re.sub(r"\s*\.\.\.\s*$", "", declaration)
name_match = re.search(r"([A-Za-z_]\w*)\s*(?:\[.*\])?$", declaration)
if not name_match:
return Parameter(f"arg{index}", declaration, script_type(declaration)[0])
name = name_match.group(1)
cpp_type = declaration[: name_match.start()].strip()
if not cpp_type:
return Parameter(f"arg{index}", declaration, script_type(declaration)[0])
mapped, nullable = script_type(cpp_type)
return Parameter(name, cpp_type, mapped, nullable)
def parse_parameters(text: str) -> list[Parameter]:
result: list[Parameter] = []
for index, item in enumerate(split_top_level(text)):
parameter = parse_parameter(item, index)
if parameter:
result.append(parameter)
return result
def class_blocks(source: str) -> dict[str, list[str]]:
result: dict[str, list[str]] = {}
masked = mask_comments(source)
pattern = re.compile(r"\b(?:class|struct)\s+([A-Za-z_]\w*)[^;{]*\{")
for match in pattern.finditer(masked):
opening = masked.find("{", match.start())
closing = matching(masked, opening, "{", "}")
if closing is not None:
result.setdefault(match.group(1), []).append(source[opening + 1 : closing])
return result
class SignatureIndex:
def __init__(self, sources: dict[Path, str]) -> None:
self.sources = sources
self.masked = {path: mask_comments(source) for path, source in sources.items()}
self.classes: dict[str, list[str]] = {}
self.aliases: dict[str, str] = {}
for source in sources.values():
for name, blocks in class_blocks(source).items():
self.classes.setdefault(name, []).extend(blocks)
for match in re.finditer(
r"\busing\s+([A-Za-z_]\w*)\s*=\s*(?:[A-Za-z_]\w*::)*([A-Za-z_]\w*)\s*(?:<[^;]+>)?\s*;",
mask_comments(source),
):
self.aliases[match.group(1)] = match.group(2)
for concrete_class in self.classes:
self.aliases.pop(concrete_class, None)
self.member_cache: dict[tuple[str, str], tuple[str, list[Parameter]] | None] = {}
self.free_cache: dict[str, tuple[str, list[Parameter]] | None] = {}
def member(self, qualified: str, method: str) -> tuple[str, list[Parameter]] | None:
class_name = qualified.split("::")[-1]
cache_key = (class_name, method)
if cache_key in self.member_cache:
return self.member_cache[cache_key]
lookup_class = self.aliases.get(class_name, class_name)
candidates: list[tuple[str, list[Parameter]]] = []
declaration_pattern = re.compile(
rf"(?m)(?:^|[;{{}}])\s*(?:virtual\s+|static\s+|inline\s+|constexpr\s+|explicit\s+)*"
rf"([A-Za-z_~][\w:\s<>,*&]*?)\b{re.escape(method)}\s*\(")
for block in self.classes.get(lookup_class, []):
masked = mask_comments(block)
for match in declaration_pattern.finditer(masked):
opening = masked.find("(", match.start())
closing = matching(block, opening)
if closing is None:
continue
candidates.append((match.group(1).strip().splitlines()[-1].strip(),
parse_parameters(block[opening + 1 : closing])))
if not candidates:
definition_pattern = re.compile(
rf"(?m)([A-Za-z_~][\w:\s<>,*&]*?)\b{re.escape(lookup_class)}::{re.escape(method)}\s*\(")
needle = f"{lookup_class}::{method}"
for path, source in self.sources.items():
if needle not in source:
continue
for match in definition_pattern.finditer(self.masked[path]):
opening = self.masked[path].find("(", match.start())
closing = matching(source, opening)
if closing is None:
continue
candidates.append((match.group(1).strip().splitlines()[-1].strip(),
parse_parameters(source[opening + 1 : closing])))
if not candidates and method == "getName":
candidates.append(("std::string", []))
if not candidates:
inherited: list[tuple[str, list[Parameter]]] = []
for blocks in self.classes.values():
for block in blocks:
masked = mask_comments(block)
for match in declaration_pattern.finditer(masked):
opening = masked.find("(", match.start())
closing = matching(block, opening)
if closing is not None:
inherited.append((match.group(1).strip().splitlines()[-1].strip(),
parse_parameters(block[opening + 1 : closing])))
shapes = {
(return_type, tuple((parameter.name, parameter.cpp_type) for parameter in parameters))
for return_type, parameters in inherited
}
if len(shapes) == 1 and inherited:
candidates.append(inherited[0])
if not candidates:
self.member_cache[cache_key] = None
return None
candidates.sort(key=lambda item: (sum(parameter.name.startswith("arg") for parameter in item[1]),
-len(item[1])))
self.member_cache[cache_key] = candidates[0]
return candidates[0]
def free(self, name: str) -> tuple[str, list[Parameter]] | None:
if name in self.free_cache:
return self.free_cache[name]
pattern = re.compile(rf"(?m)^\s*(?:static\s+)?([A-Za-z_][\w:\s<>,*&]*?)\b{re.escape(name)}\s*\(")
candidates = []
needle = f"{name}("
for path, source in self.sources.items():
if needle not in source and f"{name} (" not in source:
continue
for match in pattern.finditer(self.masked[path]):
opening = self.masked[path].find("(", match.start())
closing = matching(source, opening)
if closing is not None:
candidates.append((match.group(1).strip(), parse_parameters(source[opening + 1 : closing])))
candidates.sort(key=lambda item: (sum(parameter.name.startswith("arg") for parameter in item[1]),
-len(item[1]), item[0],
tuple((parameter.name, parameter.cpp_type) for parameter in item[1])))
self.free_cache[name] = candidates[0] if candidates else None
return self.free_cache[name]
def module_for(path: Path) -> str:
relative = path.relative_to(ROOT).parts
if len(relative) >= 3 and relative[:2] == ("src", "modules"):
return relative[2]
if len(relative) >= 3 and relative[:2] == ("src", "engine"):
return relative[2]
return "engine"
def class_bindings(source: str) -> list[tuple[int, str, str]]:
pattern = re.compile(
r"\b(?:auto|ssq::Class)\s+([A-Za-z_]\w*)\s*=\s*[A-Za-z_]\w*\.addClass"
r"(?:<\s*([A-Za-z_:]\w*(?:::\w+)*)[^>]*>)?\s*\(")
result = []
for match in pattern.finditer(mask_comments(source)):
opening = source.find("(", match.start())
closing = matching(source, opening)
if closing is None:
continue
arguments = split_top_level(source[opening + 1 : closing])
literal = re.match(r'\s*"([^"]+)"', arguments[0]) if arguments else None
result.append((match.start(), match.group(1), literal.group(1) if literal else (match.group(2) or "")))
return result
def callable_signature(expression: str, signatures: SignatureIndex) -> tuple[str, str, list[Parameter]] | None:
member = re.search(r"&\s*([A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)::([A-Za-z_]\w*)", expression)
if member:
signature = signatures.member(member.group(1), member.group(2))
return (member.group(1).split("::")[-1], *signature) if signature else None
lambda_match = re.search(r"\[[^]]*\]\s*\(", expression)
if lambda_match:
opening = expression.find("(", lambda_match.start())
closing = matching(expression, opening)
if closing is None:
return None
tail = expression[closing + 1 :]
result = re.match(r"\s*(?:mutable\s*)?(?:noexcept\s*)?(?:->\s*([^\s{]+(?:\s*[*&])?))?", tail)
return "", (result.group(1).strip() if result and result.group(1) else "dynamic"), parse_parameters(
expression[opening + 1 : closing]
)
function = re.search(r"(?:std::function\s*<[^>]+>\s*\()?\s*([A-Za-z_]\w*)\s*\)?\s*$", expression)
if function:
signature = signatures.free(function.group(1))
return ("", *signature) if signature else None
return None
def extract_contracts(sources: dict[Path, str], enabled_modules: set[str] | None = None,
platform: str = "") -> tuple[list[Contract], list[str]]:
signatures = SignatureIndex(sources)
contracts: dict[str, Contract] = {}
unresolved: list[str] = []
call_pattern = re.compile(r"\b([A-Za-z_]\w*)\.addFunc\s*\(")
for path, source in sources.items():
relative = path.relative_to(ROOT).parts
if (enabled_modules is not None and len(relative) >= 3 and relative[:2] == ("src", "modules")
and relative[2] not in enabled_modules):
continue
bindings = class_bindings(source)
masked = mask_comments(source)
for match in call_pattern.finditer(masked):
opening = source.find("(", match.start())
closing = matching(source, opening)
if closing is None:
unresolved.append(f"{path.relative_to(ROOT)}:{source.count(chr(10), 0, match.start()) + 1}: unclosed addFunc")
continue
arguments = split_top_level(source[opening + 1 : closing])
method_match = re.match(r'\s*"([^"]+)"', arguments[0]) if arguments else None
if not method_match or len(arguments) < 2:
unresolved.append(f"{path.relative_to(ROOT)}:{source.count(chr(10), 0, match.start()) + 1}: dynamic addFunc")
continue
method = method_match.group(1)
expression = ",".join(arguments[1:])
signature = callable_signature(expression, signatures)
if signature is None:
unresolved.append(
f"{path.relative_to(ROOT)}:{source.count(chr(10), 0, match.start()) + 1}: {match.group(1)}.{method}"
)
continue
cpp_class, return_cpp, parameters = signature
script_class = ""
for position, receiver, bound_class in reversed(bindings):
if position < match.start() and receiver == match.group(1):
script_class = bound_class
break
if not script_class:
script_class = cpp_class if match.group(1) not in {"table", "eve", "root", "vm"} else ""
if (parameters and cpp_class == "" and "*" in parameters[0].cpp_type
and match.group(1) not in {"table", "eve", "root", "vm"}):
parameters = parameters[1:]
mapped_return, return_nullable = script_type(return_cpp)
ownership = "borrowed" if "*" in return_cpp else "value"
module = module_for(path)
contract = Contract(module, script_class, method, parameters, mapped_return, return_nullable, ownership,
"main", [platform] if platform else [],
f"{module}.{script_class + '.' if script_class else ''}{method}",
f"{path.relative_to(ROOT).as_posix()}:{source.count(chr(10), 0, match.start()) + 1}")
existing = contracts.get(contract.key)
if existing is None or sum(p.name.startswith("arg") for p in contract.parameters) < sum(
p.name.startswith("arg") for p in existing.parameters
):
contracts[contract.key] = contract
return sorted(contracts.values(), key=lambda item: item.key), unresolved
def cpp_string(value: str) -> str:
return json.dumps(value, ensure_ascii=False)
def write_cpp(path: Path, contracts: list[Contract]) -> None:
lines = [
'// Generated by scripts/generate_binding_contracts.py; do not edit.',
'#include "common/BindingContracts.h"',
'#include "common/ScriptCompiler.h"',
'',
'#include <initializer_list>',
'',
'namespace eve::script {',
'namespace {',
'struct ParameterSeed {',
' const char* name;',
' const char* type;',
' bool nullable;',
' const char* scriptDefault;',
' ScriptUnit unit;',
' std::initializer_list<const char*> choices;',
'};',
'',
'void addContract(BindingContractRegistry& registry, const char* module, const char* scriptClass,',
' const char* method, const char* returnType, bool returnNullable, const char* ownership,',
' const char* threadAffinity, std::initializer_list<const char*> platforms,',
' const char* documentationId, std::initializer_list<ParameterSeed> parameters) {',
' BindingContract contract;',
' contract.module = module;',
' contract.scriptClass = scriptClass;',
' contract.method = method;',
' contract.returnType = returnType;',
' contract.returnNullable = returnNullable;',
' contract.ownership = ownership;',
' contract.threadAffinity = threadAffinity;',
' for (const char* platform : platforms) contract.platforms.emplace_back(platform);',
' contract.documentationId = documentationId;',
' for (const ParameterSeed& seed : parameters) {',
' BindingParameterContract parameter;',
' parameter.name = seed.name;',
' parameter.type = seed.type;',
' parameter.nullable = seed.nullable;',
' if (seed.scriptDefault != nullptr) parameter.scriptDefault = seed.scriptDefault;',
' parameter.unit = seed.unit;',
' for (const char* choice : seed.choices) parameter.choices.emplace_back(choice);',
' contract.parameters.push_back(std::move(parameter));',
' }',
' registry.registerContract(std::move(contract));',
'}',
]
calls: list[str] = []
for contract in contracts:
parameters = []
for parameter in contract.parameters:
default = cpp_string(parameter.default) if parameter.default else 'nullptr'
choices = "{" + ", ".join(cpp_string(choice) for choice in parameter.choices) + "}"
parameters.append(
f'{{{cpp_string(parameter.name)}, {cpp_string(parameter.script_type)}, '
f'{str(parameter.nullable).lower()}, {default}, ScriptUnit::{parameter.unit}, {choices}}}'
)
calls.append(
f' addContract(registry, {cpp_string(contract.module)}, {cpp_string(contract.script_class)}, '
f'{cpp_string(contract.method)}, {cpp_string(contract.return_type)}, '
f'{str(contract.return_nullable).lower()}, {cpp_string(contract.ownership)}, '
f'{cpp_string(contract.thread_affinity)}, '
f'{{{", ".join(cpp_string(platform) for platform in contract.platforms)}}}, '
f'{cpp_string(contract.documentation_id)}, {{{", ".join(parameters)}}});'
)
chunk_size = 128
chunk_count = (len(calls) + chunk_size - 1) // chunk_size
for index in range(chunk_count):
lines += [
'',
'#if defined(_MSC_VER)',
'__declspec(noinline)',
'#elif defined(__GNUC__) || defined(__clang__)',
'__attribute__((noinline))',
'#endif',
f'void registerChunk{index}(BindingContractRegistry& registry) {{',
*calls[index * chunk_size : (index + 1) * chunk_size],
'}',
]
lines += ['} // namespace', '', 'void registerEngineBindingContracts(BindingContractRegistry& registry) {']
for index in range(chunk_count):
lines.append(f' registerChunk{index}(registry);')
lines += ['}', '', '} // namespace eve::script', '']
path.parent.mkdir(parents=True, exist_ok=True)
text = "\n".join(lines)
if path.exists() and path.read_text(encoding="utf-8") == text:
return
path.write_text(text, encoding="utf-8")
def source_files() -> dict[Path, str]:
result = {}
for root in SOURCE_ROOTS:
for path in root.rglob("*"):
if path.suffix in SOURCE_SUFFIXES and ".generated." not in path.name:
result[path] = path.read_text(encoding="utf-8", errors="replace")
return result
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path)
parser.add_argument("--allow-unresolved", action="store_true")
parser.add_argument("--report", type=Path)
parser.add_argument("--modules", help="Comma-separated enabled src/modules directories; engine bindings are always included")
parser.add_argument("--platform", default="", help="Platform availability recorded on generated contracts")
args = parser.parse_args()
enabled_modules = ({item for item in args.modules.split(",") if item} if args.modules is not None else None)
contracts, unresolved = extract_contracts(source_files(), enabled_modules, args.platform)
placeholder_count = sum(parameter.name.startswith("arg") for contract in contracts for parameter in contract.parameters)
print(f"Binding Contract: {len(contracts)} contracts, {len(unresolved)} unresolved bindings, "
f"{placeholder_count} placeholder parameter names")
placeholders = [
f"{contract.source}: {contract.key}: " + ", ".join(
f"{parameter.name}<{parameter.cpp_type}>" for parameter in contract.parameters if parameter.name.startswith("arg")
)
for contract in contracts
if any(parameter.name.startswith("arg") for parameter in contract.parameters)
]
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
report = [*(f"UNRESOLVED {item}" for item in unresolved), *(f"PLACEHOLDER {item}" for item in placeholders)]
args.report.write_text("\n".join(report) + ("\n" if report else ""), encoding="utf-8")
if args.output:
write_cpp(args.output, contracts)
if (unresolved or placeholder_count) and not args.allow_unresolved:
for item in unresolved[:50]:
print(f"UNRESOLVED {item}")
for item in placeholders[:50]:
print(f"PLACEHOLDER {item}")
return 1
return 0
if __name__ == "__main__":
utf8_stdio.enable_utf8_stdio()
sys.exit(main())