omnibase_infra 0.2.8__py3-none-any.whl → 0.2.9__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 (79) hide show
  1. omnibase_infra/__init__.py +1 -1
  2. omnibase_infra/enums/__init__.py +4 -0
  3. omnibase_infra/enums/enum_declarative_node_violation.py +102 -0
  4. omnibase_infra/event_bus/adapters/__init__.py +31 -0
  5. omnibase_infra/event_bus/adapters/adapter_protocol_event_publisher_kafka.py +517 -0
  6. omnibase_infra/mixins/mixin_async_circuit_breaker.py +113 -1
  7. omnibase_infra/models/__init__.py +9 -0
  8. omnibase_infra/models/event_bus/__init__.py +22 -0
  9. omnibase_infra/models/event_bus/model_consumer_retry_config.py +367 -0
  10. omnibase_infra/models/event_bus/model_dlq_config.py +177 -0
  11. omnibase_infra/models/event_bus/model_idempotency_config.py +131 -0
  12. omnibase_infra/models/event_bus/model_offset_policy_config.py +107 -0
  13. omnibase_infra/models/resilience/model_circuit_breaker_config.py +15 -0
  14. omnibase_infra/models/validation/__init__.py +8 -0
  15. omnibase_infra/models/validation/model_declarative_node_validation_result.py +139 -0
  16. omnibase_infra/models/validation/model_declarative_node_violation.py +169 -0
  17. omnibase_infra/nodes/architecture_validator/__init__.py +28 -7
  18. omnibase_infra/nodes/architecture_validator/constants.py +36 -0
  19. omnibase_infra/nodes/architecture_validator/handlers/__init__.py +28 -0
  20. omnibase_infra/nodes/architecture_validator/handlers/contract.yaml +120 -0
  21. omnibase_infra/nodes/architecture_validator/handlers/handler_architecture_validation.py +359 -0
  22. omnibase_infra/nodes/architecture_validator/node.py +1 -0
  23. omnibase_infra/nodes/architecture_validator/node_architecture_validator.py +48 -336
  24. omnibase_infra/nodes/node_ledger_projection_compute/__init__.py +16 -2
  25. omnibase_infra/nodes/node_ledger_projection_compute/contract.yaml +14 -4
  26. omnibase_infra/nodes/node_ledger_projection_compute/handlers/__init__.py +18 -0
  27. omnibase_infra/nodes/node_ledger_projection_compute/handlers/contract.yaml +53 -0
  28. omnibase_infra/nodes/node_ledger_projection_compute/handlers/handler_ledger_projection.py +354 -0
  29. omnibase_infra/nodes/node_ledger_projection_compute/node.py +20 -256
  30. omnibase_infra/nodes/node_registry_effect/node.py +20 -73
  31. omnibase_infra/protocols/protocol_dispatch_engine.py +90 -0
  32. omnibase_infra/runtime/__init__.py +11 -0
  33. omnibase_infra/runtime/baseline_subscriptions.py +150 -0
  34. omnibase_infra/runtime/event_bus_subcontract_wiring.py +455 -24
  35. omnibase_infra/runtime/kafka_contract_source.py +13 -5
  36. omnibase_infra/runtime/service_message_dispatch_engine.py +112 -0
  37. omnibase_infra/runtime/service_runtime_host_process.py +6 -11
  38. omnibase_infra/services/__init__.py +36 -0
  39. omnibase_infra/services/contract_publisher/__init__.py +95 -0
  40. omnibase_infra/services/contract_publisher/config.py +199 -0
  41. omnibase_infra/services/contract_publisher/errors.py +243 -0
  42. omnibase_infra/services/contract_publisher/models/__init__.py +28 -0
  43. omnibase_infra/services/contract_publisher/models/model_contract_error.py +67 -0
  44. omnibase_infra/services/contract_publisher/models/model_infra_error.py +62 -0
  45. omnibase_infra/services/contract_publisher/models/model_publish_result.py +112 -0
  46. omnibase_infra/services/contract_publisher/models/model_publish_stats.py +79 -0
  47. omnibase_infra/services/contract_publisher/service.py +617 -0
  48. omnibase_infra/services/contract_publisher/sources/__init__.py +52 -0
  49. omnibase_infra/services/contract_publisher/sources/model_discovered.py +155 -0
  50. omnibase_infra/services/contract_publisher/sources/protocol.py +101 -0
  51. omnibase_infra/services/contract_publisher/sources/source_composite.py +309 -0
  52. omnibase_infra/services/contract_publisher/sources/source_filesystem.py +174 -0
  53. omnibase_infra/services/contract_publisher/sources/source_package.py +221 -0
  54. omnibase_infra/services/observability/__init__.py +40 -0
  55. omnibase_infra/services/observability/agent_actions/__init__.py +64 -0
  56. omnibase_infra/services/observability/agent_actions/config.py +209 -0
  57. omnibase_infra/services/observability/agent_actions/consumer.py +1320 -0
  58. omnibase_infra/services/observability/agent_actions/models/__init__.py +87 -0
  59. omnibase_infra/services/observability/agent_actions/models/model_agent_action.py +142 -0
  60. omnibase_infra/services/observability/agent_actions/models/model_detection_failure.py +125 -0
  61. omnibase_infra/services/observability/agent_actions/models/model_envelope.py +85 -0
  62. omnibase_infra/services/observability/agent_actions/models/model_execution_log.py +159 -0
  63. omnibase_infra/services/observability/agent_actions/models/model_performance_metric.py +130 -0
  64. omnibase_infra/services/observability/agent_actions/models/model_routing_decision.py +138 -0
  65. omnibase_infra/services/observability/agent_actions/models/model_transformation_event.py +124 -0
  66. omnibase_infra/services/observability/agent_actions/tests/__init__.py +20 -0
  67. omnibase_infra/services/observability/agent_actions/tests/test_consumer.py +1154 -0
  68. omnibase_infra/services/observability/agent_actions/tests/test_models.py +645 -0
  69. omnibase_infra/services/observability/agent_actions/tests/test_writer.py +709 -0
  70. omnibase_infra/services/observability/agent_actions/writer_postgres.py +926 -0
  71. omnibase_infra/validation/__init__.py +12 -0
  72. omnibase_infra/validation/contracts/declarative_node.validation.yaml +143 -0
  73. omnibase_infra/validation/validation_exemptions.yaml +93 -0
  74. omnibase_infra/validation/validator_declarative_node.py +850 -0
  75. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.2.9.dist-info}/METADATA +2 -2
  76. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.2.9.dist-info}/RECORD +79 -27
  77. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.2.9.dist-info}/WHEEL +0 -0
  78. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.2.9.dist-info}/entry_points.txt +0 -0
  79. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.2.9.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())