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

920 lines
51 KiB
Python

#!/usr/bin/env python3
"""Build a bounded source packet for the current module implementation turn."""
from __future__ import annotations
from record_runtime_issue import ACTIVE_STATUSES
import ast
import argparse
import hashlib
import json
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from migration_workflows import delivery_route
from implementation_first import implementation_first_enabled
from scheduler_progress import PROGRESS_PATH, read as read_progress, module_states
from failure_reconciliation import synchronize as reconcile_failures, module_context
ROOT = Path(__file__).resolve().parents[1]
BATCH_PATH = ROOT / "docs/current/module-batch-manifest.json"
OUTPUT_PATH = ROOT / "docs/current/module-implementation-packet.json"
PROBE_ROOT = ROOT / "freecad-source/src/WebAssembly/FreeCADCoreProbe"
MAX_TOTAL_BYTES = max(4096, int(os.environ.get("FREECAD_IMPLEMENTATION_PACKET_BYTES", "12288")))
MAX_RECORD_BYTES = max(256, int(os.environ.get("FREECAD_IMPLEMENTATION_RECORD_BYTES", "1600")))
MAX_NATIVE_TOTAL_BYTES = max(1024, int(os.environ.get("FREECAD_IMPLEMENTATION_NATIVE_BYTES", "4096")))
MAX_EDIT_TOTAL_BYTES = max(4096, int(os.environ.get("FREECAD_IMPLEMENTATION_EDIT_BYTES", "16384")))
MAX_EDIT_REGION_BYTES = max(1024, int(os.environ.get("FREECAD_IMPLEMENTATION_EDIT_REGION_BYTES", "2048")))
DESKTOP_ADAPTER_SPLIT_BYTES = max(32768, int(os.environ.get("FREECAD_DESKTOP_ADAPTER_SPLIT_BYTES", "98304")))
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def source_path(value: object) -> Path | None:
if not isinstance(value, str) or not value:
return None
candidate = Path(value)
if candidate.is_absolute():
return candidate if candidate.is_file() and candidate.is_relative_to(ROOT) else None
for root in (ROOT / "freecad-source", ROOT):
resolved = root / candidate
if resolved.is_file():
return resolved
return None
def compact_excerpt(text: str, limit: int) -> str:
lines = [line.rstrip() for line in text.splitlines()]
while lines and not lines[0].strip():
lines.pop(0)
while lines and not lines[-1].strip():
lines.pop()
value = "\n".join(lines)
if len(value.encode("utf-8")) <= limit:
return value
kept, size = [], 0
for line in lines:
extra = len(line.encode("utf-8")) + (1 if kept else 0)
if size + extra > limit:
break
kept.append(line)
size += extra
return "\n".join(kept)
def python_excerpt(path: Path, name: str, source_line: int | None) -> tuple[int, str]:
text = path.read_text(encoding="utf-8", errors="replace")
lines = text.splitlines()
leaf = name.rsplit(".", 1)[-1]
candidates: list[ast.AST] = []
try:
tree = ast.parse(text)
candidates = [
node for node in ast.walk(tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and node.name == leaf
]
except SyntaxError:
candidates = []
if not candidates:
factories = set()
for node in ast.walk(tree) if 'tree' in locals() else []:
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "addCommand"
and len(node.args) > 1 and isinstance(node.args[0], ast.Constant) and node.args[0].value == name
and isinstance(node.args[1], ast.Call) and isinstance(node.args[1].func, ast.Name)):
factories.add(node.args[1].func.id)
for node in ast.walk(tree) if 'tree' in locals() else []:
if isinstance(node, ast.ClassDef) and node.name in factories:
candidates.extend(member for member in node.body if isinstance(member, ast.FunctionDef) and member.name in {"Activated", "activated"})
if candidates:
target = min(candidates, key=lambda node: abs(int(getattr(node, "lineno", 1)) - int(source_line or getattr(node, "lineno", 1))))
start = int(getattr(target, "lineno", 1))
decorators = getattr(target, "decorator_list", [])
if decorators:
start = min(start, *(int(getattr(item, "lineno", start)) for item in decorators))
end = min(int(getattr(target, "end_lineno", start + 8)), start + 11)
return start, compact_excerpt("\n".join(lines[start - 1:end]), MAX_RECORD_BYTES)
line = max(1, int(source_line or 1))
start = max(1, line - 2)
end = min(len(lines), line + 7)
return start, compact_excerpt("\n".join(lines[start - 1:end]), MAX_RECORD_BYTES)
def generic_excerpt(path: Path, source_line: int | None) -> tuple[int, str]:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
line = max(1, int(source_line or 1))
start = max(1, line - 3)
end = min(len(lines), line + 5)
return start, compact_excerpt("\n".join(lines[start - 1:end]), MAX_RECORD_BYTES)
def cpp_command_excerpt(path: Path, name: str, source_line: int | None) -> tuple[int, str]:
text = path.read_text(encoding="utf-8", errors="replace")
lines = text.splitlines()
command_lines = [
index for index, value in enumerate(lines, 1)
if f'Command("{name}")' in value or f'CommandBase("{name}")' in value
]
if not command_lines:
return generic_excerpt(path, source_line)
command_line = min(command_lines, key=lambda value: abs(value - int(source_line or value)))
class_name = ""
constructor_re = re.compile(r"\b([A-Za-z_]\w*)::\1\s*\(")
for value in lines[max(0, command_line - 8):command_line + 2]:
match = constructor_re.search(value)
if match:
class_name = match.group(1)
break
if not class_name:
return generic_excerpt(path, command_line)
activated_re = re.compile(rf"\b{re.escape(class_name)}::activated\s*\(")
activated_line = next(
(index for index, value in enumerate(lines[command_line:], command_line + 1) if activated_re.search(value)),
None,
)
if activated_line is None:
return generic_excerpt(path, command_line)
start = activated_line
end = min(len(lines), start + 90)
depth = 0
opened = False
for index in range(start - 1, end):
depth += lines[index].count("{") - lines[index].count("}")
opened = opened or "{" in lines[index]
if opened and depth <= 0:
end = index + 1
break
return start, compact_excerpt("\n".join(lines[start - 1:end]), MAX_RECORD_BYTES)
def anchored_excerpt(lines: list[str], anchor: int, limit: int) -> tuple[int, str]:
"""Keep the actual edit anchor and complete source lines within the budget."""
start = end = anchor - 1
size = len(lines[start].encode("utf-8"))
# Dense legacy Workers can put an entire dispatcher on one line. Retain
# that line when the overall packet can fit it; never invent a cut line.
if size > MAX_EDIT_TOTAL_BYTES:
return anchor, ""
for _ in range(30):
added = False
if start > 0 and anchor - start <= 12:
extra = len(lines[start - 1].encode("utf-8")) + 1
if size + extra <= limit:
start -= 1
size += extra
added = True
if end + 1 < len(lines):
extra = len(lines[end + 1].encode("utf-8")) + 1
if size + extra <= limit:
end += 1
size += extra
added = True
if not added:
break
return start + 1, "\n".join(lines[start:end + 1])
def editable_excerpts(path: Path, text: str, names: list[str]) -> list[dict[str, object]]:
lines = text.splitlines()
if not lines:
return []
anchors: list[tuple[str, tuple[str, ...]]] = []
selected = tuple(name for name in names if name)
if selected:
anchors.append(("selected-command", selected))
if path.name == "native-source-policy.ts":
anchors.append(("runtime-policy", ("function nativeSourceDecision",)))
if path.suffix in {".cpp", ".h"}:
anchors.extend((
("desktop-handler", ("executeDesktop", "DesktopCommand")),
("command-registration", ("const QStringList commands", "desktopCommands", "FC_WEB_DESKTOP_COMMAND")),
("family-record-handler", ("probePythonFamilyRecord", "probeFamilyRecord", "executePythonFamily")),
("family-dispatch", ('family == QStringLiteral("desktop-command")', "pythonFamilyBatch")),
("unified-dispatch", ("fc_web_dispatch_json",)),
))
else:
anchors.extend((
("business-case-coverage", ("resolvedIssueIds", "resolvedFailureKeys", "FREECAD_BUSINESS_IDS")),
("request-contract", ("PythonFamilyRecord", "DesktopCommandRoute")),
("worker-dispatch", ("onmessage", "dispatch")),
("native-call", ("fc_web_dispatch_json",)),
("response-validation", ("native_protocol_error", "returned an invalid")),
))
regions: list[dict[str, object]] = []
for role, patterns in anchors:
line = next((index for pattern in patterns for index, value in enumerate(lines, 1)
if pattern in value and not value.lstrip().startswith("//")), None)
if line is None or any(region["line"] <= line < region["line"] + len(region["excerpt"].splitlines()) for region in regions):
continue
start, excerpt = anchored_excerpt(lines, line, MAX_EDIT_REGION_BYTES)
regions.append({
"path": str(path.relative_to(ROOT)),
"role": role,
"line": start,
"anchorLine": line,
"excerpt": excerpt,
})
if regions:
return regions
start, excerpt = anchored_excerpt(lines, len(lines), MAX_EDIT_REGION_BYTES)
return [{
"path": str(path.relative_to(ROOT)),
"role": "file-tail",
"line": start,
"anchorLine": len(lines),
"excerpt": excerpt,
}]
def application_integration_excerpts(module: str = "") -> list[dict[str, object]]:
"""Provide real command call sites, not another isolated facade preview."""
regions = []
slug = re.sub(r"[^a-z0-9]+", "", module.lower())
for relative, anchors in (
(f"web/src/{slug}-desktop-adapter.ts", ("export function",)),
("web/src/workbench-shell.tsx", ("new DesktopCommandDispatcher", "desktopCommands.execute", "return <")),
("web/src/desktop-command-dispatcher.ts", ("async execute(", "this.router.request")),
):
path = ROOT / relative
if not path.is_file():
continue
lines = path.read_text(encoding="utf-8").splitlines()
for anchor in anchors:
number = next((index for index, line in enumerate(lines, 1) if anchor in line), None)
if number is None or any(row["path"] == relative and row["line"] <= number < row["line"] + len(row["excerpt"].splitlines()) for row in regions):
continue
start, excerpt = anchored_excerpt(lines, number, 1000)
if not excerpt or len(excerpt.encode()) > 1400:
continue
regions.append({"path": relative, "role": "application-integration", "line": start,
"anchorLine": number, "excerpt": excerpt})
return regions
def probe_sources(module: str, tier: str = "", family: str = "") -> list[Path]:
normalized_module = re.sub(r"[^a-z0-9]", "", module.lower())
candidates: list[Path] = []
# Most probes use *Probe.cpp, but a few modules keep the active native
# implementation in a deliberately suffixed translation unit (for
# example RobotRuntimeProbeFixed.cpp) and link an auxiliary *Api.cpp.
# Discover only those known probe-shaped files under the bounded probe
# directory; never fall back to a repository-wide scan.
for pattern in ("*Probe.cpp", "*ProbeFixed.cpp", "*InventoryApi.cpp"):
candidates.extend(PROBE_ROOT.glob(pattern))
result = [
path for path in sorted(set(candidates))
if re.sub(r"[^a-z0-9]", "", path.stem.lower()).startswith(normalized_module)
and not (module == "Part" and path.stem.startswith("PartDesign"))
and not (module == "Mesh" and path.stem.startswith("MeshPart"))
]
desktop = PROBE_ROOT / f"{module}DesktopCommands.cpp"
if desktop.is_file():
result.append(desktop)
if tier == "format-workflow" or family == "import-export":
def format_rank(path: Path) -> tuple[int, str]:
try:
owns_dispatch = "fc_web_dispatch_json" in path.read_text(encoding="utf-8", errors="replace")
except OSError:
owns_dispatch = False
return (
0 if owns_dispatch else
1 if "RuntimeProbe" in path.name else
2 if "IoProbe" in path.name else
3,
path.name,
)
result.sort(key=format_rank)
else:
result.sort(key=lambda path: (0 if "RuntimeProbe" in path.name else 1 if "DesktopCommands" in path.name else 2, path.name))
selected_sources = result[:4]
# Inline desktop workflow implementations are part of the selected native
# source, not missing business logic. Follow only quoted, local headers.
headers = []
pending = list(selected_sources)
seen = {source.resolve() for source in selected_sources}
for source in pending:
for name in re.findall(r'^\s*#include\s+"([^"\n]+\.(?:h|hpp))"', source.read_text(encoding="utf-8", errors="replace"), re.MULTILINE):
header = (source.parent / name).resolve()
if header.parent == PROBE_ROOT.resolve() and header.is_file() and header not in seen:
seen.add(header)
headers.append(header)
pending.append(header)
return [*selected_sources, *headers]
def first_match_line(text: str, name: str) -> int | None:
patterns = (f'QStringLiteral("{name}")', f'"{name}"', name.rsplit(".", 1)[-1])
for index, line in enumerate(text.splitlines(), 1):
if any(pattern and pattern in line for pattern in patterns):
return index
return None
def file_entry(path: Path) -> dict[str, object]:
stat = path.stat()
return {
"path": str(path.relative_to(ROOT)),
"absolutePath": str(path.resolve()),
"bytes": stat.st_size,
"mtimeNs": stat.st_mtime_ns,
"sha256": sha256(path),
}
def instruction_context(paths: list[Path]) -> dict:
directories = {ROOT, ROOT.parent}
for path in paths:
directories.update(parent for parent in path.resolve().parents if parent.is_relative_to(ROOT))
return {"workspace": str(ROOT), "checkedAncestorDirectories": len(directories),
"geometryBoundary": "docs/OCCT_GEOMETRY_BOUNDARY.md",
"instructionFiles": sorted(str(parent / "AGENTS.md") for parent in directories if (parent / "AGENTS.md").is_file()),
"businessRecovery": {
"policy": "docs/UNATTENDED_WORKFLOW.md",
"workedExample": "docs/current/part-mesh-business-unblock.md",
"steps": [
"OCCT WASM is already provided: reuse it as the sole geometry backend; keep FreeCAD document semantics and Three.js display separate. Reuse local sources and current evidence.",
"After a stall, isolate the smallest failing business input; do not replay the unchanged failing full batch.",
"Time load, initialization, native stages and cleanup separately; retain the last entered and completed stage.",
"Check the traced native path for pthread pool exhaustion, recursive async work, event-loop waits and GIL ownership; do not assume every timeout has the same cause.",
"Fix the shared native owner, preserve desktop semantics and geometry checks; rebuild only affected module targets.",
"Verify the isolated failure, then affected business groups; reuse unchanged passed evidence and retain unresolved items.",
],
}}
def acceptance_build_for_ids(contract: dict, selected_ids: set[str]) -> tuple[object, bool]:
overrides = contract.get("buildOverrides", {})
choices = [overrides.get(record_id) for record_id in sorted(selected_ids)] if isinstance(overrides, dict) else []
if choices and isinstance(choices[0], dict) and choices[0] and all(choice == choices[0] for choice in choices):
return choices[0], True
return contract.get("build"), False
def workflow_worker(module: str, row: dict) -> str | None:
routes = ROOT / "web/src/module-registry.generated.ts"
if routes.is_file() and row.get("command"):
# The capability ledger's worker can be the module default. The
# generated switch is the actual command-specific browser route.
key = json.dumps(f"{module}:{row['command']}")
match = re.search(rf'case\s+{re.escape(key)}\s*:\s*return\s+("(?:[^"\\]|\\.)*")\s*;', routes.read_text())
if match:
return json.loads(match.group(1))
return row.get("worker")
def workflow_worker_sources(module: str, ids: list[str], capabilities: dict) -> list[Path]:
rows = [capabilities.get(record_id, {}) for record_id in ids]
if not rows or not all(row.get("worker") and delivery_route(row) for row in rows):
return []
workers = read_progress(ROOT / "web/public/modules/manifest.json").get("modules", {}).get(module, {}).get("workers", {})
runtimes = {workers.get(workflow_worker(module, row), {}).get("runtimeUrl") for row in rows}
if len(runtimes) != 1 or not next(iter(runtimes)):
return []
target = Path(next(iter(runtimes))).stem
if not re.fullmatch(r"FreeCADWeb[A-Za-z0-9]+Probe", target):
return []
cmake = PROBE_ROOT / "CMakeLists.txt"
if not cmake.is_file():
return []
match = re.search(rf"add_executable\(\s*{re.escape(target)}\s+([A-Za-z0-9_]+\.cpp)\b", cmake.read_text())
return [PROBE_ROOT / match.group(1)] if match and (PROBE_ROOT / match.group(1)).is_file() else []
def build_hint(module: str, probes: list[Path], tier: str = "", family: str = "") -> dict[str, object]:
cmake_path = PROBE_ROOT / "CMakeLists.txt"
try:
cmake_text = cmake_path.read_text(encoding="utf-8")
except OSError:
return {}
targets: list[str] = []
for source in probes:
targets.extend(re.findall(rf"add_executable\(\s*(FreeCADWeb[A-Za-z0-9]+Probe)\s+{re.escape(source.name)}\b", cmake_text))
build_root = ROOT / "freecad-source/build-web"
unique_targets = list(dict.fromkeys(targets))
if tier == "feature-workflow" and family.startswith("desktop-command"):
unique_targets.sort(
key=lambda target: (
0 if "DesktopCommand" in target else 1 if "RuntimeProbe" in target else 2,
target,
)
)
fallback_trees = [
path.parents[1]
for target in unique_targets
for path in build_root.glob(f"*/bin/{target}.js")
if path.with_suffix(".wasm").exists()
]
# A target may be configured but not have produced its first JS/WASM
# artifact yet. Derive the build tree from the target rule in build.ninja
# so the outer pipeline can perform that one legitimate initial build
# instead of marking the packet unavailable and sending the agent through
# repeated source-only turns.
configured_trees: list[Path] = []
for ninja in build_root.glob("*/build.ninja"):
try:
ninja_text = ninja.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
if any(target in ninja_text for target in unique_targets):
configured_trees.append(ninja.parent)
for target in unique_targets:
artifacts = [path for path in build_root.glob(f"*/bin/{target}.js") if path.with_suffix(".wasm").exists()]
def rank(path: Path) -> tuple[int, str]:
name = path.parents[1].name.lower()
if module == "TechDraw" and "techdraw-native" in name:
return (0, name)
if module == "Fem" and "fem-wasm" in name:
return (0, name)
return (1 if name == "configure" else 2, name)
if artifacts:
artifact = sorted(artifacts, key=rank)[0]
tree = artifact.parents[1]
elif fallback_trees:
tree = sorted(set(fallback_trees), key=lambda path: (0 if path.name == "configure" else 1, path.name))[0]
artifact = tree / "bin" / f"{target}.js"
elif configured_trees:
tree = sorted(set(configured_trees), key=lambda path: (0 if path.name == "configure" else 1, path.name))[0]
artifact = tree / "bin" / f"{target}.js"
else:
continue
return {
"target": target,
"tree": str(tree.relative_to(ROOT)),
"bin": str((tree / "bin").relative_to(ROOT)),
"runtime": str(artifact.relative_to(ROOT)),
"wasm": str(artifact.with_suffix(".wasm").relative_to(ROOT)),
}
return {}
def select_module_gate(module: str, slug: str, tier: str, family: str) -> Path | None:
candidates = list((ROOT / "tools").glob(f"run_p6_{slug}*module_batch_gate.mjs"))
family_tokens = {family.replace("-", "_"), family.split("-", 1)[0]}
compatible: list[tuple[int, str, Path]] = []
for path in candidates:
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
manifest_driven = "module-batch-manifest.json" in text and "batch.selected" in text and "batch.records" in text
module_match = f'"{module}"' in text or f"'{module}'" in text
tier_match = tier in text or "typeof batch.selected?.tier" in text or "typeof batch.selected.tier" in text
family_match = family in text or "typeof batch.selected?.family" in text or "typeof batch.selected.family" in text
if not all((manifest_driven, module_match, tier_match, family_match)):
continue
exact = path.name == f"run_p6_{slug}_module_batch_gate.mjs"
family_named = any(token and token in path.stem for token in family_tokens)
generic_family = "typeof batch.selected?.family" in text or "typeof batch.selected.family" in text
score = 0 if exact else 1 if family_named else 2 if generic_family else 3
compatible.append((score, path.name, path))
return min(compatible, default=(99, "", None))[2]
def main() -> int:
try:
batch = json.loads(BATCH_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
print(json.dumps({"status": "error", "error": str(error)}))
return 2
selected = batch.get("selected") if isinstance(batch.get("selected"), dict) else {}
if batch.get("status") != "ready" or not selected or not isinstance(batch.get("records"), list):
print(json.dumps({"status": "not_ready"}))
return 2
module = str(selected.get("module") or "")
tier = str(selected.get("tier") or "")
family = str(selected.get("family") or "")
if tier == "failure-reconciliation":
ledger = reconcile_failures()
keys = batch.get("failureKeys", [])
rows = [ledger["records"][key] for key in keys if key in ledger["records"] and ledger["records"][key]["status"] == "pending"]
payload = {"schema": "freecad-web-module-implementation-packet/v1", "batchId": batch.get("batchId"), "module": module,
"tier": tier, "family": family, "recordCount": len(rows), "outerPipelineAvailable": True, "build": {},
"supportFiles": [], "editableExcerpts": [], "records": [],
"repositoryContext": instruction_context([]),
"failureReconciliation": {"records": [{"key": row["key"], "category": row["category"], "modules": row["modules"],
"failureFingerprint": row["failureFingerprint"], "status": row["status"],
"command": str(row["sourceSnapshot"].get("command", ""))[:800],
"error": str(row["sourceSnapshot"].get("lastError") or row["sourceSnapshot"].get("firstError") or "")[:1200]} for row in rows]}}
OUTPUT_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({"status": "ready", "batchId": batch.get("batchId"), "module": module, "failureRecords": len(rows)}))
return 0
recovery_state = read_progress(PROGRESS_PATH)
module_recovery = module_states(recovery_state).get(module, {})
reconcile_failures()
failure_context = module_context(module, {str(row[0]) for row in batch["records"] if row})
recovering = bool(module_recovery.get("recoveryLevel") or module_recovery.get("consecutiveNoProgress"))
probes = probe_sources(module, tier, family)
records: list[dict[str, object]] = []
capability_rows = json.loads((ROOT / "docs/current/feature-capability-registry.json").read_text(encoding="utf-8")).get("records", [])
capability_by_id = {row["id"]: row for row in capability_rows}
worker_sources = workflow_worker_sources(module, [str(row[0]) for row in batch["records"] if row], capability_by_id)
probes = list(dict.fromkeys([*worker_sources, *probes]))
probe_texts = [(path, path.read_text(encoding="utf-8", errors="replace")) for path in probes]
semantic_decisions = {}
decision_file = ROOT / "docs/current/native-semantic-decisions.json"
for group in read_progress(decision_file).get("groups", []):
sources = group.get("sourceDigests", {})
if not sources:
continue
current = all((ROOT / name).resolve().is_relative_to(ROOT) and (ROOT / name).is_file()
and sha256(ROOT / name) == expected for name, expected in sources.items())
for record_id, finding in group.get("records", {}).items():
semantic_decisions[record_id] = {**finding, "sourceSnapshotCurrent": current,
"reuseInstruction": "Use this retained source finding; implement the next action, not another general review."
if current else "Source changed: revisit only the affected native branches before using this finding."}
issue_rows = json.loads((ROOT / "docs/current/runtime-issue-ledger.json").read_text(encoding="utf-8")).get("issues", [])
used = 0
native_used = 0
truncated = 0
for row in batch["records"]:
if not isinstance(row, list) or not row:
continue
api_id = str(row[0])
name = str(row[1]) if len(row) > 1 else api_id
raw_source = row[3] if len(row) > 3 else None
raw_line = row[4] if len(row) > 4 and isinstance(row[4], int) else None
resolved = source_path(raw_source)
excerpt = ""
excerpt_line = raw_line
if resolved:
try:
if resolved.suffix in {".py", ".pyi"}:
excerpt_line, excerpt = python_excerpt(resolved, name, raw_line)
elif resolved.suffix in {".cpp", ".cxx", ".cc", ".h", ".hpp"}:
excerpt_line, excerpt = cpp_command_excerpt(resolved, name, raw_line)
else:
excerpt_line, excerpt = generic_excerpt(resolved, raw_line)
except OSError:
excerpt = ""
encoded = len(excerpt.encode("utf-8"))
if used + encoded > MAX_TOTAL_BYTES:
excerpt = ""
encoded = 0
truncated += 1
used += encoded
native_matches = []
for path, text in probe_texts:
line = first_match_line(text, name)
if line is None:
continue
native_excerpt = ""
if native_used < MAX_NATIVE_TOTAL_BYTES:
_, native_excerpt = generic_excerpt(path, line)
encoded_native = len(native_excerpt.encode("utf-8"))
if native_used + encoded_native <= MAX_NATIVE_TOTAL_BYTES:
native_used += encoded_native
else:
native_excerpt = ""
native_matches.append({"path": str(path.relative_to(ROOT)), "line": line, "excerpt": native_excerpt})
records.append({
"apiId": api_id,
"name": name,
"source": str(resolved.relative_to(ROOT)) if resolved and resolved.is_relative_to(ROOT) else raw_source,
"sourceLine": raw_line,
"excerptLine": excerpt_line,
"excerpt": excerpt,
"nativeMatches": native_matches,
"semanticDecision": semantic_decisions.get(api_id),
"runtimeRouteBlock": capability_by_id.get(api_id, {}).get("runtimeRouteBlock"),
"implementationGaps": capability_by_id.get(api_id, {}).get("implementationGaps", []),
"previousAttempt": recovery_state.get("records", {}).get(api_id, {}).get("lastReason"),
"observedIssues": [{key: issue.get(key) for key in ("id", "cmd", "args", "firstError", "failureKeys")}
for issue in issue_rows if issue.get("capabilityId") == api_id and issue.get("status") in ACTIVE_STATUSES],
})
support: list[Path] = []
support.extend(probes)
if module == "BIM":
support.extend(ROOT / "web/src" / name for name in (
"bim-image-display.ts", "bim-image-plane-task.tsx", "bim-frame-view.tsx", "bim-relationship-task.tsx",
"bim-windows-contract.ts", "bim-windows-task.tsx"))
integration_regions = application_integration_excerpts(module) if tier in {"feature-workflow", "business-acceptance"} and any(":desktop:" in record["apiId"] for record in records) else []
support.extend(ROOT / region["path"] for region in integration_regions)
if module == "Test" and any(record.get("name") in {"Std_MDITest1", "Std_MDITest2", "Std_MDITest3", "Std_TestConsoleOutput"} for record in records):
support.extend(ROOT / "web/src" / name for name in (
"test-desktop-routes.json", "test-facade.ts", "test.worker.ts", "test-mdi-host.tsx", "test-desktop-output.tsx"))
if any(record.get("runtimeRouteBlock") for record in records):
support.extend([ROOT / "web/src/native-source-policy.ts", ROOT / "web/src/module-router.ts"])
slug = re.sub(r"[^a-z0-9]+", "", module.lower())
direct_facade = ROOT / f"web/src/{slug}-facade.ts"
if (family == "runtime-issue" or tier == "business-acceptance") and direct_facade.is_file():
support.append(direct_facade)
implementation_first = implementation_first_enabled()
workflow = str(batch.get("businessWorkflow") or "")
format_workflow = tier == "format-workflow" or family == "import-export" or (
workflow in {"document-import-export", "document-persistence"} or workflow.startswith("format-"))
selected_gate = None if implementation_first else select_module_gate(module, slug, tier, family)
worker_candidates = (
(ROOT / f"web/src/{slug}.worker.ts",)
if format_workflow else
(ROOT / f"web/src/{slug}-python.worker.ts", ROOT / f"web/src/{slug}.worker.ts")
)
declared_workers = [ROOT / "web/src" / workflow_worker(module, capability_by_id[str(row[0])]) for row in batch["records"]
if row and capability_by_id.get(str(row[0]), {}).get("worker")]
worker_candidates = tuple(dict.fromkeys([*declared_workers, *worker_candidates]))
facade_candidates = [] if format_workflow or tier == "product-integration" else sorted((ROOT / "web/src").glob(f"{slug}*python*family*facade.ts"))
default_facade = ROOT / f"web/src/{slug}-python-family-facade.ts"
if not format_workflow and tier != "product-integration" and default_facade.is_file() and default_facade not in facade_candidates:
facade_candidates.insert(0, default_facade)
format_facade_candidates: list[Path] = []
if format_workflow:
for pattern in (f"{slug}*io*facade.ts", f"{slug}*import*export*facade.ts", f"{slug}*save*facade.ts", f"{slug}*persistence*facade.ts"):
format_facade_candidates.extend((ROOT / "web/src").glob(pattern))
direct_format_facade = ROOT / f"web/src/{slug}-facade.ts"
if direct_format_facade.is_file():
format_facade_candidates.append(direct_format_facade)
format_dispatcher = ROOT / "web/src/format-dispatcher.ts"
if format_dispatcher.is_file():
format_facade_candidates.append(format_dispatcher)
for path in (
*facade_candidates,
*format_facade_candidates,
*worker_candidates,
selected_gate,
None if implementation_first else ROOT / "tools/wasm-probe-runtime.mjs",
):
if path is not None and path.is_file():
support.append(path)
case_paths = []
acceptance_build = {}
explicit_build = False
acceptance_setup = None
if tier == "business-acceptance" or family == "runtime-issue":
contract_path = ROOT / f"docs/business-acceptance/{module}.json"
contract = {}
if contract_path.is_file():
try:
contract = json.loads(contract_path.read_text(encoding="utf-8"))
command = contract.get("command", [])
if len(command) > 1:
script = (ROOT / command[1]).resolve()
if script.is_relative_to(ROOT) and script.is_file():
case_paths.append(script)
except (OSError, ValueError, TypeError):
pass
selected_ids = {str(row[0]) for row in batch["records"] if row}
covered_ids = set(contract.get("coveredIds", []))
acceptance_setup = {"contract": str(contract_path.relative_to(ROOT)), "caseScripts": [str(path.relative_to(ROOT)) for path in case_paths],
"selectedIds": sorted(selected_ids), "coveredIds": sorted(covered_ids),
"missingIds": sorted(selected_ids - covered_ids),
"ready": bool(case_paths and selected_ids <= covered_ids)}
declared_build, explicit_build = acceptance_build_for_ids(contract, selected_ids)
if isinstance(declared_build, dict) and declared_build.get("tree") and declared_build.get("target"):
tree = (ROOT / str(declared_build["tree"])).resolve()
target = str(declared_build["target"])
if tree.is_relative_to(ROOT) and tree.is_dir() and re.fullmatch(r"FreeCADWeb[A-Za-z0-9]+Probe", target):
runtime = tree / "bin" / f"{target}.js"
acceptance_build = {"target": target, "tree": str(tree.relative_to(ROOT)), "bin": str((tree / "bin").relative_to(ROOT)),
"runtime": str(runtime.relative_to(ROOT)), "wasm": str(runtime.with_suffix(".wasm").relative_to(ROOT))}
cmake = PROBE_ROOT / "CMakeLists.txt"
if cmake.is_file():
match = re.search(rf"add_executable\(\s*{re.escape(target)}\s+([A-Za-z0-9_]+\.cpp)\b", cmake.read_text(encoding="utf-8"))
if match and (PROBE_ROOT / match.group(1)).is_file():
support.append(PROBE_ROOT / match.group(1))
for path in (ROOT / "tools/run_business_acceptance.py", ROOT / "tools/wasm-probe-runtime.mjs", ROOT / "docs/WEB_BUSINESS_MIGRATION_RULES.md", ROOT / "docs/current/provided-source-index.md", contract_path, *case_paths):
if path.is_file():
support.append(path)
product_acceptance = (tier == "business-acceptance" or family == "runtime-issue") and records and all(str(row.get("apiId", "")).startswith("product:") for row in records)
browser_ui_batch = bool(records) and all(
(capability_by_id.get(row["apiId"], {}).get("route") or {}).get("transport") == "browser-ui" for row in records)
view_catalog = ROOT / "web/src/document-view-routes.json"
document_view_batch = module in {"Web", "Std"} and view_catalog.is_file() and bool(records) and all(
row.get("name") in (json.loads(view_catalog.read_text(encoding="utf-8")).get("commands", [])
+ json.loads(view_catalog.read_text(encoding="utf-8")).get("standardCommands", [])
+ json.loads(view_catalog.read_text(encoding="utf-8")).get("websiteCommands", [])) for row in records)
if module in {"Web", "Std"} and records:
document_view_batch = document_view_batch or all(
(capability_by_id.get(row["apiId"], {}).get("route") or {}).get("module") == "Part"
and (capability_by_id.get(row["apiId"], {}).get("route") or {}).get("command")
in {"part.viewCommand", "part.documentViewCommand"} for row in records)
if module in {"Web", "Std"} and view_catalog.is_file():
# Pending document-view commands need the existing owning transport
# too, before they can be added to the implemented command catalog.
support.extend([view_catalog, PROBE_ROOT / "PartBoxProbe.cpp", PROBE_ROOT / "DocumentProbe.cpp",
PROBE_ROOT / "NativeDocumentView.h", PROBE_ROOT / "WebViewCommands.h"])
support.extend(ROOT / "web/src" / name for name in ("part-view-facade.ts", "part.worker.ts", "part-save.worker.ts",
"web-desktop-adapter.ts", "web-view-task.tsx", "project-shell.tsx",
"web-owner-standard-adapter.ts", "web-standard-command-contract.ts", "web-standard-task.tsx",
"web-website-adapter.ts", "web-website-task.tsx", "web-group-adapter.ts", "web-group-contract.ts", "web-group-task.tsx", "web-commanddoc-contract.ts", "web-commanddoc-task.tsx",
"web-link-contract.ts", "web-source-test-adapter.ts", "web-structure-adapter.ts", "web-structure-contract.ts", "web-structure-task.tsx", "web-link-mutation-task.tsx",
"web-open-adapter.ts", "web-open-contract.ts", "web-open-task.tsx"))
if any(row.get("name") == "Std_Open" for row in records):
support.append(PROBE_ROOT / "WebOpenCommand.h")
if any(row.get("name") in {"Std_LinkMakeRelative", "Std_LinkReplace", "Std_LinkUnlink"} for row in records):
support.append(PROBE_ROOT / "WebLinkMutationCommands.h")
if any(row.get("name") in {"Std_Part", "Std_VarSet"} for row in records):
support.append(PROBE_ROOT / "WebStructureCommands.h")
support.append(ROOT / "tools/generate_feature_capability_registry.py")
if any(row.get("name") == "Std_Group" for row in records):
support.append(PROBE_ROOT / "WebGroupCommand.h")
if browser_ui_batch:
# Desktop overlay commands own UI layout, not FreeCAD document geometry.
support = [ROOT / "web/src" / name for name in (
"dock-overlay-commands.json", "dock-overlay-adapter.ts", "dock-overlay-host.tsx", "dock-overlay.css",
"scene-display-commands.json", "scene-command-adapter.ts", "scene-command-host.ts", "scene-command-host.css", "scene-overlap-picker.ts", "scene-camera-commands.ts",
"import-render-bridge.ts", "openscad-desktop-view.tsx",
"desktop-command-dispatcher.ts", "workbench-shell.tsx", "facade-envelope.ts")]
support.append(ROOT / "web/workbench.html")
if tier == "product-integration" or product_acceptance:
# Product acceptance needs real application entry points, not the
# unrelated Web workbench's native web-server fixture.
support = [path for path in support if path in case_paths or path.name in {
"run_business_acceptance.py", "WEB_BUSINESS_MIGRATION_RULES.md", "provided-source-index.md"}
or path.parent == ROOT / "docs/business-acceptance"]
for record in records:
resolved = source_path(record.get("source"))
if resolved is not None:
support.append(resolved)
product_entry = ROOT / "web/index.html"
if product_entry.is_file():
support.append(product_entry)
part_source_table = ROOT / "web/src/part-source-commands.json"
part_source_batch = module == "Part" and part_source_table.is_file() and any(
row.get("name") in json.loads(part_source_table.read_text(encoding="utf-8")) for row in records)
if part_source_batch:
support.extend([part_source_table, PROBE_ROOT / "PartBoxProbe.cpp", PROBE_ROOT / "PartSplitSource.h"])
support.extend((ROOT / "freecad-source/src/Mod/Part/BOPTools").glob("*.py"))
if any(row.get("name") in {"Part_Slice", "Part_SliceApart"} for row in records):
support.extend(ROOT / "freecad-source/src/Mod/Part/CompoundTools" / name
for name in ("Explode.py", "CompoundFilter.py"))
support.append(ROOT / "web/src/part-slice-task.tsx")
support_text = {str(path.relative_to(ROOT)): path.read_text(encoding="utf-8", errors="replace") for path in support}
facade_paths = [str(path.relative_to(ROOT)) for path in (*facade_candidates, *format_facade_candidates) if path.is_file()]
worker_paths = [str(path.relative_to(ROOT)) for path in worker_candidates if path.is_file()]
gate_path = str(selected_gate.relative_to(ROOT)) if selected_gate else ""
worker_build = build_hint(module, worker_sources, tier, family) if worker_sources else {}
build = (acceptance_build if explicit_build else worker_build or acceptance_build) or build_hint(module, probes, str(selected.get("tier") or ""), str(selected.get("family") or ""))
if part_source_batch:
build = build_hint(module, [PROBE_ROOT / "PartBoxProbe.cpp"], tier, family)
if document_view_batch:
build = build_hint("Part", [PROBE_ROOT / "PartBoxProbe.cpp"], tier, family)
if tier == "product-integration" or product_acceptance or browser_ui_batch:
build = {
"kind": "frontend",
"target": "FreeCADWebReactProduct",
"tree": "web",
"bin": "web/dist",
"runtime": "web/dist/index.html",
"wasm": "web/dist/index.html",
}
selected_names = [str(row.get("name") or "") for row in records]
feature_desktop = selected.get("tier") == "feature-workflow" and str(selected.get("family") or "").startswith("desktop-command")
desktop_adapter = next((path for path in probes if path.name == f"{module}DesktopCommands.cpp"), None)
probe_has_unified_dispatch = any("fc_web_dispatch_json" in text for _, text in probe_texts)
unified_dispatch_paths = {
path for path, text in probe_texts if "fc_web_dispatch_json" in text
}
oversized_native_sources = [
str(path.relative_to(ROOT))
for path, text in probe_texts
if len(text.encode("utf-8")) >= DESKTOP_ADAPTER_SPLIT_BYTES
and "DesktopCommands" not in path.name
]
facade_uses_unified_dispatch = any("fc_web_dispatch_json" in support_text.get(facade_path, "") for facade_path in facade_paths)
worker_uses_family_dispatch = any(
re.search(r"PythonFamily(?:Batch)?Request", support_text.get(worker_path, "")) is not None
and "dispatch" in support_text.get(worker_path, "")
for worker_path in worker_paths
)
omitted_stable_support: list[str] = []
edit_regions: list[dict[str, object]] = list(integration_regions)
edit_used = sum(len(region["excerpt"].encode()) for region in edit_regions)
edit_limit = min(MAX_EDIT_TOTAL_BYTES, 10240) if format_workflow else MAX_EDIT_TOTAL_BYTES
format_dispatch_owners = {
path for path, text in probe_texts if "fc_web_dispatch_json" in text
}
edit_support = list(dict.fromkeys(support))
if family == "runtime-issue" or tier == "business-acceptance":
edit_support.sort(key=lambda path: (0 if path in case_paths or path == contract_path else
1 if path == direct_facade or path.name == "native-source-policy.ts" else
2 if path.name.endswith(".worker.ts") else 3 if path.suffix == ".ts" else 4))
for path in edit_support:
case_file = (tier == "business-acceptance" or family == "runtime-issue") and (path in case_paths or path == contract_path)
if path.name == "wasm-probe-runtime.mjs" or (not case_file and path.suffix not in {".cpp", ".h", ".ts", ".tsx", ".html"}):
continue
relative = str(path.relative_to(ROOT))
if (tier == "product-integration" or product_acceptance) and path.suffix in {".cpp", ".h"}:
omitted_stable_support.append(relative)
continue
if any(region["path"] == relative for region in integration_regions):
continue
if family == "runtime-issue":
if path == direct_facade and not any(name in support_text.get(relative, "") for name in selected_names):
omitted_stable_support.append(relative)
continue
if path.name == "module-router.ts" and not any("module-router.ts" in str(issue.get("firstError", "")) for record in records for issue in record.get("observedIssues", [])):
omitted_stable_support.append(relative)
continue
# For a desktop feature batch, keep editable native context focused on
# the translation unit that owns the unified dispatcher. Other probe
# units remain in supportFiles with fingerprints, but their generic
# file-tail excerpts make the agent reread unrelated code and inflate
# post-read decision time. A split DesktopCommands adapter remains
# authoritative and is handled by the existing branch below.
if (
feature_desktop
and probe_has_unified_dispatch
and path in probes
and path.suffix in {".cpp", ".h"}
and desktop_adapter is None
and path not in unified_dispatch_paths
):
omitted_stable_support.append(relative)
continue
if format_workflow and path in probes and path.suffix in {".cpp", ".h"} and path not in format_dispatch_owners:
omitted_stable_support.append(relative)
continue
if feature_desktop and desktop_adapter is not None and path.suffix in {".cpp", ".h"} and path != desktop_adapter:
omitted_stable_support.append(relative)
continue
if feature_desktop and not recovering and ((relative in facade_paths and facade_uses_unified_dispatch) or (relative in worker_paths and worker_uses_family_dispatch)):
omitted_stable_support.append(relative)
continue
path_regions = editable_excerpts(path, support_text.get(relative, ""), selected_names)
has_native_edit_anchor = any(region.get("role") != "unified-dispatch" for region in path_regions)
for region in path_regions:
if feature_desktop and probe_has_unified_dispatch and path.suffix in {".cpp", ".h"} and region.get("role") == "unified-dispatch" and has_native_edit_anchor:
continue
encoded = len(str(region["excerpt"]).encode("utf-8"))
if edit_used + encoded > edit_limit:
break
edit_used += encoded
edit_regions.append(region)
probe_entries: list[dict[str, object]] = []
for path, text in probe_texts:
for line_number, line in enumerate(text.splitlines(), 1):
if "fc_web_dispatch_json" in line or re.search(r"\b(?:bool|QJsonObject)\s+execute[A-Za-z0-9_]+", line):
probe_entries.append({"path": str(path.relative_to(ROOT)), "line": line_number, "symbol": line.strip()[:180]})
payload = {
"schema": "freecad-web-module-implementation-packet/v1",
"generatedAt": datetime.now(timezone.utc).isoformat(),
"batchId": batch.get("batchId"),
"module": module,
"tier": selected.get("tier"),
"family": selected.get("family"),
"recordCount": len(records),
"excerptBytes": used,
"nativeExcerptBytes": native_used,
"editableExcerptBytes": edit_used,
"truncatedRecords": truncated,
"supportFiles": [file_entry(path) for path in dict.fromkeys(support)],
"repositoryContext": instruction_context(support),
"supportChecks": {
"probeHasUnifiedDispatch": probe_has_unified_dispatch,
"facadeUsesUnifiedDispatch": facade_uses_unified_dispatch,
"workerUsesFamilyDispatch": worker_uses_family_dispatch,
"gateReadsCurrentManifest": "module-batch-manifest.json" in support_text.get(gate_path, "") and "batch.selected" in support_text.get(gate_path, ""),
"sharedNodeLoaderAvailable": "loadWasmProbe" in support_text.get("tools/wasm-probe-runtime.mjs", ""),
"desktopAdapterRecommended": bool(feature_desktop and oversized_native_sources),
"desktopAdapterActive": desktop_adapter is not None,
"oversizedNativeSources": oversized_native_sources,
"desktopAdapterSplitBytes": DESKTOP_ADAPTER_SPLIT_BYTES,
"desktopAdapterAction": (
f"do not grow oversized runtime; put new commands in {module}DesktopCommands.cpp/.h and link them into the same module target"
if feature_desktop and oversized_native_sources else None
),
},
"build": build,
"gate": gate_path or None,
"implementationMode": "feature-first-deferred-validation" if implementation_first else "evidence-first",
"validationDeferred": implementation_first,
"outerPipelineAvailable": tier == "business-acceptance" or bool(build and (implementation_first or selected_gate)),
"acceptanceContract": f"docs/business-acceptance/{module}.json" if tier == "business-acceptance" or family == "runtime-issue" else None,
"acceptanceSetup": acceptance_setup,
"businessWorkflow": batch.get("businessWorkflow"),
"nativeEntryPoints": probe_entries[:24],
"editableExcerpts": edit_regions,
"omittedStableSupport": sorted(set(omitted_stable_support)),
"records": records,
"moduleRecovery": module_recovery,
"failureReconciliation": failure_context,
"integrationRepair": {"applicationCallSites": sorted({row["path"] for row in integration_regions}),
"instruction": "Complete the current business through these application call sites. Keep module-specific validation and selection in the existing <module>-desktop-adapter.ts; shared dispatcher edits invalidate every module. Change the common dispatcher only for a demonstrated shared-contract defect or one-time adapter wiring. Do not stop at new helpers or an unmounted component."} if integration_regions else None,
}
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
temporary = OUTPUT_PATH.with_suffix(".tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
temporary.replace(OUTPUT_PATH)
print(json.dumps({"status": "ready", "batchId": batch.get("batchId"), "records": len(records), "excerptBytes": used, "editableExcerptBytes": edit_used, "truncatedRecords": truncated}, ensure_ascii=False))
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--batch", type=Path, default=BATCH_PATH)
parser.add_argument("--output", type=Path, default=OUTPUT_PATH)
options = parser.parse_args()
BATCH_PATH = (ROOT / options.batch).resolve()
OUTPUT_PATH = (ROOT / options.output).resolve()
if not BATCH_PATH.is_relative_to(ROOT) or not OUTPUT_PATH.is_relative_to(ROOT):
parser.error("batch and output must remain inside the workspace")
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
raise SystemExit(main())