Newer
Older
navi-1 / navi / tools / scad_lint.py
@Eugene Sukhodolskiy Eugene Sukhodolskiy on 1 May 7 KB Improve 3D modeling validation prompts
"""scad_lint - lightweight OpenSCAD source sanity checks."""

from __future__ import annotations

import re
from pathlib import Path

from navi.config import settings
from navi.session_files import session_dir

from .base import Tool, ToolResult, current_session_id


_BUILTINS = {
    "abs", "acos", "asin", "atan", "atan2", "ceil", "chr", "concat", "cos", "cross",
    "cube", "cylinder", "difference", "echo", "else", "exp", "false", "floor", "for",
    "function", "hull", "if", "import", "include", "intersection", "len", "let", "ln",
    "log", "lookup", "max", "min", "mirror", "module", "norm", "PI", "polyhedron",
    "pow", "projection", "rotate", "round", "scale", "search", "sign", "sin", "sphere",
    "sqrt", "str", "surface", "tan", "translate", "true", "undef", "union", "use",
}
_NAMED_ARGS = {
    "center", "d", "d1", "d2", "h", "r", "r1", "r2", "$fa", "$fn", "$fs",
}
_NOISY_PATTERNS = (
    r"\b[Ll]et'?s\b",
    r"\bActually\b",
    r"\bWait\b",
    r"\bhacky\b",
    r"\bCorrected implementation\b",
    r"\bFINAL[, ]+FINAL\b",
    r"\bI promise\b",
    r"\boverthinking\b",
)


class ScadLintTool(Tool):
    name = "scad_lint"
    description = (
        "Lint an OpenSCAD (.scad) source file before compiling or publishing. "
        "This is a lightweight sanity check, not a full CAD validator. It catches common "
        "LLM mistakes: abandoned modules, undefined identifiers, draft/self-correction "
        "comments, suspicious chamfer hacks, and parameter inconsistencies. "
        "Run it after writing or editing .scad and fix errors before model_3d."
    )
    parameters = {
        "type": "object",
        "properties": {
            "path": {
                "type": "string",
                "description": (
                    "Absolute or relative path to the .scad file. Simple filenames are "
                    "resolved inside the current session directory."
                ),
            },
            "strict": {
                "type": "boolean",
                "description": "When true, warnings also make the tool fail. Default false.",
            },
        },
        "required": ["path"],
    }

    async def execute(self, params: dict) -> ToolResult:
        session_id = current_session_id.get()
        path = self._resolve_path(params["path"], session_id)
        session_error = self._validate_current_session_path(path, session_id)
        if session_error:
            return session_error

        if not path.exists():
            return ToolResult(False, f"SCAD file not found: {path}", error="scad_not_found")
        if not path.is_file():
            return ToolResult(False, f"Path is not a file: {path}", error="not_a_file")
        if path.suffix.lower() != ".scad":
            return ToolResult(False, f"Expected a .scad file: {path}", error="not_scad")

        source = path.read_text(encoding="utf-8", errors="replace")
        errors, warnings = lint_scad_source(source)
        strict = bool(params.get("strict", False))
        failed = bool(errors or (strict and warnings))

        output = self._format_output(path, errors, warnings, strict)
        return ToolResult(
            success=not failed,
            output=output,
            error="scad_lint_failed" if failed else None,
            metadata={"errors": errors, "warnings": warnings},
        )

    @staticmethod
    def _resolve_path(raw_path: str, session_id: str | None) -> Path:
        path = Path(raw_path).expanduser()
        if session_id and not path.is_absolute() and len(path.parts) == 1:
            return (session_dir(session_id) / path).resolve()
        return path.resolve()

    @staticmethod
    def _validate_current_session_path(path: Path, session_id: str | None) -> ToolResult | None:
        if not session_id:
            return None

        session_root = Path(settings.session_files_dir).resolve()
        current_dir = session_dir(session_id).resolve()
        if path.is_relative_to(session_root) and not path.is_relative_to(current_dir):
            return ToolResult(
                False,
                (
                    f"path points to a different session directory: {path}\n"
                    f"Current session directory is: {current_dir}\n"
                    "Use the exact current session directory. Do not manually reconstruct "
                    "or retype session IDs."
                ),
                error="wrong_session_dir",
            )
        return None

    @staticmethod
    def _format_output(path: Path, errors: list[str], warnings: list[str], strict: bool) -> str:
        lines = [f"SCAD lint: {path.name}"]
        if not errors and not warnings:
            lines.append("OK: no issues found.")
            return "\n".join(lines)

        if errors:
            lines.append(f"Errors ({len(errors)}):")
            lines.extend(f"- {item}" for item in errors)
        if warnings:
            label = "Warnings as errors" if strict else "Warnings"
            lines.append(f"{label} ({len(warnings)}):")
            lines.extend(f"- {item}" for item in warnings)
        return "\n".join(lines)


