omnibase_infra 0.2.8__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- omnibase_infra/__init__.py +1 -1
- omnibase_infra/enums/__init__.py +4 -0
- omnibase_infra/enums/enum_declarative_node_violation.py +102 -0
- omnibase_infra/errors/__init__.py +18 -0
- omnibase_infra/errors/repository/__init__.py +78 -0
- omnibase_infra/errors/repository/errors_repository.py +424 -0
- omnibase_infra/event_bus/adapters/__init__.py +31 -0
- omnibase_infra/event_bus/adapters/adapter_protocol_event_publisher_kafka.py +517 -0
- omnibase_infra/mixins/mixin_async_circuit_breaker.py +113 -1
- omnibase_infra/models/__init__.py +9 -0
- omnibase_infra/models/event_bus/__init__.py +22 -0
- omnibase_infra/models/event_bus/model_consumer_retry_config.py +367 -0
- omnibase_infra/models/event_bus/model_dlq_config.py +177 -0
- omnibase_infra/models/event_bus/model_idempotency_config.py +131 -0
- omnibase_infra/models/event_bus/model_offset_policy_config.py +107 -0
- omnibase_infra/models/resilience/model_circuit_breaker_config.py +15 -0
- omnibase_infra/models/validation/__init__.py +8 -0
- omnibase_infra/models/validation/model_declarative_node_validation_result.py +139 -0
- omnibase_infra/models/validation/model_declarative_node_violation.py +169 -0
- omnibase_infra/nodes/architecture_validator/__init__.py +28 -7
- omnibase_infra/nodes/architecture_validator/constants.py +36 -0
- omnibase_infra/nodes/architecture_validator/handlers/__init__.py +28 -0
- omnibase_infra/nodes/architecture_validator/handlers/contract.yaml +120 -0
- omnibase_infra/nodes/architecture_validator/handlers/handler_architecture_validation.py +359 -0
- omnibase_infra/nodes/architecture_validator/node.py +1 -0
- omnibase_infra/nodes/architecture_validator/node_architecture_validator.py +48 -336
- omnibase_infra/nodes/contract_registry_reducer/reducer.py +12 -2
- omnibase_infra/nodes/node_ledger_projection_compute/__init__.py +16 -2
- omnibase_infra/nodes/node_ledger_projection_compute/contract.yaml +14 -4
- omnibase_infra/nodes/node_ledger_projection_compute/handlers/__init__.py +18 -0
- omnibase_infra/nodes/node_ledger_projection_compute/handlers/contract.yaml +53 -0
- omnibase_infra/nodes/node_ledger_projection_compute/handlers/handler_ledger_projection.py +354 -0
- omnibase_infra/nodes/node_ledger_projection_compute/node.py +20 -256
- omnibase_infra/nodes/node_registry_effect/node.py +20 -73
- omnibase_infra/protocols/protocol_dispatch_engine.py +90 -0
- omnibase_infra/runtime/__init__.py +11 -0
- omnibase_infra/runtime/baseline_subscriptions.py +150 -0
- omnibase_infra/runtime/db/__init__.py +73 -0
- omnibase_infra/runtime/db/models/__init__.py +41 -0
- omnibase_infra/runtime/db/models/model_repository_runtime_config.py +211 -0
- omnibase_infra/runtime/db/postgres_repository_runtime.py +545 -0
- omnibase_infra/runtime/event_bus_subcontract_wiring.py +455 -24
- omnibase_infra/runtime/kafka_contract_source.py +13 -5
- omnibase_infra/runtime/service_message_dispatch_engine.py +112 -0
- omnibase_infra/runtime/service_runtime_host_process.py +6 -11
- omnibase_infra/services/__init__.py +36 -0
- omnibase_infra/services/contract_publisher/__init__.py +95 -0
- omnibase_infra/services/contract_publisher/config.py +199 -0
- omnibase_infra/services/contract_publisher/errors.py +243 -0
- omnibase_infra/services/contract_publisher/models/__init__.py +28 -0
- omnibase_infra/services/contract_publisher/models/model_contract_error.py +67 -0
- omnibase_infra/services/contract_publisher/models/model_infra_error.py +62 -0
- omnibase_infra/services/contract_publisher/models/model_publish_result.py +112 -0
- omnibase_infra/services/contract_publisher/models/model_publish_stats.py +79 -0
- omnibase_infra/services/contract_publisher/service.py +617 -0
- omnibase_infra/services/contract_publisher/sources/__init__.py +52 -0
- omnibase_infra/services/contract_publisher/sources/model_discovered.py +155 -0
- omnibase_infra/services/contract_publisher/sources/protocol.py +101 -0
- omnibase_infra/services/contract_publisher/sources/source_composite.py +309 -0
- omnibase_infra/services/contract_publisher/sources/source_filesystem.py +174 -0
- omnibase_infra/services/contract_publisher/sources/source_package.py +221 -0
- omnibase_infra/services/observability/__init__.py +40 -0
- omnibase_infra/services/observability/agent_actions/__init__.py +64 -0
- omnibase_infra/services/observability/agent_actions/config.py +209 -0
- omnibase_infra/services/observability/agent_actions/consumer.py +1320 -0
- omnibase_infra/services/observability/agent_actions/models/__init__.py +87 -0
- omnibase_infra/services/observability/agent_actions/models/model_agent_action.py +142 -0
- omnibase_infra/services/observability/agent_actions/models/model_detection_failure.py +125 -0
- omnibase_infra/services/observability/agent_actions/models/model_envelope.py +85 -0
- omnibase_infra/services/observability/agent_actions/models/model_execution_log.py +159 -0
- omnibase_infra/services/observability/agent_actions/models/model_performance_metric.py +130 -0
- omnibase_infra/services/observability/agent_actions/models/model_routing_decision.py +138 -0
- omnibase_infra/services/observability/agent_actions/models/model_transformation_event.py +124 -0
- omnibase_infra/services/observability/agent_actions/tests/__init__.py +20 -0
- omnibase_infra/services/observability/agent_actions/tests/test_consumer.py +1154 -0
- omnibase_infra/services/observability/agent_actions/tests/test_models.py +645 -0
- omnibase_infra/services/observability/agent_actions/tests/test_writer.py +709 -0
- omnibase_infra/services/observability/agent_actions/writer_postgres.py +926 -0
- omnibase_infra/validation/__init__.py +12 -0
- omnibase_infra/validation/contracts/declarative_node.validation.yaml +143 -0
- omnibase_infra/validation/infra_validators.py +4 -1
- omnibase_infra/validation/validation_exemptions.yaml +111 -0
- omnibase_infra/validation/validator_declarative_node.py +850 -0
- {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/METADATA +2 -2
- {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/RECORD +88 -30
- {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/WHEEL +0 -0
- {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/entry_points.txt +0 -0
- {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2025 OmniNode Team
|
|
3
|
+
"""AST-based Declarative Node Validator for ONEX.
|
|
4
|
+
|
|
5
|
+
Contract-driven validator to enforce the ONEX declarative node policy:
|
|
6
|
+
|
|
7
|
+
- Node classes MUST only extend base classes without custom logic
|
|
8
|
+
- Only __init__ with super().__init__(container) is allowed
|
|
9
|
+
- No custom methods, properties, or instance variables
|
|
10
|
+
- All behavior should be defined in contract.yaml
|
|
11
|
+
|
|
12
|
+
The validator uses Python AST to detect forbidden patterns without runtime execution.
|
|
13
|
+
|
|
14
|
+
Exemption Mechanism:
|
|
15
|
+
``# ONEX_EXCLUDE: declarative_node`` comment on the class line exempts that class.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
Programmatic::
|
|
19
|
+
|
|
20
|
+
>>> from pathlib import Path
|
|
21
|
+
>>> from omnibase_infra.validation.validator_declarative_node import (
|
|
22
|
+
... ValidatorDeclarativeNode,
|
|
23
|
+
... )
|
|
24
|
+
>>> validator = ValidatorDeclarativeNode()
|
|
25
|
+
>>> result = validator.validate(Path("src/nodes"))
|
|
26
|
+
>>> if not result.is_valid:
|
|
27
|
+
... for issue in result.issues:
|
|
28
|
+
... print(f"{issue.file_path}:{issue.line_number}: {issue.message}")
|
|
29
|
+
|
|
30
|
+
Module-level convenience functions::
|
|
31
|
+
|
|
32
|
+
>>> from omnibase_infra.validation.validator_declarative_node import (
|
|
33
|
+
... validate_declarative_nodes,
|
|
34
|
+
... validate_declarative_nodes_ci,
|
|
35
|
+
... )
|
|
36
|
+
>>> violations = validate_declarative_nodes(Path("src/nodes"))
|
|
37
|
+
>>> result = validate_declarative_nodes_ci(Path("src/nodes"))
|
|
38
|
+
>>> if not result.passed:
|
|
39
|
+
... print(f"Found {result.blocking_count} imperative nodes")
|
|
40
|
+
|
|
41
|
+
CLI::
|
|
42
|
+
|
|
43
|
+
python -m omnibase_infra.validation.validator_declarative_node src/nodes
|
|
44
|
+
|
|
45
|
+
See Also:
|
|
46
|
+
- CLAUDE.md: MANDATORY: Declarative Nodes section
|
|
47
|
+
- ValidatorBase: Base class for contract-driven validators
|
|
48
|
+
|
|
49
|
+
Limitations:
|
|
50
|
+
- **Shallow inheritance detection**: Only direct base classes (NodeEffect,
|
|
51
|
+
NodeCompute, NodeReducer, NodeOrchestrator) are recognized. Indirect
|
|
52
|
+
inheritance through custom intermediate classes is not detected.
|
|
53
|
+
- **Exemption comment window**: The ONEX_EXCLUDE comment must appear within
|
|
54
|
+
3 lines before the class definition. Classes with many decorators may
|
|
55
|
+
need the comment placed closer to the class definition.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from __future__ import annotations
|
|
59
|
+
|
|
60
|
+
import ast
|
|
61
|
+
import logging
|
|
62
|
+
import sys
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
from typing import ClassVar
|
|
65
|
+
|
|
66
|
+
from omnibase_core.models.common.model_validation_issue import ModelValidationIssue
|
|
67
|
+
from omnibase_core.models.contracts.subcontracts.model_validator_subcontract import (
|
|
68
|
+
ModelValidatorSubcontract,
|
|
69
|
+
)
|
|
70
|
+
from omnibase_core.validation.validator_base import ValidatorBase
|
|
71
|
+
from omnibase_infra.enums import EnumValidationSeverity
|
|
72
|
+
from omnibase_infra.enums.enum_declarative_node_violation import (
|
|
73
|
+
EnumDeclarativeNodeViolation,
|
|
74
|
+
)
|
|
75
|
+
from omnibase_infra.models.validation.model_declarative_node_validation_result import (
|
|
76
|
+
ModelDeclarativeNodeValidationResult,
|
|
77
|
+
)
|
|
78
|
+
from omnibase_infra.models.validation.model_declarative_node_violation import (
|
|
79
|
+
ModelDeclarativeNodeViolation,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
logger = logging.getLogger(__name__)
|
|
83
|
+
|
|
84
|
+
# Node base class names that indicate a class is an ONEX node
|
|
85
|
+
_NODE_BASE_CLASSES: frozenset[str] = frozenset(
|
|
86
|
+
{
|
|
87
|
+
"NodeEffect",
|
|
88
|
+
"NodeCompute",
|
|
89
|
+
"NodeReducer",
|
|
90
|
+
"NodeOrchestrator",
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Exemption comment pattern
|
|
95
|
+
_ONEX_EXCLUDE_PATTERN = "ONEX_EXCLUDE:"
|
|
96
|
+
_ONEX_EXCLUDE_DECLARATIVE = "declarative_node"
|
|
97
|
+
|
|
98
|
+
# Maximum file size to process (in bytes)
|
|
99
|
+
_MAX_FILE_SIZE_BYTES: int = 500_000 # 500KB
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _get_source_line(source_lines: list[str], line_number: int) -> str:
|
|
103
|
+
"""Get a source line safely (1-indexed).
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
source_lines: List of source lines.
|
|
107
|
+
line_number: 1-indexed line number.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The source line stripped, or empty string if out of bounds.
|
|
111
|
+
"""
|
|
112
|
+
if 0 < line_number <= len(source_lines):
|
|
113
|
+
return source_lines[line_number - 1].strip()
|
|
114
|
+
return ""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_class_exempted(source_lines: list[str], class_line: int) -> bool:
|
|
118
|
+
"""Check if a class is exempted via ONEX_EXCLUDE comment.
|
|
119
|
+
|
|
120
|
+
Checks the class line and the 3 lines before it for exemption comment.
|
|
121
|
+
This covers common patterns like:
|
|
122
|
+
- Comment on same line as class
|
|
123
|
+
- Comment on line immediately before class
|
|
124
|
+
- Comment before decorators (up to 2 decorators)
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
source_lines: List of source lines.
|
|
128
|
+
class_line: 1-indexed line number of the class definition.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
True if the class is exempted.
|
|
132
|
+
|
|
133
|
+
Note:
|
|
134
|
+
The 3-line lookback window may be insufficient for classes with:
|
|
135
|
+
- More than 2 decorators above the exemption comment
|
|
136
|
+
- Long multi-line decorators
|
|
137
|
+
In such cases, place the exemption comment closer to the class
|
|
138
|
+
definition or on the same line.
|
|
139
|
+
"""
|
|
140
|
+
start_line = max(1, class_line - 3)
|
|
141
|
+
for line_num in range(start_line, class_line + 1):
|
|
142
|
+
line = _get_source_line(source_lines, line_num)
|
|
143
|
+
if _ONEX_EXCLUDE_PATTERN in line and _ONEX_EXCLUDE_DECLARATIVE in line:
|
|
144
|
+
return True
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _get_base_class_names(class_node: ast.ClassDef) -> set[str]:
|
|
149
|
+
"""Extract base class names from a class definition.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
class_node: AST ClassDef node.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Set of base class names (handles Name, Attribute, and Subscript nodes).
|
|
156
|
+
|
|
157
|
+
Note:
|
|
158
|
+
Subscript nodes represent generic types like NodeReducer["State", "Output"].
|
|
159
|
+
We extract the base class name from the subscript value.
|
|
160
|
+
"""
|
|
161
|
+
base_names: set[str] = set()
|
|
162
|
+
for base in class_node.bases:
|
|
163
|
+
if isinstance(base, ast.Name):
|
|
164
|
+
base_names.add(base.id)
|
|
165
|
+
elif isinstance(base, ast.Attribute):
|
|
166
|
+
base_names.add(base.attr)
|
|
167
|
+
elif isinstance(base, ast.Subscript):
|
|
168
|
+
# Handle generic types like NodeReducer["State", "Output"]
|
|
169
|
+
subscript_value = base.value
|
|
170
|
+
if isinstance(subscript_value, ast.Name):
|
|
171
|
+
base_names.add(subscript_value.id)
|
|
172
|
+
elif isinstance(subscript_value, ast.Attribute):
|
|
173
|
+
base_names.add(subscript_value.attr)
|
|
174
|
+
return base_names
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _is_node_class(class_node: ast.ClassDef) -> bool:
|
|
178
|
+
"""Check if a class is an ONEX node class.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
class_node: AST ClassDef node.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
True if the class inherits from a known node base class.
|
|
185
|
+
|
|
186
|
+
Note:
|
|
187
|
+
This function only checks direct base class names. Indirect inheritance
|
|
188
|
+
(e.g., class MyNode(MyCustomBase) where MyCustomBase extends NodeEffect)
|
|
189
|
+
is NOT detected. This is intentional per ONEX policy which discourages
|
|
190
|
+
deep inheritance hierarchies in node classes.
|
|
191
|
+
"""
|
|
192
|
+
base_names = _get_base_class_names(class_node)
|
|
193
|
+
return bool(base_names & _NODE_BASE_CLASSES)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _is_valid_init(func_node: ast.FunctionDef, source_lines: list[str]) -> bool:
|
|
197
|
+
"""Check if __init__ method is valid (only super().__init__ call).
|
|
198
|
+
|
|
199
|
+
A valid __init__:
|
|
200
|
+
- May have a docstring (Expr with Constant str)
|
|
201
|
+
- Must have exactly one other statement: super().__init__(container)
|
|
202
|
+
- May have pass statement if also has super() call
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
func_node: AST FunctionDef node for __init__.
|
|
206
|
+
source_lines: Source lines for context.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
True if the __init__ is valid (declarative).
|
|
210
|
+
"""
|
|
211
|
+
body = func_node.body
|
|
212
|
+
|
|
213
|
+
# Filter out docstrings and pass statements
|
|
214
|
+
significant_stmts = []
|
|
215
|
+
for stmt in body:
|
|
216
|
+
# Skip docstrings (Expr containing a Constant string)
|
|
217
|
+
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant):
|
|
218
|
+
if isinstance(stmt.value.value, str):
|
|
219
|
+
continue
|
|
220
|
+
# Skip pass statements
|
|
221
|
+
if isinstance(stmt, ast.Pass):
|
|
222
|
+
continue
|
|
223
|
+
significant_stmts.append(stmt)
|
|
224
|
+
|
|
225
|
+
# Should have exactly one significant statement
|
|
226
|
+
if len(significant_stmts) != 1:
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
stmt = significant_stmts[0]
|
|
230
|
+
|
|
231
|
+
# Must be an Expr containing a Call
|
|
232
|
+
if not isinstance(stmt, ast.Expr):
|
|
233
|
+
return False
|
|
234
|
+
if not isinstance(stmt.value, ast.Call):
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
call = stmt.value
|
|
238
|
+
|
|
239
|
+
# Check if it's super().__init__(...)
|
|
240
|
+
if not isinstance(call.func, ast.Attribute):
|
|
241
|
+
return False
|
|
242
|
+
if call.func.attr != "__init__":
|
|
243
|
+
return False
|
|
244
|
+
if not isinstance(call.func.value, ast.Call):
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
super_call = call.func.value
|
|
248
|
+
if not isinstance(super_call.func, ast.Name):
|
|
249
|
+
return False
|
|
250
|
+
if super_call.func.id != "super":
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
# Validate super() has no args/keywords (must be bare super())
|
|
254
|
+
if super_call.args or super_call.keywords:
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
# Validate __init__ call has exactly 1 positional arg named "container"
|
|
258
|
+
if len(call.args) != 1:
|
|
259
|
+
return False
|
|
260
|
+
if call.keywords:
|
|
261
|
+
return False
|
|
262
|
+
if not isinstance(call.args[0], ast.Name):
|
|
263
|
+
return False
|
|
264
|
+
if call.args[0].id != "container":
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
return True
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _find_instance_variables(func_node: ast.FunctionDef) -> list[tuple[int, str]]:
|
|
271
|
+
"""Find instance variable assignments in __init__ (excluding docstrings).
|
|
272
|
+
|
|
273
|
+
Uses ast.walk() to recursively find all assignments including those nested
|
|
274
|
+
in if/for/try/while blocks. Handles tuple/list unpacking targets.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
func_node: AST FunctionDef node for __init__.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
List of (line_number, variable_name) tuples for instance variables.
|
|
281
|
+
"""
|
|
282
|
+
instance_vars: list[tuple[int, str]] = []
|
|
283
|
+
|
|
284
|
+
def _extract_self_attrs(target: ast.AST, lineno: int) -> None:
|
|
285
|
+
"""Extract self.x attributes from a target, handling tuple/list unpacking."""
|
|
286
|
+
if isinstance(target, ast.Attribute):
|
|
287
|
+
if isinstance(target.value, ast.Name) and target.value.id == "self":
|
|
288
|
+
instance_vars.append((lineno, target.attr))
|
|
289
|
+
elif isinstance(target, (ast.Tuple, ast.List)):
|
|
290
|
+
for elt in target.elts:
|
|
291
|
+
_extract_self_attrs(elt, lineno)
|
|
292
|
+
|
|
293
|
+
for node in ast.walk(func_node):
|
|
294
|
+
# Skip the docstring at the start of __init__
|
|
295
|
+
if (
|
|
296
|
+
isinstance(node, ast.Expr)
|
|
297
|
+
and isinstance(node.value, ast.Constant)
|
|
298
|
+
and isinstance(node.value.value, str)
|
|
299
|
+
and node in func_node.body
|
|
300
|
+
):
|
|
301
|
+
continue
|
|
302
|
+
|
|
303
|
+
# Check for self.xxx = ... assignments
|
|
304
|
+
if isinstance(node, ast.Assign):
|
|
305
|
+
for target in node.targets:
|
|
306
|
+
_extract_self_attrs(target, node.lineno)
|
|
307
|
+
elif isinstance(node, ast.AnnAssign):
|
|
308
|
+
if node.target is not None:
|
|
309
|
+
_extract_self_attrs(node.target, node.lineno)
|
|
310
|
+
elif isinstance(node, ast.AugAssign):
|
|
311
|
+
# Handle self.x += ... augmented assignments
|
|
312
|
+
_extract_self_attrs(node.target, node.lineno)
|
|
313
|
+
|
|
314
|
+
return instance_vars
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _validate_node_class(
|
|
318
|
+
class_node: ast.ClassDef,
|
|
319
|
+
file_path: Path,
|
|
320
|
+
source_lines: list[str],
|
|
321
|
+
) -> list[ModelDeclarativeNodeViolation]:
|
|
322
|
+
"""Validate a single node class for declarative compliance.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
class_node: AST ClassDef node.
|
|
326
|
+
file_path: Path to the source file.
|
|
327
|
+
source_lines: Source lines for context.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
List of violations found in this class.
|
|
331
|
+
"""
|
|
332
|
+
violations: list[ModelDeclarativeNodeViolation] = []
|
|
333
|
+
class_name = class_node.name
|
|
334
|
+
|
|
335
|
+
# Check for class-level variables (excluding type annotations without values)
|
|
336
|
+
for stmt in class_node.body:
|
|
337
|
+
# Skip docstrings
|
|
338
|
+
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant):
|
|
339
|
+
if isinstance(stmt.value.value, str):
|
|
340
|
+
continue
|
|
341
|
+
|
|
342
|
+
# Skip pass statements
|
|
343
|
+
if isinstance(stmt, ast.Pass):
|
|
344
|
+
continue
|
|
345
|
+
|
|
346
|
+
# Skip function definitions (handled separately)
|
|
347
|
+
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
# Class variable assignment
|
|
351
|
+
if isinstance(stmt, ast.Assign):
|
|
352
|
+
snippet = _get_source_line(source_lines, stmt.lineno)
|
|
353
|
+
violations.append(
|
|
354
|
+
ModelDeclarativeNodeViolation(
|
|
355
|
+
file_path=file_path,
|
|
356
|
+
line_number=stmt.lineno,
|
|
357
|
+
violation_type=EnumDeclarativeNodeViolation.CLASS_VARIABLE,
|
|
358
|
+
code_snippet=snippet,
|
|
359
|
+
suggestion=EnumDeclarativeNodeViolation.CLASS_VARIABLE.suggestion,
|
|
360
|
+
node_class_name=class_name,
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# Annotated assignment with value
|
|
365
|
+
if isinstance(stmt, ast.AnnAssign) and stmt.value is not None:
|
|
366
|
+
snippet = _get_source_line(source_lines, stmt.lineno)
|
|
367
|
+
violations.append(
|
|
368
|
+
ModelDeclarativeNodeViolation(
|
|
369
|
+
file_path=file_path,
|
|
370
|
+
line_number=stmt.lineno,
|
|
371
|
+
violation_type=EnumDeclarativeNodeViolation.CLASS_VARIABLE,
|
|
372
|
+
code_snippet=snippet,
|
|
373
|
+
suggestion=EnumDeclarativeNodeViolation.CLASS_VARIABLE.suggestion,
|
|
374
|
+
node_class_name=class_name,
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
# Check methods
|
|
379
|
+
for item in class_node.body:
|
|
380
|
+
if isinstance(item, ast.FunctionDef):
|
|
381
|
+
method_name = item.name
|
|
382
|
+
|
|
383
|
+
# Check for properties (including qualified decorators like functools.cached_property)
|
|
384
|
+
for decorator in item.decorator_list:
|
|
385
|
+
is_property = False
|
|
386
|
+
if (
|
|
387
|
+
isinstance(decorator, ast.Name)
|
|
388
|
+
and decorator.id
|
|
389
|
+
in {
|
|
390
|
+
"property",
|
|
391
|
+
"cached_property",
|
|
392
|
+
}
|
|
393
|
+
) or (
|
|
394
|
+
isinstance(decorator, ast.Attribute)
|
|
395
|
+
and decorator.attr
|
|
396
|
+
in {
|
|
397
|
+
"property",
|
|
398
|
+
"cached_property",
|
|
399
|
+
}
|
|
400
|
+
):
|
|
401
|
+
is_property = True
|
|
402
|
+
|
|
403
|
+
if is_property:
|
|
404
|
+
snippet = _get_source_line(source_lines, item.lineno)
|
|
405
|
+
violations.append(
|
|
406
|
+
ModelDeclarativeNodeViolation(
|
|
407
|
+
file_path=file_path,
|
|
408
|
+
line_number=item.lineno,
|
|
409
|
+
violation_type=EnumDeclarativeNodeViolation.CUSTOM_PROPERTY,
|
|
410
|
+
code_snippet=snippet,
|
|
411
|
+
suggestion=EnumDeclarativeNodeViolation.CUSTOM_PROPERTY.suggestion,
|
|
412
|
+
node_class_name=class_name,
|
|
413
|
+
method_name=method_name,
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
break
|
|
417
|
+
else:
|
|
418
|
+
# Not a property, check if it's a valid method
|
|
419
|
+
if method_name == "__init__":
|
|
420
|
+
# Validate __init__
|
|
421
|
+
if not _is_valid_init(item, source_lines):
|
|
422
|
+
snippet = _get_source_line(source_lines, item.lineno)
|
|
423
|
+
violations.append(
|
|
424
|
+
ModelDeclarativeNodeViolation(
|
|
425
|
+
file_path=file_path,
|
|
426
|
+
line_number=item.lineno,
|
|
427
|
+
violation_type=EnumDeclarativeNodeViolation.INIT_CUSTOM_LOGIC,
|
|
428
|
+
code_snippet=snippet,
|
|
429
|
+
suggestion=EnumDeclarativeNodeViolation.INIT_CUSTOM_LOGIC.suggestion,
|
|
430
|
+
node_class_name=class_name,
|
|
431
|
+
method_name="__init__",
|
|
432
|
+
)
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
# Check for instance variables
|
|
436
|
+
instance_vars = _find_instance_variables(item)
|
|
437
|
+
for line_num, var_name in instance_vars:
|
|
438
|
+
snippet = _get_source_line(source_lines, line_num)
|
|
439
|
+
violations.append(
|
|
440
|
+
ModelDeclarativeNodeViolation(
|
|
441
|
+
file_path=file_path,
|
|
442
|
+
line_number=line_num,
|
|
443
|
+
violation_type=EnumDeclarativeNodeViolation.INSTANCE_VARIABLE,
|
|
444
|
+
code_snippet=snippet,
|
|
445
|
+
suggestion=EnumDeclarativeNodeViolation.INSTANCE_VARIABLE.suggestion,
|
|
446
|
+
node_class_name=class_name,
|
|
447
|
+
method_name=f"self.{var_name}",
|
|
448
|
+
)
|
|
449
|
+
)
|
|
450
|
+
else:
|
|
451
|
+
# Custom method (not __init__)
|
|
452
|
+
snippet = _get_source_line(source_lines, item.lineno)
|
|
453
|
+
violations.append(
|
|
454
|
+
ModelDeclarativeNodeViolation(
|
|
455
|
+
file_path=file_path,
|
|
456
|
+
line_number=item.lineno,
|
|
457
|
+
violation_type=EnumDeclarativeNodeViolation.CUSTOM_METHOD,
|
|
458
|
+
code_snippet=snippet,
|
|
459
|
+
suggestion=EnumDeclarativeNodeViolation.CUSTOM_METHOD.suggestion,
|
|
460
|
+
node_class_name=class_name,
|
|
461
|
+
method_name=method_name,
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
# Check for async methods
|
|
466
|
+
elif isinstance(item, ast.AsyncFunctionDef):
|
|
467
|
+
snippet = _get_source_line(source_lines, item.lineno)
|
|
468
|
+
violations.append(
|
|
469
|
+
ModelDeclarativeNodeViolation(
|
|
470
|
+
file_path=file_path,
|
|
471
|
+
line_number=item.lineno,
|
|
472
|
+
violation_type=EnumDeclarativeNodeViolation.CUSTOM_METHOD,
|
|
473
|
+
code_snippet=snippet,
|
|
474
|
+
suggestion=EnumDeclarativeNodeViolation.CUSTOM_METHOD.suggestion,
|
|
475
|
+
node_class_name=class_name,
|
|
476
|
+
method_name=item.name,
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
return violations
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
# Map EnumDeclarativeNodeViolation to contract rule IDs
|
|
484
|
+
_VIOLATION_TO_RULE_ID: dict[EnumDeclarativeNodeViolation, str] = {
|
|
485
|
+
EnumDeclarativeNodeViolation.CUSTOM_METHOD: "DECL-001",
|
|
486
|
+
EnumDeclarativeNodeViolation.CUSTOM_PROPERTY: "DECL-002",
|
|
487
|
+
EnumDeclarativeNodeViolation.INIT_CUSTOM_LOGIC: "DECL-003",
|
|
488
|
+
EnumDeclarativeNodeViolation.INSTANCE_VARIABLE: "DECL-004",
|
|
489
|
+
EnumDeclarativeNodeViolation.CLASS_VARIABLE: "DECL-005",
|
|
490
|
+
EnumDeclarativeNodeViolation.SYNTAX_ERROR: "DECL-006",
|
|
491
|
+
EnumDeclarativeNodeViolation.NO_NODE_CLASS: "DECL-007",
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
# =============================================================================
|
|
496
|
+
# SHARED CORE VALIDATION FUNCTIONS
|
|
497
|
+
# =============================================================================
|
|
498
|
+
# These internal functions contain the core validation logic that is shared
|
|
499
|
+
# between the ValidatorDeclarativeNode class and the module-level functions.
|
|
500
|
+
# This eliminates duplication while providing multiple API styles.
|
|
501
|
+
# =============================================================================
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _validate_file_core(
|
|
505
|
+
file_path: Path,
|
|
506
|
+
) -> list[ModelDeclarativeNodeViolation]:
|
|
507
|
+
"""Core file validation logic shared by class and legacy functions.
|
|
508
|
+
|
|
509
|
+
Performs the following validation steps:
|
|
510
|
+
1. Check file size against limit
|
|
511
|
+
2. Read file content
|
|
512
|
+
3. Parse AST
|
|
513
|
+
4. Find node classes and validate for declarative compliance
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
file_path: Path to the node.py file to validate.
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
List of ModelDeclarativeNodeViolation instances for violations found.
|
|
520
|
+
Returns empty list if file cannot be read/parsed or is too large.
|
|
521
|
+
"""
|
|
522
|
+
# Check file size
|
|
523
|
+
try:
|
|
524
|
+
file_size = file_path.stat().st_size
|
|
525
|
+
if file_size > _MAX_FILE_SIZE_BYTES:
|
|
526
|
+
logger.warning(
|
|
527
|
+
"Skipping file %s: size %d exceeds limit %d",
|
|
528
|
+
file_path,
|
|
529
|
+
file_size,
|
|
530
|
+
_MAX_FILE_SIZE_BYTES,
|
|
531
|
+
)
|
|
532
|
+
return []
|
|
533
|
+
except OSError as e:
|
|
534
|
+
logger.warning("Cannot stat file %s: %s", file_path, e)
|
|
535
|
+
return []
|
|
536
|
+
|
|
537
|
+
# Read file
|
|
538
|
+
try:
|
|
539
|
+
source = file_path.read_text(encoding="utf-8")
|
|
540
|
+
except OSError as e:
|
|
541
|
+
logger.warning("Cannot read file %s: %s", file_path, e)
|
|
542
|
+
return []
|
|
543
|
+
|
|
544
|
+
source_lines = source.splitlines()
|
|
545
|
+
|
|
546
|
+
# Parse AST
|
|
547
|
+
try:
|
|
548
|
+
tree = ast.parse(source, filename=str(file_path))
|
|
549
|
+
except SyntaxError as e:
|
|
550
|
+
return [
|
|
551
|
+
ModelDeclarativeNodeViolation(
|
|
552
|
+
file_path=file_path,
|
|
553
|
+
line_number=e.lineno or 1,
|
|
554
|
+
violation_type=EnumDeclarativeNodeViolation.SYNTAX_ERROR,
|
|
555
|
+
code_snippet=str(e.msg) if e.msg else "Syntax error",
|
|
556
|
+
suggestion=EnumDeclarativeNodeViolation.SYNTAX_ERROR.suggestion,
|
|
557
|
+
severity=EnumValidationSeverity.ERROR,
|
|
558
|
+
)
|
|
559
|
+
]
|
|
560
|
+
|
|
561
|
+
violations: list[ModelDeclarativeNodeViolation] = []
|
|
562
|
+
found_node_class = False
|
|
563
|
+
|
|
564
|
+
# Find and validate node classes
|
|
565
|
+
for node in ast.walk(tree):
|
|
566
|
+
if isinstance(node, ast.ClassDef):
|
|
567
|
+
if _is_node_class(node):
|
|
568
|
+
found_node_class = True
|
|
569
|
+
|
|
570
|
+
# Check for exemption
|
|
571
|
+
if _is_class_exempted(source_lines, node.lineno):
|
|
572
|
+
logger.debug(
|
|
573
|
+
"Skipping exempted class %s in %s",
|
|
574
|
+
node.name,
|
|
575
|
+
file_path,
|
|
576
|
+
)
|
|
577
|
+
continue
|
|
578
|
+
|
|
579
|
+
class_violations = _validate_node_class(node, file_path, source_lines)
|
|
580
|
+
violations.extend(class_violations)
|
|
581
|
+
|
|
582
|
+
# Emit warning for node.py files in nodes/ that don't contain node classes
|
|
583
|
+
if not found_node_class and "nodes" in file_path.parts:
|
|
584
|
+
violations.append(
|
|
585
|
+
ModelDeclarativeNodeViolation(
|
|
586
|
+
file_path=file_path,
|
|
587
|
+
line_number=1,
|
|
588
|
+
violation_type=EnumDeclarativeNodeViolation.NO_NODE_CLASS,
|
|
589
|
+
code_snippet="# No Node class found in file",
|
|
590
|
+
suggestion=EnumDeclarativeNodeViolation.NO_NODE_CLASS.suggestion,
|
|
591
|
+
severity=EnumValidationSeverity.WARNING,
|
|
592
|
+
)
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
return violations
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _validate_directory_with_count(
|
|
599
|
+
directory: Path,
|
|
600
|
+
recursive: bool = True,
|
|
601
|
+
) -> tuple[list[ModelDeclarativeNodeViolation], int]:
|
|
602
|
+
"""Validate all node.py files in a directory with single-pass traversal.
|
|
603
|
+
|
|
604
|
+
This function performs a single directory traversal, collecting both
|
|
605
|
+
violations and file count simultaneously for efficiency.
|
|
606
|
+
|
|
607
|
+
Args:
|
|
608
|
+
directory: Directory to scan for node.py files.
|
|
609
|
+
recursive: If True, scan subdirectories.
|
|
610
|
+
|
|
611
|
+
Returns:
|
|
612
|
+
Tuple of (list of all violations found, count of files checked).
|
|
613
|
+
"""
|
|
614
|
+
violations: list[ModelDeclarativeNodeViolation] = []
|
|
615
|
+
files_checked = 0
|
|
616
|
+
pattern = "**/node.py" if recursive else "node.py"
|
|
617
|
+
|
|
618
|
+
for file_path in directory.glob(pattern):
|
|
619
|
+
if file_path.is_file():
|
|
620
|
+
files_checked += 1
|
|
621
|
+
file_violations = _validate_file_core(file_path)
|
|
622
|
+
violations.extend(file_violations)
|
|
623
|
+
|
|
624
|
+
return violations, files_checked
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
class ValidatorDeclarativeNode(ValidatorBase):
|
|
628
|
+
"""Contract-driven declarative node validator for ONEX.
|
|
629
|
+
|
|
630
|
+
This validator uses AST analysis to detect imperative patterns in node.py files:
|
|
631
|
+
- Custom methods beyond __init__ (should be in contract/handlers)
|
|
632
|
+
- @property decorators (state should be in models)
|
|
633
|
+
- Custom logic in __init__ beyond super().__init__(container)
|
|
634
|
+
- Instance variable assignments (state belongs in container/models)
|
|
635
|
+
- Class-level variable assignments (configuration belongs in contract)
|
|
636
|
+
|
|
637
|
+
The validator is contract-driven via declarative_node.validation.yaml, supporting:
|
|
638
|
+
- Configurable rules with enable/disable per rule
|
|
639
|
+
- Per-rule severity overrides
|
|
640
|
+
- Suppression comments for intentional exceptions
|
|
641
|
+
- Glob-based file targeting and exclusion
|
|
642
|
+
|
|
643
|
+
Thread Safety:
|
|
644
|
+
ValidatorDeclarativeNode instances are NOT thread-safe due to internal mutable
|
|
645
|
+
state inherited from ValidatorBase. When using parallel execution
|
|
646
|
+
(e.g., pytest-xdist), create separate validator instances per worker.
|
|
647
|
+
|
|
648
|
+
Attributes:
|
|
649
|
+
validator_id: Unique identifier for this validator ("declarative_node").
|
|
650
|
+
|
|
651
|
+
Usage Example:
|
|
652
|
+
>>> from pathlib import Path
|
|
653
|
+
>>> from omnibase_infra.validation.validator_declarative_node import (
|
|
654
|
+
... ValidatorDeclarativeNode,
|
|
655
|
+
... )
|
|
656
|
+
>>> validator = ValidatorDeclarativeNode()
|
|
657
|
+
>>> result = validator.validate(Path("src/nodes"))
|
|
658
|
+
>>> print(f"Valid: {result.is_valid}, Issues: {len(result.issues)}")
|
|
659
|
+
|
|
660
|
+
CLI Usage:
|
|
661
|
+
python -m omnibase_infra.validation.validator_declarative_node src/nodes
|
|
662
|
+
"""
|
|
663
|
+
|
|
664
|
+
# ONEX_EXCLUDE: string_id - human-readable validator identifier
|
|
665
|
+
validator_id: ClassVar[str] = "declarative_node"
|
|
666
|
+
|
|
667
|
+
def _validate_file(
|
|
668
|
+
self,
|
|
669
|
+
path: Path,
|
|
670
|
+
contract: ModelValidatorSubcontract,
|
|
671
|
+
) -> tuple[ModelValidationIssue, ...]:
|
|
672
|
+
"""Validate a single node.py file for declarative compliance.
|
|
673
|
+
|
|
674
|
+
Uses AST analysis to detect imperative patterns:
|
|
675
|
+
- Custom methods (not __init__)
|
|
676
|
+
- Properties
|
|
677
|
+
- Custom init logic
|
|
678
|
+
- Instance variables
|
|
679
|
+
- Class variables
|
|
680
|
+
|
|
681
|
+
This method delegates to _validate_file_core for the actual validation,
|
|
682
|
+
then converts ModelDeclarativeNodeViolation to ModelValidationIssue
|
|
683
|
+
with contract-based rule filtering.
|
|
684
|
+
|
|
685
|
+
Args:
|
|
686
|
+
path: Path to the node.py file to validate.
|
|
687
|
+
contract: Validator contract with rule configurations.
|
|
688
|
+
|
|
689
|
+
Returns:
|
|
690
|
+
Tuple of ModelValidationIssue instances for violations found.
|
|
691
|
+
"""
|
|
692
|
+
# Use shared core validation logic
|
|
693
|
+
violations = _validate_file_core(path)
|
|
694
|
+
|
|
695
|
+
# Convert violations to ModelValidationIssue with contract filtering
|
|
696
|
+
issues: list[ModelValidationIssue] = []
|
|
697
|
+
for violation in violations:
|
|
698
|
+
# Map violation type to rule ID
|
|
699
|
+
rule_id = _VIOLATION_TO_RULE_ID.get(violation.violation_type)
|
|
700
|
+
if rule_id is None:
|
|
701
|
+
logger.warning(
|
|
702
|
+
"Unknown violation type %s, skipping",
|
|
703
|
+
violation.violation_type,
|
|
704
|
+
)
|
|
705
|
+
continue
|
|
706
|
+
|
|
707
|
+
# Check if rule is enabled and get severity
|
|
708
|
+
enabled, severity = self._get_rule_config(rule_id, contract)
|
|
709
|
+
if not enabled:
|
|
710
|
+
logger.debug(
|
|
711
|
+
"Rule %s is disabled, skipping violation",
|
|
712
|
+
rule_id,
|
|
713
|
+
)
|
|
714
|
+
continue
|
|
715
|
+
|
|
716
|
+
# Convert to ModelValidationIssue
|
|
717
|
+
context: dict[str, str] = {
|
|
718
|
+
"violation_type": violation.violation_type.value,
|
|
719
|
+
}
|
|
720
|
+
if violation.node_class_name:
|
|
721
|
+
context["class_name"] = violation.node_class_name
|
|
722
|
+
if violation.method_name:
|
|
723
|
+
context["method_name"] = violation.method_name
|
|
724
|
+
|
|
725
|
+
issues.append(
|
|
726
|
+
ModelValidationIssue(
|
|
727
|
+
severity=severity,
|
|
728
|
+
message=self._format_message(violation),
|
|
729
|
+
code=rule_id,
|
|
730
|
+
file_path=path,
|
|
731
|
+
line_number=violation.line_number,
|
|
732
|
+
rule_name=violation.violation_type.value,
|
|
733
|
+
suggestion=violation.suggestion,
|
|
734
|
+
context=context,
|
|
735
|
+
)
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
return tuple(issues)
|
|
739
|
+
|
|
740
|
+
def _format_message(self, violation: ModelDeclarativeNodeViolation) -> str:
|
|
741
|
+
"""Format a human-readable message for a violation.
|
|
742
|
+
|
|
743
|
+
Args:
|
|
744
|
+
violation: The violation to format.
|
|
745
|
+
|
|
746
|
+
Returns:
|
|
747
|
+
Human-readable message describing the violation.
|
|
748
|
+
"""
|
|
749
|
+
vtype = violation.violation_type
|
|
750
|
+
class_name = violation.node_class_name or "Unknown"
|
|
751
|
+
|
|
752
|
+
if vtype == EnumDeclarativeNodeViolation.CUSTOM_METHOD:
|
|
753
|
+
method = violation.method_name or "unknown"
|
|
754
|
+
return f"Class '{class_name}' has custom method '{method}' - declarative nodes must not have custom methods"
|
|
755
|
+
elif vtype == EnumDeclarativeNodeViolation.CUSTOM_PROPERTY:
|
|
756
|
+
prop = violation.method_name or "unknown"
|
|
757
|
+
return f"Class '{class_name}' has property '{prop}' - declarative nodes must not have properties"
|
|
758
|
+
elif vtype == EnumDeclarativeNodeViolation.INIT_CUSTOM_LOGIC:
|
|
759
|
+
return f"Class '{class_name}' has custom logic in __init__ - only super().__init__(container) is allowed"
|
|
760
|
+
elif vtype == EnumDeclarativeNodeViolation.INSTANCE_VARIABLE:
|
|
761
|
+
var = violation.method_name or "unknown"
|
|
762
|
+
return f"Class '{class_name}' creates instance variable '{var}' - declarative nodes must not store state"
|
|
763
|
+
elif vtype == EnumDeclarativeNodeViolation.CLASS_VARIABLE:
|
|
764
|
+
return f"Class '{class_name}' has class variable - configuration should be in contract.yaml"
|
|
765
|
+
elif vtype == EnumDeclarativeNodeViolation.SYNTAX_ERROR:
|
|
766
|
+
return f"File has syntax error: {violation.code_snippet}"
|
|
767
|
+
elif vtype == EnumDeclarativeNodeViolation.NO_NODE_CLASS:
|
|
768
|
+
path = violation.file_path
|
|
769
|
+
return f"File '{path.name}' is named node.py but contains no Node class"
|
|
770
|
+
else:
|
|
771
|
+
return f"Class '{class_name}' violates declarative node policy: {violation.code_snippet}"
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
# =============================================================================
|
|
775
|
+
# MODULE-LEVEL CONVENIENCE FUNCTIONS
|
|
776
|
+
# =============================================================================
|
|
777
|
+
# These functions provide a simpler API for common use cases.
|
|
778
|
+
# They delegate to the shared core functions defined above.
|
|
779
|
+
# =============================================================================
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def validate_declarative_node_in_file(
|
|
783
|
+
file_path: Path,
|
|
784
|
+
) -> list[ModelDeclarativeNodeViolation]:
|
|
785
|
+
"""Validate a single node.py file for declarative compliance.
|
|
786
|
+
|
|
787
|
+
Convenience function that delegates to the core validation logic.
|
|
788
|
+
|
|
789
|
+
Args:
|
|
790
|
+
file_path: Path to the node.py file.
|
|
791
|
+
|
|
792
|
+
Returns:
|
|
793
|
+
List of violations found (empty if compliant or not a node file).
|
|
794
|
+
"""
|
|
795
|
+
return _validate_file_core(file_path)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def validate_declarative_nodes(
|
|
799
|
+
directory: Path,
|
|
800
|
+
recursive: bool = True,
|
|
801
|
+
) -> list[ModelDeclarativeNodeViolation]:
|
|
802
|
+
"""Validate all node.py files in a directory.
|
|
803
|
+
|
|
804
|
+
Convenience function that validates all node.py files, returning only violations.
|
|
805
|
+
For CI integration with file counts, use validate_declarative_nodes_ci instead.
|
|
806
|
+
|
|
807
|
+
Args:
|
|
808
|
+
directory: Directory to scan for node.py files.
|
|
809
|
+
recursive: If True, scan subdirectories.
|
|
810
|
+
|
|
811
|
+
Returns:
|
|
812
|
+
List of all violations found.
|
|
813
|
+
"""
|
|
814
|
+
violations, _ = _validate_directory_with_count(directory, recursive)
|
|
815
|
+
return violations
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def validate_declarative_nodes_ci(
|
|
819
|
+
directory: Path,
|
|
820
|
+
recursive: bool = True,
|
|
821
|
+
) -> ModelDeclarativeNodeValidationResult:
|
|
822
|
+
"""CI gate entry point for declarative node validation.
|
|
823
|
+
|
|
824
|
+
Uses single-pass directory traversal for efficiency, collecting both
|
|
825
|
+
violations and file count simultaneously.
|
|
826
|
+
|
|
827
|
+
Args:
|
|
828
|
+
directory: Directory to validate.
|
|
829
|
+
recursive: If True, scan subdirectories.
|
|
830
|
+
|
|
831
|
+
Returns:
|
|
832
|
+
Result model with pass/fail status suitable for CI integration.
|
|
833
|
+
"""
|
|
834
|
+
violations, files_checked = _validate_directory_with_count(directory, recursive)
|
|
835
|
+
return ModelDeclarativeNodeValidationResult.from_violations(
|
|
836
|
+
violations, files_checked
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
__all__ = [
|
|
841
|
+
"ValidatorDeclarativeNode",
|
|
842
|
+
"validate_declarative_nodes",
|
|
843
|
+
"validate_declarative_nodes_ci",
|
|
844
|
+
"validate_declarative_node_in_file",
|
|
845
|
+
]
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
# CLI entry point
|
|
849
|
+
if __name__ == "__main__":
|
|
850
|
+
sys.exit(ValidatorDeclarativeNode.main())
|