framesdkpy 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://frame.dev/schemas/0.3.0/map.schema.json",
4
+ "title": "FRAME Map",
5
+ "description": "Where things live -- roots, groups, paths, entrypoints, and managed paths.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["frame"],
9
+ "properties": {
10
+ "frame": {
11
+ "allOf": [
12
+ { "$ref": "./frame.schema.json#/$defs/frame_header" },
13
+ {
14
+ "type": "object",
15
+ "properties": {
16
+ "file": { "const": "map" },
17
+ "role": { "const": "repo_context_map" }
18
+ }
19
+ }
20
+ ]
21
+ },
22
+ "structure": {
23
+ "type": "string",
24
+ "maxLength": 800,
25
+ "description": "Quick visual overview of repo layout. Flat text: 'Backend/ → FastAPI (routes→services→models), Frontend/ → Vue 3 (views→stores→services)'."
26
+ },
27
+ "roots": {
28
+ "type": "object",
29
+ "additionalProperties": true,
30
+ "description": "Top-level directory purposes. Keys are directory names."
31
+ },
32
+ "groups": {
33
+ "type": "array",
34
+ "description": "Logical groupings of paths. Supports wildcards for flexible coverage.",
35
+ "items": {
36
+ "type": "object",
37
+ "additionalProperties": false,
38
+ "required": ["id", "label", "paths"],
39
+ "properties": {
40
+ "id": { "$ref": "./frame.schema.json#/$defs/stable_id" },
41
+ "label": { "type": "string", "maxLength": 150 },
42
+ "paths": {
43
+ "type": "array",
44
+ "items": { "type": "string", "maxLength": 300 },
45
+ "description": "Wildcards allowed (e.g. 'Backend/app/**/*.py')."
46
+ },
47
+ "links": { "$ref": "./frame.schema.json#/$defs/links" }
48
+ }
49
+ }
50
+ },
51
+ "paths": {
52
+ "type": "array",
53
+ "description": "Critical individual files. Explicit paths only.",
54
+ "items": {
55
+ "type": "object",
56
+ "additionalProperties": false,
57
+ "required": ["path", "purpose"],
58
+ "properties": {
59
+ "id": { "$ref": "./frame.schema.json#/$defs/stable_id" },
60
+ "path": { "type": "string", "maxLength": 200 },
61
+ "purpose": { "type": "string", "maxLength": 300 },
62
+ "links": { "$ref": "./frame.schema.json#/$defs/links" }
63
+ }
64
+ }
65
+ },
66
+ "entrypoints": {
67
+ "type": "array",
68
+ "description": "CLI/API/web entry points. Explicit paths only.",
69
+ "items": {
70
+ "type": "object",
71
+ "additionalProperties": false,
72
+ "required": ["id", "path", "kind"],
73
+ "properties": {
74
+ "id": { "$ref": "./frame.schema.json#/$defs/stable_id" },
75
+ "path": { "type": "string", "maxLength": 200 },
76
+ "kind": {
77
+ "type": "string",
78
+ "enum": ["cli", "api", "web", "script"]
79
+ },
80
+ "description": { "type": "string", "maxLength": 300 },
81
+ "links": { "$ref": "./frame.schema.json#/$defs/links" }
82
+ }
83
+ }
84
+ },
85
+ "managed_paths": {
86
+ "type": "array",
87
+ "description": "Paths under special rules. Supports wildcards.",
88
+ "items": {
89
+ "type": "object",
90
+ "additionalProperties": false,
91
+ "required": ["path", "rule"],
92
+ "properties": {
93
+ "id": { "$ref": "./frame.schema.json#/$defs/stable_id" },
94
+ "path": { "type": "string", "maxLength": 200, "description": "Wildcards allowed (e.g. 'node_modules/**', '*.pyc')." },
95
+ "rule": {
96
+ "type": "string",
97
+ "enum": ["generated", "config", "immutable"]
98
+ },
99
+ "links": { "$ref": "./frame.schema.json#/$defs/links" }
100
+ }
101
+ }
102
+ },
103
+ "unmapped_paths": {
104
+ "type": "array",
105
+ "description": "Paths not yet placed in the map. Honest about gaps.",
106
+ "items": {
107
+ "type": "object",
108
+ "additionalProperties": false,
109
+ "required": ["path", "reason"],
110
+ "properties": {
111
+ "path": { "type": "string", "maxLength": 200 },
112
+ "reason": { "type": "string", "maxLength": 200 }
113
+ }
114
+ }
115
+ },
116
+ "evidence": { "$ref": "./frame.schema.json#/$defs/evidence_list" },
117
+ "links": { "$ref": "./frame.schema.json#/$defs/links" }
118
+ }
119
+ }
@@ -0,0 +1,140 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://frame.dev/schemas/0.3.0/rules.schema.json",
4
+ "title": "FRAME Rules",
5
+ "description": "How to work safely in this repo. Commands, constraints, policies, and triggers that need human approval.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["frame"],
9
+ "properties": {
10
+ "frame": {
11
+ "allOf": [
12
+ { "$ref": "./frame.schema.json#/$defs/frame_header" },
13
+ {
14
+ "type": "object",
15
+ "properties": {
16
+ "file": { "const": "rules" },
17
+ "role": { "const": "project_instruction_blueprint" }
18
+ }
19
+ }
20
+ ]
21
+ },
22
+ "governance_level": {
23
+ "type": "string",
24
+ "enum": ["relaxed", "normal", "strict"],
25
+ "default": "normal",
26
+ "description": "Controls how aggressively Haxaml enforces rules, ask_first, and validation. Does NOT affect mechanical validator (always runs)."
27
+ },
28
+ "rules": {
29
+ "type": "array",
30
+ "description": "Core behavioral constraints.",
31
+ "items": {
32
+ "type": "object",
33
+ "additionalProperties": false,
34
+ "required": ["id", "rule"],
35
+ "properties": {
36
+ "id": { "$ref": "./frame.schema.json#/$defs/stable_id" },
37
+ "rule": { "type": "string", "maxLength": 500 },
38
+ "links": { "$ref": "./frame.schema.json#/$defs/links" }
39
+ }
40
+ }
41
+ },
42
+ "policies": {
43
+ "type": "array",
44
+ "description": "Durable project policies (role access, lifecycle, audit, auth). Moved from Facts.",
45
+ "items": {
46
+ "type": "object",
47
+ "additionalProperties": false,
48
+ "required": ["id", "name", "rule"],
49
+ "properties": {
50
+ "id": { "$ref": "./frame.schema.json#/$defs/stable_id" },
51
+ "name": { "type": "string", "maxLength": 150 },
52
+ "rule": { "type": "string", "maxLength": 500 },
53
+ "links": { "$ref": "./frame.schema.json#/$defs/links" }
54
+ }
55
+ }
56
+ },
57
+ "commands": {
58
+ "type": "object",
59
+ "description": "Named shell commands. Keys are command names used by command_ref.",
60
+ "additionalProperties": {
61
+ "type": "object",
62
+ "additionalProperties": false,
63
+ "required": ["run", "kind", "purpose"],
64
+ "properties": {
65
+ "run": { "type": "string", "maxLength": 500, "description": "Shell command to execute." },
66
+ "kind": {
67
+ "type": "string",
68
+ "enum": ["setup", "verify", "run"],
69
+ "description": "setup=install deps, verify=validation check, run=server/interactive."
70
+ },
71
+ "purpose": { "type": "string", "maxLength": 300 },
72
+ "links": { "$ref": "./frame.schema.json#/$defs/links" }
73
+ }
74
+ }
75
+ },
76
+ "code_style": {
77
+ "type": "object",
78
+ "additionalProperties": true,
79
+ "description": "Formatting, naming, conventions. Advisory limit: 1000 chars total."
80
+ },
81
+ "git": {
82
+ "type": "object",
83
+ "additionalProperties": true,
84
+ "description": "Branch strategy, commit style, PR rules. Advisory limit: 1000 chars total."
85
+ },
86
+ "donts": {
87
+ "type": "array",
88
+ "description": "Things you must never do.",
89
+ "items": {
90
+ "type": "object",
91
+ "additionalProperties": false,
92
+ "required": ["id", "rule"],
93
+ "properties": {
94
+ "id": { "$ref": "./frame.schema.json#/$defs/stable_id" },
95
+ "rule": { "type": "string", "maxLength": 300 },
96
+ "severity": {
97
+ "type": "string",
98
+ "enum": ["critical", "warning"],
99
+ "default": "critical"
100
+ },
101
+ "links": { "$ref": "./frame.schema.json#/$defs/links" }
102
+ }
103
+ }
104
+ },
105
+ "ask_first": {
106
+ "type": "array",
107
+ "description": "Triggers that need human approval before the agent proceeds.",
108
+ "items": {
109
+ "type": "object",
110
+ "additionalProperties": false,
111
+ "required": ["id", "trigger_type", "trigger", "reason"],
112
+ "properties": {
113
+ "id": { "$ref": "./frame.schema.json#/$defs/stable_id" },
114
+ "trigger_type": {
115
+ "type": "string",
116
+ "enum": ["file_pattern", "task_pattern"]
117
+ },
118
+ "trigger": { "type": "string", "maxLength": 300 },
119
+ "reason": { "type": "string", "maxLength": 300 },
120
+ "links": { "$ref": "./frame.schema.json#/$defs/links" }
121
+ }
122
+ }
123
+ },
124
+ "hints": {
125
+ "type": "array",
126
+ "description": "Skill references, known gotchas, task-specific guidance.",
127
+ "items": {
128
+ "type": "object",
129
+ "additionalProperties": false,
130
+ "required": ["id", "hint"],
131
+ "properties": {
132
+ "id": { "$ref": "./frame.schema.json#/$defs/stable_id" },
133
+ "hint": { "type": "string", "maxLength": 300 }
134
+ }
135
+ }
136
+ },
137
+ "evidence": { "$ref": "./frame.schema.json#/$defs/evidence_list" },
138
+ "links": { "$ref": "./frame.schema.json#/$defs/links" }
139
+ }
140
+ }
@@ -0,0 +1,24 @@
1
+ """FRAME translators -- YAML/JSON conversion with normalization.
2
+
3
+ Public API:
4
+ translate_file("facts.yaml") → dict
5
+ translate_directory(".haxaml/") → {"facts": {...}, "rules": {...}, ...}
6
+ translate_to_dict(yaml_string) → dict
7
+ translate_to_json_string(yaml) → str
8
+ """
9
+
10
+ from framesdkpy.translators.yaml_to_json import (
11
+ translate_file,
12
+ translate_directory,
13
+ translate_to_dict,
14
+ translate_to_json_string,
15
+ )
16
+ from framesdkpy.translators.normalizer import TranslationError
17
+
18
+ __all__ = [
19
+ "translate_file",
20
+ "translate_directory",
21
+ "translate_to_dict",
22
+ "translate_to_json_string",
23
+ "TranslationError",
24
+ ]
@@ -0,0 +1,106 @@
1
+ """YAML type normalizer -- resolves YAML quirks into clean JSON-compatible types.
2
+
3
+ Handles the full YAML 1.2 quirk table defined in the translators spec:
4
+ yes/no → True/False
5
+ ~/null → None
6
+ Date-like strings preserved as strings
7
+ Empty strings and empty fields handled correctly
8
+
9
+ Raises TranslationError on ambiguous input (on/off).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+ from datetime import date, datetime
16
+
17
+
18
+ class TranslationError(ValueError):
19
+ """Raised when YAML input is ambiguous and cannot be safely translated."""
20
+
21
+ def __init__(self, path: str, value: Any, reason: str):
22
+ self.path = path
23
+ self.value = value
24
+ self.reason = reason
25
+ super().__init__(f"{path}: {reason} (got {value!r})")
26
+
27
+
28
+ def normalize_yaml_value(value: Any, path: str = "$") -> Any:
29
+ """Resolve a single YAML value to a JSON-compatible type.
30
+
31
+ Recursively handles dicts and lists. The path argument tracks position
32
+ for error messages -- use normalize_dict() for top-level entry.
33
+ """
34
+ # Dict: recurse into values, tracking keys for error paths
35
+ if isinstance(value, dict):
36
+ result: dict[str, Any] = {}
37
+ for k, v in value.items():
38
+ result[k] = normalize_yaml_value(v, f"{path}.{k}")
39
+ return result
40
+
41
+ # List: recurse into items
42
+ if isinstance(value, list):
43
+ return [normalize_yaml_value(item, f"{path}[{i}]") for i, item in enumerate(value)]
44
+
45
+ # None / null
46
+ if value is None:
47
+ return None
48
+
49
+ # Date/datetime: PyYAML SafeLoader parses bare YYYY-MM-DD scalars into
50
+ # date objects. FRAME preserves dates as strings so JSON output is stable
51
+ # across languages and serializers.
52
+ if isinstance(value, datetime):
53
+ return value.isoformat()
54
+ if isinstance(value, date):
55
+ return value.isoformat()
56
+
57
+ # String: check for YAML quirks
58
+ if isinstance(value, str):
59
+ return _normalize_string(value, path)
60
+
61
+ # Numbers and booleans pass through as-is (YAML already parsed them correctly)
62
+ return value
63
+
64
+
65
+ def _normalize_string(value: str, path: str) -> Any:
66
+ """Normalize a YAML string -- handle yes/no, null, empty, date-like."""
67
+
68
+ # Empty string: preserve as-is (not coerced to null)
69
+ if value == "":
70
+ return ""
71
+
72
+ # Explicit null values
73
+ if value.lower() in ("null", "~"):
74
+ return None
75
+
76
+ # YAML 1.2 booleans (only yes/no -- on/off are ambiguous and rejected)
77
+ if value.lower() in ("yes", "true", "y"):
78
+ return True
79
+ if value.lower() in ("no", "false", "n"):
80
+ return False
81
+
82
+ # Ambiguous: on/off are YAML 1.1 booleans. YAML 1.2 doesn't treat them as
83
+ # booleans, but some parsers do. We fail loudly rather than guessing.
84
+ if value.lower() in ("on", "off"):
85
+ raise TranslationError(
86
+ path, value,
87
+ "'on'/'off' is ambiguous in YAML. Use explicit 'true' or 'false' or quote as string.",
88
+ )
89
+
90
+ # Date-like strings (YYYY-MM-DD, YYYY/MM/DD): YAML parsers may convert these
91
+ # to datetime objects. We preserve them as strings for FRAME consistency.
92
+ # The caller's YAML loader should use a loader that doesn't auto-parse dates.
93
+ # If a datetime slips through (parsed by the YAML lib), we catch it above in
94
+ # the type check -- datetimes are normalized back to strings before JSON/schema validation.
95
+
96
+ return value
97
+
98
+
99
+ def normalize_dict(data: dict[str, Any]) -> dict[str, Any]:
100
+ """Normalize an entire YAML dict. Entry point for the translator."""
101
+ return normalize_yaml_value(data, "$")
102
+
103
+
104
+ def normalize_list(data: list[Any]) -> list[Any]:
105
+ """Normalize a YAML list. Convenience for array-only files."""
106
+ return normalize_yaml_value(data, "$")
@@ -0,0 +1,88 @@
1
+ """YAML to JSON translator -- reads FRAME YAML files and produces clean JSON.
2
+
3
+ Uses the normalizer to resolve YAML quirks, then validates the output against
4
+ the JSON Schema shape. Returns a clean dict ready for the validator or assembler.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ import yaml
13
+
14
+ from framesdkpy.translators.normalizer import normalize_dict
15
+
16
+
17
+ # Use PyYAML SafeLoader, then normalize parsed values into JSON-compatible FRAME values.
18
+ # This prevents YAML quirks from reaching the normalizer.
19
+ _YAML_LOADER = yaml.SafeLoader
20
+
21
+
22
+ def translate_to_dict(yaml_string: str) -> dict:
23
+ """Parse a YAML string into a clean JSON-compatible dict.
24
+
25
+ Handles all YAML to JSON normalization per the translators spec.
26
+ Raises TranslationError on ambiguous input.
27
+ """
28
+ raw = yaml.load(yaml_string, Loader=_YAML_LOADER)
29
+ if raw is None:
30
+ return {}
31
+ if not isinstance(raw, dict):
32
+ raise ValueError(f"Expected YAML object, got {type(raw).__name__}")
33
+ return normalize_dict(raw)
34
+
35
+
36
+ def translate_file(file_path: str | Path) -> dict:
37
+ """Read a YAML file and translate to a clean JSON-compatible dict.
38
+
39
+ Args:
40
+ file_path: Path to a .yaml FRAME file (facts.yaml, rules.yaml, etc.)
41
+
42
+ Returns:
43
+ Clean dict with all YAML quirks resolved.
44
+ """
45
+ path = Path(file_path)
46
+ if not path.exists():
47
+ raise FileNotFoundError(f"FRAME file not found: {path}")
48
+ if path.suffix not in (".yaml", ".yml"):
49
+ raise ValueError(f"Expected .yaml file, got {path.suffix}: {path}")
50
+
51
+ yaml_string = path.read_text()
52
+ return translate_to_dict(yaml_string)
53
+
54
+
55
+ def translate_directory(dir_path: str | Path) -> dict[str, dict]:
56
+ """Read all 5 FRAME YAML files from a directory.
57
+
58
+ Args:
59
+ dir_path: Directory containing facts.yaml, rules.yaml, map.yaml,
60
+ expect.yaml, acts.yaml.
61
+
62
+ Returns:
63
+ Dict mapping file stem to translated dict, e.g.:
64
+ {"facts": {...}, "rules": {...}, "map": {...}, "expect": {...}, "acts": {...}}
65
+ """
66
+ directory = Path(dir_path)
67
+ if not directory.is_dir():
68
+ raise FileNotFoundError(f"FRAME directory not found: {directory}")
69
+
70
+ expected_files = ["facts", "rules", "map", "expect", "acts"]
71
+ result: dict[str, dict] = {}
72
+
73
+ for stem in expected_files:
74
+ yaml_file = directory / f"{stem}.yaml"
75
+ if not yaml_file.exists():
76
+ raise FileNotFoundError(
77
+ f"Missing FRAME file: {stem}.yaml in {directory}. "
78
+ f"All 5 files must be in the same directory."
79
+ )
80
+ result[stem] = translate_file(yaml_file)
81
+
82
+ return result
83
+
84
+
85
+ def translate_to_json_string(yaml_string: str, indent: int = 2) -> str:
86
+ """Parse YAML and return a JSON string directly."""
87
+ data = translate_to_dict(yaml_string)
88
+ return json.dumps(data, indent=indent, ensure_ascii=False)
@@ -0,0 +1,74 @@
1
+ """FRAME validators -- schema, character limits, and cross-file consistency.
2
+
3
+ Public API:
4
+ validate_file("facts.yaml") → ValidationResult (single file)
5
+ validate_frame(".haxaml/") → ValidationResult (all 5 + cross-file)
6
+ validate_against_schema(data, "facts") → ValidationResult (in-memory)
7
+ validate_limits(data, "facts") → ValidationResult (in-memory)
8
+ """
9
+
10
+ from framesdkpy.validators.result import ValidationResult, ValidationError, ValidationWarning
11
+ from framesdkpy.validators.schema_validator import validate_against_schema, validate_yaml_file
12
+ from framesdkpy.validators.limits_validator import validate_limits
13
+ from framesdkpy.validators.cross_file_validator import validate_cross_file
14
+
15
+
16
+ def validate_file(file_path: str) -> ValidationResult:
17
+ """Validate a single FRAME YAML file against schema + limits.
18
+
19
+ Does NOT run cross-file checks (only one file).
20
+ """
21
+ result = validate_yaml_file(file_path)
22
+
23
+ # Also run limits validation
24
+ import yaml
25
+ from pathlib import Path
26
+ from framesdkpy.translators.normalizer import normalize_dict
27
+
28
+ path = Path(file_path)
29
+ raw = yaml.safe_load(path.read_text())
30
+ if raw and isinstance(raw, dict):
31
+ clean = normalize_dict(raw)
32
+ limits_result = validate_limits(clean, path.stem)
33
+ result = result.merge(limits_result)
34
+
35
+ return result
36
+
37
+
38
+ def validate_frame(dir_path: str) -> ValidationResult:
39
+ """Validate all 5 FRAME files in a directory + cross-file consistency.
40
+
41
+ Steps:
42
+ 1. Schema validation for each file
43
+ 2. Character limit validation for each file
44
+ 3. Cross-file consistency (versions, file/role matching)
45
+ """
46
+ from pathlib import Path
47
+ from framesdkpy.translators.yaml_to_json import translate_directory
48
+
49
+ # Translate all 5 files to clean dicts
50
+ parts = translate_directory(dir_path)
51
+
52
+ result = ValidationResult()
53
+
54
+ # Schema + limits for each file
55
+ for stem, data in parts.items():
56
+ result = result.merge(validate_against_schema(data, stem))
57
+ result = result.merge(validate_limits(data, stem))
58
+
59
+ # Cross-file consistency
60
+ result = result.merge(validate_cross_file(parts))
61
+
62
+ return result
63
+
64
+
65
+ __all__ = [
66
+ "ValidationResult",
67
+ "ValidationError",
68
+ "ValidationWarning",
69
+ "validate_file",
70
+ "validate_frame",
71
+ "validate_against_schema",
72
+ "validate_limits",
73
+ "validate_cross_file",
74
+ ]
@@ -0,0 +1,85 @@
1
+ """Cross-file validator -- checks consistency across all 5 FRAME files.
2
+
3
+ Verifies:
4
+ - All schema_version fields match
5
+ - All file/role fields match expected values (facts file must have file: facts)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from framesdkpy.validators.result import ValidationResult
11
+
12
+
13
+ _EXPECTED_FILE_MAP = {
14
+ "facts": "facts",
15
+ "rules": "rules",
16
+ "map": "map",
17
+ "expect": "expect",
18
+ "acts": "acts",
19
+ }
20
+
21
+ _EXPECTED_ROLE_MAP = {
22
+ "facts": "current_project_truth",
23
+ "rules": "project_instruction_blueprint",
24
+ "map": "repo_context_map",
25
+ "expect": "project_correctness_contract",
26
+ "acts": "checked_activity_record",
27
+ }
28
+
29
+
30
+ def validate_cross_file(parts: dict[str, dict]) -> ValidationResult:
31
+ """Check cross-file consistency across all 5 translated FRAME parts.
32
+
33
+ Args:
34
+ parts: Dict mapping file stem to translated dict.
35
+ e.g., {"facts": {...}, "rules": {...}, ...}
36
+
37
+ Returns:
38
+ ValidationResult with errors for mismatched versions or file/role fields.
39
+ """
40
+ result = ValidationResult()
41
+
42
+ # Gather schema_version from every file that has a frame header
43
+ versions: dict[str, str] = {}
44
+ for stem, data in parts.items():
45
+ header = data.get("frame", {})
46
+ version = header.get("schema_version")
47
+ if version:
48
+ versions[stem] = version
49
+
50
+ # All present schema_versions must match
51
+ if len(set(versions.values())) > 1:
52
+ details = ", ".join(f"{s}={v}" for s, v in versions.items())
53
+ result.add_error(
54
+ path="frame.schema_version",
55
+ message=f"Schema version mismatch across files: {details}",
56
+ code="schema_version_mismatch",
57
+ )
58
+
59
+ # Each file's 'file' and 'role' fields must match expected values
60
+ for stem, data in parts.items():
61
+ header = data.get("frame", {})
62
+
63
+ expected_file = _EXPECTED_FILE_MAP.get(stem)
64
+ actual_file = header.get("file")
65
+ if expected_file and actual_file and actual_file != expected_file:
66
+ result.add_error(
67
+ path=f"{stem}.frame.file",
68
+ message=f"File field mismatch in {stem}.yaml",
69
+ code="file_role_mismatch",
70
+ expected=expected_file,
71
+ actual=actual_file,
72
+ )
73
+
74
+ expected_role = _EXPECTED_ROLE_MAP.get(stem)
75
+ actual_role = header.get("role")
76
+ if expected_role and actual_role and actual_role != expected_role:
77
+ result.add_error(
78
+ path=f"{stem}.frame.role",
79
+ message=f"Role field mismatch in {stem}.yaml",
80
+ code="file_role_mismatch",
81
+ expected=expected_role,
82
+ actual=actual_role,
83
+ )
84
+
85
+ return result