Files
FreeCADWasmDev/tools/generate_web_dependency_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

490 lines
22 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate a source-backed dependency closure for the first Web kernel slice.
The report deliberately keeps CMake conditions and unresolved variables visible.
It is an inventory of the current checkout, not a claim that the targets already
compile under Emscripten.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import subprocess
import sys
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
SOURCE_EXTENSIONS = {
".c",
".cc",
".cpp",
".cxx",
".h",
".hh",
".hpp",
".hxx",
".inl",
".l",
".py",
".pyi",
".qrc",
".xsd",
".y",
}
TARGETS: dict[str, dict[str, Any]] = {
"FreeCADBase": {
"cmake": "src/Base/CMakeLists.txt",
"source_root": "src/Base",
"internal": [],
"packages": [
{"name": "Boost", "evidence": "${Boost_LIBRARIES}", "condition": "always"},
{"name": "Xerces-C", "evidence": "${XercesC_LIBRARIES}", "condition": "always"},
{"name": "zlib", "evidence": "${ZLIB_LIBRARIES}", "condition": "always"},
{"name": "ICU", "evidence": "${ICU_LIBRARIES}", "condition": "always"},
{"name": "Qt5::Core or Qt6::Core", "evidence": "${QtCore_LIBRARIES}", "condition": "always"},
{"name": "libfastsignals", "evidence": "libfastsignals", "condition": "always; in-tree"},
{"name": "fmt", "evidence": "fmt::fmt", "condition": "always"},
{"name": "Python3", "evidence": "${Python3_LIBRARIES}", "condition": "BUILD_DYNAMIC_LINK_PYTHON"},
{"name": "PyCXX", "evidence": "${PYCXX_SOURCES}; ${PYCXX_INCLUDE_DIR}", "condition": "always; source/include"},
{"name": "Zipios++", "evidence": "${zipios_SRCS} or ${ZIPIOS_LIBRARY}", "condition": "FREECAD_USE_EXTERNAL_ZIPIOS selects link vs in-tree"},
{"name": "POSIX libutil/libdl", "evidence": "-lutil -ldl", "condition": "CMAKE_SYSTEM_NAME == Linux"},
],
"conditions": ["BUILD_GUI is not required by this target", "Python bindings are compiled in this target"],
},
"FreeCADApp": {
"cmake": "src/App/CMakeLists.txt",
"source_root": "src/App",
"internal": ["FreeCADBase"],
"packages": [
{"name": "Boost", "evidence": "${Boost_LIBRARIES}", "condition": "always"},
{"name": "fmt", "evidence": "fmt::fmt", "condition": "always"},
{"name": "Qt5::Core or Qt6::Core", "evidence": "${QtCore_LIBRARIES}", "condition": "always"},
{"name": "Qt5::Xml or Qt6::Xml", "evidence": "${QtXml_LIBRARIES}", "condition": "always"},
],
"conditions": ["FreeCADBase must be built", "Python bindings are compiled in this target"],
},
"Materials": {
"cmake": "src/Mod/Material/App/CMakeLists.txt",
"source_root": "src/Mod/Material/App",
"internal": ["FreeCADApp"],
"packages": [
{"name": "Qt5::Concurrent or Qt6::Concurrent", "evidence": "${QtConcurrent_LIBRARIES}", "condition": "always"},
{"name": "yaml-cpp", "evidence": "${YAML_CPP_LIBRARIES} or yaml-cpp::yaml-cpp", "condition": "always"},
],
"conditions": ["Built when BUILD_MATERIAL is enabled", "Material external manager is optional"],
},
"Part": {
"cmake": "src/Mod/Part/App/CMakeLists.txt",
"source_root": "src/Mod/Part/App",
"internal": ["FreeCADApp", "Materials"],
"packages": [
{"name": "OpenCASCADE", "evidence": "${OCC_LIBRARIES}; ${OCC_DEBUG_LIBRARIES}", "condition": "always"},
{"name": "FreeType", "evidence": "${FREETYPE_LIBRARIES}", "condition": "FREETYPE_FOUND"},
{"name": "HarfBuzz", "evidence": "harfbuzz::harfbuzz", "condition": "FREETYPE_FOUND"},
],
"conditions": ["BUILD_PART", "Materials target must be available"],
},
"PartDesign": {
"cmake": "src/Mod/PartDesign/App/CMakeLists.txt",
"source_root": "src/Mod/PartDesign/App",
"internal": ["Part", "FreeCADApp"],
"packages": [
{"name": "OpenCASCADE", "evidence": "${OCC_LIBRARIES}; ${OCC_DEBUG_LIBRARIES}", "condition": "always"},
{"name": "nlohmann-json", "evidence": "src/3rdParty/json include", "condition": "header-only; always"},
],
"conditions": ["BUILD_PART_DESIGN", "Part target must be available"],
},
"Sketcher": {
"cmake": "src/Mod/Sketcher/App/CMakeLists.txt",
"source_root": "src/Mod/Sketcher/App",
"internal": ["Part", "FreeCADApp"],
"packages": [
{"name": "Eigen3", "evidence": "${EIGEN3_INCLUDE_DIR}", "condition": "EIGEN3_FOUND"},
{"name": "nlohmann-json", "evidence": "src/3rdParty/json include", "condition": "header-only; always"},
],
"conditions": ["BUILD_SKETCHER", "EIGEN3_FOUND is required by src/Mod/CMakeLists.txt", "Part target must be available"],
},
}
PACKAGE_ALIASES = {
"Boost": "Boost",
"Xerces-C": "Xerces-C",
"zlib": "zlib",
"ICU": "ICU",
"Qt5::Core or Qt6::Core": "Qt",
"Qt5::Xml or Qt6::Xml": "Qt",
"Qt5::Concurrent or Qt6::Concurrent": "Qt",
"OpenCASCADE": "Open CASCADE",
"FreeType": "FreeType",
"Eigen3": "Eigen",
"Python3": "Python",
"PyCXX": "PyCXX",
"Zipios++": "Zipios++",
"yaml-cpp": "yaml-cpp",
}
INCLUDE_FAMILIES = (
("OpenCASCADE", ("BRep", "ChFi", "Geom", "gp.", "GProp", "HLR", "IGES", "Interface_", "Message", "NCollection", "STEP", "Standard", "TCol", "TopAbs", "TopExp", "TopoDS", "Transfer", "UnitsMethods", "XSControl")),
("Eigen3", ("Eigen/",)),
("Boost", ("boost/",)),
("Xerces-C", ("xercesc/",)),
("Qt", ("Qt", "Q")),
("Python", ("Python.h", "python", "structmember.h")),
("PyCXX", ("CXX/", "CXX_")),
("yaml-cpp", ("yaml-cpp/",)),
("fmt", ("fmt/",)),
("nlohmann-json", ("nlohmann/",)),
("Zipios++", ("zipios",)),
("ICU", ("unicode/",)),
)
def run(*args: str) -> str | None:
try:
result = subprocess.run(args, check=False, capture_output=True, text=True)
except OSError:
return None
if result.returncode != 0:
return None
return result.stdout.strip()
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8", errors="replace")
def source_files(root: Path) -> list[Path]:
return sorted(path for path in root.rglob("*") if path.is_file() and path.suffix in SOURCE_EXTENSIONS)
def classify_include(include: str, delimiter: str, source_file: Path, source_root: Path, source_tree: Path) -> str:
if delimiter == '"':
candidates = [source_file.parent / include, source_root / include, source_tree / include]
if any(candidate.is_file() for candidate in candidates):
return "FreeCAD source"
for family, prefixes in INCLUDE_FAMILIES:
if include.startswith(prefixes):
return family
if include == "json.hpp":
return "nlohmann-json"
if delimiter == "<":
return "unresolved system header"
return "unresolved quoted header"
def include_evidence(files: list[Path], source_root: Path, source_tree: Path) -> dict[str, Any]:
counts: Counter[str] = Counter()
files_by_family: dict[str, set[str]] = defaultdict(set)
unresolved: Counter[str] = Counter()
total = 0
pattern = re.compile(r'^\s*#\s*include\s*([<"])([^>"]+)[>"]', re.MULTILINE)
for path in files:
text = read_text(path)
for delimiter, include in pattern.findall(text):
total += 1
family = classify_include(include.strip(), delimiter, path, source_root, source_tree)
counts[family] += 1
rel = path.relative_to(source_tree).as_posix()
files_by_family[family].add(rel)
if family.startswith("unresolved"):
unresolved[include.strip()] += 1
return {
"total_include_directives": total,
"by_family": dict(sorted(counts.items())),
"files_by_family": {key: sorted(value) for key, value in sorted(files_by_family.items())},
"unresolved_headers": [
{"header": name, "count": count} for name, count in unresolved.most_common()
],
}
def cmake_evidence(path: Path) -> dict[str, Any]:
text = read_text(path)
variables = sorted(set(re.findall(r"\$\{([^}]+)\}", text)))
commands = sorted(set(match.group(1) for match in re.finditer(r"\b(target_[a-z_]+|add_library|add_dependencies|target_link_directories)\s*\(", text)))
return {
"sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(),
"variable_references": variables,
"commands": commands,
}
def parse_lock(path: Path, archive_root: Path, hash_archives: bool) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
with path.open(encoding="utf-8", errors="replace", newline="") as handle:
headers = handle.readline().rstrip("\n").split("\t")
for line in handle:
values = line.rstrip("\n").split("\t")
if len(values) != len(headers):
continue
row = dict(zip(headers, values))
archive = row.get("archive", "")
archive_path = archive_root / archive if archive and archive != "-" else None
item: dict[str, Any] = {
"project": row.get("project", ""),
"requested_version": row.get("requested_version", ""),
"resolved_version": row.get("resolved_version", ""),
"type": row.get("type", ""),
"archive": archive,
"directory": row.get("directory", ""),
"note": row.get("note", ""),
"archive_present": bool(archive_path and archive_path.is_file()),
"archive_size": archive_path.stat().st_size if archive_path and archive_path.is_file() else None,
"archive_sha256": None,
"sha256_status": "not_requested" if archive_path else "not_applicable",
}
if archive_path and archive_path.is_file() and hash_archives:
digest = hashlib.sha256()
with archive_path.open("rb") as archive_handle:
for chunk in iter(lambda: archive_handle.read(1024 * 1024), b""):
digest.update(chunk)
item["archive_sha256"] = digest.hexdigest()
item["sha256_status"] = "computed"
rows.append(item)
return rows
def transitive_targets(name: str) -> list[str]:
seen: set[str] = set()
ordered: list[str] = []
def visit(current: str) -> None:
if current in seen:
return
seen.add(current)
ordered.append(current)
for dependency in TARGETS[current]["internal"]:
visit(dependency)
visit(name)
return ordered
def transitive_packages(name: str) -> list[str]:
return sorted(
{
requirement["name"]
for target_name in transitive_targets(name)
for requirement in TARGETS[target_name]["packages"]
}
)
def package_closure() -> list[dict[str, Any]]:
packages: dict[str, dict[str, Any]] = {}
for target_name, target in TARGETS.items():
for requirement in target["packages"]:
name = requirement["name"]
entry = packages.setdefault(name, {"name": name, "used_by": [], "evidence": [], "conditions": []})
entry["used_by"].append(target_name)
entry["evidence"].append(requirement["evidence"])
entry["conditions"].append(requirement["condition"])
for entry in packages.values():
entry["used_by"] = sorted(set(entry["used_by"]))
entry["evidence"] = sorted(set(entry["evidence"]))
entry["conditions"] = sorted(set(entry["conditions"]))
return [packages[key] for key in sorted(packages)]
def relative(path: Path, root: Path) -> str:
return path.relative_to(root).as_posix()
def build_report(source_root: Path, lock_path: Path, hash_archives: bool) -> dict[str, Any]:
workspace_root = source_root.parent.parent
source_tree = source_root / "src"
archive_root = lock_path.parent / "sources" / "archives"
lock_rows = parse_lock(lock_path, archive_root, hash_archives)
lock_by_project = {row["project"]: row for row in lock_rows}
target_rows: list[dict[str, Any]] = []
all_source_files: set[str] = set()
for name, target in TARGETS.items():
cmake_path = source_root / target["cmake"]
root = source_root / target["source_root"]
files = source_files(root)
all_source_files.update(relative(path, source_root) for path in files)
extension_counts = Counter(path.suffix or "<none>" for path in files)
target_rows.append(
{
"name": name,
"cmake_file": relative(cmake_path, source_root),
"cmake_file_exists": cmake_path.is_file(),
"source_root": relative(root, source_root),
"source_files": [relative(path, source_root) for path in files],
"source_file_count": len(files),
"source_file_count_by_extension": dict(sorted(extension_counts.items())),
"cmake": cmake_evidence(cmake_path) if cmake_path.is_file() else None,
"direct_internal_targets": sorted(target["internal"]),
"direct_packages": target["packages"],
"conditions": target["conditions"],
"include_evidence": include_evidence(files, root, source_tree),
"transitive_targets": transitive_targets(name),
"transitive_external_packages": transitive_packages(name),
}
)
relevant_projects = sorted(
{
PACKAGE_ALIASES[requirement["name"]]
for target in TARGETS.values()
for requirement in target["packages"]
if requirement["name"] in PACKAGE_ALIASES
}
)
locked = []
for project in relevant_projects:
if project in lock_by_project:
locked.append(lock_by_project[project])
git_head = run("git", "-C", str(source_root), "rev-parse", "HEAD")
git_status = run("git", "-C", str(source_root), "status", "--short") or ""
version_text = read_text(source_root / "CMakeLists.txt")
version = ".".join(re.search(rf'set\(PACKAGE_VERSION_{part}\s+"([^"]+)"\)', version_text).group(1) for part in ("MAJOR", "MINOR", "PATCH"))
emcc_version = run("emcc", "--version")
cmake_version = run("cmake", "--version")
report = {
"schema": "freecad-web-dependency-closure/v1",
"generated_at_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"scope": ["FreeCADBase", "FreeCADApp", "Part", "PartDesign", "Sketcher"],
"source_baseline": {
"source_root": str(source_root),
"git_head": git_head,
"git_status_porcelain": git_status.splitlines(),
"package_version": version,
"cmake_minimum_required": re.search(r"cmake_minimum_required\(VERSION ([^ )]+)", version_text).group(1),
"cmake_version": cmake_version.splitlines()[0] if cmake_version else None,
"emcc_version": emcc_version.splitlines()[0] if emcc_version else None,
},
"method": {
"source_inventory": "all source/header/resource-like files below each target's App/Base source root",
"include_scan": "preprocessor include directives in the inventory; unresolved/generated headers remain visible",
"cmake_evidence": "target CMakeLists variable references and target commands, without evaluating conditions",
"archive_sha256": "computed only with --hash-archives; null means no checksum was requested",
},
"targets": target_rows,
"target_graph": {
name: {
"direct": sorted(target["internal"]),
"transitive": transitive_targets(name),
"transitive_external_packages": transitive_packages(name),
}
for name, target in TARGETS.items()
},
"root_closures": {
name: {
"targets": transitive_targets(name),
"external_packages": transitive_packages(name),
}
for name in ("FreeCADBase", "FreeCADApp", "Part", "PartDesign", "Sketcher")
},
"external_packages": package_closure(),
"source_lock": {
"path": relative(lock_path, workspace_root),
"relevant_rows": locked,
"missing_from_lock": [project for project in relevant_projects if project not in lock_by_project],
},
"inventory_summary": {
"target_count": len(TARGETS),
"unique_inventory_files": len(all_source_files),
"target_source_file_counts": {row["name"]: row["source_file_count"] for row in target_rows},
},
"known_limits": [
"CMake conditionals, imported targets, generated sources, and platform-specific link resolution are not evaluated by this static scan.",
"The source lock has no checksum column; archive_sha256 is null unless --hash-archives is used.",
"A dependency in this report is not Web-compatible until an Emscripten configure/build probe succeeds.",
"The report is tied to the recorded git checkout and must be regenerated after source or build-option changes.",
],
}
return report
def markdown(report: dict[str, Any]) -> str:
baseline = report["source_baseline"]
lines = [
"# FreeCAD Web Dependency Closure",
"",
"This report is generated from the current FreeCAD checkout. It is an evidence inventory for the Web kernel P0 slice; it does not claim that the targets already compile under Emscripten or that Web behavior is feature-complete.",
"",
f"- Schema: `{report['schema']}`",
f"- Generated: `{report['generated_at_utc']}`",
f"- Source HEAD: `{baseline['git_head']}`",
f"- FreeCAD package version: `{baseline['package_version']}`",
f"- CMake: `{baseline['cmake_version'] or 'unavailable'}`; minimum `{baseline['cmake_minimum_required']}`",
f"- Emscripten: `{baseline['emcc_version'] or 'unavailable'}`",
"",
"## Target Inventory",
"",
"| Target | CMake source root | Files | Direct internal targets |",
"|---|---|---:|---|",
]
for target in report["targets"]:
lines.append(
f"| `{target['name']}` | `{target['source_root']}` | {target['source_file_count']} | {', '.join(f'`{name}`' for name in target['direct_internal_targets']) or '-'} |"
)
lines.extend(["", "## Root Dependency Closure", ""])
for name, closure in report["root_closures"].items():
lines.append(f"### `{name}`")
lines.append("")
lines.append("- Targets: " + ", ".join(f"`{item}`" for item in closure["targets"]))
lines.append("- External packages: " + ", ".join(f"`{item}`" for item in closure["external_packages"]))
lines.append("")
lines.extend(["", "`Materials` is included because `Part` links it, even though it is outside the five requested root targets.", "", "## External Package Closure", "", "| Package | Used by | Locked source | Conditions / caveat |", "|---|---|---|---|"])
lock_rows = {row["project"]: row for row in report["source_lock"]["relevant_rows"]}
for package in report["external_packages"]:
lock_name = PACKAGE_ALIASES.get(package["name"])
lock = lock_rows.get(lock_name) if lock_name else None
locked = f"{lock['resolved_version']} (`{lock['archive']}`)" if lock else "not in sources.lock.tsv"
conditions = "; ".join(package["conditions"])
lines.append(f"| `{package['name']}` | {', '.join(f'`{name}`' for name in package['used_by'])} | {locked} | {conditions} |")
lines.extend(["", "## Source Include Evidence", "", "The JSON report contains the full file lists and per-file include family evidence. The aggregate counts below show which external families are visible in the target source trees.", ""])
for target in report["targets"]:
families = target["include_evidence"]["by_family"]
visible = ", ".join(f"`{name}`={count}" for name, count in families.items() if name != "FreeCAD source")
lines.append(f"- `{target['name']}`: {visible or 'no classified external includes'}")
lines.extend(["", "## Conditions and Limits", ""])
for target in report["targets"]:
lines.append(f"- `{target['name']}`: " + "; ".join(target["conditions"]) + ".")
for limit in report["known_limits"]:
lines.append(f"- {limit}")
lines.extend(["", "## Reproduction", "", "```bash", "python3 FreeCAD/tools/generate_web_dependency_closure.py", "python3 FreeCAD/tools/generate_web_dependency_closure.py --hash-archives", "```", "", "The second command is the full checkpoint command. It reads the existing local archives and does not download anything. The first command is a faster inventory refresh that leaves archive checksums null.", ""])
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source-root", type=Path, help="FreeCAD source checkout")
parser.add_argument("--output-dir", type=Path, help="directory for dependency-closure.json/.md")
parser.add_argument("--hash-archives", action="store_true", help="compute SHA-256 for local source archives")
args = parser.parse_args()
script_root = Path(__file__).resolve().parents[1]
source_root = (args.source_root or script_root / "freecad-source").resolve()
output_dir = (args.output_dir or script_root / "docs").resolve()
lock_path = script_root / "FreeCADBaseLib" / "sources.lock.tsv"
if not source_root.is_dir():
parser.error(f"source root does not exist: {source_root}")
if not lock_path.is_file():
parser.error(f"source lock does not exist: {lock_path}")
output_dir.mkdir(parents=True, exist_ok=True)
report = build_report(source_root, lock_path, args.hash_archives)
json_path = output_dir / "dependency-closure.json"
markdown_path = output_dir / "dependency-closure.md"
json_path.write_text(json.dumps(report, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
markdown_path.write_text(markdown(report), encoding="utf-8")
print(f"wrote {json_path}")
print(f"wrote {markdown_path}")
print(f"targets={len(report['targets'])} unique_inventory_files={report['inventory_summary']['unique_inventory_files']}")
return 0
if __name__ == "__main__":
sys.exit(main())