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

180 lines
7.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""Search only source files relevant to the active incremental batch.
The command intentionally avoids recursive searches from the repository root.
By default it resolves source paths from ``module-batch-manifest.json`` and
searches those files (or a single cited directory) with ripgrep.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BATCH = Path(os.environ.get("FREECAD_SOURCE_BATCH", str(ROOT / "docs/current/module-batch-manifest.json")))
RUNTIME_DIRS = (
ROOT / "freecad-source/src/WebAssembly/FreeCADCoreProbe",
ROOT / "web/src",
)
def ripgrep() -> str:
# The scheduler's rg wrapper rejects directories. This helper already
# validates workspace scope and bounds output, so use the real executable.
real = os.environ.get("FREECAD_REAL_RG")
if real and Path(real).is_file():
return real
wrapper = (ROOT / "tools/scheduler-bin").resolve()
search_path = os.pathsep.join(
entry for entry in os.get_exec_path() if Path(entry).resolve() != wrapper
)
executable = shutil.which("rg", path=search_path)
if not executable:
raise SystemExit("FAST_SOURCE_SEARCH: ripgrep executable unavailable")
return executable
def batch_paths() -> list[Path]:
try:
payload = json.loads(BATCH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
if not isinstance(payload, dict):
return []
paths: set[Path] = set()
sources = [str(row[3]) for row in payload.get("records", []) if isinstance(row, list) and len(row) >= 4 and row[3]]
sources.extend(source for source in payload.get("sourcePaths", []) if isinstance(source, str))
for source in sources:
source = source.replace("\\", "/")
for base in (ROOT, ROOT / "freecad-source"):
candidate = (base / source).resolve()
try:
candidate.relative_to(ROOT.resolve())
except ValueError:
continue
if candidate.is_file():
paths.add(candidate)
break
if candidate.is_dir():
paths.update(list_source_files(candidate))
break
return sorted(paths)
def list_source_files(directory: Path, limit: int = 5000) -> list[Path]:
try:
result = subprocess.run(
[ripgrep(), "--files", "--sort", "path", str(directory)],
cwd=ROOT,
text=True,
capture_output=True,
check=False,
)
except OSError as error:
raise SystemExit(f"FAST_SOURCE_SEARCH: file enumeration failed: {error}")
if result.returncode not in (0, 1):
raise SystemExit(f"FAST_SOURCE_SEARCH: file enumeration failed: {result.stderr.strip()}")
files = []
for line in result.stdout.splitlines():
path = (ROOT / line).resolve()
if not path.is_relative_to(ROOT.resolve()):
continue
if path.is_file() and path.suffix in {".cpp", ".cxx", ".h", ".hpp", ".py", ".cmake", ".ts", ".tsx", ".mjs"}:
files.append(path)
if len(files) > limit:
raise SystemExit(f"FAST_SOURCE_SEARCH: more than {limit} source files in {directory}; cite a narrower --path")
return files
def resolve_explicit(value: str) -> list[Path]:
candidate = (ROOT / value).resolve() if not Path(value).is_absolute() else Path(value).resolve()
try:
candidate.relative_to(ROOT.resolve())
except ValueError:
raise SystemExit(f"path outside workspace: {value}")
if candidate.is_file():
return [candidate]
if candidate.is_dir():
return list_source_files(candidate)
return []
def main() -> int:
marker = os.environ.get("FREECAD_SOURCE_BUDGET_MARKER", "")
if marker and Path(marker).is_file():
print(
"SCHEDULER_SOURCE_BUDGET_REACHED: search was not executed. Reuse excerpts or use "
"python3 tools/read_implementation_source.py FILE --start LINE --lines 60 --reason 'missing anchor' "
"for an exact current-packet file.",
file=sys.stderr,
)
return 64
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pattern", nargs="?", help="ripgrep pattern or literal symbol")
parser.add_argument("--pattern", dest="pattern_option", help="named form of the search pattern")
parser.add_argument("extra_paths", nargs="*", help="optional explicit workspace-relative paths (compatibility form; --path is preferred)")
parser.add_argument("--path", action="append", default=[], help="explicit workspace-relative file or directory; repeatable")
parser.add_argument("--paths", nargs="+", default=[], help="compatibility alias accepting one or more explicit paths")
parser.add_argument("--glob", action="append", default=[], help="forward a ripgrep glob while retaining workspace scoping")
parser.add_argument("-n", action="store_true", help=argparse.SUPPRESS)
parser.add_argument("--runtime", action="store_true", help="also search the small runtime adapter directories")
parser.add_argument("--fixed-strings", action="store_true", help="treat pattern as a literal string")
parser.add_argument("--context", type=int, default=2, help="lines of context around each match")
parser.add_argument("--max-count", "--max-results", dest="max_count", type=int, default=80, help="global maximum matching lines")
parse = parser.parse_intermixed_args if hasattr(parser, "parse_intermixed_args") else parser.parse_args
args = parse()
if args.pattern_option is not None:
if args.pattern is not None:
args.extra_paths.insert(0, args.pattern)
args.pattern = args.pattern_option
if args.pattern is None:
parser.error("a positional pattern or --pattern is required")
explicit_paths = [*args.path, *args.paths, *args.extra_paths]
# Explicit scope must not be crowded out by unrelated batch matches.
paths: set[Path] = set() if explicit_paths else set(batch_paths())
for value in explicit_paths:
paths.update(resolve_explicit(value))
if args.runtime:
for directory in RUNTIME_DIRS:
paths.update(list_source_files(directory))
paths = {path for path in paths if path.is_file()}
if not paths:
print("FAST_SOURCE_SEARCH: no scoped source files; cite --path or prepare a batch manifest", file=sys.stderr)
return 2
command = [ripgrep(), "-n", "-H", "-S", "--no-heading", "--color", "never", "--max-count", str(max(1, args.max_count))]
if args.fixed_strings:
command.append("--fixed-strings")
if args.context > 0:
command.extend(["-C", str(min(args.context, 12))])
for pattern in args.glob:
command.extend(["--glob", pattern])
command.extend([args.pattern, "--", *map(str, sorted(paths))])
result = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, check=False)
if result.returncode == 1:
# ripgrep uses 1 for a valid search with no matches. Treat that as a
# successful exploratory step so an `rg ... && edit` chain does not
# become a scheduler failure merely because the symbol is absent.
print(f"FAST_SOURCE_SEARCH: files={len(paths)} pattern={args.pattern!r} matches=0", file=sys.stderr)
return 0
output_lines = result.stdout.splitlines()
global_limit = max(1, args.max_count)
for line in output_lines[:global_limit]:
print(line)
if result.stderr:
print(result.stderr, end="", file=sys.stderr)
truncated = max(0, len(output_lines) - global_limit)
print(f"FAST_SOURCE_SEARCH: files={len(paths)} pattern={args.pattern!r} lines={min(len(output_lines), global_limit)} truncated={truncated}", file=sys.stderr)
return result.returncode
if __name__ == "__main__":
raise SystemExit(main())