返回 ppt-master
project_specs.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Project Specification Helpers
4
5 Scaffold and validate the Markdown planning artifacts used by project_manager.py.
6 The module keeps schema parsing and deterministic scaffold rendering independent
7 from the broader project-management command surface.
8
9 Usage:
10 Import validate_project_artifacts() or scaffold_project_artifact().
11
12 Examples:
13 from project_management.project_specs import validate_markdown_schema
14
15 Dependencies:
16 None (only uses the standard library and local project modules)
17 """
18
19 from __future__ import annotations
20
21 import json
22 import math
23 import re
24 from pathlib import Path
25 from typing import Mapping
26
27 from .paths import (
28 SCAFFOLD_DIR,
29 SCHEMA_DIR,
30 SCRIPTS_DIR,
31 SKILL_DIR,
32 )
33
34 try:
35 from project_utils import (
36 CANVAS_FORMATS,
37 get_project_info as get_project_info_common,
38 validate_communication_trace,
39 )
40 except ImportError:
41 import sys
42
43 tools_dir = SCRIPTS_DIR
44 if str(tools_dir) not in sys.path:
45 sys.path.insert(0, str(tools_dir))
46 from project_utils import ( # type: ignore
47 CANVAS_FORMATS,
48 get_project_info as get_project_info_common,
49 validate_communication_trace,
50 )
51
52
53 TOOLS_DIR = SCRIPTS_DIR
54
55 _CUSTOM_REFERENCE_CATALOGS = (
56 ("mode", "mode", "mode_references", SKILL_DIR / "references" / "modes"),
57 (
58 "visual_style",
59 "visual_style",
60 "visual_style_references",
61 SKILL_DIR / "references" / "visual-styles",
62 ),
63 (
64 "colors",
65 "image_rendering",
66 "image_rendering_references",
67 SKILL_DIR / "references" / "image-renderings",
68 ),
69 )
70
71 _MARKDOWN_H2_RE = re.compile(r"^##[ \t]+(.+?)[ \t]*$", re.MULTILINE)
72 _MARKDOWN_SUBHEADING_RE = re.compile(r"^#{3,6}[ \t]+(.+?)[ \t]*$", re.MULTILINE)
73 _MARKDOWN_DATA_LINE_RE = re.compile(
74 r"^[ \t]*-[ \t]+(?:\*\*)?([^:\n*]+?)(?:\*\*)?[ \t]*:[ \t]*(.*)$",
75 re.MULTILINE,
76 )
77 _IMAGE_PATH_SUFFIXES = frozenset(
78 {
79 ".bmp",
80 ".emf",
81 ".gif",
82 ".jpeg",
83 ".jpg",
84 ".png",
85 ".svg",
86 ".tif",
87 ".tiff",
88 ".webp",
89 ".wmf",
90 }
91 )
92 _IMAGE_ACQUISITION_SOURCES = frozenset(
93 {"ai", "web", "user", "formula", "placeholder", "slice"}
94 )
95 _IMAGE_CROP_POLICIES = frozenset({"adaptive", "no-crop"})
96 _LEGACY_IMAGE_METADATA_KEYS = frozenset(
97 {
98 "image_rendering",
99 "image_rendering_behavior",
100 "image_rendering_references",
101 }
102 )
103 _LEGACY_SPEC_LOCK_FORBIDDEN = frozenset({"Mixing icon libraries"})
104 _SCAFFOLD_TOKEN_RE = re.compile(r"\{\{[A-Z_]+\}\}")
105 _SCHEMA_MARKER_RE = re.compile(
106 r"^<!--[ \t]+ppt-master-schema:[ \t]*([a-z0-9-]+/v[1-9][0-9]*)[ \t]+-->$",
107 re.IGNORECASE,
108 )
109
110 def _normalize_schema_value(value: str) -> str:
111 """Normalize a Markdown scalar before enum, pattern, and catalog checks."""
112 normalized = value.strip()
113 if (
114 len(normalized) >= 2
115 and normalized[0] == normalized[-1]
116 and normalized[0] in "'\"`"
117 ):
118 return normalized[1:-1].strip()
119 return normalized
120
121
122 def _extract_schema_marker(text: str) -> tuple[str | None, str | None]:
123 """Read the optional version marker from the first non-empty line."""
124 first_line = next((line.strip() for line in text.splitlines() if line.strip()), "")
125 if not first_line.startswith("<!--") or "ppt-master-schema:" not in first_line:
126 return None, None
127 match = _SCHEMA_MARKER_RE.fullmatch(first_line)
128 if match is None:
129 return None, "has a malformed ppt-master-schema marker"
130 return match.group(1).casefold(), None
131
132
133 def _parse_markdown_sections(
134 text: str,
135 *,
136 report_duplicate_fields: bool,
137 ) -> tuple[list[dict[str, object]], list[str]]:
138 """Parse H2 sections, data lines, and nested headings from Markdown."""
139 headings = list(_MARKDOWN_H2_RE.finditer(text))
140 sections: list[dict[str, object]] = []
141 errors: list[str] = []
142
143 for index, heading_match in enumerate(headings):
144 body_start = heading_match.end()
145 body_end = headings[index + 1].start() if index + 1 < len(headings) else len(text)
146 body = text[body_start:body_end]
147 fields: dict[str, str] = {}
148 field_names: dict[str, str] = {}
149
150 for field_match in _MARKDOWN_DATA_LINE_RE.finditer(body):
151 field_name = field_match.group(1).strip()
152 field_key = field_name
153 if field_key in fields and report_duplicate_fields:
154 errors.append(
155 f"section '{heading_match.group(1).strip()}' repeats data key "
156 f"'{field_name}'"
157 )
158 continue
159 fields[field_key] = field_match.group(2).strip()
160 field_names[field_key] = field_name
161
162 sections.append(
163 {
164 "heading": heading_match.group(1).strip(),
165 "offset": heading_match.start(),
166 "body": body,
167 "fields": fields,
168 "field_names": field_names,
169 "subheadings": [
170 match.group(1).strip()
171 for match in _MARKDOWN_SUBHEADING_RE.finditer(body)
172 ],
173 }
174 )
175
176 return sections, errors
177
178
179 def parse_markdown_artifact(
180 markdown_path: Path,
181 *,
182 report_duplicate_fields: bool = False,
183 ) -> list[dict[str, object]]:
184 """Parse one Markdown planning artifact without changing it.
185
186 This is the public read-only entry point for consumers that need the same
187 heading/data-line grammar as schema validation. Keeping the parser here
188 prevents runtime projections from drifting into their own lock grammar.
189 """
190 text = markdown_path.read_text(encoding="utf-8")
191 sections, errors = _parse_markdown_sections(
192 text,
193 report_duplicate_fields=report_duplicate_fields,
194 )
195 if errors:
196 raise ValueError("; ".join(errors))
197 return sections
198
199
200 def _looks_like_image_path(raw: str) -> bool:
201 """Return whether one lock token looks like a project image path."""
202 token = raw.strip().strip("`'\"").replace("\\", "/")
203 return bool(token) and Path(token).suffix.casefold() in _IMAGE_PATH_SUFFIXES
204
205
206 def parse_spec_lock_image_value(key: str, value: str) -> dict[str, str]:
207 """Parse one image-lock row while preserving supported legacy rows.
208
209 Current rows use ``<path> | source=... | pattern=... | crop=...``. Legacy
210 rows remain readable, but any row that starts using named metadata must
211 provide the complete current contract.
212 """
213 normalized_key = str(key).strip()
214 normalized_value = str(value).strip()
215 parts = [part.strip() for part in normalized_value.split("|")]
216 path_part = parts[0] if parts else ""
217
218 if _looks_like_image_path(normalized_key) and not _looks_like_image_path(path_part):
219 parts.insert(0, normalized_key)
220 path_part = normalized_key
221 elif (
222 len(parts) >= 2
223 and parts[0].casefold() in _IMAGE_ACQUISITION_SOURCES
224 and _looks_like_image_path(parts[1])
225 ):
226 path_part = parts[1]
227
228 metadata_parts = [part for part in parts[1:] if "=" in part]
229 if not metadata_parts:
230 legacy_crop = (
231 "no-crop"
232 if any(
233 re.search(r"(?<![a-z])no-crop(?![a-z])", part, re.IGNORECASE)
234 for part in parts[1:]
235 )
236 else ""
237 )
238 return {
239 "path": path_part,
240 "source": "",
241 "pattern": "",
242 "crop": legacy_crop,
243 "legacy": "true",
244 }
245
246 metadata: dict[str, str] = {}
247 unsupported_parts: list[str] = []
248 for part in parts[1:]:
249 field, separator, raw = part.partition("=")
250 if not separator:
251 unsupported_parts.append(part)
252 continue
253 field = field.strip().casefold()
254 if field in metadata:
255 raise ValueError(f"repeats metadata field {field!r}")
256 metadata[field] = raw.strip()
257
258 expected_fields = {"source", "pattern", "crop"}
259 unknown_fields = sorted(set(metadata) - expected_fields)
260 missing_fields = sorted(expected_fields - set(metadata))
261 if unsupported_parts:
262 shown = ", ".join(repr(part) for part in unsupported_parts)
263 raise ValueError(f"has unsupported metadata token(s) {shown}")
264 if unknown_fields:
265 raise ValueError(
266 f"has unknown metadata field(s) {', '.join(unknown_fields)}"
267 )
268 if missing_fields:
269 raise ValueError(
270 f"misses metadata field(s) {', '.join(missing_fields)}"
271 )
272 if not _looks_like_image_path(path_part):
273 raise ValueError(f"has invalid image path {path_part!r}")
274
275 normalized_path = path_part.replace("\\", "/")
276 path = Path(normalized_path)
277 if (
278 path.is_absolute()
279 or len(path.parts) != 2
280 or path.parts[0] != "images"
281 or path.name in {"", ".", ".."}
282 or ":" in path.name
283 or normalized_path != f"images/{path.name}"
284 ):
285 raise ValueError(
286 f"must use canonical project path images/<filename>, got {path_part!r}"
287 )
288
289 source = metadata["source"].casefold()
290 if source not in _IMAGE_ACQUISITION_SOURCES:
291 allowed = ", ".join(sorted(_IMAGE_ACQUISITION_SOURCES))
292 raise ValueError(f"source must be one of {allowed}, got {metadata['source']!r}")
293 if not metadata["pattern"]:
294 raise ValueError("pattern must be non-empty")
295 crop = metadata["crop"].casefold()
296 if crop not in _IMAGE_CROP_POLICIES:
297 allowed = ", ".join(sorted(_IMAGE_CROP_POLICIES))
298 raise ValueError(f"crop must be one of {allowed}, got {metadata['crop']!r}")
299
300 return {
301 "path": normalized_path,
302 "source": source,
303 "pattern": metadata["pattern"],
304 "crop": crop,
305 "legacy": "false",
306 }
307
308
309 def parse_spec_lock_artifact(
310 lock_path: Path,
311 *,
312 report_duplicate_fields: bool = False,
313 compatibility_warnings: list[str] | None = None,
314 ) -> list[dict[str, object]]:
315 """Parse one execution lock and normalize supported legacy image rows.
316
317 New locks use ``- <key>: <path> | source=... | pattern=... | crop=...``.
318 Some versioned projects instead placed the image path before the colon.
319 Preserve those projects by projecting the key path back into the value so
320 every consumer sees the same path-first image value.
321 """
322 sections = parse_markdown_artifact(
323 lock_path,
324 report_duplicate_fields=report_duplicate_fields,
325 )
326 normalized_sections: list[dict[str, object]] = []
327 compatibility_keys: list[str] = []
328
329 for section in sections:
330 if str(section.get("heading", "")).strip().casefold() != "images":
331 normalized_sections.append(section)
332 continue
333 raw_fields = section.get("fields")
334 if not isinstance(raw_fields, dict):
335 normalized_sections.append(section)
336 continue
337
338 fields: dict[str, str] = {}
339 for raw_key, raw_value in raw_fields.items():
340 key = str(raw_key)
341 value = str(raw_value).strip()
342 value_path = value.split("|", 1)[0].strip()
343 if _looks_like_image_path(key) and not _looks_like_image_path(value_path):
344 value = f"{key} | {value}" if value else key
345 compatibility_keys.append(key)
346 fields[key] = value
347
348 normalized_section = dict(section)
349 normalized_section["fields"] = fields
350 normalized_sections.append(normalized_section)
351
352 if compatibility_warnings is not None and compatibility_keys:
353 sample = ", ".join(compatibility_keys[:3])
354 suffix = "" if len(compatibility_keys) <= 3 else ", ..."
355 compatibility_warnings.append(
356 f"{lock_path.name} images: normalized {len(compatibility_keys)} legacy "
357 "path-as-key row(s); new locks should use '- <key>: <path> | "
358 "source=... | pattern=... | crop=...' "
359 f"(found: {sample}{suffix})"
360 )
361 return normalized_sections
362
363
364 def parse_spec_lock(
365 lock_path: Path,
366 *,
367 report_duplicate_fields: bool = False,
368 compatibility_warnings: list[str] | None = None,
369 ) -> dict[str, dict[str, str]]:
370 """Return one execution lock as ``{section: {key: value}}`."""
371 sections = parse_spec_lock_artifact(
372 lock_path,
373 report_duplicate_fields=report_duplicate_fields,
374 compatibility_warnings=compatibility_warnings,
375 )
376 parsed: dict[str, dict[str, str]] = {}
377 for section in sections:
378 raw_fields = section.get("fields")
379 if not isinstance(raw_fields, dict):
380 continue
381 parsed[str(section.get("heading", "")).strip()] = {
382 str(key): str(value) for key, value in raw_fields.items()
383 }
384 return parsed
385
386
387 def default_spec_lock_forbidden() -> frozenset[str]:
388 """Return the versioned scaffold's universal forbidden-item defaults."""
389 sections = parse_markdown_artifact(SCAFFOLD_DIR / "spec_lock.md")
390 section = next(
391 (
392 item
393 for item in sections
394 if str(item.get("heading", "")).strip().casefold() == "forbidden"
395 ),
396 None,
397 )
398 if section is None:
399 raise ValueError("spec-lock scaffold has no forbidden section")
400 current = frozenset(
401 re.sub(r"^-[ \t]+", "", line.strip())
402 for line in str(section.get("body", "")).splitlines()
403 if line.strip()
404 )
405 return current | _LEGACY_SPEC_LOCK_FORBIDDEN
406
407
408 def _load_markdown_schema(schema_path: Path) -> dict[str, object]:
409 """Load and sanity-check one versioned Markdown schema."""
410 with schema_path.open("r", encoding="utf-8") as stream:
411 schema = json.load(stream)
412 contract = schema.get("x-markdown")
413 if not isinstance(contract, dict):
414 raise ValueError(f"Schema is missing x-markdown: {schema_path}")
415 if contract.get("version") != 1:
416 raise ValueError(f"Unsupported Markdown schema version: {schema_path}")
417 return schema
418
419
420 def _catalog_values(
421 schema_path: Path,
422 value_catalog: Mapping[str, object],
423 ) -> tuple[Path, dict[str, object]]:
424 """Resolve a schema-declared JSON catalog and object pointer."""
425 relative_path = value_catalog.get("path")
426 pointer = value_catalog.get("pointer", [])
427 if not isinstance(relative_path, str) or not isinstance(pointer, list):
428 raise ValueError("value_catalog requires string path and list pointer")
429
430 catalog_path = (schema_path.parent / relative_path).resolve()
431 with catalog_path.open("r", encoding="utf-8") as stream:
432 node: object = json.load(stream)
433 for pointer_part in pointer:
434 if not isinstance(node, dict):
435 raise ValueError(f"pointer enters a non-object at '{pointer_part}'")
436 node = node[str(pointer_part)]
437 if not isinstance(node, dict):
438 raise ValueError("catalog pointer does not resolve to an object")
439 return catalog_path, node
440
441
442 def _validate_catalog_values(
443 *,
444 markdown_name: str,
445 section_id: str,
446 fields: Mapping[str, object],
447 schema_path: Path,
448 value_catalog: Mapping[str, object],
449 ) -> list[str]:
450 """Validate catalog membership and any schema-declared asset path."""
451 errors: list[str] = []
452 relative_path = value_catalog.get("path")
453 try:
454 catalog_path, catalog = _catalog_values(schema_path, value_catalog)
455 except (OSError, KeyError, UnicodeError, json.JSONDecodeError, ValueError) as exc:
456 return [
457 f"{markdown_name} schema: cannot read value catalog "
458 f"'{relative_path}': {exc}"
459 ]
460
461 asset_pattern = value_catalog.get("asset_path_pattern")
462 for field_value in fields.values():
463 value = _normalize_schema_value(str(field_value))
464 if value not in catalog:
465 errors.append(
466 f"{markdown_name} schema: section '{section_id}' value '{value}' "
467 f"is absent from {catalog_path.name}"
468 )
469 continue
470 if not isinstance(asset_pattern, str):
471 continue
472 try:
473 relative_asset_path = asset_pattern.format(key=value, value=value)
474 except (KeyError, ValueError) as exc:
475 errors.append(
476 f"{markdown_name} schema: invalid asset_path_pattern "
477 f"'{asset_pattern}': {exc}"
478 )
479 break
480 asset_path = (schema_path.parent / relative_asset_path).resolve()
481 if asset_path.suffix.casefold() != ".svg" or not asset_path.is_file():
482 errors.append(
483 f"{markdown_name} schema: section '{section_id}' value '{value}' "
484 f"does not resolve to an SVG asset at {asset_path}"
485 )
486 return errors
487
488
489 def _validate_section(
490 *,
491 markdown_name: str,
492 section_id: str,
493 section: Mapping[str, object],
494 definition: Mapping[str, object],
495 schema_path: Path,
496 ) -> list[str]:
497 """Apply one section definition to one matched Markdown section."""
498 errors: list[str] = []
499 fields = section["fields"]
500 assert isinstance(fields, dict)
501
502 required_fields = definition.get("required_fields", [])
503 allow_empty = {
504 str(field_name)
505 for field_name in definition.get("allow_empty_fields", [])
506 }
507 if isinstance(required_fields, list):
508 for field_name in required_fields:
509 field_key = str(field_name)
510 if field_key not in fields:
511 errors.append(
512 f"{markdown_name} schema: section '{section_id}' is missing field "
513 f"'{field_name}'"
514 )
515 elif (
516 field_key not in allow_empty
517 and not _normalize_schema_value(str(fields[field_key]))
518 ):
519 errors.append(
520 f"{markdown_name} schema: section '{section_id}' field "
521 f"'{field_name}' must not be empty"
522 )
523
524 allowed_fields = definition.get("allowed_fields")
525 if isinstance(allowed_fields, list):
526 allowed = {str(field_name) for field_name in allowed_fields}
527 for field_name in fields:
528 if field_name not in allowed:
529 errors.append(
530 f"{markdown_name} schema: section '{section_id}' has unknown "
531 f"field '{field_name}'"
532 )
533
534 field_enums = definition.get("field_enums", {})
535 if isinstance(field_enums, dict):
536 for field_name, allowed in field_enums.items():
537 field_key = str(field_name)
538 if field_key not in fields or not isinstance(allowed, list):
539 continue
540 value = _normalize_schema_value(str(fields[field_key]))
541 if value not in [str(item) for item in allowed]:
542 errors.append(
543 f"{markdown_name} schema: section '{section_id}' field "
544 f"'{field_name}' has illegal value '{value}'"
545 )
546
547 field_patterns = definition.get("field_patterns", {})
548 if isinstance(field_patterns, dict):
549 for field_name, pattern in field_patterns.items():
550 field_key = str(field_name)
551 if field_key not in fields:
552 continue
553 value = _normalize_schema_value(str(fields[field_key]))
554 if re.fullmatch(str(pattern), value) is None:
555 errors.append(
556 f"{markdown_name} schema: section '{section_id}' field "
557 f"'{field_name}' does not match '{pattern}'"
558 )
559
560 field_value_rules = definition.get("field_value_rules", [])
561 if isinstance(field_value_rules, list):
562 for rule in field_value_rules:
563 if not isinstance(rule, dict):
564 continue
565 key_pattern = rule.get("key_pattern")
566 value_pattern = rule.get("value_pattern")
567 if not isinstance(key_pattern, str) or not isinstance(
568 value_pattern, str
569 ):
570 continue
571 requirement = str(rule.get("requirement", "match its value grammar"))
572 for field_name, raw_value in fields.items():
573 if re.fullmatch(key_pattern, str(field_name)) is None:
574 continue
575 value = str(raw_value).strip()
576 if bool(rule.get("normalize", True)):
577 value = _normalize_schema_value(value)
578 value_matches = re.fullmatch(value_pattern, value) is not None
579 if value_matches and rule.get("numeric") == "positive_finite":
580 try:
581 number = float(value)
582 except ValueError:
583 value_matches = False
584 else:
585 value_matches = math.isfinite(number) and number > 0
586 if not value_matches:
587 errors.append(
588 f"{markdown_name} schema: section '{section_id}' field "
589 f"'{field_name}' must {requirement}; found '{value}'"
590 )
591
592 minimum = definition.get("min_entries")
593 if isinstance(minimum, int) and len(fields) < minimum:
594 errors.append(
595 f"{markdown_name} schema: section '{section_id}' needs at least "
596 f"{minimum} data line(s)"
597 )
598
599 min_body_chars = definition.get("min_body_chars")
600 if isinstance(min_body_chars, int) and len(str(section["body"]).strip()) < min_body_chars:
601 errors.append(
602 f"{markdown_name} schema: section '{section_id}' must contain content"
603 )
604
605 entry_key_pattern = definition.get("entry_key_pattern")
606 if isinstance(entry_key_pattern, str):
607 field_names = section["field_names"]
608 assert isinstance(field_names, dict)
609 for field_name in field_names.values():
610 if re.fullmatch(entry_key_pattern, str(field_name)) is None:
611 errors.append(
612 f"{markdown_name} schema: section '{section_id}' has malformed "
613 f"entry key '{field_name}'"
614 )
615
616 value_enum = definition.get("value_enum")
617 if isinstance(value_enum, list):
618 allowed_values = [str(item) for item in value_enum]
619 for field_value in fields.values():
620 value = _normalize_schema_value(str(field_value))
621 if value not in allowed_values:
622 errors.append(
623 f"{markdown_name} schema: section '{section_id}' has illegal "
624 f"value '{value}'"
625 )
626
627 value_pattern = definition.get("value_pattern")
628 if isinstance(value_pattern, str):
629 for field_value in fields.values():
630 value = _normalize_schema_value(str(field_value))
631 if re.fullmatch(value_pattern, value) is None:
632 errors.append(
633 f"{markdown_name} schema: section '{section_id}' has malformed "
634 f"value '{value}'"
635 )
636
637 value_catalog = definition.get("value_catalog")
638 if isinstance(value_catalog, dict):
639 errors.extend(
640 _validate_catalog_values(
641 markdown_name=markdown_name,
642 section_id=section_id,
643 fields=fields,
644 schema_path=schema_path,
645 value_catalog=value_catalog,
646 )
647 )
648 return errors
649
650
651 def _condition_applies(
652 when: Mapping[str, object],
653 matched: Mapping[str, dict[str, object] | None],
654 ) -> bool:
655 """Return whether a schema condition applies to the matched document."""
656 section = matched.get(str(when.get("section", "")))
657 if section is None:
658 return False
659 applies = True
660 field_name = when.get("field")
661 if field_name is not None:
662 fields = section["fields"]
663 assert isinstance(fields, dict)
664 field_value = fields.get(str(field_name))
665 applies = field_value is not None
666 if applies and "equals" in when:
667 applies = _normalize_schema_value(str(field_value)) == str(when["equals"])
668 body_regex = when.get("body_regex")
669 if applies and isinstance(body_regex, str):
670 applies = re.search(body_regex, str(section["body"])) is not None
671 return applies
672
673
674 def _validate_condition(
675 *,
676 markdown_name: str,
677 condition_id: str,
678 then: Mapping[str, object],
679 matched: Mapping[str, dict[str, object] | None],
680 ) -> list[str]:
681 """Apply one active cross-section condition."""
682 errors: list[str] = []
683
684 required_sections = then.get("required_sections", [])
685 if isinstance(required_sections, list):
686 for section_id in required_sections:
687 if matched.get(str(section_id)) is None:
688 errors.append(
689 f"{markdown_name} schema: condition '{condition_id}' requires "
690 f"section '{section_id}'"
691 )
692
693 forbidden_sections = then.get("forbidden_sections", [])
694 if isinstance(forbidden_sections, list):
695 for section_id in forbidden_sections:
696 if matched.get(str(section_id)) is not None:
697 errors.append(
698 f"{markdown_name} schema: condition '{condition_id}' forbids "
699 f"section '{section_id}'"
700 )
701
702 field_groups = then.get("required_fields", [])
703 if isinstance(field_groups, list):
704 for group in field_groups:
705 if not isinstance(group, dict):
706 continue
707 target_id = str(group.get("section", ""))
708 target = matched.get(target_id)
709 if target is None:
710 continue
711 target_fields = target["fields"]
712 assert isinstance(target_fields, dict)
713 for field_name in group.get("fields", []):
714 field_key = str(field_name)
715 if field_key not in target_fields:
716 errors.append(
717 f"{markdown_name} schema: condition '{condition_id}' requires "
718 f"field '{field_name}' in section '{target_id}'"
719 )
720 elif not _normalize_schema_value(str(target_fields[field_key])):
721 errors.append(
722 f"{markdown_name} schema: condition '{condition_id}' requires "
723 f"non-empty field '{field_name}' in section '{target_id}'"
724 )
725
726 field_values = then.get("field_values", [])
727 if isinstance(field_values, list):
728 for value_rule in field_values:
729 if not isinstance(value_rule, dict):
730 continue
731 target_id = str(value_rule.get("section", ""))
732 target = matched.get(target_id)
733 if target is None:
734 continue
735 target_fields = target["fields"]
736 assert isinstance(target_fields, dict)
737 target_field = str(value_rule.get("field", ""))
738 value = target_fields.get(target_field)
739 allowed = value_rule.get("enum", [])
740 if value is None or not isinstance(allowed, list):
741 continue
742 normalized = _normalize_schema_value(str(value))
743 if normalized not in [str(item) for item in allowed]:
744 errors.append(
745 f"{markdown_name} schema: condition '{condition_id}' requires "
746 f"'{target_id}.{target_field}' to be one of {allowed}"
747 )
748
749 subheading_rules = then.get("required_subheadings", [])
750 if isinstance(subheading_rules, list):
751 for rule in subheading_rules:
752 if not isinstance(rule, dict):
753 continue
754 target_id = str(rule.get("section", ""))
755 target = matched.get(target_id)
756 if target is None:
757 continue
758 heading = str(rule.get("heading", ""))
759 subheadings = target["subheadings"]
760 assert isinstance(subheadings, list)
761 if not any(
762 str(item).startswith(heading)
763 for item in subheadings
764 ):
765 errors.append(
766 f"{markdown_name} schema: condition '{condition_id}' requires "
767 f"subheading '{heading}' in section '{target_id}'"
768 )
769 return errors
770
771
772 def _validate_slides(
773 *,
774 markdown_name: str,
775 slide_contract: Mapping[str, object],
776 matched: Mapping[str, dict[str, object] | None],
777 ) -> list[str]:
778 """Validate repeated slide blocks inside the configured outline section."""
779 outline = matched.get(str(slide_contract.get("section", "")))
780 heading_pattern = str(slide_contract.get("heading_pattern", ""))
781 if outline is None or not heading_pattern:
782 return []
783
784 body = str(outline["body"])
785 heading_matches = [
786 match
787 for match in _MARKDOWN_SUBHEADING_RE.finditer(body)
788 if re.match(heading_pattern, match.group(1))
789 ]
790 if not heading_matches:
791 return [f"{markdown_name} schema: content outline has no Slide blocks"]
792
793 errors: list[str] = []
794 required_fields = slide_contract.get("required_fields", [])
795 if not isinstance(required_fields, list):
796 return errors
797 for index, slide_match in enumerate(heading_matches):
798 block_end = (
799 heading_matches[index + 1].start()
800 if index + 1 < len(heading_matches)
801 else len(body)
802 )
803 block = body[slide_match.end():block_end]
804 for field_name in required_fields:
805 pattern = (
806 rf"^[ \t]*-[ \t]+(?:\*\*)?{re.escape(str(field_name))}"
807 rf"(?:\*\*)?[ \t]*:"
808 )
809 if re.search(pattern, block, flags=re.MULTILINE) is None:
810 errors.append(
811 f"{markdown_name} schema: '{slide_match.group(1)}' is missing "
812 f"field '{field_name}'"
813 )
814 return errors
815
816
817 def _validate_references(
818 *,
819 markdown_path: Path,
820 markdown_name: str,
821 rules: object,
822 matched: Mapping[str, dict[str, object] | None],
823 ) -> list[str]:
824 """Validate schema-declared cross-section keys and project assets."""
825 if not isinstance(rules, list):
826 return []
827 errors: list[str] = []
828 project_root = markdown_path.parent.resolve()
829
830 for rule in rules:
831 if not isinstance(rule, dict):
832 continue
833 rule_id = str(rule.get("id", "reference"))
834 source_id = str(rule.get("from_section", ""))
835 source = matched.get(source_id)
836 if source is None:
837 continue
838 source_fields = source["fields"]
839 assert isinstance(source_fields, dict)
840
841 target_id = rule.get("target_section")
842 target_fields: Mapping[str, object] | None = None
843 if isinstance(target_id, str):
844 target = matched.get(target_id)
845 if target is None:
846 continue
847 raw_target_fields = target["fields"]
848 assert isinstance(raw_target_fields, dict)
849 target_fields = raw_target_fields
850
851 component = rule.get("value_component")
852 asset_pattern = rule.get("asset_path_pattern")
853 for source_key, raw_value in source_fields.items():
854 value = _normalize_schema_value(str(raw_value))
855 reference_value = value
856 if isinstance(component, dict):
857 separator = str(component.get("separator", "|"))
858 index = component.get("index", 0)
859 parts = [part.strip() for part in value.split(separator)]
860 if not isinstance(index, int) or index >= len(parts):
861 errors.append(
862 f"{markdown_name} schema: reference '{rule_id}' cannot "
863 f"parse value '{value}' from section '{source_id}'"
864 )
865 continue
866 reference_value = _normalize_schema_value(parts[index])
867
868 if target_fields is not None:
869 if reference_value not in target_fields:
870 errors.append(
871 f"{markdown_name} schema: reference '{rule_id}' value "
872 f"'{reference_value}' from '{source_id}.{source_key}' is not "
873 f"declared in section '{target_id}'"
874 )
875
876 if isinstance(asset_pattern, str):
877 asset_value = reference_value
878 suffix_match = re.search(r"\{value\}(\.[A-Za-z0-9]+)$", asset_pattern)
879 if (
880 suffix_match is not None
881 and asset_value.casefold().endswith(
882 suffix_match.group(1).casefold()
883 )
884 ):
885 asset_value = asset_value[: -len(suffix_match.group(1))]
886 try:
887 relative_asset = asset_pattern.format(value=asset_value)
888 except (KeyError, ValueError) as exc:
889 errors.append(
890 f"{markdown_name} schema: reference '{rule_id}' has invalid "
891 f"asset_path_pattern '{asset_pattern}': {exc}"
892 )
893 continue
894 asset_path = (project_root / relative_asset).resolve()
895 try:
896 asset_path.relative_to(project_root)
897 except ValueError:
898 errors.append(
899 f"{markdown_name} schema: reference '{rule_id}' escapes the "
900 f"project root for value '{reference_value}'"
901 )
902 continue
903 if asset_path.suffix.casefold() != ".svg" or not asset_path.is_file():
904 errors.append(
905 f"{markdown_name} schema: reference '{rule_id}' value "
906 f"'{reference_value}' does not resolve to {asset_path}"
907 )
908 return errors
909
910
911 def _validate_strict_data_surface(
912 markdown_name: str,
913 text: str,
914 sections: list[dict[str, object]],
915 matched: Mapping[str, dict[str, object] | None],
916 ) -> list[str]:
917 """Reject unknown lock sections and prose outside the data-line grammar."""
918 errors: list[str] = []
919 section_ids = {
920 int(section["offset"]): section_id
921 for section_id, section in matched.items()
922 if section is not None
923 }
924 first_offset = min((int(section["offset"]) for section in sections), default=len(text))
925 for line in text[:first_offset].splitlines():
926 stripped = line.strip()
927 if not stripped or stripped.startswith("<!--") or stripped.startswith("# "):
928 continue
929 errors.append(f"{markdown_name} schema: unsupported preamble line '{stripped}'")
930
931 for section in sections:
932 heading = str(section["heading"])
933 section_id = section_ids.get(int(section["offset"]))
934 if section_id is None:
935 errors.append(f"{markdown_name} schema: unknown section '{heading}'")
936 continue
937 for line in str(section["body"]).splitlines():
938 stripped = line.strip()
939 if not stripped:
940 continue
941 if section_id == "forbidden" and stripped.startswith("- "):
942 continue
943 if _MARKDOWN_DATA_LINE_RE.fullmatch(line) is not None:
944 continue
945 errors.append(
946 f"{markdown_name} schema: section '{section_id}' has unsupported "
947 f"line '{stripped}'"
948 )
949 return errors
950
951
952 def _validate_spec_lock_relations(
953 markdown_path: Path,
954 matched: Mapping[str, dict[str, object] | None],
955 ) -> list[str]:
956 """Validate cross-section references that JSON field rules cannot express."""
957 markdown_name = markdown_path.name
958 errors: list[str] = []
959
960 def fields(section_id: str) -> dict[str, str]:
961 section = matched.get(section_id)
962 if section is None:
963 return {}
964 raw_fields = section["fields"]
965 assert isinstance(raw_fields, dict)
966 return {str(key): str(value) for key, value in raw_fields.items()}
967
968 for section_id, selector_field, references_field, catalog_dir in (
969 _CUSTOM_REFERENCE_CATALOGS
970 ):
971 section_fields = fields(section_id)
972 is_custom = (
973 _normalize_schema_value(section_fields.get(selector_field, "")) == "custom"
974 )
975 raw_references = _normalize_schema_value(
976 section_fields.get(references_field, "")
977 )
978 if not is_custom:
979 if raw_references:
980 errors.append(
981 f"{markdown_name} schema: field '{references_field}' is valid "
982 f"only when '{selector_field}' is custom"
983 )
984 continue
985 if not raw_references:
986 continue
987 references = [item.strip() for item in raw_references.split(",")]
988 duplicates = sorted(
989 reference
990 for reference in set(references)
991 if references.count(reference) > 1
992 )
993 if duplicates:
994 errors.append(
995 f"{markdown_name} schema: field '{references_field}' repeats "
996 f"catalog id(s) {', '.join(duplicates)}"
997 )
998 for reference in references:
999 catalog_file = catalog_dir / f"{reference}.md"
1000 if reference == "custom" or not catalog_file.is_file():
1001 errors.append(
1002 f"{markdown_name} schema: field '{references_field}' references "
1003 f"unknown catalog id '{reference}'"
1004 )
1005
1006 for key, value in fields("images").items():
1007 if key.strip().casefold() in _LEGACY_IMAGE_METADATA_KEYS:
1008 continue
1009 try:
1010 parse_spec_lock_image_value(key, value)
1011 except ValueError as exc:
1012 errors.append(
1013 f"{markdown_name} schema: images row {key!r} {exc}"
1014 )
1015
1016 rhythm = fields("page_rhythm")
1017 layouts = fields("pptx_layouts")
1018 page_pptx_layouts = fields("page_pptx_layouts")
1019 page_layouts = fields("page_layouts")
1020 structure = fields("pptx_structure")
1021
1022 for layout_key, raw_value in layouts.items():
1023 parts = [part.strip() for part in raw_value.split("|")]
1024 if len(parts) != 3:
1025 continue
1026 _, _, source = parts
1027 if source.startswith("template:"):
1028 basename = source.removeprefix("template:").strip()
1029 if basename.casefold().endswith(".svg"):
1030 basename = basename[:-4]
1031 template_path = markdown_path.parent / "templates" / f"{basename}.svg"
1032 if not template_path.is_file():
1033 errors.append(
1034 f"{markdown_name} schema: layout '{layout_key}' references "
1035 f"missing template SVG '{basename}.svg'"
1036 )
1037 elif source not in rhythm:
1038 errors.append(
1039 f"{markdown_name} schema: layout '{layout_key}' has unknown "
1040 f"prototype source '{source}'"
1041 )
1042
1043 if structure.get("mode") == "structured":
1044 expected_pages = set(rhythm)
1045 for section_id, mapping in (
1046 ("page_pptx_layouts", page_pptx_layouts),
1047 ("page_layouts", page_layouts),
1048 ):
1049 missing = sorted(expected_pages - set(mapping))
1050 extra = sorted(set(mapping) - expected_pages)
1051 if missing:
1052 errors.append(
1053 f"{markdown_name} schema: section '{section_id}' misses pages "
1054 f"{', '.join(missing)}"
1055 )
1056 if extra:
1057 errors.append(
1058 f"{markdown_name} schema: section '{section_id}' has unknown "
1059 f"pages {', '.join(extra)}"
1060 )
1061
1062 chart_pages = set(fields("page_charts"))
1063 unknown_chart_pages = sorted(chart_pages - set(rhythm))
1064 if unknown_chart_pages:
1065 errors.append(
1066 f"{markdown_name} schema: page_charts has unknown pages "
1067 f"{', '.join(unknown_chart_pages)}"
1068 )
1069
1070 info = get_project_info_common(str(markdown_path.parent))
1071 format_key = str(info.get("format", "unknown"))
1072 canvas = CANVAS_FORMATS.get(format_key)
1073 canvas_fields = fields("canvas")
1074 if canvas is not None:
1075 expected_format = str(canvas["name"])
1076 expected_viewbox = str(canvas["viewbox"])
1077 if (
1078 "format" in canvas_fields
1079 and _normalize_schema_value(canvas_fields["format"]) != expected_format
1080 ):
1081 errors.append(
1082 f"{markdown_name} schema: canvas.format must be '{expected_format}'"
1083 )
1084 if (
1085 "viewBox" in canvas_fields
1086 and _normalize_schema_value(canvas_fields["viewBox"]) != expected_viewbox
1087 ):
1088 errors.append(
1089 f"{markdown_name} schema: canvas.viewBox must be '{expected_viewbox}'"
1090 )
1091 return errors
1092
1093
1094 def validate_markdown_schema(markdown_path: Path, schema_path: Path) -> list[str]:
1095 """Validate one existing Markdown artifact against a versioned schema."""
1096 try:
1097 text = markdown_path.read_text(encoding="utf-8-sig")
1098 schema = _load_markdown_schema(schema_path)
1099 except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc:
1100 return [f"Schema validation could not read {markdown_path.name}: {exc}"]
1101
1102 contract = schema["x-markdown"]
1103 assert isinstance(contract, dict)
1104 marker, marker_error = _extract_schema_marker(text)
1105 if marker_error is not None:
1106 return [f"{markdown_path.name} schema: {marker_error}"]
1107 expected_marker = contract.get("marker")
1108 if isinstance(expected_marker, str):
1109 if marker is None:
1110 return [
1111 f"{markdown_path.name} schema: missing ppt-master-schema marker "
1112 f"'{expected_marker}'"
1113 ]
1114 if marker != expected_marker.casefold():
1115 return [
1116 f"{markdown_path.name} schema: marker '{marker}' does not match "
1117 f"'{expected_marker}'"
1118 ]
1119 sections, parse_errors = _parse_markdown_sections(
1120 text,
1121 report_duplicate_fields=contract.get("parser") == "heading-data-lines-v1",
1122 )
1123 definitions = contract.get("sections", [])
1124 if not isinstance(definitions, list):
1125 return [f"Schema validation could not read {schema_path.name}: sections must be a list"]
1126
1127 markdown_name = markdown_path.name
1128 errors = [f"{markdown_name} schema: {message}" for message in parse_errors]
1129 unresolved_patterns = contract.get("unresolved_patterns", [])
1130 if isinstance(unresolved_patterns, list):
1131 for pattern in unresolved_patterns:
1132 if not isinstance(pattern, str):
1133 continue
1134 matches = list(re.finditer(pattern, text))
1135 if matches:
1136 errors.append(
1137 f"{markdown_name} schema: contains {len(matches)} unresolved "
1138 f"placeholder(s) matching '{pattern}'"
1139 )
1140
1141 matched: dict[str, dict[str, object] | None] = {}
1142 for definition in definitions:
1143 if not isinstance(definition, dict):
1144 continue
1145 section_id = str(definition.get("id", ""))
1146 pattern = str(definition.get("pattern", ""))
1147 if not section_id or not pattern:
1148 continue
1149 candidates = [
1150 section
1151 for section in sections
1152 if re.fullmatch(pattern, str(section["heading"]))
1153 ]
1154 if len(candidates) > 1:
1155 errors.append(
1156 f"{markdown_name} schema: section '{section_id}' appears more than once"
1157 )
1158 section = candidates[0] if candidates else None
1159 matched[section_id] = section
1160 if definition.get("required") is True and section is None:
1161 errors.append(f"{markdown_name} schema: missing section '{section_id}'")
1162 continue
1163 if section is not None:
1164 errors.extend(
1165 _validate_section(
1166 markdown_name=markdown_name,
1167 section_id=section_id,
1168 section=section,
1169 definition=definition,
1170 schema_path=schema_path,
1171 )
1172 )
1173
1174 section_order = contract.get("section_order", [])
1175 if isinstance(section_order, list):
1176 ordered_sections = [
1177 (str(section_id), matched.get(str(section_id)))
1178 for section_id in section_order
1179 if matched.get(str(section_id)) is not None
1180 ]
1181 offsets = [int(section["offset"]) for _, section in ordered_sections if section]
1182 if offsets != sorted(offsets):
1183 expected = " -> ".join(str(section_id) for section_id in section_order)
1184 errors.append(
1185 f"{markdown_name} schema: sections are out of order; expected {expected}"
1186 )
1187
1188 conditions = contract.get("conditions", [])
1189 if isinstance(conditions, list):
1190 for condition in conditions:
1191 if not isinstance(condition, dict):
1192 continue
1193 when = condition.get("when", {})
1194 then = condition.get("then", {})
1195 if not isinstance(when, dict) or not isinstance(then, dict):
1196 continue
1197 if _condition_applies(when, matched):
1198 errors.extend(
1199 _validate_condition(
1200 markdown_name=markdown_name,
1201 condition_id=str(condition.get("id", "conditional rule")),
1202 then=then,
1203 matched=matched,
1204 )
1205 )
1206
1207 errors.extend(
1208 _validate_references(
1209 markdown_path=markdown_path,
1210 markdown_name=markdown_name,
1211 rules=contract.get("references"),
1212 matched=matched,
1213 )
1214 )
1215
1216 slide_contract = contract.get("slides")
1217 if isinstance(slide_contract, dict):
1218 errors.extend(
1219 _validate_slides(
1220 markdown_name=markdown_name,
1221 slide_contract=slide_contract,
1222 matched=matched,
1223 )
1224 )
1225 if contract.get("strict_lines") is True:
1226 errors.extend(
1227 _validate_strict_data_surface(markdown_name, text, sections, matched)
1228 )
1229 if schema.get("$id") == "ppt-master://schemas/spec-lock/v1":
1230 errors.extend(_validate_spec_lock_relations(markdown_path, matched))
1231 return errors
1232
1233
1234 def validate_project_artifacts(
1235 project_path: Path,
1236 project_info: Mapping[str, object] | None = None,
1237 *,
1238 include_design: bool = True,
1239 ) -> tuple[list[str], list[str]]:
1240 """Validate the lock and, when requested, the human-facing design brief."""
1241 info = project_info or get_project_info_common(str(project_path))
1242 errors: list[str] = []
1243 warnings: list[str] = []
1244 artifacts: list[tuple[Path, Path, str]] = []
1245 spec_name = info.get("spec_file")
1246 if include_design and isinstance(spec_name, str):
1247 artifacts.append(
1248 (
1249 project_path / spec_name,
1250 SCHEMA_DIR / "design_spec.schema.json",
1251 "design",
1252 )
1253 )
1254 lock_path = project_path / "spec_lock.md"
1255 if lock_path.is_file():
1256 artifacts.append((lock_path, SCHEMA_DIR / "spec_lock.schema.json", "lock"))
1257 elif isinstance(spec_name, str):
1258 errors.append(
1259 "Communication trace: missing spec_lock.md with a "
1260 "## communication section."
1261 )
1262
1263 legacy_design = False
1264 legacy_lock = False
1265 versioned_lock_valid = False
1266 for artifact_path, schema_path, artifact_kind in artifacts:
1267 try:
1268 text = artifact_path.read_text(encoding="utf-8-sig")
1269 except (OSError, UnicodeError) as exc:
1270 errors.append(f"Schema validation could not read {artifact_path.name}: {exc}")
1271 continue
1272 marker, marker_error = _extract_schema_marker(text)
1273 if marker_error is not None:
1274 errors.append(f"{artifact_path.name} schema: {marker_error}")
1275 continue
1276 if marker is None:
1277 warnings.append(
1278 f"{artifact_path.name}: legacy artifact has no ppt-master-schema "
1279 "marker; skipped versioned schema validation"
1280 )
1281 legacy_design = legacy_design or artifact_kind == "design"
1282 legacy_lock = legacy_lock or artifact_kind == "lock"
1283 continue
1284 artifact_errors = validate_markdown_schema(artifact_path, schema_path)
1285 errors.extend(artifact_errors)
1286 if artifact_kind == "lock" and not artifact_errors:
1287 try:
1288 parse_spec_lock_artifact(
1289 artifact_path,
1290 compatibility_warnings=warnings,
1291 )
1292 except (OSError, UnicodeError, ValueError) as exc:
1293 errors.append(f"spec_lock.md compatibility parse failed: {exc}")
1294 continue
1295 versioned_lock_valid = True
1296 if versioned_lock_valid:
1297 try:
1298 from svg_to_pptx.pptx_package.template_structure import (
1299 TemplateStructureError,
1300 load_pptx_structure_lock,
1301 template_prototype_lock_errors,
1302 )
1303
1304 structure_lock = load_pptx_structure_lock(project_path)
1305 if structure_lock is not None:
1306 errors.extend(template_prototype_lock_errors(structure_lock))
1307 except (ImportError, TemplateStructureError) as exc:
1308 errors.append(f"spec_lock.md structure preflight failed: {exc}")
1309 if legacy_design or legacy_lock:
1310 errors.extend(
1311 validate_communication_trace(
1312 project_path,
1313 check_lock=legacy_lock,
1314 check_design=legacy_design,
1315 )
1316 )
1317 return errors, warnings
1318
1319
1320 def scaffold_project_artifact(project_path: Path, artifact: str) -> str:
1321 """Render one versioned Markdown scaffold without overwriting user work."""
1322 assets = {
1323 "design_spec": (SCAFFOLD_DIR / "design_spec.md", "design_spec.md"),
1324 "spec_lock": (SCAFFOLD_DIR / "spec_lock.md", "spec_lock.md"),
1325 }
1326 if artifact not in assets:
1327 raise ValueError(f"Unsupported scaffold artifact: {artifact}")
1328 if not project_path.exists() or not project_path.is_dir():
1329 raise FileNotFoundError(f"Project directory does not exist: {project_path}")
1330
1331 info = get_project_info_common(str(project_path))
1332 format_key = str(info.get("format", "unknown"))
1333 if format_key not in CANVAS_FORMATS:
1334 raise ValueError(
1335 "Cannot derive the canvas format from the project directory name. "
1336 "Use a standard <name>_<format>_<YYYYMMDD> project path."
1337 )
1338 canvas = CANVAS_FORMATS[format_key]
1339 created_date = str(info.get("date_formatted", "Unknown date"))
1340 if created_date == "Unknown date":
1341 created_date = "[fill]"
1342 context = {
1343 "PROJECT_NAME": str(info.get("name", project_path.name)),
1344 "CANVAS_NAME": str(canvas["name"]),
1345 "CANVAS_DIMENSIONS": str(canvas["dimensions"]),
1346 "VIEWBOX": str(canvas["viewbox"]),
1347 "CREATED_DATE": created_date,
1348 }
1349
1350 scaffold_path, target_name = assets[artifact]
1351 target_path = project_path / target_name
1352 existing_spec = info.get("spec_file") if artifact == "design_spec" else None
1353 if isinstance(existing_spec, str):
1354 existing_path = project_path / existing_spec
1355 raise FileExistsError(
1356 f"Refusing to shadow existing design spec: {existing_path}"
1357 )
1358 if target_path.exists() or target_path.is_symlink():
1359 raise FileExistsError(f"Refusing to overwrite existing artifact: {target_path}")
1360 rendered = scaffold_path.read_text(encoding="utf-8")
1361 for key, value in context.items():
1362 rendered = rendered.replace(f"{{{{{key}}}}}", value)
1363 unresolved = sorted(set(_SCAFFOLD_TOKEN_RE.findall(rendered)))
1364 if unresolved:
1365 raise ValueError(
1366 f"Unresolved scaffold token(s) in {scaffold_path}: {', '.join(unresolved)}"
1367 )
1368 with target_path.open("x", encoding="utf-8") as stream:
1369 stream.write(rendered)
1370 return str(target_path)
1371
1371 lines PYTHON