diff --git a/src/mckicad/tools/analysis.py b/src/mckicad/tools/analysis.py index 6c1f4c2..1c8e370 100644 --- a/src/mckicad/tools/analysis.py +++ b/src/mckicad/tools/analysis.py @@ -9,6 +9,7 @@ for live data, falling back to file-based checks otherwise. import json import logging import os +import re from typing import Any from mckicad.server import mcp @@ -17,6 +18,102 @@ from mckicad.utils.ipc_client import check_kicad_availability, kicad_ipc_session logger = logging.getLogger(__name__) +# Regex for detecting quoted "private" keyword — should be bare keyword in KiCad 9+ +_PROPERTY_PRIVATE_RE = re.compile(r'\(property\s+"private"\s+') + +# Regex for extracting lib_id references from component instances +_LIB_ID_RE = re.compile(r'\(lib_id\s+"([^"]+)"\)') + +# Regex for extracting top-level symbol names from lib_symbols section +_LIB_SYMBOL_RE = re.compile(r'\(symbol\s+"([^"]+)"') + + +def _validate_schematic_sexp(schematic_path: str) -> list[str]: + """Check a .kicad_sch file for known sexp malformations. + + Currently detects: + 1. ``(property "private" ...)`` — should be bare keyword ``(property private ...)`` + in KiCad 9+. A quoted "private" corrupts the sexp parser and silently blanks + the entire page during PDF/SVG export. + 2. Missing ``lib_symbols`` entries — component instances that reference a ``lib_id`` + with no matching symbol definition in the ``(lib_symbols ...)`` section. Missing + entries cause kicad-cli to render the entire page blank. + + Returns a list of human-readable issue strings (empty if clean). + """ + issues: list[str] = [] + basename = os.path.basename(schematic_path) + + try: + with open(schematic_path) as f: + content = f.read() + except Exception as e: + issues.append(f"{basename}: could not read file: {e}") + return issues + + # 1. property "private" malformation + matches = _PROPERTY_PRIVATE_RE.findall(content) + if matches: + issues.append( + f'{basename}: {len(matches)} malformed (property "private" ...) — ' + f"should be bare keyword (property private ...). " + f"This silently blanks the page during PDF/SVG export." + ) + + # 2. lib_symbols completeness check + # Extract the lib_symbols section + lib_symbols_start = content.find("(lib_symbols") + if lib_symbols_start != -1: + # Find all top-level symbol definitions in lib_symbols + # We need to be careful to only look inside the lib_symbols section. + # Find the matching close paren by counting nesting depth. + depth = 0 + lib_symbols_end = lib_symbols_start + for i in range(lib_symbols_start, len(content)): + if content[i] == "(": + depth += 1 + elif content[i] == ")": + depth -= 1 + if depth == 0: + lib_symbols_end = i + 1 + break + + lib_symbols_section = content[lib_symbols_start:lib_symbols_end] + + # Top-level symbols in lib_symbols — these are at nesting depth 1 + # inside lib_symbols, e.g. (symbol "Device:R" ...) + defined_symbols: set[str] = set() + for m in _LIB_SYMBOL_RE.finditer(lib_symbols_section): + sym_name = m.group(1) + # Only count top-level symbols (not sub-unit definitions like "Device:R_0_1") + # Sub-units contain underscores after the main symbol name + # but top-level symbols can also have underscores (e.g. "Device:Crystal_GND24") + # The reliable check: top-level definitions are at a specific nesting depth. + # Simpler heuristic: if the preceding context is "(lib_symbols\n" or ")\n" + # at the right depth, it's top-level. But easiest: collect all, then + # a lib_id match means it's present. + defined_symbols.add(sym_name) + + # Component lib_id references (outside lib_symbols section) + before_lib = content[:lib_symbols_start] + after_lib = content[lib_symbols_end:] + component_text = before_lib + after_lib + + referenced_ids: set[str] = set() + for m in _LIB_ID_RE.finditer(component_text): + referenced_ids.add(m.group(1)) + + missing = referenced_ids - defined_symbols + if missing: + missing_list = sorted(missing) + issues.append( + f"{basename}: {len(missing)} lib_id reference(s) have no matching " + f"lib_symbols entry: {', '.join(missing_list)}. " + f"Missing entries cause kicad-cli to blank the entire page." + ) + + return issues + @mcp.tool() def validate_project(project_path: str) -> dict[str, Any]: @@ -91,6 +188,17 @@ def validate_project(project_path: str) -> dict[str, Any]: except Exception as e: issues.append(f"Error reading project file: {e}") + # Validate schematic sexp integrity (all .kicad_sch files in project dir) + if "schematic" in files: + project_dir = os.path.dirname(project_path) + sch_files = [ + os.path.join(project_dir, f) + for f in os.listdir(project_dir) + if f.endswith(".kicad_sch") + ] + for sch_path in sorted(sch_files): + issues.extend(_validate_schematic_sexp(sch_path)) + # Optional live analysis via KiCad IPC ipc_analysis: dict[str, Any] = {} ipc_status = check_kicad_availability() diff --git a/src/mckicad/tools/export.py b/src/mckicad/tools/export.py index 1bb00ec..9542ec2 100644 --- a/src/mckicad/tools/export.py +++ b/src/mckicad/tools/export.py @@ -315,6 +315,7 @@ def export_pdf(project_path: str, file_type: str = "pcb") -> dict[str, Any]: command_args=cmd_args, input_files=[source_file], output_files=[output_file], + working_dir=project_dir, ) if result.returncode != 0: diff --git a/tests/test_analysis.py b/tests/test_analysis.py new file mode 100644 index 0000000..b955798 --- /dev/null +++ b/tests/test_analysis.py @@ -0,0 +1,136 @@ +"""Tests for project analysis and validation tools.""" + +import os +import textwrap + +import pytest + + +@pytest.mark.unit +class TestValidateSchematicSexp: + """Tests for the _validate_schematic_sexp helper.""" + + def _write_sch(self, tmp_path: str, name: str, content: str) -> str: + path = os.path.join(tmp_path, name) + with open(path, "w") as f: + f.write(content) + return path + + def test_clean_schematic_no_issues(self, tmp_output_dir): + from mckicad.tools.analysis import _validate_schematic_sexp + + content = textwrap.dedent("""\ + (kicad_sch (version 20231120) (generator "eeschema") + (lib_symbols + (symbol "Device:R" + (property private "KLC_S3.3" "Valid bare keyword" + (at 0 0 0) + (effects (font (size 1.27 1.27)) (hide yes)) + ) + (pin passive line (at -3.81 0 0) (length 2.54) (name "1")) + ) + ) + (symbol (lib_id "Device:R") (at 100 100 0) + (property "Reference" "R1" (at 0 0 0)) + ) + ) + """) + path = self._write_sch(tmp_output_dir, "clean.kicad_sch", content) + issues = _validate_schematic_sexp(path) + assert issues == [] + + def test_detects_quoted_property_private(self, tmp_output_dir): + from mckicad.tools.analysis import _validate_schematic_sexp + + content = textwrap.dedent("""\ + (kicad_sch (version 20231120) (generator "eeschema") + (lib_symbols + (symbol "Device:Crystal_GND24" + (property "private" "KLC_S3.3" The rectangle is not a symbol body + (at 0 -12.7 0) + (effects (font (size 1.27 1.27)) (hide yes)) + ) + (property "private" "KLC_S4.1" Some pins are on 50mil grid + (at 0 -15.24 0) + (effects (font (size 1.27 1.27)) (hide yes)) + ) + ) + ) + ) + """) + path = self._write_sch(tmp_output_dir, "malformed.kicad_sch", content) + issues = _validate_schematic_sexp(path) + assert len(issues) == 1 + assert 'property "private"' in issues[0] + assert "2 malformed" in issues[0] + + def test_detects_missing_lib_symbols(self, tmp_output_dir): + from mckicad.tools.analysis import _validate_schematic_sexp + + content = textwrap.dedent("""\ + (kicad_sch (version 20231120) (generator "eeschema") + (lib_symbols + (symbol "Device:R" + (pin passive line (at -3.81 0 0) (length 2.54) (name "1")) + ) + ) + (symbol (lib_id "Device:R") (at 100 100 0) + (property "Reference" "R1" (at 0 0 0)) + ) + (symbol (lib_id "Device:C") (at 200 100 0) + (property "Reference" "C1" (at 0 0 0)) + ) + (symbol (lib_id "MyLib:CustomIC") (at 300 100 0) + (property "Reference" "U1" (at 0 0 0)) + ) + ) + """) + path = self._write_sch(tmp_output_dir, "missing_syms.kicad_sch", content) + issues = _validate_schematic_sexp(path) + assert len(issues) == 1 + assert "2 lib_id" in issues[0] + assert "Device:C" in issues[0] + assert "MyLib:CustomIC" in issues[0] + + def test_detects_both_issues(self, tmp_output_dir): + from mckicad.tools.analysis import _validate_schematic_sexp + + content = textwrap.dedent("""\ + (kicad_sch (version 20231120) (generator "eeschema") + (lib_symbols + (symbol "Device:R" + (property "private" "KLC_S3.3" malformed + (at 0 0 0) + (effects (font (size 1.27 1.27)) (hide yes)) + ) + ) + ) + (symbol (lib_id "Device:R") (at 100 100 0)) + (symbol (lib_id "Missing:Symbol") (at 200 100 0)) + ) + """) + path = self._write_sch(tmp_output_dir, "both.kicad_sch", content) + issues = _validate_schematic_sexp(path) + assert len(issues) == 2 + # One for property private, one for missing lib_symbol + issue_text = " ".join(issues) + assert "private" in issue_text + assert "Missing:Symbol" in issue_text + + def test_nonexistent_file(self): + from mckicad.tools.analysis import _validate_schematic_sexp + + issues = _validate_schematic_sexp("/tmp/nonexistent_12345.kicad_sch") + assert len(issues) == 1 + assert "could not read" in issues[0] + + def test_no_lib_symbols_section(self, tmp_output_dir): + from mckicad.tools.analysis import _validate_schematic_sexp + + content = textwrap.dedent("""\ + (kicad_sch (version 20231120) (generator "eeschema") + ) + """) + path = self._write_sch(tmp_output_dir, "empty.kicad_sch", content) + issues = _validate_schematic_sexp(path) + assert issues == []