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