def lint_scad_source(source: str) -> tuple[list[str], list[str]]:
    errors: list[str] = []
    warnings: list[str] = []
    code = _strip_comments_and_strings(source)

    _check_noisy_source(source, errors)
    _check_undefined_identifiers(code, errors)
    _check_abandoned_modules(code, warnings)
    _check_suspicious_geometry(source, warnings)

    return errors, warnings


def _strip_comments_and_strings(source: str) -> str:
    source = re.sub(r"/\*.*?\*/", " ", source, flags=re.S)
    source = re.sub(r"//.*", " ", source)
    source = re.sub(r'"(?:\\.|[^"\\])*"', '""', source)
    return source


def _check_noisy_source(source: str, errors: list[str]) -> None:
    for pattern in _NOISY_PATTERNS:
        if re.search(pattern, source):
            errors.append(
                "Source contains draft/self-correction language "
                f"matching `{pattern}`. Rewrite as clean production OpenSCAD."
            )


def _check_undefined_identifiers(code: str, errors: list[str]) -> None:
    declared = set(re.findall(r"\b([A-Za-z_]\w*)\s*=", code))
    modules = set(re.findall(r"\bmodule\s+([A-Za-z_]\w*)\s*\(", code))
    functions = set(re.findall(r"\bfunction\s+([A-Za-z_]\w*)\s*\(", code))
    loop_vars = set(re.findall(r"\bfor\s*\(\s*([A-Za-z_]\w*)\s*=", code))
    module_params = set()
    for params in re.findall(r"\bmodule\s+[A-Za-z_]\w*\s*\(([^)]*)\)", code):
        module_params.update(re.findall(r"\b([A-Za-z_]\w*)\b", params))

    known = declared | modules | functions | loop_vars | module_params | _BUILTINS | _NAMED_ARGS
    tokens = set(re.findall(r"\b[A-Za-z_]\w*\b", code))
    unknown = sorted(t for t in tokens if t not in known)
    if unknown:
        errors.append("Unknown identifier(s): " + ", ".join(unknown[:20]))


def _check_abandoned_modules(code: str, warnings: list[str]) -> None:
    modules = re.findall(r"\bmodule\s+([A-Za-z_]\w*)\s*\(", code)
    for name in modules:
        uses = len(re.findall(rf"\b{re.escape(name)}\s*\(", code))
        if uses <= 1:
            warnings.append(f"Module `{name}` is defined but never called.")


def _check_suspicious_geometry(source: str, warnings: list[str]) -> None:
    if re.search(r"\bcube\s*\(\s*\[\s*[^]]*\+\s*chamfer_size\s*\*\s*2", source):
        warnings.append(
            "Suspicious chamfer construction: subtracting an oversized cube often slices "
            "off a cap instead of creating edge chamfers."
        )
    if re.search(r"\bhole_diameter\s*/\s*2\b", source):
        warnings.append(
            "`hole_diameter / 2` used in geometry. If the OpenSCAD parameter is named "
            "`hole_diameter`, cylinder holes should usually use `d=hole_diameter`."
        )