#!/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())