Skills › Scientific Agent Skills › matlab › validate_project_manifest.py
validate_project_manifest.py
348 lines · skills/matlab/scripts/validate_project_manifest.py at 5f2bc03
1#!/usr/bin/env python3
2"""Validate a bounded MATLAB/Octave project and product manifest."""
4from __future__ import annotations
6import argparse
7import re
8import sys
9from pathlib import Path
10from typing import Any
12from _common import (
13 CliError,
14 checked_input,
15 checked_root,
16 emit_json,
17 fail_json,
18 load_json,
19 relative_id,
20 validate_release,
21)
23TOOL = "validate_project_manifest"
24TOP_LEVEL = {
25 "schema_version",
26 "project_name",
27 "runtime",
28 "matlab_release",
29 "octave_version",
30 "entry_points",
31 "test_paths",
32 "required_products",
33 "optional_products",
34 "octave_packages",
35 "startup_actions",
36 "shutdown_actions",
37 "external_interfaces",
38 "generated_artifacts",
39 "notes",
40}
41REQUIRED = {
42 "schema_version",
43 "project_name",
44 "runtime",
45 "entry_points",
46 "test_paths",
47 "required_products",
48 "optional_products",
49 "octave_packages",
50 "startup_actions",
51 "shutdown_actions",
52 "external_interfaces",
53 "generated_artifacts",
54 "notes",
55}
56PRODUCT_KEYS = {"name", "purpose", "minimum_release", "license_status"}
57ENTRY_KEYS = {"path", "kind"}
58OCTAVE_VERSION = re.compile(r"^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$")
59SHA256 = re.compile(r"^[0-9a-f]{64}$")
60SAFE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._+()/-]{0,126}$")
63def _string(value: Any, name: str, *, maximum: int = 500) -> str:
64 if not isinstance(value, str) or not value or len(value) > maximum:
65 raise CliError(f"{name} must be a nonempty string up to {maximum} characters")
66 if any(ord(character) < 32 for character in value):
67 raise CliError(f"{name} contains control characters")
68 return value
71def _list(value: Any, name: str, *, maximum: int = 500) -> list[Any]:
72 if not isinstance(value, list):
73 raise CliError(f"{name} must be an array")
74 if len(value) > maximum:
75 raise CliError(f"{name} has {len(value)} entries; limit is {maximum}")
76 return value
79def _validate_product(
80 value: Any, *, field: str, index: int, warnings: list[str]
81) -> str:
82 if not isinstance(value, dict) or set(value) != PRODUCT_KEYS:
83 raise CliError(
84 f"{field}[{index}] must contain exactly {sorted(PRODUCT_KEYS)}"
85 )
86 name = _string(value["name"], f"{field}[{index}].name", maximum=127)
87 if not SAFE_NAME.fullmatch(name):
88 raise CliError(f"{field}[{index}].name contains unsupported characters")
89 _string(value["purpose"], f"{field}[{index}].purpose", maximum=1000)
90 release = value["minimum_release"]
91 if release is not None:
92 validate_release(_string(release, f"{field}[{index}].minimum_release"))
93 status = value["license_status"]
94 if status not in {"unknown", "confirmed", "unavailable"}:
95 raise CliError(
96 f"{field}[{index}].license_status must be unknown, confirmed, "
97 "or unavailable"
98 )
99 if status == "confirmed":
100 warnings.append(
101 f"{name}: manifest says license confirmed; validator cannot verify "
102 "installation, entitlement, or checkout availability"
103 )
104 return name
107def _validate_path(
108 raw: Any,
109 *,
110 root: Path,
111 name: str,
112 allow_missing: bool,
113 kind: str = "any",
114 suffixes: set[str] | None = None,
115) -> str:
116 text = _string(raw, name)
117 if allow_missing:
118 candidate = Path(text)
119 if (
120 candidate.is_absolute()
121 or ".." in candidate.parts
122 or "://" in text
123 or "\x00" in text
124 ):
125 raise CliError(f"{name} must be a relative path without traversal")
126 if suffixes and candidate.suffix.casefold() not in suffixes:
127 raise CliError(f"{name} has unsupported suffix")
128 return candidate.as_posix()
129 path = checked_input(text, root=root, kind=kind, suffixes=suffixes)
130 return relative_id(path, root)
133def validate(args: argparse.Namespace) -> tuple[dict[str, Any], int]:
134 root = checked_root(args.root)
135 manifest_path = checked_input(
136 args.manifest, root=root, suffixes={".json"}, max_bytes=2 * 1024 * 1024
137 )
138 data = load_json(manifest_path)
139 if not isinstance(data, dict):
140 raise CliError("manifest root must be a JSON object")
141 unknown = set(data) - TOP_LEVEL
142 missing = REQUIRED - set(data)
143 if unknown:
144 raise CliError(f"unknown manifest fields: {sorted(unknown)}")
145 if missing:
146 raise CliError(f"missing manifest fields: {sorted(missing)}")
147 if data["schema_version"] != "1.0":
148 raise CliError("schema_version must be 1.0")
149 project_name = _string(data["project_name"], "project_name", maximum=127)
150 if not SAFE_NAME.fullmatch(project_name):
151 raise CliError("project_name contains unsupported characters")
152 runtime = data["runtime"]
153 if runtime not in {"matlab", "octave", "both"}:
154 raise CliError("runtime must be matlab, octave, or both")
155 matlab_release = data.get("matlab_release")
156 octave_version = data.get("octave_version")
157 if runtime in {"matlab", "both"}:
158 validate_release(_string(matlab_release, "matlab_release"))
159 elif matlab_release is not None:
160 validate_release(_string(matlab_release, "matlab_release"))
161 if runtime in {"octave", "both"}:
162 version = _string(octave_version, "octave_version")
163 if not OCTAVE_VERSION.fullmatch(version):
164 raise CliError("octave_version must be a three-part numeric version")
165 elif octave_version is not None:
166 version = _string(octave_version, "octave_version")
167 if not OCTAVE_VERSION.fullmatch(version):
168 raise CliError("octave_version must be a three-part numeric version")
170 warnings: list[str] = []
171 entry_ids: list[str] = []
172 for index, entry in enumerate(_list(data["entry_points"], "entry_points", maximum=100)):
173 if not isinstance(entry, dict) or set(entry) != ENTRY_KEYS:
174 raise CliError(
175 f"entry_points[{index}] must contain exactly {sorted(ENTRY_KEYS)}"
176 )
177 kind = entry["kind"]
178 if kind not in {"function", "script", "live_script"}:
179 raise CliError(
180 f"entry_points[{index}].kind must be function, script, or live_script"
181 )
182 suffixes = {".mlx"} if kind == "live_script" else {".m"}
183 entry_id = _validate_path(
184 entry["path"],
185 root=root,
186 name=f"entry_points[{index}].path",
187 allow_missing=args.allow_missing_paths,
188 kind="file",
189 suffixes=suffixes,
190 )
191 entry_ids.append(entry_id)
192 if kind == "live_script":
193 warnings.append(
194 f"{entry_id}: live script is opaque; export reviewed code to .m "
195 "before execution"
196 )
197 if len(set(entry_ids)) != len(entry_ids):
198 raise CliError("entry point paths must be unique")
200 test_ids = [
201 _validate_path(
202 value,
203 root=root,
204 name=f"test_paths[{index}]",
205 allow_missing=args.allow_missing_paths,
206 kind="any",
207 )
208 for index, value in enumerate(
209 _list(data["test_paths"], "test_paths", maximum=100)
210 )
211 ]
212 if len(set(test_ids)) != len(test_ids):
213 raise CliError("test paths must be unique")
215 product_names: list[str] = []
216 for field in ("required_products", "optional_products"):
217 for index, value in enumerate(_list(data[field], field, maximum=200)):
218 product_names.append(
219 _validate_product(
220 value, field=field, index=index, warnings=warnings
221 ).casefold()
222 )
223 if len(set(product_names)) != len(product_names):
224 raise CliError("product names must be unique across required and optional lists")
225 required_names = {
226 value["name"].casefold() for value in data["required_products"]
227 }
228 if runtime in {"matlab", "both"} and "matlab" not in required_names:
229 raise CliError("MATLAB runtime manifests must declare MATLAB as required")
231 package_names: set[str] = set()
232 for index, value in enumerate(
233 _list(data["octave_packages"], "octave_packages", maximum=200)
234 ):
235 if not isinstance(value, dict) or set(value) != {
236 "name",
237 "version",
238 "source",
239 "sha256",
240 }:
241 raise CliError(
242 "each octave_packages entry must contain name, version, source, "
243 "and sha256"
244 )
245 name = _string(value["name"], f"octave_packages[{index}].name", maximum=127)
246 _string(value["version"], f"octave_packages[{index}].version", maximum=64)
247 _string(value["source"], f"octave_packages[{index}].source", maximum=500)
248 checksum = _string(
249 value["sha256"], f"octave_packages[{index}].sha256", maximum=64
250 )
251 if not SHA256.fullmatch(checksum):
252 raise CliError(f"octave_packages[{index}].sha256 must be lowercase SHA-256")
253 if name.casefold() in package_names:
254 raise CliError("Octave package names must be unique")
255 package_names.add(name.casefold())
256 if runtime == "matlab" and data["octave_packages"]:
257 warnings.append("Octave packages are declared for a MATLAB-only runtime")
259 action_ids: list[str] = []
260 for field in ("startup_actions", "shutdown_actions"):
261 for index, value in enumerate(_list(data[field], field, maximum=100)):
262 action_ids.append(
263 _validate_path(
264 value,
265 root=root,
266 name=f"{field}[{index}]",
267 allow_missing=args.allow_missing_paths,
268 kind="file",
269 suffixes={".m"},
270 )
271 )
272 if action_ids:
273 warnings.append(
274 "Startup/shutdown actions require explicit code review before a project opens"
275 )
277 generated_ids = [
278 _validate_path(
279 value,
280 root=root,
281 name=f"generated_artifacts[{index}]",
282 allow_missing=True,
283 )
284 for index, value in enumerate(
285 _list(data["generated_artifacts"], "generated_artifacts", maximum=200)
286 )
287 ]
288 external = [
289 _string(value, f"external_interfaces[{index}]", maximum=500)
290 for index, value in enumerate(
291 _list(data["external_interfaces"], "external_interfaces", maximum=100)
292 )
293 ]
294 notes = [
295 _string(value, f"notes[{index}]", maximum=1000)
296 for index, value in enumerate(_list(data["notes"], "notes", maximum=100))
297 ]
298 report = {
299 "allow_missing_paths": bool(args.allow_missing_paths),
300 "entry_points": entry_ids,
301 "executes": False,
302 "external_interface_count": len(external),
303 "generated_artifacts": generated_ids,
304 "license_verified": False,
305 "manifest": relative_id(manifest_path, root),
306 "network_accessed": False,
307 "notes_count": len(notes),
308 "octave_package_count": len(package_names),
309 "ok": True,
310 "product_count": len(product_names),
311 "project_name": project_name,
312 "runtime": runtime,
313 "schema_version": "1.0",
314 "test_paths": test_ids,
315 "tool": TOOL,
316 "warnings": warnings,
317 }
318 return report, 0
321def parser() -> argparse.ArgumentParser:
322 result = argparse.ArgumentParser(
323 description=(
324 "Validate a strict local MATLAB/Octave project, product, and license-"
325 "status manifest. The validator never launches either runtime."
326 )
327 )
328 result.add_argument("manifest", help="local JSON manifest")
329 result.add_argument("--root", default=".", help="allowed project root")
330 result.add_argument(
331 "--allow-missing-paths",
332 action="store_true",
333 help="schema-check paths without requiring them to exist",
334 )
335 return result
338def main() -> int:
339 try:
340 report, status = validate(parser().parse_args())
341 emit_json(report)
342 return status
343 except CliError as exc:
344 return fail_json(TOOL, exc)
347if __name__ == "__main__":
348 sys.exit(main())