metaobjects 0.9.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.
- metaobjects/__init__.py +75 -0
- metaobjects/agent_context/__init__.py +55 -0
- metaobjects/agent_context/_content/README.md +14 -0
- metaobjects/agent_context/_content/servers/csharp.meta.json +5 -0
- metaobjects/agent_context/_content/servers/java.meta.json +5 -0
- metaobjects/agent_context/_content/servers/kotlin.meta.json +5 -0
- metaobjects/agent_context/_content/servers/python.meta.json +5 -0
- metaobjects/agent_context/_content/servers/typescript.meta.json +5 -0
- metaobjects/agent_context/_content/skills/metaobjects-authoring/SKILL.md +301 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/SKILL.md +99 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/csharp.md +87 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/java.md +94 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/kotlin.md +110 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/typescript.md +135 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/SKILL.md +148 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/csharp.md +110 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/java.md +108 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/kotlin.md +130 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/python.md +116 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/typescript.md +150 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/SKILL.md +130 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/java.md +96 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/kotlin.md +99 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/react.md +86 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/tanstack.md +119 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/typescript.md +92 -0
- metaobjects/agent_context/_content/skills/metaobjects-verify/SKILL.md +107 -0
- metaobjects/agent_context/_content/skills/metaobjects-verify/references/migration.md +72 -0
- metaobjects/agent_context/_content/templates/always-on.md.mustache +27 -0
- metaobjects/agent_context/assemble.py +133 -0
- metaobjects/agent_context/content_root.py +54 -0
- metaobjects/agent_context/scaffold.py +191 -0
- metaobjects/agent_context/types.py +44 -0
- metaobjects/attr_class_map.py +23 -0
- metaobjects/cli.py +696 -0
- metaobjects/codegen/__init__.py +0 -0
- metaobjects/codegen/config.py +11 -0
- metaobjects/codegen/constants.py +13 -0
- metaobjects/codegen/extract_delegate_emitter.py +384 -0
- metaobjects/codegen/extract_schema_emitter.py +139 -0
- metaobjects/codegen/format.py +31 -0
- metaobjects/codegen/fr010_field_mapping.py +220 -0
- metaobjects/codegen/generator.py +62 -0
- metaobjects/codegen/generator_registry.py +163 -0
- metaobjects/codegen/generators/__init__.py +0 -0
- metaobjects/codegen/generators/entity_model.py +263 -0
- metaobjects/codegen/generators/extractor_generator.py +317 -0
- metaobjects/codegen/generators/filter_allowlist_generator.py +309 -0
- metaobjects/codegen/generators/m2m_codegen.py +192 -0
- metaobjects/codegen/generators/output_parser_generator.py +272 -0
- metaobjects/codegen/generators/output_prompt_generator.py +192 -0
- metaobjects/codegen/generators/payload_vo_generator.py +672 -0
- metaobjects/codegen/generators/render_helper_generator.py +451 -0
- metaobjects/codegen/generators/router_generator.py +635 -0
- metaobjects/codegen/generators/template_generator.py +70 -0
- metaobjects/codegen/generators/tph_plan.py +120 -0
- metaobjects/codegen/generators/trace_helper_generator.py +336 -0
- metaobjects/codegen/instance_artifacts.py +15 -0
- metaobjects/codegen/output_format_spec_emitter.py +79 -0
- metaobjects/codegen/overwrite_policy.py +27 -0
- metaobjects/codegen/runner.py +110 -0
- metaobjects/codegen/runtime/__init__.py +6 -0
- metaobjects/codegen/runtime/filter_parser.py +193 -0
- metaobjects/codegen/type_map.py +84 -0
- metaobjects/core_types.py +809 -0
- metaobjects/datatype.py +19 -0
- metaobjects/documentation/__init__.py +28 -0
- metaobjects/documentation/doc_constants.py +20 -0
- metaobjects/documentation/doc_provider.py +20 -0
- metaobjects/documentation/doc_schema.py +24 -0
- metaobjects/errors.py +124 -0
- metaobjects/loader/__init__.py +0 -0
- metaobjects/loader/merge.py +287 -0
- metaobjects/loader/meta_data_loader.py +245 -0
- metaobjects/loader/sources/__init__.py +24 -0
- metaobjects/loader/sources/directory_source.py +50 -0
- metaobjects/loader/sources/file_source.py +41 -0
- metaobjects/loader/sources/meta_data_source.py +67 -0
- metaobjects/loader/sources/uri_source.py +56 -0
- metaobjects/loader/validate_discriminator.py +181 -0
- metaobjects/loader/validate_field_readonly.py +146 -0
- metaobjects/loader/validate_source_parameter_ref.py +159 -0
- metaobjects/loader/validate_source_physical_names.py +140 -0
- metaobjects/loader/validation_passes.py +1513 -0
- metaobjects/meta/__init__.py +1 -0
- metaobjects/meta/core/__init__.py +0 -0
- metaobjects/meta/core/attr/__init__.py +0 -0
- metaobjects/meta/core/attr/attr_constants.py +31 -0
- metaobjects/meta/core/attr/meta_attr.py +136 -0
- metaobjects/meta/core/field/__init__.py +0 -0
- metaobjects/meta/core/field/field_constants.py +105 -0
- metaobjects/meta/core/field/meta_field.py +76 -0
- metaobjects/meta/core/identity/__init__.py +0 -0
- metaobjects/meta/core/identity/identity_constants.py +19 -0
- metaobjects/meta/core/identity/meta_identity.py +8 -0
- metaobjects/meta/core/object/__init__.py +0 -0
- metaobjects/meta/core/object/meta_object.py +65 -0
- metaobjects/meta/core/object/meta_object_aware.py +43 -0
- metaobjects/meta/core/object/object_class_registry.py +56 -0
- metaobjects/meta/core/object/object_constants.py +13 -0
- metaobjects/meta/core/object/object_extract.py +400 -0
- metaobjects/meta/core/object/value_object.py +70 -0
- metaobjects/meta/core/relationship/__init__.py +0 -0
- metaobjects/meta/core/relationship/derive_m2m_fields.py +180 -0
- metaobjects/meta/core/relationship/meta_relationship.py +54 -0
- metaobjects/meta/core/relationship/relationship_constants.py +51 -0
- metaobjects/meta/core/validator/__init__.py +0 -0
- metaobjects/meta/core/validator/validator_constants.py +18 -0
- metaobjects/meta/meta_data.py +206 -0
- metaobjects/meta/meta_root.py +8 -0
- metaobjects/meta/persistence/__init__.py +0 -0
- metaobjects/meta/persistence/db/__init__.py +1 -0
- metaobjects/meta/persistence/db/db_constants.py +41 -0
- metaobjects/meta/persistence/db/db_provider.py +60 -0
- metaobjects/meta/persistence/origin/__init__.py +0 -0
- metaobjects/meta/persistence/origin/meta_origin.py +8 -0
- metaobjects/meta/persistence/origin/origin_constants.py +20 -0
- metaobjects/meta/persistence/source/__init__.py +0 -0
- metaobjects/meta/persistence/source/meta_source.py +137 -0
- metaobjects/meta/persistence/source/source_constants.py +115 -0
- metaobjects/meta/presentation/__init__.py +0 -0
- metaobjects/meta/presentation/layout/__init__.py +0 -0
- metaobjects/meta/presentation/layout/layout_constants.py +13 -0
- metaobjects/meta/presentation/layout/meta_layout.py +8 -0
- metaobjects/meta/presentation/view/__init__.py +0 -0
- metaobjects/meta/presentation/view/meta_view.py +8 -0
- metaobjects/meta/presentation/view/view_constants.py +22 -0
- metaobjects/meta/template/__init__.py +0 -0
- metaobjects/meta/template/meta_template.py +46 -0
- metaobjects/meta/template/template_constants.py +112 -0
- metaobjects/meta/template/template_provider.py +43 -0
- metaobjects/parser.py +380 -0
- metaobjects/parser_yaml.py +82 -0
- metaobjects/provider.py +111 -0
- metaobjects/py.typed +0 -0
- metaobjects/registry.py +210 -0
- metaobjects/registry_manifest.py +223 -0
- metaobjects/render/__init__.py +74 -0
- metaobjects/render/email_document.py +14 -0
- metaobjects/render/escapers.py +109 -0
- metaobjects/render/extract/__init__.py +59 -0
- metaobjects/render/extract/coerce.py +279 -0
- metaobjects/render/extract/extract.py +211 -0
- metaobjects/render/extract/extract_map.py +61 -0
- metaobjects/render/extract/json_forgiving_reader.py +203 -0
- metaobjects/render/extract/locate.py +65 -0
- metaobjects/render/extract/normalize.py +96 -0
- metaobjects/render/extract/strip.py +20 -0
- metaobjects/render/extract/types.py +332 -0
- metaobjects/render/extract/xml_forgiving_reader.py +162 -0
- metaobjects/render/filesystem_provider.py +51 -0
- metaobjects/render/prompt/__init__.py +32 -0
- metaobjects/render/prompt/output_format_renderer.py +340 -0
- metaobjects/render/prompt/output_format_spec.py +28 -0
- metaobjects/render/prompt/prompt_field.py +29 -0
- metaobjects/render/prompt/prompt_overrides.py +29 -0
- metaobjects/render/prompt/prompt_style.py +38 -0
- metaobjects/render/renderer.py +358 -0
- metaobjects/render/verify.py +266 -0
- metaobjects/runtime/__init__.py +39 -0
- metaobjects/runtime/llm_recorder.py +210 -0
- metaobjects/runtime/n2m_resolver.py +155 -0
- metaobjects/runtime/object_manager.py +715 -0
- metaobjects/runtime/tph.py +50 -0
- metaobjects/serializer_json.py +172 -0
- metaobjects/shared/__init__.py +0 -0
- metaobjects/shared/base_types.py +16 -0
- metaobjects/shared/separators.py +4 -0
- metaobjects/shared/structural.py +9 -0
- metaobjects/source/__init__.py +79 -0
- metaobjects/source/error_source.py +266 -0
- metaobjects/source/json_path.py +106 -0
- metaobjects/source/semantic_diff.py +98 -0
- metaobjects/source/yaml_positions.py +174 -0
- metaobjects/super_resolve.py +128 -0
- metaobjects/yaml_desugar.py +481 -0
- metaobjects-0.9.0.dist-info/METADATA +97 -0
- metaobjects-0.9.0.dist-info/RECORD +181 -0
- metaobjects-0.9.0.dist-info/WHEEL +4 -0
- metaobjects-0.9.0.dist-info/entry_points.txt +2 -0
- metaobjects-0.9.0.dist-info/licenses/LICENSE +189 -0
|
@@ -0,0 +1,1513 @@
|
|
|
1
|
+
"""Loader validation passes — run after super-resolution, before freeze (ADR-0002).
|
|
2
|
+
|
|
3
|
+
Errors are cross-node checks that cannot live on a single node. Each pass
|
|
4
|
+
appends to `errors` (list[MetaError]) or `warnings` (list[str]).
|
|
5
|
+
Error message text is free; error CODES are the conformance contract.
|
|
6
|
+
Warning strings are byte-identical to the expected-warnings fixtures.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from ..errors import ErrorCode, MetaError
|
|
14
|
+
from ..source.error_source import LoaderWarning
|
|
15
|
+
from .validate_source_physical_names import validate_source_physical_names
|
|
16
|
+
from .validate_field_readonly import validate_field_readonly
|
|
17
|
+
from .validate_discriminator import validate_discriminator
|
|
18
|
+
from .validate_source_parameter_ref import validate_source_parameter_ref
|
|
19
|
+
from ..meta.core.field.field_constants import (
|
|
20
|
+
ENUM_MEMBER_PATTERN,
|
|
21
|
+
FIELD_ATTR_COERCE_DEFAULT,
|
|
22
|
+
FIELD_ATTR_DEFAULT,
|
|
23
|
+
FIELD_ATTR_OBJECT_REF,
|
|
24
|
+
FIELD_ATTR_STORAGE,
|
|
25
|
+
FIELD_ATTR_VALUES,
|
|
26
|
+
FIELD_SUBTYPE_BOOLEAN,
|
|
27
|
+
FIELD_SUBTYPE_CURRENCY,
|
|
28
|
+
FIELD_SUBTYPE_DECIMAL,
|
|
29
|
+
FIELD_SUBTYPE_DOUBLE,
|
|
30
|
+
FIELD_SUBTYPE_ENUM,
|
|
31
|
+
FIELD_SUBTYPE_FLOAT,
|
|
32
|
+
FIELD_SUBTYPE_INT,
|
|
33
|
+
FIELD_SUBTYPE_LONG,
|
|
34
|
+
FIELD_SUBTYPE_OBJECT,
|
|
35
|
+
FIELD_SUBTYPE_STRING,
|
|
36
|
+
FIELD_SUBTYPE_TIMESTAMP,
|
|
37
|
+
)
|
|
38
|
+
from ..meta.persistence.db.db_constants import (
|
|
39
|
+
DB_COLUMN_TYPE_JSONB,
|
|
40
|
+
DB_COLUMN_TYPE_TIMESTAMP_TZ,
|
|
41
|
+
DB_COLUMN_TYPE_UUID,
|
|
42
|
+
FIELD_ATTR_DB_COLUMN_TYPE,
|
|
43
|
+
VALID_DB_COLUMN_TYPES,
|
|
44
|
+
)
|
|
45
|
+
from ..meta.core.object.meta_object import MetaObject
|
|
46
|
+
from ..meta.meta_data import MetaData
|
|
47
|
+
from ..meta.persistence.source.meta_source import MetaSource
|
|
48
|
+
from ..meta.persistence.source.source_constants import SOURCE_ROLE_PRIMARY
|
|
49
|
+
from ..meta.core.attr.attr_constants import (
|
|
50
|
+
ATTR_SUBTYPE_PROPERTIES,
|
|
51
|
+
ATTR_SUBTYPE_STRINGARRAY,
|
|
52
|
+
)
|
|
53
|
+
from ..registry import AttrSchema, TypeRegistry
|
|
54
|
+
from ..shared.base_types import (
|
|
55
|
+
TYPE_FIELD,
|
|
56
|
+
TYPE_IDENTITY,
|
|
57
|
+
TYPE_LAYOUT,
|
|
58
|
+
TYPE_OBJECT,
|
|
59
|
+
TYPE_ORIGIN,
|
|
60
|
+
TYPE_RELATIONSHIP,
|
|
61
|
+
TYPE_SOURCE,
|
|
62
|
+
TYPE_TEMPLATE,
|
|
63
|
+
)
|
|
64
|
+
from ..meta.template import template_constants as tc
|
|
65
|
+
from ..meta.presentation.layout.layout_constants import (
|
|
66
|
+
LAYOUT_ATTR_DEFAULT_SORT_FIELD,
|
|
67
|
+
LAYOUT_ATTR_FILTER,
|
|
68
|
+
LAYOUT_SUBTYPE_DATA_GRID,
|
|
69
|
+
)
|
|
70
|
+
from ..meta.persistence.origin.origin_constants import (
|
|
71
|
+
ORIGIN_ATTR_FROM,
|
|
72
|
+
ORIGIN_ATTR_OF,
|
|
73
|
+
ORIGIN_ATTR_VIA,
|
|
74
|
+
ORIGIN_SUBTYPE_AGGREGATE,
|
|
75
|
+
ORIGIN_SUBTYPE_PASSTHROUGH,
|
|
76
|
+
)
|
|
77
|
+
from ..meta.core.relationship.relationship_constants import (
|
|
78
|
+
CARDINALITY_MANY,
|
|
79
|
+
RELATIONSHIP_ATTR_CARDINALITY,
|
|
80
|
+
RELATIONSHIP_ATTR_OBJECT_REF,
|
|
81
|
+
RELATIONSHIP_ATTR_SOURCE_REF_FIELD,
|
|
82
|
+
RELATIONSHIP_ATTR_SYMMETRIC,
|
|
83
|
+
RELATIONSHIP_ATTR_THROUGH,
|
|
84
|
+
)
|
|
85
|
+
from ..meta.core.identity.identity_constants import IDENTITY_SUBTYPE_REFERENCE
|
|
86
|
+
from ..shared.separators import PACKAGE_SEP
|
|
87
|
+
from ..meta.core.object.object_constants import OBJECT_SUBTYPE_ENTITY, OBJECT_SUBTYPE_VALUE
|
|
88
|
+
from ..meta.core.identity.identity_constants import IDENTITY_ATTR_FIELDS
|
|
89
|
+
from ..source import resolved_source
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Public entry point
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def run_validations(
|
|
97
|
+
root: MetaData,
|
|
98
|
+
registry: TypeRegistry,
|
|
99
|
+
errors: list[MetaError],
|
|
100
|
+
warnings: list[str],
|
|
101
|
+
envelope_warnings: list[LoaderWarning] | None = None,
|
|
102
|
+
strict: bool = False,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Run all validation passes in order.
|
|
105
|
+
|
|
106
|
+
Passes are designed to be additive: later tasks add passes here.
|
|
107
|
+
|
|
108
|
+
``envelope_warnings`` (optional) — FR5c-style envelope warning channel for
|
|
109
|
+
validation passes that produce envelope-shaped warnings (e.g. FR-016's
|
|
110
|
+
``WARN_LEGACY_PHYSICAL_NAME_ALIAS``). When ``None``, those passes still
|
|
111
|
+
push the warning code onto the legacy ``warnings`` channel so existing
|
|
112
|
+
consumers see something.
|
|
113
|
+
"""
|
|
114
|
+
_validate_attr_schema(root, registry, errors, strict)
|
|
115
|
+
_validate_enum_values(root, errors)
|
|
116
|
+
_validate_field_defaults(root, errors)
|
|
117
|
+
_validate_db_column_type(root, errors)
|
|
118
|
+
_validate_datagrid_sort_fields(root, errors)
|
|
119
|
+
_validate_datagrid_filter_values(root, errors)
|
|
120
|
+
_validate_origin_paths(root, errors)
|
|
121
|
+
_validate_relationships(root, errors)
|
|
122
|
+
_validate_one_primary_source(root, errors)
|
|
123
|
+
# FR-016 / ADR-0018 — per-kind physical-name aliases on source.rdb.
|
|
124
|
+
validate_source_physical_names(root, errors, envelope_warnings, warnings)
|
|
125
|
+
# FR-013 — field-level @readOnly cross-attribute rules.
|
|
126
|
+
validate_field_readonly(root, errors, envelope_warnings, warnings)
|
|
127
|
+
# FR-014 — TPH discriminator cross-attribute rules.
|
|
128
|
+
validate_discriminator(root, errors)
|
|
129
|
+
# FR-015 — source.rdb @parameterRef typed-input rules.
|
|
130
|
+
validate_source_parameter_ref(root, errors)
|
|
131
|
+
_validate_field_object_storage(root, errors)
|
|
132
|
+
_validate_templates(root, errors)
|
|
133
|
+
_validate_subtype_rules(root, errors, warnings)
|
|
134
|
+
_validate_filterable_has_index(root, warnings)
|
|
135
|
+
# SP-H Unit9 — @filterable on a subtype with no operator band → error.
|
|
136
|
+
_validate_filterable_has_supported_ops(root, errors)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# Walk helper
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _walk(root: MetaData) -> list[MetaData]:
|
|
145
|
+
"""Return all authored nodes in the tree (BFS order, including root)."""
|
|
146
|
+
result: list[MetaData] = []
|
|
147
|
+
queue: list[MetaData] = [root]
|
|
148
|
+
while queue:
|
|
149
|
+
node = queue.pop(0)
|
|
150
|
+
result.append(node)
|
|
151
|
+
queue.extend(node.own_children())
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# Pass: attr-schema validation
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# Three checks per node:
|
|
159
|
+
# 1. Required attrs present (checks effective attr set — inherited attrs satisfy
|
|
160
|
+
# the requirement, mirroring the TS reference in attr-schema-validate.ts).
|
|
161
|
+
# 2. Type check: for each OWN attr whose name matches a schema, validate the
|
|
162
|
+
# stored (post-desugar) value type against schema.value_type.
|
|
163
|
+
# 3. Allowed-values check: for own attrs with a matching schema that declares
|
|
164
|
+
# allowed_values, the value must be a member.
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _type_ok(value: object, value_type: str) -> bool:
|
|
168
|
+
"""Return True if *value* matches *value_type* (an attr subtype name)."""
|
|
169
|
+
if value_type == "int":
|
|
170
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
171
|
+
if value_type == "long":
|
|
172
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
173
|
+
if value_type == "double":
|
|
174
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
175
|
+
if value_type == "boolean":
|
|
176
|
+
return isinstance(value, bool)
|
|
177
|
+
if value_type == "string":
|
|
178
|
+
return isinstance(value, str)
|
|
179
|
+
if value_type == "stringarray":
|
|
180
|
+
return isinstance(value, list)
|
|
181
|
+
if value_type in ("filter", "properties"):
|
|
182
|
+
# Object-typed attrs must be a dict (not a string, not an array).
|
|
183
|
+
# A legacy-string @filter (not desugared to a dict) is invalid:
|
|
184
|
+
# FilterAttr.desugar only applies when the input IS a dict; if a string
|
|
185
|
+
# was passed it remains a str. Mirrors C# ValueMatchesType (properties
|
|
186
|
+
# + filter both require IReadOnlyDictionary) — feeds the FR-010
|
|
187
|
+
# @enumAlias/@enumDoc shape guard.
|
|
188
|
+
return isinstance(value, dict)
|
|
189
|
+
# Unknown value types (e.g. "class") — allow anything.
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _node_label(node: MetaData) -> str:
|
|
194
|
+
head = f"{node.type}.{node.sub_type}"
|
|
195
|
+
return f"{head} '{node.name}'" if node.name else head
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _effective_schemas(
|
|
199
|
+
type_: str,
|
|
200
|
+
sub_type: str,
|
|
201
|
+
common_attrs: list[AttrSchema],
|
|
202
|
+
registry: TypeRegistry,
|
|
203
|
+
errors: list[MetaError],
|
|
204
|
+
node: MetaData,
|
|
205
|
+
) -> tuple[list[AttrSchema], dict[str, AttrSchema]]:
|
|
206
|
+
"""Compute the effective attr schema for a (type, sub_type).
|
|
207
|
+
|
|
208
|
+
Per-type attrs win over common attrs of the same name. If any collision
|
|
209
|
+
exists, append a single ERR_PROVIDER_ATTR_CONFLICT for this (type, sub_type).
|
|
210
|
+
*node* supplies the FR5a envelope for the conflict error (matches C#
|
|
211
|
+
ValidationPasses.cs:593-596 — ``Envelope: node.Source``).
|
|
212
|
+
"""
|
|
213
|
+
per_type_attrs = registry.attrs_of(type_, sub_type)
|
|
214
|
+
per_type_names = {s.name for s in per_type_attrs}
|
|
215
|
+
|
|
216
|
+
for ca in common_attrs:
|
|
217
|
+
if ca.name in per_type_names:
|
|
218
|
+
errors.append(
|
|
219
|
+
MetaError(
|
|
220
|
+
f"{type_}.{sub_type} has a per-type attr '@{ca.name}' "
|
|
221
|
+
f"that conflicts with a common attr of the same name",
|
|
222
|
+
ErrorCode.ERR_PROVIDER_ATTR_CONFLICT,
|
|
223
|
+
envelope=node.source,
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
break # one error per (type, sub_type) is sufficient
|
|
227
|
+
|
|
228
|
+
schemas = per_type_attrs + [ca for ca in common_attrs if ca.name not in per_type_names]
|
|
229
|
+
return schemas, {s.name: s for s in schemas}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _validate_attr_schema(
|
|
233
|
+
root: MetaData,
|
|
234
|
+
registry: TypeRegistry,
|
|
235
|
+
errors: list[MetaError],
|
|
236
|
+
strict: bool = False,
|
|
237
|
+
) -> None:
|
|
238
|
+
common_attrs = registry.get_common_attrs()
|
|
239
|
+
# Cache effective schemas per (type, sub_type) — also dedupes the per-type-vs-common
|
|
240
|
+
# conflict report (the registry is global, so each (type, sub_type) yields one error).
|
|
241
|
+
schema_cache: dict[tuple[str, str], tuple[list[AttrSchema], dict[str, AttrSchema]]] = {}
|
|
242
|
+
|
|
243
|
+
for node in _walk(root):
|
|
244
|
+
key = (node.type, node.sub_type)
|
|
245
|
+
cached = schema_cache.get(key)
|
|
246
|
+
if cached is None:
|
|
247
|
+
cached = _effective_schemas(node.type, node.sub_type, common_attrs, registry, errors, node)
|
|
248
|
+
schema_cache[key] = cached
|
|
249
|
+
schemas, schema_by_name = cached
|
|
250
|
+
|
|
251
|
+
# --- Check 0 (ADR-0023): strict-load undeclared-attr rejection ---
|
|
252
|
+
#
|
|
253
|
+
# Runs BEFORE the `not schemas` early-return: a node type with no
|
|
254
|
+
# per-type schema and no common attrs must still reject an authored
|
|
255
|
+
# @-attr under strict. Own-attrs only — an inherited/overlaid declared
|
|
256
|
+
# attr was validated on its declaring node and never appears in
|
|
257
|
+
# own_meta_attrs(). An own attr matching neither a per-type schema entry
|
|
258
|
+
# nor a commonAttr is a made-up attribute → ERR_UNKNOWN_ATTR (closing the
|
|
259
|
+
# open policy). In lax mode (the default) this is a no-op, preserving the
|
|
260
|
+
# legacy open-attr behavior so downstream apps can loosen.
|
|
261
|
+
if strict:
|
|
262
|
+
for attr_node in node.own_meta_attrs():
|
|
263
|
+
# attr.properties is a first-class, registered, canonical attr
|
|
264
|
+
# subtype whose designed purpose is an arbitrary-named structural
|
|
265
|
+
# property bag (its NAME is intentionally not declared by any
|
|
266
|
+
# per-type schema). It is sanctioned vocabulary, not a made-up
|
|
267
|
+
# attribute, so strict-attr exempts a materialized properties-attr
|
|
268
|
+
# from ERR_UNKNOWN_ATTR. (A typo'd plain @-attr still fails — only
|
|
269
|
+
# the `properties` subtype is exempt.) Mirrors the TS reference
|
|
270
|
+
# Check-0 in attr-schema-validate.ts.
|
|
271
|
+
if attr_node.sub_type == ATTR_SUBTYPE_PROPERTIES:
|
|
272
|
+
continue
|
|
273
|
+
if attr_node.name not in schema_by_name:
|
|
274
|
+
errors.append(
|
|
275
|
+
MetaError(
|
|
276
|
+
f"Unknown attribute '@{attr_node.name}' on "
|
|
277
|
+
f"{_node_label(node)} — not declared by any registered "
|
|
278
|
+
f"provider for {node.type}.{node.sub_type}",
|
|
279
|
+
ErrorCode.ERR_UNKNOWN_ATTR,
|
|
280
|
+
envelope=node.source,
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if not schemas:
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
# --- Check 1: required attrs must be present (uses node.attrs() = effective,
|
|
288
|
+
# so an inherited attr from the super chain satisfies the requirement) ---
|
|
289
|
+
present_attrs = node.attrs()
|
|
290
|
+
for schema in schemas:
|
|
291
|
+
if not schema.required:
|
|
292
|
+
continue
|
|
293
|
+
if schema.name not in present_attrs:
|
|
294
|
+
errors.append(
|
|
295
|
+
MetaError(
|
|
296
|
+
f"{_node_label(node)} is missing required attribute '@{schema.name}'",
|
|
297
|
+
ErrorCode.ERR_MISSING_REQUIRED_ATTR,
|
|
298
|
+
envelope=node.source,
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# --- Checks 2 + 3: own attrs only (inherited attrs were already checked on
|
|
303
|
+
# the node that declared them; re-checking would double-report) ---
|
|
304
|
+
for attr_node in node.own_meta_attrs():
|
|
305
|
+
maybe_schema: AttrSchema | None = schema_by_name.get(attr_node.name)
|
|
306
|
+
if maybe_schema is None:
|
|
307
|
+
continue # undeclared attr — open policy: ignore
|
|
308
|
+
schema = maybe_schema
|
|
309
|
+
|
|
310
|
+
raw_value = getattr(attr_node, "value", None)
|
|
311
|
+
if raw_value is None:
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
# Check 2: type validation. An array-valued attr (the `string` +
|
|
315
|
+
# is_array model that replaced the `stringarray` subtype) is validated
|
|
316
|
+
# as a string array.
|
|
317
|
+
effective_value_type = (
|
|
318
|
+
ATTR_SUBTYPE_STRINGARRAY
|
|
319
|
+
if schema.value_type is not None
|
|
320
|
+
and (schema.is_array or schema.value_type == ATTR_SUBTYPE_STRINGARRAY)
|
|
321
|
+
else schema.value_type
|
|
322
|
+
)
|
|
323
|
+
if effective_value_type is not None:
|
|
324
|
+
if not _type_ok(raw_value, effective_value_type):
|
|
325
|
+
errors.append(
|
|
326
|
+
MetaError(
|
|
327
|
+
f"{_node_label(node)} attribute '@{attr_node.name}' has value "
|
|
328
|
+
f"{raw_value!r} which does not match expected type '{effective_value_type}'",
|
|
329
|
+
ErrorCode.ERR_BAD_ATTR_VALUE,
|
|
330
|
+
envelope=node.source,
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
continue # type wrong — skip allowed_values check
|
|
334
|
+
|
|
335
|
+
# Check 3: allowed_values membership
|
|
336
|
+
if schema.allowed_values is not None and len(schema.allowed_values) > 0:
|
|
337
|
+
if raw_value not in schema.allowed_values:
|
|
338
|
+
allowed_str = ", ".join(str(v) for v in schema.allowed_values)
|
|
339
|
+
errors.append(
|
|
340
|
+
MetaError(
|
|
341
|
+
f"{_node_label(node)} attribute '@{attr_node.name}' has value "
|
|
342
|
+
f"'{raw_value}' which is not one of the allowed values: {allowed_str}",
|
|
343
|
+
ErrorCode.ERR_BAD_ATTR_VALUE,
|
|
344
|
+
envelope=node.source,
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# ---------------------------------------------------------------------------
|
|
350
|
+
# Pass: field.enum @values content validation (cross-language contract)
|
|
351
|
+
# ---------------------------------------------------------------------------
|
|
352
|
+
# Checks OWN @values only — inherited members were already validated on the node
|
|
353
|
+
# that declared them (own-only rule, mirrors TS/C#/Java behaviour).
|
|
354
|
+
#
|
|
355
|
+
# Three content rules, all → ERR_BAD_ATTR_VALUE:
|
|
356
|
+
# 1. Non-empty: @values must contain at least one member.
|
|
357
|
+
# 2. Identifier-safe: every member must match ENUM_MEMBER_PATTERN.
|
|
358
|
+
# 3. No duplicates.
|
|
359
|
+
|
|
360
|
+
_ENUM_MEMBER_RE = re.compile(ENUM_MEMBER_PATTERN)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _validate_enum_values(
|
|
364
|
+
root: MetaData,
|
|
365
|
+
errors: list[MetaError],
|
|
366
|
+
) -> None:
|
|
367
|
+
for node in _walk(root):
|
|
368
|
+
if node.type != TYPE_FIELD or node.sub_type != FIELD_SUBTYPE_ENUM:
|
|
369
|
+
continue
|
|
370
|
+
|
|
371
|
+
# FR-011 own-attr checks apply to every enum node (a concrete enum can own
|
|
372
|
+
# @coerceDefault / @default / @normalize while inheriting @values).
|
|
373
|
+
_validate_enum_fr011_attrs(node, errors)
|
|
374
|
+
|
|
375
|
+
# Own-only: node.attr() reads only this node's own attrs (never the super
|
|
376
|
+
# chain), so an inherited @values yields None here and is skipped.
|
|
377
|
+
own_values = node.attr(FIELD_ATTR_VALUES)
|
|
378
|
+
if own_values is None:
|
|
379
|
+
# No own @values — required-attr check (ERR_MISSING_REQUIRED_ATTR) is
|
|
380
|
+
# handled by _validate_attr_schema. Nothing more to do here.
|
|
381
|
+
continue
|
|
382
|
+
|
|
383
|
+
if not isinstance(own_values, list):
|
|
384
|
+
# Type mismatch — already reported by _validate_attr_schema.
|
|
385
|
+
continue
|
|
386
|
+
|
|
387
|
+
label = _node_label(node)
|
|
388
|
+
|
|
389
|
+
# Rule 1: non-empty
|
|
390
|
+
if len(own_values) == 0:
|
|
391
|
+
errors.append(
|
|
392
|
+
MetaError(
|
|
393
|
+
f"{label} attribute '@{FIELD_ATTR_VALUES}' must not be empty",
|
|
394
|
+
ErrorCode.ERR_BAD_ATTR_VALUE,
|
|
395
|
+
envelope=node.source,
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
continue # further checks don't apply to empty list
|
|
399
|
+
|
|
400
|
+
# Rule 2: identifier-safe members
|
|
401
|
+
for member in own_values:
|
|
402
|
+
if not isinstance(member, str) or not _ENUM_MEMBER_RE.match(member):
|
|
403
|
+
errors.append(
|
|
404
|
+
MetaError(
|
|
405
|
+
f"{label} attribute '@{FIELD_ATTR_VALUES}' member {member!r} "
|
|
406
|
+
f"is not a valid identifier (must match {ENUM_MEMBER_PATTERN})",
|
|
407
|
+
ErrorCode.ERR_BAD_ATTR_VALUE,
|
|
408
|
+
envelope=node.source,
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
break # one error per field is sufficient
|
|
412
|
+
|
|
413
|
+
# Rule 3: no duplicates
|
|
414
|
+
if len(own_values) != len(set(own_values)):
|
|
415
|
+
errors.append(
|
|
416
|
+
MetaError(
|
|
417
|
+
f"{label} attribute '@{FIELD_ATTR_VALUES}' contains duplicate members",
|
|
418
|
+
ErrorCode.ERR_BAD_ATTR_VALUE,
|
|
419
|
+
envelope=node.source,
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _effective_enum_values(node: MetaData) -> list[str]:
|
|
425
|
+
"""The effective ``@values`` members of an enum node (own or inherited via
|
|
426
|
+
``extends:``). Empty list when absent. Mirrors Java ``effectiveEnumValues``."""
|
|
427
|
+
v = node.attrs().get(FIELD_ATTR_VALUES)
|
|
428
|
+
if isinstance(v, (list, tuple)):
|
|
429
|
+
return [str(x) for x in v if x is not None]
|
|
430
|
+
if isinstance(v, str):
|
|
431
|
+
return [t for t in (s.strip() for s in v.split(",")) if t]
|
|
432
|
+
return []
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _validate_enum_fr011_attrs(node: MetaData, errors: list[MetaError]) -> None:
|
|
436
|
+
"""FR-011 own-attr validation for a ``field.enum`` node.
|
|
437
|
+
|
|
438
|
+
* ``@coerceDefault`` (own) must be a member of the EFFECTIVE ``@values``
|
|
439
|
+
(own or inherited) → ``ERR_BAD_ATTR_VALUE``.
|
|
440
|
+
* ``@default`` (own, the absent-fill member) must likewise be a member of the
|
|
441
|
+
effective ``@values`` → ``ERR_BAD_ATTR_VALUE``.
|
|
442
|
+
``@normalize`` mode validation is NOT done here: it is a closed enum gated by the
|
|
443
|
+
registered ``allowed_values=NORMALIZE_MODES`` on the ``field.enum`` attr schema, so the
|
|
444
|
+
generic attr-schema pass already emits the single ``ERR_BAD_ATTR_VALUE``. Re-checking it
|
|
445
|
+
here double-reported the same node (one envelope entry per port is the cross-port contract).
|
|
446
|
+
|
|
447
|
+
Own-only policy: only checks attrs declared on THIS node, matching the ``@values``
|
|
448
|
+
pass. The membership set is read effectively so an enum that owns ``@coerceDefault``
|
|
449
|
+
/ ``@default`` while inheriting ``@values`` still validates correctly.
|
|
450
|
+
"""
|
|
451
|
+
label = _node_label(node)
|
|
452
|
+
members: list[str] | None = None # lazily computed (only when a member attr is owned)
|
|
453
|
+
|
|
454
|
+
for attr_name in (FIELD_ATTR_COERCE_DEFAULT, FIELD_ATTR_DEFAULT):
|
|
455
|
+
own = node.attr(attr_name)
|
|
456
|
+
if not isinstance(own, str):
|
|
457
|
+
continue
|
|
458
|
+
if members is None:
|
|
459
|
+
members = _effective_enum_values(node)
|
|
460
|
+
if own not in members:
|
|
461
|
+
errors.append(
|
|
462
|
+
MetaError(
|
|
463
|
+
f"{label} attribute '@{attr_name}' value {own!r} "
|
|
464
|
+
f"is not one of '@{FIELD_ATTR_VALUES}': {', '.join(members)}",
|
|
465
|
+
ErrorCode.ERR_BAD_ATTR_VALUE,
|
|
466
|
+
envelope=node.source,
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ---------------------------------------------------------------------------
|
|
472
|
+
# Pass: generalized @default per-type validation (Phase B)
|
|
473
|
+
# ---------------------------------------------------------------------------
|
|
474
|
+
# @default is registered on the field base (FIELD_ATTR_DEFAULT) so any field subtype
|
|
475
|
+
# may declare it. Its string value must coerce to the field's type:
|
|
476
|
+
# - int / long / currency → integer parse (or finite-number truncation fallback)
|
|
477
|
+
# - double / float / decimal → finite-number parse
|
|
478
|
+
# - boolean → exact "true"|"false"
|
|
479
|
+
# - enum → member of @values (handled by _validate_enum_fr011_attrs)
|
|
480
|
+
# - string / date / time / object / others → any value allowed
|
|
481
|
+
# A violation emits ERR_BAD_ATTR_VALUE, mirroring the enum @default membership check.
|
|
482
|
+
# Own-only: validates @default declared on THIS node. Mirrors Java ValidationPhase
|
|
483
|
+
# .validateFieldDefaults (cross-port) + the engine's Coerce.scalar parse semantics.
|
|
484
|
+
|
|
485
|
+
_INT_SUBTYPES = (FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_CURRENCY)
|
|
486
|
+
_NUM_SUBTYPES = (FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, FIELD_SUBTYPE_DECIMAL)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _parses_as_finite_number(s: str) -> bool:
|
|
490
|
+
try:
|
|
491
|
+
return math.isfinite(float(s.strip()))
|
|
492
|
+
except ValueError:
|
|
493
|
+
return False
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _parses_as_long(s: str) -> bool:
|
|
497
|
+
t = s.strip()
|
|
498
|
+
try:
|
|
499
|
+
int(t)
|
|
500
|
+
return True
|
|
501
|
+
except ValueError:
|
|
502
|
+
# Accept a finite decimal that truncates to an integer value (matches the
|
|
503
|
+
# engine's Coerce.scalar INT/LONG fallback).
|
|
504
|
+
return _parses_as_finite_number(t)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _validate_field_defaults(root: MetaData, errors: list[MetaError]) -> None:
|
|
508
|
+
for node in _walk(root):
|
|
509
|
+
if node.type != TYPE_FIELD:
|
|
510
|
+
continue
|
|
511
|
+
# Enum @default membership is validated by _validate_enum_fr011_attrs.
|
|
512
|
+
if node.sub_type == FIELD_SUBTYPE_ENUM:
|
|
513
|
+
continue
|
|
514
|
+
# Own-only: node.attr() reads only this node's own attrs.
|
|
515
|
+
own = node.attr(FIELD_ATTR_DEFAULT)
|
|
516
|
+
if not isinstance(own, str):
|
|
517
|
+
continue
|
|
518
|
+
|
|
519
|
+
sub = node.sub_type
|
|
520
|
+
if sub in _INT_SUBTYPES:
|
|
521
|
+
ok = _parses_as_long(own)
|
|
522
|
+
elif sub in _NUM_SUBTYPES:
|
|
523
|
+
ok = _parses_as_finite_number(own)
|
|
524
|
+
elif sub == FIELD_SUBTYPE_BOOLEAN:
|
|
525
|
+
ok = own in ("true", "false")
|
|
526
|
+
else:
|
|
527
|
+
ok = True # string / date / time / object / others — any value allowed
|
|
528
|
+
|
|
529
|
+
if not ok:
|
|
530
|
+
errors.append(
|
|
531
|
+
MetaError(
|
|
532
|
+
f"{_node_label(node)} attribute '@{FIELD_ATTR_DEFAULT}' value "
|
|
533
|
+
f"{own!r} is not coercible to the field's type",
|
|
534
|
+
ErrorCode.ERR_BAD_ATTR_VALUE,
|
|
535
|
+
envelope=node.source,
|
|
536
|
+
)
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# ---------------------------------------------------------------------------
|
|
541
|
+
# Pass: @dbColumnType physical column-type validation (R6 Plan 2b, ADR-0013)
|
|
542
|
+
# ---------------------------------------------------------------------------
|
|
543
|
+
# Own-only validation of the @dbColumnType physical column-type attribute,
|
|
544
|
+
# mirroring the field.enum @values precedent. Two rules, both → ERR_BAD_ATTR_VALUE:
|
|
545
|
+
#
|
|
546
|
+
# 1. The value must be one of the closed set uuid|jsonb|timestamp_with_tz.
|
|
547
|
+
# (@dbColumnType is registered as a bare string attr — no allowed_values — so
|
|
548
|
+
# this pass is the SOLE enforcer of the closed set: an unknown value fires
|
|
549
|
+
# exactly one ERR_BAD_ATTR_VALUE, matching TS/Java/C#.)
|
|
550
|
+
# 2. The (logical subtype × value) pairing must be legal:
|
|
551
|
+
# uuid → field.string
|
|
552
|
+
# jsonb → field.string
|
|
553
|
+
# timestamp_with_tz → field.timestamp
|
|
554
|
+
#
|
|
555
|
+
# Own-only: only @dbColumnType declared on THIS node is validated (a physical
|
|
556
|
+
# attr is never inherited via extends:). Cross-port: TS/C#/Java run the identical
|
|
557
|
+
# own-only check.
|
|
558
|
+
|
|
559
|
+
# value → the field subtype it is legal on.
|
|
560
|
+
_DB_COLUMN_TYPE_REQUIRED_SUBTYPE: dict[str, str] = {
|
|
561
|
+
DB_COLUMN_TYPE_UUID: FIELD_SUBTYPE_STRING,
|
|
562
|
+
DB_COLUMN_TYPE_JSONB: FIELD_SUBTYPE_STRING,
|
|
563
|
+
DB_COLUMN_TYPE_TIMESTAMP_TZ: FIELD_SUBTYPE_TIMESTAMP,
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _validate_db_column_type(root: MetaData, errors: list[MetaError]) -> None:
|
|
568
|
+
for node in _walk(root):
|
|
569
|
+
if node.type != TYPE_FIELD:
|
|
570
|
+
continue
|
|
571
|
+
# Own-only: node.attr() reads only this node's own attrs (never the super
|
|
572
|
+
# chain), so an inherited @dbColumnType yields None here and is skipped.
|
|
573
|
+
value = node.attr(FIELD_ATTR_DB_COLUMN_TYPE)
|
|
574
|
+
if not isinstance(value, str):
|
|
575
|
+
continue
|
|
576
|
+
|
|
577
|
+
# Rule 1: recognized value.
|
|
578
|
+
if value not in VALID_DB_COLUMN_TYPES:
|
|
579
|
+
errors.append(
|
|
580
|
+
MetaError(
|
|
581
|
+
f"field '{node.name}' attribute '@{FIELD_ATTR_DB_COLUMN_TYPE}' "
|
|
582
|
+
f"value {value!r} is not a valid value; allowed: "
|
|
583
|
+
f"{', '.join(VALID_DB_COLUMN_TYPES)}",
|
|
584
|
+
ErrorCode.ERR_BAD_ATTR_VALUE,
|
|
585
|
+
envelope=node.source,
|
|
586
|
+
)
|
|
587
|
+
)
|
|
588
|
+
continue
|
|
589
|
+
|
|
590
|
+
# Rule 2: legal (subtype × value) pairing.
|
|
591
|
+
required_subtype = _DB_COLUMN_TYPE_REQUIRED_SUBTYPE[value]
|
|
592
|
+
if node.sub_type != required_subtype:
|
|
593
|
+
# Derive the allowed-pairings list from the map so it stays the single
|
|
594
|
+
# source of truth for pairing legality.
|
|
595
|
+
pairings = ", ".join(
|
|
596
|
+
f"{v}→field.{st}" for v, st in _DB_COLUMN_TYPE_REQUIRED_SUBTYPE.items()
|
|
597
|
+
)
|
|
598
|
+
errors.append(
|
|
599
|
+
MetaError(
|
|
600
|
+
f"field '{node.name}' attribute '@{FIELD_ATTR_DB_COLUMN_TYPE}' "
|
|
601
|
+
f"value {value!r} is not valid on field.{node.sub_type} "
|
|
602
|
+
f"(requires field.{required_subtype}); allowed pairings: {pairings}",
|
|
603
|
+
ErrorCode.ERR_BAD_ATTR_VALUE,
|
|
604
|
+
envelope=node.source,
|
|
605
|
+
)
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
# ---------------------------------------------------------------------------
|
|
610
|
+
# Pass: dataGrid @defaultSortField validation
|
|
611
|
+
# ---------------------------------------------------------------------------
|
|
612
|
+
# For each object.* node, check each layout.dataGrid child: if @defaultSortField
|
|
613
|
+
# is set and not in the object's effective field names → ERR_BAD_DEFAULT_SORT_FIELD.
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _validate_datagrid_sort_fields(
|
|
617
|
+
root: MetaData,
|
|
618
|
+
errors: list[MetaError],
|
|
619
|
+
) -> None:
|
|
620
|
+
for node in _walk(root):
|
|
621
|
+
if node.type != TYPE_OBJECT:
|
|
622
|
+
continue
|
|
623
|
+
if not isinstance(node, MetaObject):
|
|
624
|
+
continue
|
|
625
|
+
|
|
626
|
+
field_names: set[str] = {f.name for f in node.fields()}
|
|
627
|
+
|
|
628
|
+
for child in node.children():
|
|
629
|
+
if child.type != TYPE_LAYOUT or child.sub_type != LAYOUT_SUBTYPE_DATA_GRID:
|
|
630
|
+
continue
|
|
631
|
+
sort_field = child.attr(LAYOUT_ATTR_DEFAULT_SORT_FIELD)
|
|
632
|
+
if sort_field is None:
|
|
633
|
+
continue
|
|
634
|
+
if not isinstance(sort_field, str):
|
|
635
|
+
continue
|
|
636
|
+
if sort_field not in field_names:
|
|
637
|
+
errors.append(
|
|
638
|
+
MetaError(
|
|
639
|
+
f"{_node_label(node)} layout.dataGrid '{child.name}' references "
|
|
640
|
+
f"@defaultSortField='{sort_field}' which is not a field on this object "
|
|
641
|
+
f"(known fields: {sorted(field_names)})",
|
|
642
|
+
ErrorCode.ERR_BAD_DEFAULT_SORT_FIELD,
|
|
643
|
+
envelope=child.source,
|
|
644
|
+
)
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
# ---------------------------------------------------------------------------
|
|
649
|
+
# Ops-per-field-subtype allow-table (from query-constants.ts)
|
|
650
|
+
# ---------------------------------------------------------------------------
|
|
651
|
+
# string / enum → eq, ne, in, like, isNull
|
|
652
|
+
# uuid → eq, ne, in, isNull (no like — not a substring type, no ordering)
|
|
653
|
+
# boolean → eq, isNull
|
|
654
|
+
# numerics + currency + temporal → eq, ne, gt, gte, lt, lte, in, isNull
|
|
655
|
+
|
|
656
|
+
_OPS_STRING: frozenset[str] = frozenset({"eq", "ne", "in", "like", "isNull"})
|
|
657
|
+
_OPS_UUID: frozenset[str] = frozenset({"eq", "ne", "in", "isNull"})
|
|
658
|
+
_OPS_BOOLEAN: frozenset[str] = frozenset({"eq", "isNull"})
|
|
659
|
+
_OPS_NUMERIC_TEMPORAL: frozenset[str] = frozenset({"eq", "ne", "gt", "gte", "lt", "lte", "in", "isNull"})
|
|
660
|
+
|
|
661
|
+
# string-shaped subtypes (string op band): string + enum.
|
|
662
|
+
_STRING_SUBTYPES: frozenset[str] = frozenset({"string", "enum"})
|
|
663
|
+
# currency = integer minor units (an orderable number) → numeric band.
|
|
664
|
+
_NUMERIC_TEMPORAL_SUBTYPES: frozenset[str] = frozenset(
|
|
665
|
+
{"int", "long", "double", "float", "decimal", "currency", "date", "time", "timestamp"}
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def ops_for_subtype(field_subtype: str) -> frozenset[str]:
|
|
670
|
+
"""Return the set of allowed filter operators for a given field subtype.
|
|
671
|
+
|
|
672
|
+
Mirrors the ops-per-subtype allow-table from query-constants.ts.
|
|
673
|
+
"""
|
|
674
|
+
if field_subtype in _STRING_SUBTYPES:
|
|
675
|
+
return _OPS_STRING
|
|
676
|
+
if field_subtype == "uuid":
|
|
677
|
+
return _OPS_UUID
|
|
678
|
+
if field_subtype == "boolean":
|
|
679
|
+
return _OPS_BOOLEAN
|
|
680
|
+
if field_subtype in _NUMERIC_TEMPORAL_SUBTYPES:
|
|
681
|
+
return _OPS_NUMERIC_TEMPORAL
|
|
682
|
+
# Unknown/extension subtypes: closed allowlist — no operators permitted.
|
|
683
|
+
return frozenset()
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
# ---------------------------------------------------------------------------
|
|
687
|
+
# Pass: dataGrid @filter field + op validation
|
|
688
|
+
# ---------------------------------------------------------------------------
|
|
689
|
+
# For each object.* node, build a filterable map from effective fields.
|
|
690
|
+
# For each layout.dataGrid child's @filter dict: check that each referenced
|
|
691
|
+
# field is filterable and that each operator is allowed for that field's subtype.
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _validate_datagrid_filter_values(
|
|
695
|
+
root: MetaData,
|
|
696
|
+
errors: list[MetaError],
|
|
697
|
+
) -> None:
|
|
698
|
+
for node in _walk(root):
|
|
699
|
+
if node.type != TYPE_OBJECT:
|
|
700
|
+
continue
|
|
701
|
+
if not isinstance(node, MetaObject):
|
|
702
|
+
continue
|
|
703
|
+
|
|
704
|
+
# Build filterable map: field_name → allowed ops set
|
|
705
|
+
filterable: dict[str, frozenset[str]] = {
|
|
706
|
+
f.name: ops_for_subtype(f.sub_type)
|
|
707
|
+
for f in node.fields()
|
|
708
|
+
if f.attr("filterable") is True
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
for child in node.children():
|
|
712
|
+
if child.type != TYPE_LAYOUT or child.sub_type != LAYOUT_SUBTYPE_DATA_GRID:
|
|
713
|
+
continue
|
|
714
|
+
filter_value = child.attr(LAYOUT_ATTR_FILTER)
|
|
715
|
+
if filter_value is None:
|
|
716
|
+
continue
|
|
717
|
+
if not isinstance(filter_value, dict):
|
|
718
|
+
# Type check handled by attr-schema pass (ERR_BAD_ATTR_VALUE).
|
|
719
|
+
continue
|
|
720
|
+
|
|
721
|
+
for field_name, clause in filter_value.items():
|
|
722
|
+
if field_name not in filterable:
|
|
723
|
+
errors.append(
|
|
724
|
+
MetaError(
|
|
725
|
+
f"{_node_label(node)} layout.dataGrid '{child.name}' @filter "
|
|
726
|
+
f"references field '{field_name}' which is not a filterable field "
|
|
727
|
+
f"on this object",
|
|
728
|
+
ErrorCode.ERR_BAD_ATTR_FILTER,
|
|
729
|
+
envelope=child.source,
|
|
730
|
+
)
|
|
731
|
+
)
|
|
732
|
+
continue
|
|
733
|
+
|
|
734
|
+
allowed_ops = filterable[field_name]
|
|
735
|
+
if not isinstance(clause, dict):
|
|
736
|
+
# Shorthand (scalar/list/null) desugared to op-object by FilterAttr;
|
|
737
|
+
# if still not a dict here, skip (attr-schema pass covers type errors).
|
|
738
|
+
continue
|
|
739
|
+
for op in clause:
|
|
740
|
+
if op not in allowed_ops:
|
|
741
|
+
field_obj = node.find_field(field_name)
|
|
742
|
+
sub = field_obj.sub_type if field_obj is not None else "?"
|
|
743
|
+
errors.append(
|
|
744
|
+
MetaError(
|
|
745
|
+
f"{_node_label(node)} layout.dataGrid '{child.name}' @filter "
|
|
746
|
+
f"uses operator '{op}' on field '{field_name}' which is not "
|
|
747
|
+
f"allowed for field subtype '{sub}'",
|
|
748
|
+
ErrorCode.ERR_BAD_ATTR_FILTER,
|
|
749
|
+
envelope=child.source,
|
|
750
|
+
)
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
# ---------------------------------------------------------------------------
|
|
755
|
+
# Pass: origin @from / @of / @via path validation
|
|
756
|
+
# ---------------------------------------------------------------------------
|
|
757
|
+
# For each field node that has an origin.passthrough or origin.aggregate child,
|
|
758
|
+
# validate that the dotted references resolve against the known object index.
|
|
759
|
+
#
|
|
760
|
+
# @from (passthrough) / @of (aggregate): "Entity.fieldName"
|
|
761
|
+
# - The entity must exist in the tree; the field must exist on that entity.
|
|
762
|
+
#
|
|
763
|
+
# @via (optional on passthrough, required on aggregate): "Entity.rel1[.rel2...]"
|
|
764
|
+
# - Split on "."; first segment is the entity name (must exist in index).
|
|
765
|
+
# - Each subsequent segment is a relationship name on the current entity;
|
|
766
|
+
# the relationship's @objectRef names the next entity (must exist in index);
|
|
767
|
+
# advance the current-entity pointer.
|
|
768
|
+
# - Any missing entity/relationship → ERR_INVALID_ORIGIN.
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _build_object_index(root: MetaData) -> dict[str, MetaObject]:
|
|
772
|
+
"""Return a name → MetaObject index of all top-level objects in *root*."""
|
|
773
|
+
index: dict[str, MetaObject] = {}
|
|
774
|
+
for child in root.own_children():
|
|
775
|
+
if child.type == TYPE_OBJECT and isinstance(child, MetaObject):
|
|
776
|
+
if child.name:
|
|
777
|
+
index[child.name] = child
|
|
778
|
+
return index
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _relationships_by_name(obj: MetaObject) -> dict[str, MetaData]:
|
|
782
|
+
"""Return a name → node map of all relationship children on *obj* (effective)."""
|
|
783
|
+
result: dict[str, MetaData] = {}
|
|
784
|
+
for child in obj.children():
|
|
785
|
+
if child.type == TYPE_RELATIONSHIP and child.name:
|
|
786
|
+
result[child.name] = child
|
|
787
|
+
return result
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _validate_entity_field_ref(
|
|
791
|
+
ref: str,
|
|
792
|
+
attr_name: str,
|
|
793
|
+
context: str,
|
|
794
|
+
object_index: dict[str, MetaObject],
|
|
795
|
+
errors: list[MetaError],
|
|
796
|
+
origin_node: MetaData,
|
|
797
|
+
referrer: str,
|
|
798
|
+
) -> bool:
|
|
799
|
+
"""Validate a dotted 'Entity.fieldName' reference.
|
|
800
|
+
|
|
801
|
+
Appends ERR_INVALID_ORIGIN to *errors* if invalid; returns True if valid.
|
|
802
|
+
|
|
803
|
+
*attr_name* is used only for the error message text; *context* identifies the
|
|
804
|
+
origin node for diagnostic purposes; *origin_node* carries the parse-time
|
|
805
|
+
envelope (files/json_path); *referrer* is the canonical referrer FQN
|
|
806
|
+
(``<projection-FQN>::<fieldName>``) attached to the FR5d ResolvedSource
|
|
807
|
+
envelope so consumers know which node declared the broken reference.
|
|
808
|
+
"""
|
|
809
|
+
parts = ref.split(".", 1)
|
|
810
|
+
if len(parts) != 2:
|
|
811
|
+
# Malformed shape — not a reference-resolution failure per se, but TS
|
|
812
|
+
# emits format=resolved here too (with target=the bad string) so every
|
|
813
|
+
# FR5d site is shape-consistent across the four ports.
|
|
814
|
+
errors.append(
|
|
815
|
+
MetaError(
|
|
816
|
+
f"{context} @{attr_name}='{ref}' must be in 'EntityName.fieldName' format",
|
|
817
|
+
ErrorCode.ERR_INVALID_ORIGIN,
|
|
818
|
+
envelope=resolved_source(origin_node.source, referrer, ref),
|
|
819
|
+
)
|
|
820
|
+
)
|
|
821
|
+
return False
|
|
822
|
+
entity_name, field_name = parts
|
|
823
|
+
entity = object_index.get(entity_name)
|
|
824
|
+
if entity is None:
|
|
825
|
+
# FR5d — entity half of the ref didn't resolve. target = full ref.
|
|
826
|
+
errors.append(
|
|
827
|
+
MetaError(
|
|
828
|
+
f"{context} @{attr_name}='{ref}' references unknown entity '{entity_name}'",
|
|
829
|
+
ErrorCode.ERR_INVALID_ORIGIN,
|
|
830
|
+
envelope=resolved_source(origin_node.source, referrer, ref),
|
|
831
|
+
)
|
|
832
|
+
)
|
|
833
|
+
return False
|
|
834
|
+
field_names = {f.name for f in entity.fields()}
|
|
835
|
+
if field_name not in field_names:
|
|
836
|
+
# FR5d — entity resolved, field on it did not. target = full ref.
|
|
837
|
+
errors.append(
|
|
838
|
+
MetaError(
|
|
839
|
+
f"{context} @{attr_name}='{ref}' references field '{field_name}' which does "
|
|
840
|
+
f"not exist on entity '{entity_name}' (known fields: {sorted(field_names)})",
|
|
841
|
+
ErrorCode.ERR_INVALID_ORIGIN,
|
|
842
|
+
envelope=resolved_source(origin_node.source, referrer, ref),
|
|
843
|
+
)
|
|
844
|
+
)
|
|
845
|
+
return False
|
|
846
|
+
return True
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _validate_via_path(
|
|
850
|
+
via: str,
|
|
851
|
+
context: str,
|
|
852
|
+
object_index: dict[str, MetaObject],
|
|
853
|
+
errors: list[MetaError],
|
|
854
|
+
origin_node: MetaData,
|
|
855
|
+
referrer: str,
|
|
856
|
+
) -> bool:
|
|
857
|
+
"""Validate a dotted relationship path 'Entity.rel1[.rel2...]'.
|
|
858
|
+
|
|
859
|
+
Returns True if valid; appends ERR_INVALID_ORIGIN and returns False if not.
|
|
860
|
+
|
|
861
|
+
*origin_node* carries the parse-time envelope (files/json_path); *referrer*
|
|
862
|
+
is the canonical referrer FQN (``<projection-FQN>::<fieldName>``) attached
|
|
863
|
+
to the FR5d ResolvedSource envelope.
|
|
864
|
+
|
|
865
|
+
Multi-hop walks track the deepest-valid-prefix and name it in the error
|
|
866
|
+
message on a hop failure (mirrors TS reference at validation-passes.ts
|
|
867
|
+
L304-L325).
|
|
868
|
+
"""
|
|
869
|
+
segments = via.split(".")
|
|
870
|
+
if len(segments) < 2:
|
|
871
|
+
errors.append(
|
|
872
|
+
MetaError(
|
|
873
|
+
f"{context} @via='{via}' must be in 'EntityName.relName[.relName...]' format",
|
|
874
|
+
ErrorCode.ERR_INVALID_ORIGIN,
|
|
875
|
+
envelope=resolved_source(origin_node.source, referrer, via),
|
|
876
|
+
)
|
|
877
|
+
)
|
|
878
|
+
return False
|
|
879
|
+
|
|
880
|
+
# First segment: starting entity
|
|
881
|
+
current_name = segments[0]
|
|
882
|
+
current_entity = object_index.get(current_name)
|
|
883
|
+
if current_entity is None:
|
|
884
|
+
errors.append(
|
|
885
|
+
MetaError(
|
|
886
|
+
f"{context} @via='{via}' references unknown entity '{current_name}'",
|
|
887
|
+
ErrorCode.ERR_INVALID_ORIGIN,
|
|
888
|
+
envelope=resolved_source(origin_node.source, referrer, via),
|
|
889
|
+
)
|
|
890
|
+
)
|
|
891
|
+
return False
|
|
892
|
+
|
|
893
|
+
# FR5d — track the deepest-valid-prefix as we walk. The prefix starts at
|
|
894
|
+
# the entity name (resolved above) and grows by one segment per successful
|
|
895
|
+
# relationship hop. On hop failure the message names the prefix that DID
|
|
896
|
+
# resolve so authors can fix multi-hop typos quickly.
|
|
897
|
+
valid_segments: list[str] = [current_name]
|
|
898
|
+
for rel_name in segments[1:]:
|
|
899
|
+
rels = _relationships_by_name(current_entity)
|
|
900
|
+
rel_node = rels.get(rel_name)
|
|
901
|
+
if rel_node is None:
|
|
902
|
+
prefix = ".".join(valid_segments)
|
|
903
|
+
errors.append(
|
|
904
|
+
MetaError(
|
|
905
|
+
f"{context} @via='{via}' — entity '{current_entity.name}' has no "
|
|
906
|
+
f"relationship '{rel_name}' (known relationships: {sorted(rels)}). "
|
|
907
|
+
f'Deepest valid prefix was "{prefix}".',
|
|
908
|
+
ErrorCode.ERR_INVALID_ORIGIN,
|
|
909
|
+
envelope=resolved_source(origin_node.source, referrer, via),
|
|
910
|
+
)
|
|
911
|
+
)
|
|
912
|
+
return False
|
|
913
|
+
|
|
914
|
+
# Advance to the referenced entity
|
|
915
|
+
obj_ref = rel_node.attr(RELATIONSHIP_ATTR_OBJECT_REF)
|
|
916
|
+
if not isinstance(obj_ref, str):
|
|
917
|
+
errors.append(
|
|
918
|
+
MetaError(
|
|
919
|
+
f"{context} @via='{via}' — relationship '{rel_name}' on entity "
|
|
920
|
+
f"'{current_entity.name}' has no @objectRef",
|
|
921
|
+
ErrorCode.ERR_INVALID_ORIGIN,
|
|
922
|
+
envelope=resolved_source(origin_node.source, referrer, via),
|
|
923
|
+
)
|
|
924
|
+
)
|
|
925
|
+
return False
|
|
926
|
+
|
|
927
|
+
next_entity = object_index.get(obj_ref)
|
|
928
|
+
if next_entity is None:
|
|
929
|
+
# FR5d — relationship's @objectRef points at a missing entity.
|
|
930
|
+
# target=the @objectRef value (mirrors TS validation-passes.ts L342-353).
|
|
931
|
+
errors.append(
|
|
932
|
+
MetaError(
|
|
933
|
+
f"{context} @via='{via}' — relationship '{rel_name}' on entity "
|
|
934
|
+
f"'{current_entity.name}' references unknown entity '{obj_ref}'",
|
|
935
|
+
ErrorCode.ERR_INVALID_ORIGIN,
|
|
936
|
+
envelope=resolved_source(origin_node.source, referrer, obj_ref),
|
|
937
|
+
)
|
|
938
|
+
)
|
|
939
|
+
return False
|
|
940
|
+
|
|
941
|
+
valid_segments.append(rel_name)
|
|
942
|
+
current_entity = next_entity
|
|
943
|
+
|
|
944
|
+
return True
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _validate_origin_paths(
|
|
948
|
+
root: MetaData,
|
|
949
|
+
errors: list[MetaError],
|
|
950
|
+
) -> None:
|
|
951
|
+
"""Validate @from/@of/@via dotted-path attrs on origin.passthrough and
|
|
952
|
+
origin.aggregate nodes.
|
|
953
|
+
|
|
954
|
+
Errors use ERR_INVALID_ORIGIN. Only validates; does NOT alter the tree.
|
|
955
|
+
"""
|
|
956
|
+
object_index = _build_object_index(root)
|
|
957
|
+
|
|
958
|
+
for node in _walk(root):
|
|
959
|
+
if node.type != TYPE_FIELD:
|
|
960
|
+
continue
|
|
961
|
+
# The projection that owns this field. The field walks via _walk so the
|
|
962
|
+
# field's parent is the containing object (.projection in source-v2).
|
|
963
|
+
projection = node.parent if hasattr(node, "parent") else None
|
|
964
|
+
for origin in node.children():
|
|
965
|
+
if origin.type != TYPE_ORIGIN:
|
|
966
|
+
continue
|
|
967
|
+
ctx = f"field '{node.name}' origin.{origin.sub_type}"
|
|
968
|
+
# FR5d — referrer is `<projection-FQN>::<fieldName>` (the canonical
|
|
969
|
+
# "where the broken reference lives" identifier). When we cannot
|
|
970
|
+
# find a projection (defensive), fall back to the field's own FQN.
|
|
971
|
+
if projection is not None and hasattr(projection, "fqn"):
|
|
972
|
+
referrer = f"{projection.fqn()}::{node.name}"
|
|
973
|
+
else:
|
|
974
|
+
referrer = node.fqn() if hasattr(node, "fqn") else node.name
|
|
975
|
+
|
|
976
|
+
if origin.sub_type == ORIGIN_SUBTYPE_PASSTHROUGH:
|
|
977
|
+
from_ref = origin.attr(ORIGIN_ATTR_FROM)
|
|
978
|
+
if not isinstance(from_ref, str) or not from_ref:
|
|
979
|
+
# Missing-attr (not a reference resolution failure) — keep
|
|
980
|
+
# the origin node's own source envelope (json/yaml/merged).
|
|
981
|
+
# Mirrors TS validation-passes.ts L370-378.
|
|
982
|
+
errors.append(
|
|
983
|
+
MetaError(
|
|
984
|
+
f"{ctx} is missing required attribute '@{ORIGIN_ATTR_FROM}'",
|
|
985
|
+
ErrorCode.ERR_INVALID_ORIGIN,
|
|
986
|
+
envelope=origin.source,
|
|
987
|
+
)
|
|
988
|
+
)
|
|
989
|
+
else:
|
|
990
|
+
_validate_entity_field_ref(
|
|
991
|
+
from_ref, ORIGIN_ATTR_FROM, ctx, object_index, errors, origin,
|
|
992
|
+
referrer,
|
|
993
|
+
)
|
|
994
|
+
via = origin.attr(ORIGIN_ATTR_VIA)
|
|
995
|
+
if isinstance(via, str) and via:
|
|
996
|
+
_validate_via_path(via, ctx, object_index, errors, origin, referrer)
|
|
997
|
+
|
|
998
|
+
elif origin.sub_type == ORIGIN_SUBTYPE_AGGREGATE:
|
|
999
|
+
of_ref = origin.attr(ORIGIN_ATTR_OF)
|
|
1000
|
+
if not isinstance(of_ref, str) or not of_ref:
|
|
1001
|
+
errors.append(
|
|
1002
|
+
MetaError(
|
|
1003
|
+
f"{ctx} is missing required attribute '@{ORIGIN_ATTR_OF}'",
|
|
1004
|
+
ErrorCode.ERR_INVALID_ORIGIN,
|
|
1005
|
+
envelope=origin.source,
|
|
1006
|
+
)
|
|
1007
|
+
)
|
|
1008
|
+
else:
|
|
1009
|
+
_validate_entity_field_ref(
|
|
1010
|
+
of_ref, ORIGIN_ATTR_OF, ctx, object_index, errors, origin,
|
|
1011
|
+
referrer,
|
|
1012
|
+
)
|
|
1013
|
+
via = origin.attr(ORIGIN_ATTR_VIA)
|
|
1014
|
+
if not isinstance(via, str) or not via:
|
|
1015
|
+
errors.append(
|
|
1016
|
+
MetaError(
|
|
1017
|
+
f"{ctx} is missing required attribute '@{ORIGIN_ATTR_VIA}'",
|
|
1018
|
+
ErrorCode.ERR_INVALID_ORIGIN,
|
|
1019
|
+
envelope=origin.source,
|
|
1020
|
+
)
|
|
1021
|
+
)
|
|
1022
|
+
else:
|
|
1023
|
+
_validate_via_path(via, ctx, object_index, errors, origin, referrer)
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
# ---------------------------------------------------------------------------
|
|
1027
|
+
# Pass: M:N relationship validation (FR-017 slim vocabulary)
|
|
1028
|
+
# ---------------------------------------------------------------------------
|
|
1029
|
+
# Deferred-resolution validation (runs after all files load + super-resolution,
|
|
1030
|
+
# like origin paths), enforcing the cross-port M:N contract:
|
|
1031
|
+
#
|
|
1032
|
+
# (a) @symmetric:true is valid only on a self-join (@objectRef == declaring
|
|
1033
|
+
# entity). Otherwise ERR_BAD_ATTR_VALUE.
|
|
1034
|
+
# (b) @symmetric and @sourceRefField are mutually exclusive → ERR_BAD_ATTR_VALUE.
|
|
1035
|
+
# (c) When @through is present: the named entity must exist and declare exactly
|
|
1036
|
+
# two identity.reference children; @sourceRefField (if present) must match
|
|
1037
|
+
# one of those references' FK fields → ERR_INVALID_RELATIONSHIP.
|
|
1038
|
+
# (d) @through / @sourceRefField / @symmetric are invalid on a non-M:N
|
|
1039
|
+
# relationship (@cardinality != "many", or no @through) → ERR_INVALID_RELATIONSHIP.
|
|
1040
|
+
#
|
|
1041
|
+
# Own-relationships only: a relationship is validated on the entity that declares
|
|
1042
|
+
# it (matching the own-attrs policy of the other passes). Mirrors the TS
|
|
1043
|
+
# reference (validation-passes.ts validateRelationships).
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _strip_package(name: str) -> str:
|
|
1047
|
+
idx = name.rfind(PACKAGE_SEP)
|
|
1048
|
+
return name[idx + len(PACKAGE_SEP):] if idx >= 0 else name
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def _junction_reference_fk_fields(junction: MetaData) -> list[str]:
|
|
1052
|
+
"""FK field names declared by an entity's identity.reference children."""
|
|
1053
|
+
out: list[str] = []
|
|
1054
|
+
for child in junction.own_children():
|
|
1055
|
+
if child.type != TYPE_IDENTITY or child.sub_type != IDENTITY_SUBTYPE_REFERENCE:
|
|
1056
|
+
continue
|
|
1057
|
+
fields = child.attr(IDENTITY_ATTR_FIELDS)
|
|
1058
|
+
if isinstance(fields, str):
|
|
1059
|
+
first = fields.split(",")[0].strip()
|
|
1060
|
+
if first:
|
|
1061
|
+
out.append(first)
|
|
1062
|
+
elif isinstance(fields, (list, tuple)) and fields and isinstance(fields[0], str):
|
|
1063
|
+
out.append(fields[0])
|
|
1064
|
+
return out
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _count_junction_references(junction: MetaData) -> int:
|
|
1068
|
+
return sum(
|
|
1069
|
+
1
|
|
1070
|
+
for c in junction.own_children()
|
|
1071
|
+
if c.type == TYPE_IDENTITY and c.sub_type == IDENTITY_SUBTYPE_REFERENCE
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None:
|
|
1076
|
+
object_index = _build_object_index(root)
|
|
1077
|
+
|
|
1078
|
+
for obj in (c for c in root.own_children() if c.type == TYPE_OBJECT):
|
|
1079
|
+
for rel in (c for c in obj.own_children() if c.type == TYPE_RELATIONSHIP):
|
|
1080
|
+
through = rel.attr(RELATIONSHIP_ATTR_THROUGH)
|
|
1081
|
+
source_ref_field = rel.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD)
|
|
1082
|
+
symmetric = rel.attr(RELATIONSHIP_ATTR_SYMMETRIC) is True
|
|
1083
|
+
cardinality = rel.attr(RELATIONSHIP_ATTR_CARDINALITY)
|
|
1084
|
+
object_ref = rel.attr(RELATIONSHIP_ATTR_OBJECT_REF)
|
|
1085
|
+
|
|
1086
|
+
has_through = isinstance(through, str) and through != ""
|
|
1087
|
+
has_source_ref_field = (
|
|
1088
|
+
isinstance(source_ref_field, str) and source_ref_field != ""
|
|
1089
|
+
)
|
|
1090
|
+
is_many = cardinality == CARDINALITY_MANY
|
|
1091
|
+
is_m2m = has_through and is_many
|
|
1092
|
+
|
|
1093
|
+
# Rule (d): M:N-only attrs on a non-M:N relationship.
|
|
1094
|
+
if not is_m2m:
|
|
1095
|
+
if has_through:
|
|
1096
|
+
errors.append(MetaError(
|
|
1097
|
+
f'relationship "{obj.name}.{rel.name}" sets '
|
|
1098
|
+
f'@{RELATIONSHIP_ATTR_THROUGH} but is not a M:N relationship '
|
|
1099
|
+
f'(requires @{RELATIONSHIP_ATTR_CARDINALITY}: "{CARDINALITY_MANY}").',
|
|
1100
|
+
ErrorCode.ERR_INVALID_RELATIONSHIP,
|
|
1101
|
+
envelope=rel.source,
|
|
1102
|
+
))
|
|
1103
|
+
if has_source_ref_field:
|
|
1104
|
+
errors.append(MetaError(
|
|
1105
|
+
f'relationship "{obj.name}.{rel.name}" sets '
|
|
1106
|
+
f'@{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is not a M:N relationship.',
|
|
1107
|
+
ErrorCode.ERR_INVALID_RELATIONSHIP,
|
|
1108
|
+
envelope=rel.source,
|
|
1109
|
+
))
|
|
1110
|
+
if symmetric:
|
|
1111
|
+
errors.append(MetaError(
|
|
1112
|
+
f'relationship "{obj.name}.{rel.name}" sets '
|
|
1113
|
+
f'@{RELATIONSHIP_ATTR_SYMMETRIC} but is not a M:N relationship.',
|
|
1114
|
+
ErrorCode.ERR_INVALID_RELATIONSHIP,
|
|
1115
|
+
envelope=rel.source,
|
|
1116
|
+
))
|
|
1117
|
+
continue
|
|
1118
|
+
|
|
1119
|
+
# Rule (b): @symmetric and @sourceRefField are mutually exclusive.
|
|
1120
|
+
if symmetric and has_source_ref_field:
|
|
1121
|
+
errors.append(MetaError(
|
|
1122
|
+
f'relationship "{obj.name}.{rel.name}" sets both '
|
|
1123
|
+
f'@{RELATIONSHIP_ATTR_SYMMETRIC} and '
|
|
1124
|
+
f'@{RELATIONSHIP_ATTR_SOURCE_REF_FIELD}; they are mutually exclusive.',
|
|
1125
|
+
ErrorCode.ERR_BAD_ATTR_VALUE,
|
|
1126
|
+
envelope=rel.source,
|
|
1127
|
+
))
|
|
1128
|
+
|
|
1129
|
+
# Rule (a): @symmetric valid only on a self-join (@objectRef == declaring entity).
|
|
1130
|
+
is_self_join = (
|
|
1131
|
+
isinstance(object_ref, str) and _strip_package(object_ref) == obj.name
|
|
1132
|
+
)
|
|
1133
|
+
if symmetric and not is_self_join:
|
|
1134
|
+
errors.append(MetaError(
|
|
1135
|
+
f'relationship "{obj.name}.{rel.name}" sets '
|
|
1136
|
+
f'@{RELATIONSHIP_ATTR_SYMMETRIC} but @{RELATIONSHIP_ATTR_OBJECT_REF} '
|
|
1137
|
+
f'"{object_ref}" is not the declaring entity "{obj.name}"; '
|
|
1138
|
+
f'@{RELATIONSHIP_ATTR_SYMMETRIC} is self-join-only.',
|
|
1139
|
+
ErrorCode.ERR_BAD_ATTR_VALUE,
|
|
1140
|
+
envelope=rel.source,
|
|
1141
|
+
))
|
|
1142
|
+
|
|
1143
|
+
# Rule (c): @through must name an entity declaring exactly two
|
|
1144
|
+
# identity.reference children.
|
|
1145
|
+
junction = object_index.get(_strip_package(through)) # type: ignore[arg-type]
|
|
1146
|
+
if junction is None:
|
|
1147
|
+
errors.append(MetaError(
|
|
1148
|
+
f'relationship "{obj.name}.{rel.name}" '
|
|
1149
|
+
f'@{RELATIONSHIP_ATTR_THROUGH} "{through}" does not resolve to an entity.',
|
|
1150
|
+
ErrorCode.ERR_INVALID_RELATIONSHIP,
|
|
1151
|
+
envelope=resolved_source(
|
|
1152
|
+
rel.source, f"{obj.fqn()}::{rel.name}", str(through)
|
|
1153
|
+
),
|
|
1154
|
+
))
|
|
1155
|
+
continue
|
|
1156
|
+
ref_count = _count_junction_references(junction)
|
|
1157
|
+
if ref_count != 2:
|
|
1158
|
+
errors.append(MetaError(
|
|
1159
|
+
f'relationship "{obj.name}.{rel.name}" '
|
|
1160
|
+
f'@{RELATIONSHIP_ATTR_THROUGH} "{through}" must declare exactly two '
|
|
1161
|
+
f'identity.reference children (one per FK side); found {ref_count}.',
|
|
1162
|
+
ErrorCode.ERR_INVALID_RELATIONSHIP,
|
|
1163
|
+
envelope=rel.source,
|
|
1164
|
+
))
|
|
1165
|
+
continue
|
|
1166
|
+
# @sourceRefField (if present) must match one of the junction's
|
|
1167
|
+
# reference FK fields.
|
|
1168
|
+
if has_source_ref_field:
|
|
1169
|
+
fk_fields = _junction_reference_fk_fields(junction)
|
|
1170
|
+
if source_ref_field not in fk_fields:
|
|
1171
|
+
available = ", ".join(fk_fields) or "(none)"
|
|
1172
|
+
errors.append(MetaError(
|
|
1173
|
+
f'relationship "{obj.name}.{rel.name}" '
|
|
1174
|
+
f'@{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} "{source_ref_field}" '
|
|
1175
|
+
f'does not match any identity.reference FK field on junction '
|
|
1176
|
+
f'"{through}". Available: {available}.',
|
|
1177
|
+
ErrorCode.ERR_INVALID_RELATIONSHIP,
|
|
1178
|
+
envelope=rel.source,
|
|
1179
|
+
))
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
# ---------------------------------------------------------------------------
|
|
1183
|
+
# Pass: one-primary multi-source rule (ADR-0007 source v2)
|
|
1184
|
+
# ---------------------------------------------------------------------------
|
|
1185
|
+
# Walks every object.entity / object.value; counts source own-children with
|
|
1186
|
+
# role == "primary" (using the default-aware MetaSource.role() getter):
|
|
1187
|
+
# - 0 sources total → skip (object is not persisted).
|
|
1188
|
+
# - exactly 1 primary → OK.
|
|
1189
|
+
# - 0 primaries → ERR_SOURCE_NO_PRIMARY.
|
|
1190
|
+
# - >1 primaries → ERR_SOURCE_MULTIPLE_PRIMARY.
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def _validate_one_primary_source(
|
|
1194
|
+
root: MetaData,
|
|
1195
|
+
errors: list[MetaError],
|
|
1196
|
+
) -> None:
|
|
1197
|
+
for node in _walk(root):
|
|
1198
|
+
if node.type != TYPE_OBJECT:
|
|
1199
|
+
continue
|
|
1200
|
+
if not isinstance(node, MetaObject):
|
|
1201
|
+
continue
|
|
1202
|
+
|
|
1203
|
+
sources = [c for c in node.own_children() if c.type == TYPE_SOURCE]
|
|
1204
|
+
if not sources:
|
|
1205
|
+
continue
|
|
1206
|
+
|
|
1207
|
+
primary_count = sum(
|
|
1208
|
+
1
|
|
1209
|
+
for s in sources
|
|
1210
|
+
if isinstance(s, MetaSource) and s.role() == SOURCE_ROLE_PRIMARY
|
|
1211
|
+
)
|
|
1212
|
+
|
|
1213
|
+
if primary_count == 0:
|
|
1214
|
+
errors.append(
|
|
1215
|
+
MetaError(
|
|
1216
|
+
f"{_node_label(node)} declares {len(sources)} source(s) but "
|
|
1217
|
+
f"none has role '{SOURCE_ROLE_PRIMARY}'",
|
|
1218
|
+
ErrorCode.ERR_SOURCE_NO_PRIMARY,
|
|
1219
|
+
envelope=node.source,
|
|
1220
|
+
)
|
|
1221
|
+
)
|
|
1222
|
+
elif primary_count > 1:
|
|
1223
|
+
errors.append(
|
|
1224
|
+
MetaError(
|
|
1225
|
+
f"{_node_label(node)} declares {primary_count} sources with "
|
|
1226
|
+
f"role '{SOURCE_ROLE_PRIMARY}'; exactly one is required",
|
|
1227
|
+
ErrorCode.ERR_SOURCE_MULTIPLE_PRIMARY,
|
|
1228
|
+
envelope=node.source,
|
|
1229
|
+
)
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
# ---------------------------------------------------------------------------
|
|
1234
|
+
# Pass: subtype-rules
|
|
1235
|
+
# ---------------------------------------------------------------------------
|
|
1236
|
+
# object.entity with no effective primary identity and not abstract → warning.
|
|
1237
|
+
# object.value with a primary identity → ERR_SUBTYPE_RULE_VIOLATION (error).
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def _validate_subtype_rules(
|
|
1241
|
+
root: MetaData,
|
|
1242
|
+
errors: list[MetaError],
|
|
1243
|
+
warnings: list[str],
|
|
1244
|
+
) -> None:
|
|
1245
|
+
for node in _walk(root):
|
|
1246
|
+
if node.type != TYPE_OBJECT:
|
|
1247
|
+
continue
|
|
1248
|
+
if not isinstance(node, MetaObject):
|
|
1249
|
+
continue
|
|
1250
|
+
|
|
1251
|
+
if node.sub_type == OBJECT_SUBTYPE_ENTITY:
|
|
1252
|
+
# Concrete (non-abstract) entity with no primary identity → warning.
|
|
1253
|
+
if not node.is_abstract and node.primary_identity() is None:
|
|
1254
|
+
warnings.append(
|
|
1255
|
+
f"entity object '{node.name}' has no primary identity "
|
|
1256
|
+
f"(add an identity child or mark @isAbstract: true)"
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
elif node.sub_type == OBJECT_SUBTYPE_VALUE:
|
|
1260
|
+
# value object should NOT have a primary identity.
|
|
1261
|
+
if node.primary_identity() is not None:
|
|
1262
|
+
errors.append(
|
|
1263
|
+
MetaError(
|
|
1264
|
+
f"{_node_label(node)} is a value object but declares a primary identity",
|
|
1265
|
+
ErrorCode.ERR_SUBTYPE_RULE_VIOLATION,
|
|
1266
|
+
envelope=node.source,
|
|
1267
|
+
)
|
|
1268
|
+
)
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
# ---------------------------------------------------------------------------
|
|
1272
|
+
# Pass: filterable-without-index
|
|
1273
|
+
# ---------------------------------------------------------------------------
|
|
1274
|
+
# For each field with @filterable: true that is NOT part of any identity (@fields)
|
|
1275
|
+
# on its owning object AND has no @db.indexed: true → warning.
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def _identity_field_names(obj: MetaObject) -> set[str]:
|
|
1279
|
+
"""Return the set of field names covered by ANY identity on *obj* (effective)."""
|
|
1280
|
+
covered: set[str] = set()
|
|
1281
|
+
for child in obj.children():
|
|
1282
|
+
if child.type != TYPE_IDENTITY:
|
|
1283
|
+
continue
|
|
1284
|
+
fields_val = child.attr(IDENTITY_ATTR_FIELDS)
|
|
1285
|
+
if isinstance(fields_val, list):
|
|
1286
|
+
covered.update(str(f) for f in fields_val)
|
|
1287
|
+
elif isinstance(fields_val, str):
|
|
1288
|
+
covered.add(fields_val)
|
|
1289
|
+
return covered
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
def _validate_filterable_has_index(
|
|
1293
|
+
root: MetaData,
|
|
1294
|
+
warnings: list[str],
|
|
1295
|
+
) -> None:
|
|
1296
|
+
for node in _walk(root):
|
|
1297
|
+
if node.type != TYPE_OBJECT:
|
|
1298
|
+
continue
|
|
1299
|
+
if not isinstance(node, MetaObject):
|
|
1300
|
+
continue
|
|
1301
|
+
|
|
1302
|
+
covered = _identity_field_names(node)
|
|
1303
|
+
|
|
1304
|
+
for field in node.fields():
|
|
1305
|
+
if field.attr("filterable") is not True:
|
|
1306
|
+
continue
|
|
1307
|
+
if field.name in covered:
|
|
1308
|
+
continue
|
|
1309
|
+
if field.attr("db.indexed") is True:
|
|
1310
|
+
continue
|
|
1311
|
+
warnings.append(
|
|
1312
|
+
f'[filterable-without-index] field "{node.name}.{field.name}" has @filterable: true '
|
|
1313
|
+
f"but is not part of any identity. Filtering on this field will sequential-scan. "
|
|
1314
|
+
f"Add @db.indexed: true to the field (when supported), or remove @filterable: true."
|
|
1315
|
+
)
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
# ---------------------------------------------------------------------------
|
|
1319
|
+
# Pass: @filterable on a subtype with no operator band (SP-H Unit9)
|
|
1320
|
+
# ---------------------------------------------------------------------------
|
|
1321
|
+
# A field marked @filterable: true whose subtype has no op band (e.g.
|
|
1322
|
+
# field.object) would silently generate a filter with an empty operator set —
|
|
1323
|
+
# a route that rejects every request. Error early.
|
|
1324
|
+
# → ERR_FILTERABLE_UNSUPPORTED_SUBTYPE.
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def _validate_filterable_has_supported_ops(
|
|
1328
|
+
root: MetaData,
|
|
1329
|
+
errors: list[MetaError],
|
|
1330
|
+
) -> None:
|
|
1331
|
+
for node in _walk(root):
|
|
1332
|
+
if node.type != TYPE_OBJECT or not isinstance(node, MetaObject):
|
|
1333
|
+
continue
|
|
1334
|
+
for field in node.fields():
|
|
1335
|
+
if field.attr("filterable") is not True:
|
|
1336
|
+
continue
|
|
1337
|
+
if ops_for_subtype(field.sub_type):
|
|
1338
|
+
continue
|
|
1339
|
+
errors.append(
|
|
1340
|
+
MetaError(
|
|
1341
|
+
f'Field "{node.name}.{field.name}" has @filterable: true but its subtype '
|
|
1342
|
+
f'"{field.sub_type}" has no filter-operator band. Remove @filterable, or use a '
|
|
1343
|
+
f"field subtype that supports filtering "
|
|
1344
|
+
f"(string/enum/uuid/number/currency/date/boolean).",
|
|
1345
|
+
ErrorCode.ERR_FILTERABLE_UNSUPPORTED_SUBTYPE,
|
|
1346
|
+
envelope=field.source,
|
|
1347
|
+
)
|
|
1348
|
+
)
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
# ---------------------------------------------------------------------------
|
|
1352
|
+
# Pass: field.object @storage validation
|
|
1353
|
+
# ---------------------------------------------------------------------------
|
|
1354
|
+
# Cross-port rules (ADR-0013):
|
|
1355
|
+
# 1. A field.object ALWAYS requires @objectRef → ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF.
|
|
1356
|
+
# A field.object models a typed nested value; without @objectRef it is an
|
|
1357
|
+
# oxymoron at the logical layer. Open/untyped JSON uses the physical
|
|
1358
|
+
# @dbColumnType: jsonb escape hatch on field.string, NOT a bare object. This
|
|
1359
|
+
# rule subsumes the legacy @storage-without-@objectRef check (@storage is only
|
|
1360
|
+
# meaningful on a field.object), so missing-@objectRef now always reports this
|
|
1361
|
+
# single, clearer error — one error per node (the flattened/array check is
|
|
1362
|
+
# skipped when @objectRef is absent).
|
|
1363
|
+
# 2. @storage="flattened" + isArray → ERR_STORAGE_FLATTENED_ARRAY (flattened
|
|
1364
|
+
# materialises one-column-per-field; arrays require @storage="jsonb").
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def _validate_field_object_storage(root: MetaData, errors: list[MetaError]) -> None:
|
|
1368
|
+
for node in _walk(root):
|
|
1369
|
+
if node.type != TYPE_FIELD or node.sub_type != FIELD_SUBTYPE_OBJECT:
|
|
1370
|
+
continue
|
|
1371
|
+
object_ref = node.attr(FIELD_ATTR_OBJECT_REF)
|
|
1372
|
+
if not (isinstance(object_ref, str) and object_ref):
|
|
1373
|
+
errors.append(MetaError(
|
|
1374
|
+
code=ErrorCode.ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF,
|
|
1375
|
+
message=(
|
|
1376
|
+
f"field.object '{node.name}' has no @objectRef — a field.object "
|
|
1377
|
+
f"requires @objectRef. For an open/untyped JSON map use "
|
|
1378
|
+
f"@dbColumnType: jsonb on a field.string instead of a bare object."
|
|
1379
|
+
),
|
|
1380
|
+
envelope=node.source,
|
|
1381
|
+
))
|
|
1382
|
+
continue
|
|
1383
|
+
storage = node.attr(FIELD_ATTR_STORAGE)
|
|
1384
|
+
if storage is None:
|
|
1385
|
+
continue
|
|
1386
|
+
if storage == "flattened" and getattr(node, "is_array", False):
|
|
1387
|
+
errors.append(MetaError(
|
|
1388
|
+
code=ErrorCode.ERR_STORAGE_FLATTENED_ARRAY,
|
|
1389
|
+
message=(
|
|
1390
|
+
f"field.object '{node.name}' @storage=\"flattened\" cannot be combined "
|
|
1391
|
+
f"with isArray=true (use @storage=\"jsonb\" for owned-array storage)"
|
|
1392
|
+
),
|
|
1393
|
+
envelope=node.source,
|
|
1394
|
+
))
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
# ---------------------------------------------------------------------------
|
|
1398
|
+
# Pass: template.* validation (FR-004)
|
|
1399
|
+
# ---------------------------------------------------------------------------
|
|
1400
|
+
# Four cross-port rules:
|
|
1401
|
+
# R1 — template.prompt requires @payloadRef → ERR_MISSING_REQUIRED_ATTR
|
|
1402
|
+
# R2 — @payloadRef resolves to a root-level object.value → ERR_INVALID_TEMPLATE
|
|
1403
|
+
# R3 — @requiredSlots entries are fields on the payload → ERR_INVALID_TEMPLATE
|
|
1404
|
+
# R4 — @format (if set) is in the closed enum set → ERR_BAD_ATTR_VALUE
|
|
1405
|
+
# (handled by AttrSchema.allowed_values already; included for parity).
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def _validate_templates(root: MetaData, errors: list[MetaError]) -> None:
|
|
1409
|
+
objects_by_name: dict[str, MetaData] = {}
|
|
1410
|
+
for child in root.own_children():
|
|
1411
|
+
if child.type == TYPE_OBJECT:
|
|
1412
|
+
objects_by_name.setdefault(child.name, child)
|
|
1413
|
+
|
|
1414
|
+
for tpl in root.own_children():
|
|
1415
|
+
if tpl.type != TYPE_TEMPLATE:
|
|
1416
|
+
continue
|
|
1417
|
+
is_prompt = tpl.sub_type == tc.TEMPLATE_SUBTYPE_PROMPT
|
|
1418
|
+
payload_ref = tpl.attr(tc.TEMPLATE_ATTR_PAYLOAD_REF)
|
|
1419
|
+
has_payload_ref = isinstance(payload_ref, str) and payload_ref
|
|
1420
|
+
|
|
1421
|
+
# --- @kind / textRef / email part-ref cross-field rules ---
|
|
1422
|
+
# template.output is either a document (@kind absent/"document" -> @textRef
|
|
1423
|
+
# required) or an email (@kind="email" -> @subjectRef + @htmlBodyRef required,
|
|
1424
|
+
# @textRef unused). template.prompt always requires @textRef. Closed-enum
|
|
1425
|
+
# membership of @kind is enforced by allowed_values (ERR_BAD_ATTR_VALUE);
|
|
1426
|
+
# here we enforce only conditional ref presence. Mirrors TS/Java.
|
|
1427
|
+
if tpl.sub_type == tc.TEMPLATE_SUBTYPE_OUTPUT:
|
|
1428
|
+
if tpl.attr(tc.TEMPLATE_ATTR_KIND) == tc.TEMPLATE_KIND_EMAIL:
|
|
1429
|
+
if not isinstance(tpl.attr(tc.TEMPLATE_ATTR_SUBJECT_REF), str):
|
|
1430
|
+
errors.append(MetaError(
|
|
1431
|
+
code=ErrorCode.ERR_INVALID_TEMPLATE,
|
|
1432
|
+
message=f'template "{tpl.name}" @kind "email" requires @subjectRef',
|
|
1433
|
+
envelope=tpl.source,
|
|
1434
|
+
))
|
|
1435
|
+
if not isinstance(tpl.attr(tc.TEMPLATE_ATTR_HTML_BODY_REF), str):
|
|
1436
|
+
errors.append(MetaError(
|
|
1437
|
+
code=ErrorCode.ERR_INVALID_TEMPLATE,
|
|
1438
|
+
message=f'template "{tpl.name}" @kind "email" requires @htmlBodyRef',
|
|
1439
|
+
envelope=tpl.source,
|
|
1440
|
+
))
|
|
1441
|
+
else:
|
|
1442
|
+
# @kind absent or "document" -> require @textRef so a document is
|
|
1443
|
+
# never bodyless. (An out-of-enum @kind is flagged separately by
|
|
1444
|
+
# the allowed_values schema check.)
|
|
1445
|
+
if not isinstance(tpl.attr(tc.TEMPLATE_ATTR_TEXT_REF), str):
|
|
1446
|
+
errors.append(MetaError(
|
|
1447
|
+
code=ErrorCode.ERR_INVALID_TEMPLATE,
|
|
1448
|
+
message=f'template "{tpl.name}" @kind "document" requires @textRef',
|
|
1449
|
+
envelope=tpl.source,
|
|
1450
|
+
))
|
|
1451
|
+
elif is_prompt:
|
|
1452
|
+
# template.prompt always carries a renderable body via @textRef.
|
|
1453
|
+
if not isinstance(tpl.attr(tc.TEMPLATE_ATTR_TEXT_REF), str):
|
|
1454
|
+
errors.append(MetaError(
|
|
1455
|
+
code=ErrorCode.ERR_INVALID_TEMPLATE,
|
|
1456
|
+
message=f'template "{tpl.name}" requires @textRef',
|
|
1457
|
+
envelope=tpl.source,
|
|
1458
|
+
))
|
|
1459
|
+
|
|
1460
|
+
# @payloadRef required-ness is enforced by the generic required-attr schema
|
|
1461
|
+
# check (Check 1) — payloadRef is declared required on the concrete template
|
|
1462
|
+
# subtypes. No separate manual emit here (matches TS). If absent, the
|
|
1463
|
+
# reference-resolution checks below simply skip.
|
|
1464
|
+
if not has_payload_ref:
|
|
1465
|
+
continue
|
|
1466
|
+
|
|
1467
|
+
# R2 — @payloadRef must resolve to a root-level object.value
|
|
1468
|
+
# FR5d — @payloadRef is a reference; emit format=resolved with
|
|
1469
|
+
# referrer=template FQN, target=the unresolved payloadRef string.
|
|
1470
|
+
payload = objects_by_name.get(payload_ref)
|
|
1471
|
+
if payload is None or payload.sub_type != OBJECT_SUBTYPE_VALUE:
|
|
1472
|
+
errors.append(MetaError(
|
|
1473
|
+
code=ErrorCode.ERR_INVALID_TEMPLATE,
|
|
1474
|
+
message=(
|
|
1475
|
+
f"template '{tpl.name}' @payloadRef '{payload_ref}' "
|
|
1476
|
+
f"does not resolve to an object.value at root"
|
|
1477
|
+
),
|
|
1478
|
+
envelope=resolved_source(tpl.source, tpl.fqn(), payload_ref),
|
|
1479
|
+
))
|
|
1480
|
+
continue
|
|
1481
|
+
|
|
1482
|
+
# R3 — required-slots membership
|
|
1483
|
+
if is_prompt:
|
|
1484
|
+
slots_raw = tpl.attr(tc.TEMPLATE_ATTR_REQUIRED_SLOTS)
|
|
1485
|
+
slots = _parse_string_list(slots_raw)
|
|
1486
|
+
if slots:
|
|
1487
|
+
payload_fields = {f.name for f in payload.own_children() if f.type == TYPE_FIELD}
|
|
1488
|
+
for slot in slots:
|
|
1489
|
+
if slot not in payload_fields:
|
|
1490
|
+
# FR5d — @requiredSlots is a field-on-payload reference;
|
|
1491
|
+
# emit format=resolved with target=`payloadRef.slot`
|
|
1492
|
+
# (the dotted ref that did not resolve to a payload
|
|
1493
|
+
# field). Mirrors TS validation-passes.ts L122-137.
|
|
1494
|
+
errors.append(MetaError(
|
|
1495
|
+
code=ErrorCode.ERR_INVALID_TEMPLATE,
|
|
1496
|
+
message=(
|
|
1497
|
+
f"template.prompt '{tpl.name}' @requiredSlots includes '{slot}' "
|
|
1498
|
+
f"which is not a field on payload '{payload_ref}'"
|
|
1499
|
+
),
|
|
1500
|
+
envelope=resolved_source(
|
|
1501
|
+
tpl.source, tpl.fqn(), f"{payload_ref}.{slot}",
|
|
1502
|
+
),
|
|
1503
|
+
))
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
def _parse_string_list(raw: object) -> tuple[str, ...]:
|
|
1507
|
+
if raw is None:
|
|
1508
|
+
return ()
|
|
1509
|
+
if isinstance(raw, str):
|
|
1510
|
+
return tuple(s.strip() for s in raw.split(",") if s.strip())
|
|
1511
|
+
if isinstance(raw, (list, tuple)):
|
|
1512
|
+
return tuple(str(x) for x in raw)
|
|
1513
|
+
return ()
|