Files
FreeCADWasmDev/tools/check_web_link_closure.py
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

215 lines
9.7 KiB
Python

#!/usr/bin/env python3
"""Preflight one Emscripten Web probe link rule before spending link time."""
from __future__ import annotations
import argparse
import json
import re
import shlex
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CACHE_PATH = ROOT / "docs/current/web-link-preflight-cache.json"
def link_options(flags: str, libraries: str) -> dict:
tokens = shlex.split(flags)
settings = {}
for index, token in enumerate(tokens):
setting = tokens[index + 1] if token == "-s" and index + 1 < len(tokens) else token[2:] if token.startswith("-s") else ""
key, separator, value = setting.partition("=")
if separator:
settings[key] = value
concurrent = any(Path(token).name in {"libQt5Concurrent.a", "libQt6Concurrent.a"}
or token in {"-lQt5Concurrent", "-lQt6Concurrent"} for token in shlex.split(libraries))
return {"settings": settings, "hasPthreadFlag": "-pthread" in tokens,
"needsPthread": concurrent or "-pthread" in tokens or settings.get("USE_PTHREADS") == "1" or "PTHREAD_POOL_SIZE" in settings}
def target_rule(build_dir: Path, target: str) -> tuple[str, str, str] | None:
ninja = build_dir / "build.ninja"
try:
lines = ninja.read_text(encoding="utf-8", errors="ignore").splitlines()
except OSError:
return None
target = target.removesuffix(".js")
marker = f"build bin/{target}.js:"
for index, line in enumerate(lines):
if not line.startswith(marker):
continue
flags = ""
libraries = ""
for candidate in lines[index + 1 : index + 8]:
if candidate.startswith(" LINK_FLAGS = "):
flags = candidate.removeprefix(" LINK_FLAGS = ")
elif candidate.startswith(" LINK_LIBRARIES = "):
libraries = candidate.removeprefix(" LINK_LIBRARIES = ")
return line, flags, libraries
return None
def resolve_target_alias(build_dir: Path, target: str) -> str:
"""Resolve the common generic CoreProbe name to the active module probe."""
normalized = target.removesuffix(".js")
if normalized != "FreeCADCoreProbe":
return normalized
aliases = {
"Part": "FreeCADWebPartBoxProbe",
"PartDesign": "FreeCADWebPartDesignRuntimeProbe",
"Sketcher": "FreeCADWebSketcherProbe",
"TechDraw": "FreeCADWebTechDrawRuntimeProbe",
"Measure": "FreeCADWebMeasureKernelProbe",
"Material": "FreeCADWebMaterialRuntimeProbe",
"Mesh": "FreeCADWebMeshRuntimeProbe",
"Assembly": "FreeCADWebAssemblyProbe",
}
try:
batch = json.loads((ROOT / "docs/current/module-batch-manifest.json").read_text(encoding="utf-8"))
module = (batch.get("selected") or {}).get("module") if isinstance(batch, dict) else None
except (OSError, json.JSONDecodeError):
module = None
candidate = aliases.get(module)
if candidate and target_rule(build_dir, candidate) is not None:
return candidate
return normalized
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("build_dir", type=Path)
parser.add_argument("target")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
build_dir = args.build_dir if args.build_dir.is_absolute() else ROOT / args.build_dir
requested_build_dir = build_dir
if build_dir.name == "bin" and not (build_dir / "build.ninja").is_file() and (build_dir.parent / "build.ninja").is_file():
build_dir = build_dir.parent
requested_target = args.target.removesuffix(".js")
args.target = resolve_target_alias(build_dir, requested_target)
errors: list[str] = []
warnings: list[str] = []
missing_archives: list[str] = []
# The link rule is static until CMake rewrites build.ninja. Cache only the
# rule text and its file stamp; all archive and artifact checks below still
# run on every invocation so newly installed libraries are noticed.
rule = None
cache_hit = False
ninja_path = build_dir / "build.ninja"
cache_key = f"{build_dir.resolve()}::{args.target.removesuffix('.js')}"
try:
ninja_stat = ninja_path.stat()
except OSError:
ninja_stat = None
try:
cache_payload = json.loads(CACHE_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
cache_payload = {}
cache_entries = cache_payload.get("entries", {}) if isinstance(cache_payload, dict) else {}
cached = cache_entries.get(cache_key) if isinstance(cache_entries, dict) else None
if (
ninja_stat is not None
and isinstance(cached, dict)
and cached.get("size") == ninja_stat.st_size
and cached.get("mtime_ns") == ninja_stat.st_mtime_ns
and cached.get("inode") == ninja_stat.st_ino
and isinstance(cached.get("rule"), list)
and len(cached["rule"]) == 3
):
rule = tuple(str(value) for value in cached["rule"])
cache_hit = True
if rule is None:
rule = target_rule(build_dir, args.target)
if rule is not None and ninja_stat is not None:
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
updated_entries = dict(cache_entries) if isinstance(cache_entries, dict) else {}
updated_entries[cache_key] = {
"size": ninja_stat.st_size,
"mtime_ns": ninja_stat.st_mtime_ns,
"inode": ninja_stat.st_ino,
"rule": list(rule),
}
temporary = CACHE_PATH.with_suffix(".tmp")
temporary.write_text(
json.dumps({"schema": "freecad-web-link-preflight-cache/v1", "entries": updated_entries}, ensure_ascii=False, separators=(",", ":")) + "\n",
encoding="utf-8",
)
temporary.replace(CACHE_PATH)
if rule is None:
errors.append(f"link rule not found for {args.target}")
flags = libraries = ""
else:
_, flags, libraries = rule
options = link_options(flags, libraries)
if options["settings"].get("WASM") != "1" or options["settings"].get("MODULARIZE") != "1":
errors.append("target is not an Emscripten modular WASM executable")
# Do not match library directory names such as `python-pthread-install`
# or `libboost_thread.a`: those archives can be linked in a
# single-threaded probe. Require an explicit Emscripten/Qt concurrent
# feature that actually needs the pthread link mode.
needs_pthread = options["needsPthread"]
if needs_pthread and not options["hasPthreadFlag"]:
errors.append("pthread-dependent closure has no -pthread link flag")
if needs_pthread and "PTHREAD_POOL_SIZE" not in options["settings"]:
errors.append("pthread-dependent closure has no explicit PTHREAD_POOL_SIZE link setting")
try:
cache_text = (build_dir / "CMakeCache.txt").read_text(encoding="utf-8", errors="ignore")
except OSError:
cache_text = ""
# CMake can emit a bare -lTK* when an optional OCCT component is
# listed in the package metadata but absent from the selected Web
# install. Detect that dangling archive before invoking wasm-ld.
occt_roots: list[Path] = []
cache_match = re.search(r"^FREECAD_WEB_OCCT_ROOT:PATH=([^\n]+)", cache_text, re.MULTILINE)
if cache_match:
occt_roots.append(Path(cache_match.group(1)).expanduser() / "lib")
for token in libraries.split():
match = re.fullmatch(r"-l(TK[A-Za-z0-9_]+)", token)
if not match:
continue
archive = f"lib{match.group(1)}.a"
if not any((root / archive).is_file() for root in occt_roots):
missing_archives.append(match.group(1))
if missing_archives:
errors.append("missing OCCT archives: " + ", ".join(sorted(set(missing_archives))))
if "FREECAD_WEB_FAST_LINK:BOOL=ON" in cache_text:
profile = "fast-dev"
else:
profile = "release"
output = build_dir / "bin" / f"{args.target.removesuffix('.js')}.js"
wasm = build_dir / "bin" / f"{args.target.removesuffix('.js')}.wasm"
if output.exists() != wasm.exists():
warnings.append("runtime and WASM output presence are inconsistent")
result = {
"schema": "freecad-web-link-preflight/v1",
"buildDirectory": str(build_dir.relative_to(ROOT)) if build_dir.is_relative_to(ROOT) else str(build_dir),
"requestedBuildDirectory": str(requested_build_dir.relative_to(ROOT)) if requested_build_dir.is_relative_to(ROOT) else str(requested_build_dir),
"target": args.target.removesuffix(".js"),
"requestedTarget": requested_target,
"resolvedTarget": args.target.removesuffix(".js"),
"profile": profile,
"needsPthread": needs_pthread,
"hasPthreadFlag": options["hasPthreadFlag"],
"missingArchives": sorted(set(missing_archives)),
"runtimePresent": output.exists(),
"wasmPresent": wasm.exists(),
"errors": errors,
"warnings": warnings,
"cacheHit": cache_hit,
}
print(json.dumps(result, ensure_ascii=False) if args.json else json.dumps(result, ensure_ascii=False, indent=2))
return 1 if errors else 0
result = {
"schema": "freecad-web-link-preflight/v1",
"buildDirectory": str(build_dir),
"target": args.target.removesuffix(".js"),
"errors": errors,
"warnings": warnings,
}
print(json.dumps(result, ensure_ascii=False) if args.json else json.dumps(result, ensure_ascii=False, indent=2))
return 1
if __name__ == "__main__":
raise SystemExit(main())