File size: 8,994 Bytes
bd0a5c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
"""Static validation of generated ROS 2 packages, grounded in the indexed interface definitions.

No ROS installation is needed. Checks:
- file paths are relative and stay inside the package tree
- every package has a parseable package.xml with a name and build type, plus
  setup.py (ament_python) or CMakeLists.txt with ament_package() (ament_cmake)
- Python files and Python launch files parse; launch files define generate_launch_description()
- XML launch files parse and have a <launch> root
- every message/service type used (Python imports, C++ includes and qualified names)
  exists in the ROS 2 distro, according to data/ros2_interfaces.json
- interface packages that are used are declared as dependencies in package.xml (warning)
"""

from __future__ import annotations

import ast
import json
import re
import xml.etree.ElementTree as ET
from dataclasses import asdict, dataclass, field
from functools import lru_cache
from pathlib import Path, PurePosixPath


@dataclass
class CodeValidation:
    valid: bool
    errors: list[str] = field(default_factory=list)
    warnings: list[str] = field(default_factory=list)
    stats: dict = field(default_factory=dict)

    def to_dict(self) -> dict:
        return asdict(self)

    def feedback(self) -> str:
        lines = ["The generated package failed validation:"] + [f"- ERROR: {e}" for e in self.errors]
        lines += [f"- warning: {w}" for w in self.warnings]
        return "\n".join(lines)


@lru_cache
def load_interfaces(path: str) -> dict[str, dict[str, str]]:
    p = Path(path)
    return json.loads(p.read_text()) if p.is_file() else {}


def _camel(snake: str) -> str:
    return "".join(part.capitalize() for part in snake.split("_"))


_CPP_INCLUDE = re.compile(r'#include\s*[<"](\w+)/(msg|srv|action)/(\w+)\.hpp[>"]')
_CPP_QUALIFIED = re.compile(r"\b(\w+)::(msg|srv|action)::(\w+)\b")
_DEPENDS = ("depend", "exec_depend", "build_depend", "build_export_depend")


def _python_interfaces(tree: ast.AST) -> set[str]:
    used = set()
    for node in ast.walk(tree):
        if isinstance(node, ast.ImportFrom) and node.module:
            parts = node.module.split(".")
            if len(parts) == 2 and parts[1] in ("msg", "srv", "action"):
                for alias in node.names:
                    if alias.name != "*":
                        used.add(f"{parts[0]}/{parts[1]}/{alias.name}")
        elif isinstance(node, ast.Attribute) and isinstance(node.value, ast.Attribute):
            # std_msgs.msg.String style
            inner = node.value
            if isinstance(inner.value, ast.Name) and inner.attr in ("msg", "srv", "action") and node.attr[:1].isupper():
                used.add(f"{inner.value.id}/{inner.attr}/{node.attr}")
    return used


def _cpp_interfaces(text: str) -> set[str]:
    used = {f"{p}/{k}/{_camel(n)}" for p, k, n in _CPP_INCLUDE.findall(text)}
    used |= {f"{p}/{k}/{n}" for p, k, n in _CPP_QUALIFIED.findall(text) if n[:1].isupper() and p != "std"}
    return used


def _package_roots(files: dict[str, str]) -> dict[str, str]:
    """Directory -> package name for every package.xml."""
    roots = {}
    for path, content in files.items():
        if PurePosixPath(path).name == "package.xml":
            try:
                name = (ET.fromstring(content).findtext("name") or "").strip()
            except ET.ParseError:
                name = ""
            roots[str(PurePosixPath(path).parent)] = name
    return roots


def _owning_root(path: str, roots: dict[str, str]) -> str | None:
    best = None
    for root in roots:
        prefix = "" if root == "." else root + "/"
        if path.startswith(prefix) and (best is None or len(root) > len(best)):
            best = root
    return best


