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.
Files changed (181) hide show
  1. metaobjects/__init__.py +75 -0
  2. metaobjects/agent_context/__init__.py +55 -0
  3. metaobjects/agent_context/_content/README.md +14 -0
  4. metaobjects/agent_context/_content/servers/csharp.meta.json +5 -0
  5. metaobjects/agent_context/_content/servers/java.meta.json +5 -0
  6. metaobjects/agent_context/_content/servers/kotlin.meta.json +5 -0
  7. metaobjects/agent_context/_content/servers/python.meta.json +5 -0
  8. metaobjects/agent_context/_content/servers/typescript.meta.json +5 -0
  9. metaobjects/agent_context/_content/skills/metaobjects-authoring/SKILL.md +301 -0
  10. metaobjects/agent_context/_content/skills/metaobjects-codegen/SKILL.md +99 -0
  11. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/csharp.md +87 -0
  12. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/java.md +94 -0
  13. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/kotlin.md +110 -0
  14. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/typescript.md +135 -0
  15. metaobjects/agent_context/_content/skills/metaobjects-prompts/SKILL.md +148 -0
  16. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/csharp.md +110 -0
  17. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/java.md +108 -0
  18. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/kotlin.md +130 -0
  19. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/python.md +116 -0
  20. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/typescript.md +150 -0
  21. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/SKILL.md +130 -0
  22. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/java.md +96 -0
  23. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/kotlin.md +99 -0
  24. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/react.md +86 -0
  25. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/tanstack.md +119 -0
  26. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/typescript.md +92 -0
  27. metaobjects/agent_context/_content/skills/metaobjects-verify/SKILL.md +107 -0
  28. metaobjects/agent_context/_content/skills/metaobjects-verify/references/migration.md +72 -0
  29. metaobjects/agent_context/_content/templates/always-on.md.mustache +27 -0
  30. metaobjects/agent_context/assemble.py +133 -0
  31. metaobjects/agent_context/content_root.py +54 -0
  32. metaobjects/agent_context/scaffold.py +191 -0
  33. metaobjects/agent_context/types.py +44 -0
  34. metaobjects/attr_class_map.py +23 -0
  35. metaobjects/cli.py +696 -0
  36. metaobjects/codegen/__init__.py +0 -0
  37. metaobjects/codegen/config.py +11 -0
  38. metaobjects/codegen/constants.py +13 -0
  39. metaobjects/codegen/extract_delegate_emitter.py +384 -0
  40. metaobjects/codegen/extract_schema_emitter.py +139 -0
  41. metaobjects/codegen/format.py +31 -0
  42. metaobjects/codegen/fr010_field_mapping.py +220 -0
  43. metaobjects/codegen/generator.py +62 -0
  44. metaobjects/codegen/generator_registry.py +163 -0
  45. metaobjects/codegen/generators/__init__.py +0 -0
  46. metaobjects/codegen/generators/entity_model.py +263 -0
  47. metaobjects/codegen/generators/extractor_generator.py +317 -0
  48. metaobjects/codegen/generators/filter_allowlist_generator.py +309 -0
  49. metaobjects/codegen/generators/m2m_codegen.py +192 -0
  50. metaobjects/codegen/generators/output_parser_generator.py +272 -0
  51. metaobjects/codegen/generators/output_prompt_generator.py +192 -0
  52. metaobjects/codegen/generators/payload_vo_generator.py +672 -0
  53. metaobjects/codegen/generators/render_helper_generator.py +451 -0
  54. metaobjects/codegen/generators/router_generator.py +635 -0
  55. metaobjects/codegen/generators/template_generator.py +70 -0
  56. metaobjects/codegen/generators/tph_plan.py +120 -0
  57. metaobjects/codegen/generators/trace_helper_generator.py +336 -0
  58. metaobjects/codegen/instance_artifacts.py +15 -0
  59. metaobjects/codegen/output_format_spec_emitter.py +79 -0
  60. metaobjects/codegen/overwrite_policy.py +27 -0
  61. metaobjects/codegen/runner.py +110 -0
  62. metaobjects/codegen/runtime/__init__.py +6 -0
  63. metaobjects/codegen/runtime/filter_parser.py +193 -0
  64. metaobjects/codegen/type_map.py +84 -0
  65. metaobjects/core_types.py +809 -0
  66. metaobjects/datatype.py +19 -0
  67. metaobjects/documentation/__init__.py +28 -0
  68. metaobjects/documentation/doc_constants.py +20 -0
  69. metaobjects/documentation/doc_provider.py +20 -0
  70. metaobjects/documentation/doc_schema.py +24 -0
  71. metaobjects/errors.py +124 -0
  72. metaobjects/loader/__init__.py +0 -0
  73. metaobjects/loader/merge.py +287 -0
  74. metaobjects/loader/meta_data_loader.py +245 -0
  75. metaobjects/loader/sources/__init__.py +24 -0
  76. metaobjects/loader/sources/directory_source.py +50 -0
  77. metaobjects/loader/sources/file_source.py +41 -0
  78. metaobjects/loader/sources/meta_data_source.py +67 -0
  79. metaobjects/loader/sources/uri_source.py +56 -0
  80. metaobjects/loader/validate_discriminator.py +181 -0
  81. metaobjects/loader/validate_field_readonly.py +146 -0
  82. metaobjects/loader/validate_source_parameter_ref.py +159 -0
  83. metaobjects/loader/validate_source_physical_names.py +140 -0
  84. metaobjects/loader/validation_passes.py +1513 -0
  85. metaobjects/meta/__init__.py +1 -0
  86. metaobjects/meta/core/__init__.py +0 -0
  87. metaobjects/meta/core/attr/__init__.py +0 -0
  88. metaobjects/meta/core/attr/attr_constants.py +31 -0
  89. metaobjects/meta/core/attr/meta_attr.py +136 -0
  90. metaobjects/meta/core/field/__init__.py +0 -0
  91. metaobjects/meta/core/field/field_constants.py +105 -0
  92. metaobjects/meta/core/field/meta_field.py +76 -0
  93. metaobjects/meta/core/identity/__init__.py +0 -0
  94. metaobjects/meta/core/identity/identity_constants.py +19 -0
  95. metaobjects/meta/core/identity/meta_identity.py +8 -0
  96. metaobjects/meta/core/object/__init__.py +0 -0
  97. metaobjects/meta/core/object/meta_object.py +65 -0
  98. metaobjects/meta/core/object/meta_object_aware.py +43 -0
  99. metaobjects/meta/core/object/object_class_registry.py +56 -0
  100. metaobjects/meta/core/object/object_constants.py +13 -0
  101. metaobjects/meta/core/object/object_extract.py +400 -0
  102. metaobjects/meta/core/object/value_object.py +70 -0
  103. metaobjects/meta/core/relationship/__init__.py +0 -0
  104. metaobjects/meta/core/relationship/derive_m2m_fields.py +180 -0
  105. metaobjects/meta/core/relationship/meta_relationship.py +54 -0
  106. metaobjects/meta/core/relationship/relationship_constants.py +51 -0
  107. metaobjects/meta/core/validator/__init__.py +0 -0
  108. metaobjects/meta/core/validator/validator_constants.py +18 -0
  109. metaobjects/meta/meta_data.py +206 -0
  110. metaobjects/meta/meta_root.py +8 -0
  111. metaobjects/meta/persistence/__init__.py +0 -0
  112. metaobjects/meta/persistence/db/__init__.py +1 -0
  113. metaobjects/meta/persistence/db/db_constants.py +41 -0
  114. metaobjects/meta/persistence/db/db_provider.py +60 -0
  115. metaobjects/meta/persistence/origin/__init__.py +0 -0
  116. metaobjects/meta/persistence/origin/meta_origin.py +8 -0
  117. metaobjects/meta/persistence/origin/origin_constants.py +20 -0
  118. metaobjects/meta/persistence/source/__init__.py +0 -0
  119. metaobjects/meta/persistence/source/meta_source.py +137 -0
  120. metaobjects/meta/persistence/source/source_constants.py +115 -0
  121. metaobjects/meta/presentation/__init__.py +0 -0
  122. metaobjects/meta/presentation/layout/__init__.py +0 -0
  123. metaobjects/meta/presentation/layout/layout_constants.py +13 -0
  124. metaobjects/meta/presentation/layout/meta_layout.py +8 -0
  125. metaobjects/meta/presentation/view/__init__.py +0 -0
  126. metaobjects/meta/presentation/view/meta_view.py +8 -0
  127. metaobjects/meta/presentation/view/view_constants.py +22 -0
  128. metaobjects/meta/template/__init__.py +0 -0
  129. metaobjects/meta/template/meta_template.py +46 -0
  130. metaobjects/meta/template/template_constants.py +112 -0
  131. metaobjects/meta/template/template_provider.py +43 -0
  132. metaobjects/parser.py +380 -0
  133. metaobjects/parser_yaml.py +82 -0
  134. metaobjects/provider.py +111 -0
  135. metaobjects/py.typed +0 -0
  136. metaobjects/registry.py +210 -0
  137. metaobjects/registry_manifest.py +223 -0
  138. metaobjects/render/__init__.py +74 -0
  139. metaobjects/render/email_document.py +14 -0
  140. metaobjects/render/escapers.py +109 -0
  141. metaobjects/render/extract/__init__.py +59 -0
  142. metaobjects/render/extract/coerce.py +279 -0
  143. metaobjects/render/extract/extract.py +211 -0
  144. metaobjects/render/extract/extract_map.py +61 -0
  145. metaobjects/render/extract/json_forgiving_reader.py +203 -0
  146. metaobjects/render/extract/locate.py +65 -0
  147. metaobjects/render/extract/normalize.py +96 -0
  148. metaobjects/render/extract/strip.py +20 -0
  149. metaobjects/render/extract/types.py +332 -0
  150. metaobjects/render/extract/xml_forgiving_reader.py +162 -0
  151. metaobjects/render/filesystem_provider.py +51 -0
  152. metaobjects/render/prompt/__init__.py +32 -0
  153. metaobjects/render/prompt/output_format_renderer.py +340 -0
  154. metaobjects/render/prompt/output_format_spec.py +28 -0
  155. metaobjects/render/prompt/prompt_field.py +29 -0
  156. metaobjects/render/prompt/prompt_overrides.py +29 -0
  157. metaobjects/render/prompt/prompt_style.py +38 -0
  158. metaobjects/render/renderer.py +358 -0
  159. metaobjects/render/verify.py +266 -0
  160. metaobjects/runtime/__init__.py +39 -0
  161. metaobjects/runtime/llm_recorder.py +210 -0
  162. metaobjects/runtime/n2m_resolver.py +155 -0
  163. metaobjects/runtime/object_manager.py +715 -0
  164. metaobjects/runtime/tph.py +50 -0
  165. metaobjects/serializer_json.py +172 -0
  166. metaobjects/shared/__init__.py +0 -0
  167. metaobjects/shared/base_types.py +16 -0
  168. metaobjects/shared/separators.py +4 -0
  169. metaobjects/shared/structural.py +9 -0
  170. metaobjects/source/__init__.py +79 -0
  171. metaobjects/source/error_source.py +266 -0
  172. metaobjects/source/json_path.py +106 -0
  173. metaobjects/source/semantic_diff.py +98 -0
  174. metaobjects/source/yaml_positions.py +174 -0
  175. metaobjects/super_resolve.py +128 -0
  176. metaobjects/yaml_desugar.py +481 -0
  177. metaobjects-0.9.0.dist-info/METADATA +97 -0
  178. metaobjects-0.9.0.dist-info/RECORD +181 -0
  179. metaobjects-0.9.0.dist-info/WHEEL +4 -0
  180. metaobjects-0.9.0.dist-info/entry_points.txt +2 -0
  181. 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 ()