def validate_package(files: dict[str, str], distro: str, interfaces: dict[str, dict[str, str]]) -> CodeValidation:
    errors: list[str] = []
    warnings: list[str] = []
    if not files:
        return CodeValidation(False, ["no files were generated"])

    for path in files:
        p = PurePosixPath(path)
        if p.is_absolute() or ".." in p.parts or path.startswith("~"):
            errors.append(f"{path}: file paths must be relative and inside the package")
    if errors:
        return CodeValidation(False, errors)

    known = interfaces.get(distro, {})
    known_packages = {name.split("/")[0] for name in known}
    if not known:
        warnings.append(f"no interface catalogue for {distro}; message types were not checked (run rag.ingest)")

    # Custom interfaces defined in the generated packages themselves.
    defined = set()
    for path in files:
        p = PurePosixPath(path)
        if p.suffix in (".msg", ".srv", ".action") and p.parent.name in ("msg", "srv", "action"):
            roots = _package_roots(files)
            root = _owning_root(path, roots)
            pkg = roots.get(root, "") if root is not None else ""
            defined.add(f"{pkg}/{p.parent.name}/{p.stem}")

    roots = _package_roots(files)
    if not roots:
        errors.append("no package.xml found")
    deps_by_root: dict[str, set[str]] = {}
    for root, name in roots.items():
        pkg_xml = files[f"{root}/package.xml" if root != "." else "package.xml"]
        try:
            tree = ET.fromstring(pkg_xml)
        except ET.ParseError as e:
            errors.append(f"{root}/package.xml: XML parse error: {e}")
            continue
        if not name:
            errors.append(f"{root}/package.xml: missing <name>")
        build_type = (tree.findtext("export/build_type") or "").strip()
        deps_by_root[root] = {(d.text or "").strip() for tag in _DEPENDS for d in tree.iter(tag)}
        prefix = "" if root == "." else root + "/"
        if build_type == "ament_python":
            if f"{prefix}setup.py" not in files:
                errors.append(f"{root}: ament_python package without setup.py")
            if name and f"{prefix}{name}/__init__.py" not in files:
                warnings.append(f"{root}: no {name}/__init__.py (the Python module directory)")
            if f"{prefix}resource/{name}" not in files:
                warnings.append(f"{root}: no resource/{name} marker file (ament index)")
        elif build_type == "ament_cmake":
            cmake = files.get(f"{prefix}CMakeLists.txt")
            if cmake is None:
                errors.append(f"{root}: ament_cmake package without CMakeLists.txt")
            elif "ament_package()" not in cmake:
                errors.append(f"{root}/CMakeLists.txt: missing ament_package()")
        else:
            errors.append(f"{root}/package.xml: <export><build_type> must be ament_python or ament_cmake")

    used_by_root: dict[str, set[str]] = {}
    for path, content in files.items():
        p = PurePosixPath(path)
        used: set[str] = set()
        if p.suffix == ".py":
            try:
                tree = ast.parse(content, filename=path)
            except SyntaxError as e:
                errors.append(f"{path}:{e.lineno}: Python syntax error: {e.msg}")
                continue
            used = _python_interfaces(tree)
            if path.endswith("launch.py"):
                funcs = {n.name for n in tree.body if isinstance(n, ast.FunctionDef)}
                if "generate_launch_description" not in funcs:
                    errors.append(f"{path}: launch file does not define generate_launch_description()")
        elif p.suffix in (".cpp", ".hpp", ".h", ".cc"):
            used = _cpp_interfaces(content)
        elif path.endswith(("launch.xml", ".launch")) or (p.suffix == ".xml" and "launch" in p.parts):
            try:
                root_el = ET.fromstring(content)
                if root_el.tag != "launch":
                    errors.append(f"{path}: XML launch file root must be <launch>, got <{root_el.tag}>")
            except ET.ParseError as e:
                errors.append(f"{path}: XML parse error: {e}")
        for name in sorted(used):
            if name in known or name in defined:
                continue
            pkg = name.split("/")[0]
            if pkg in known_packages:
                errors.append(f"{path}: {name} does not exist in ROS 2 {distro}")
            else:
                warnings.append(f"{path}: {name} is from a package that is not indexed; not verified")
        owner = _owning_root(path, roots)
        if owner is not None:
            used_by_root.setdefault(owner, set()).update(used)

    for root, used in used_by_root.items():
        own = roots.get(root)
        for pkg in sorted({u.split("/")[0] for u in used} - {own}):
            if pkg not in deps_by_root.get(root, set()):
                warnings.append(f"{root}/package.xml: uses {pkg} but does not declare it as a dependency")

    stats = {
        "files": len(files),
        "packages": sorted(n for n in roots.values() if n),
        "interfaces_used": sorted(set().union(*used_by_root.values())) if used_by_root else [],
    }
    return CodeValidation(not errors, errors, warnings, stats)