Coverage for src / agent_contracts / validator.py: 84%

126 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-09 00:42 +0900

1"""ContractValidator - Static validation for node contracts. 

2 

3Validates contracts at registration time to catch configuration 

4errors before runtime. 

5""" 

6from __future__ import annotations 

7 

8from dataclasses import dataclass, field 

9from typing import TYPE_CHECKING 

10 

11from agent_contracts.utils.logging import get_logger 

12 

13if TYPE_CHECKING: 

14 from agent_contracts.registry import NodeRegistry 

15 

16logger = get_logger("agent_contracts.validator") 

17 

18 

19# ============================================================================= 

20# Validation Result 

21# ============================================================================= 

22 

23@dataclass 

24class ValidationResult: 

25 """Result of contract validation. 

26  

27 Attributes: 

28 errors: Fatal issues that prevent execution (unknown slices, etc.) 

29 warnings: Potential issues that deserve attention 

30 info: Informational messages (shared writers, etc.) 

31 """ 

32 errors: list[str] = field(default_factory=list) 

33 warnings: list[str] = field(default_factory=list) 

34 info: list[str] = field(default_factory=list) 

35 

36 @property 

37 def has_errors(self) -> bool: 

38 """Check if there are any errors.""" 

39 return len(self.errors) > 0 

40 

41 @property 

42 def has_warnings(self) -> bool: 

43 """Check if there are any warnings.""" 

44 return len(self.warnings) > 0 

45 

46 @property 

47 def is_valid(self) -> bool: 

48 """Check if validation passed (no errors).""" 

49 return not self.has_errors 

50 

51 def __str__(self) -> str: 

52 """Human-readable format.""" 

53 lines = [] 

54 

55 if self.errors: 

56 lines.append("ERRORS:") 

57 for error in self.errors: 

58 lines.append(f" - {error}") 

59 

60 if self.warnings: 

61 lines.append("WARNINGS:") 

62 for warning in self.warnings: 

63 lines.append(f" - {warning}") 

64 

65 if self.info: 

66 lines.append("INFO:") 

67 for info_msg in self.info: 

68 lines.append(f" - {info_msg}") 

69 

70 if not lines: 

71 lines.append("✅ All validations passed") 

72 

73 return "\n".join(lines) 

74 

75 

76# ============================================================================= 

77# Contract Validator 

78# ============================================================================= 

79 

80class ContractValidator: 

81 """Validator for node contracts. 

82  

83 Performs static analysis on registered contracts to detect: 

84 - Unknown slice names in reads/writes 

85 - Missing service dependencies 

86 - Orphan/unreachable nodes 

87 - Shared writers (informational) 

88  

89 Example: 

90 from agent_contracts import ContractValidator, get_node_registry 

91  

92 registry = get_node_registry() 

93 # ... register nodes ... 

94  

95 validator = ContractValidator(registry) 

96 result = validator.validate() 

97  

98 if result.has_errors: 

99 print(result) 

100 sys.exit(1) 

101 """ 

102 

103 def __init__( 

104 self, 

105 registry: "NodeRegistry", 

106 known_services: set[str] | None = None, 

107 ): 

108 """Initialize validator. 

109  

110 Args: 

111 registry: Node registry to validate 

112 known_services: Set of known service names for validation. 

113 If None, service validation is skipped. 

114 """ 

115 self._registry = registry 

116 self._known_services = known_services 

117 

118 def validate(self) -> ValidationResult: 

119 """Run all validations. 

120  

121 Returns: 

122 ValidationResult with errors, warnings, and info 

123 """ 

124 result = ValidationResult() 

125 

126 # Run validations 

127 self._validate_slices(result) 

128 self._validate_services(result) 

129 self._validate_reachability(result) 

130 self._report_shared_writers(result) 

131 

132 # Log summary 

133 if result.has_errors: 

134 logger.error(f"Contract validation failed: {len(result.errors)} errors") 

135 elif result.has_warnings: 

136 logger.warning(f"Contract validation passed with {len(result.warnings)} warnings") 

137 else: 

138 logger.info("Contract validation passed") 

139 

140 return result 

141 

142 def _validate_slices(self, result: ValidationResult) -> None: 

143 """Validate that all slice names are known.""" 

144 valid_slices = self._registry._valid_slices 

145 

146 for name in self._registry.get_all_nodes(): 

147 contract = self._registry.get_contract(name) 

148 if not contract: 

149 continue 

150 

151 # Check reads 

152 for slice_name in contract.reads: 

153 if slice_name not in valid_slices: 

154 result.errors.append( 

155 f"Unknown slice '{slice_name}' in node '{name}' reads" 

156 ) 

157 

158 # Check writes 

159 for slice_name in contract.writes: 

160 if slice_name not in valid_slices: 

161 result.errors.append( 

162 f"Unknown slice '{slice_name}' in node '{name}' writes" 

163 ) 

164 

165 def _validate_services(self, result: ValidationResult) -> None: 

166 """Validate that required services are known.""" 

167 if self._known_services is None: 

168 return # Skip if no known services provided 

169 

170 for name in self._registry.get_all_nodes(): 

171 contract = self._registry.get_contract(name) 

172 if not contract: 

173 continue 

174 

175 for service_name in contract.services: 

176 if service_name not in self._known_services: 

177 result.warnings.append( 

178 f"Unknown service '{service_name}' required by node '{name}'" 

179 ) 

180 

181 def _validate_reachability(self, result: ValidationResult) -> None: 

182 """Check for orphan/unreachable nodes.""" 

183 for name in self._registry.get_all_nodes(): 

184 contract = self._registry.get_contract(name) 

185 if not contract: 

186 continue 

187 

188 # Check for orphan (no supervisor) 

189 if not contract.supervisor: 

190 result.warnings.append( 

191 f"Node '{name}' has no supervisor (orphan)" 

192 ) 

193 continue 

194 

195 # Check for unreachable (no trigger conditions) 

196 if not contract.trigger_conditions: 

197 result.warnings.append( 

198 f"Node '{name}' has no trigger conditions (may be unreachable)" 

199 ) 

200 

201 def _report_shared_writers(self, result: ValidationResult) -> None: 

202 """Report slices with multiple writers (informational).""" 

203 shared_writers = self.get_shared_writers() 

204 

205 for slice_name, writers in shared_writers.items(): 

206 if len(writers) > 1: 

207 writers_str = ", ".join(sorted(writers)) 

208 result.info.append( 

209 f"Shared writers for '{slice_name}': {writers_str}" 

210 ) 

211 

212 def get_shared_writers(self) -> dict[str, list[str]]: 

213 """Get all slices and their writers. 

214  

215 Returns: 

216 {slice_name: [node_names that write to it]} 

217 """ 

218 writers: dict[str, list[str]] = {} 

219 

220 for name in self._registry.get_all_nodes(): 

221 contract = self._registry.get_contract(name) 

222 if not contract: 

223 continue 

224 

225 for slice_name in contract.writes: 

226 if slice_name not in writers: 

227 writers[slice_name] = [] 

228 writers[slice_name].append(name) 

229 

230 return writers 

231 

232 def get_slice_readers(self) -> dict[str, list[str]]: 

233 """Get all slices and their readers. 

234  

235 Returns: 

236 {slice_name: [node_names that read from it]} 

237 """ 

238 readers: dict[str, list[str]] = {} 

239 

240 for name in self._registry.get_all_nodes(): 

241 contract = self._registry.get_contract(name) 

242 if not contract: 

243 continue 

244 

245 for slice_name in contract.reads: 

246 if slice_name not in readers: 

247 readers[slice_name] = [] 

248 readers[slice_name].append(name) 

249 

250 return readers 

251 

252 def get_unused_slices(self) -> dict[str, str]: 

253 """Find slices that are written but never read, or vice versa. 

254  

255 Returns: 

256 {slice_name: "write_only" | "read_only"} 

257 """ 

258 writers = self.get_shared_writers() 

259 readers = self.get_slice_readers() 

260 

261 all_slices = set(writers.keys()) | set(readers.keys()) 

262 unused: dict[str, str] = {} 

263 

264 for slice_name in all_slices: 

265 has_writers = slice_name in writers and len(writers[slice_name]) > 0 

266 has_readers = slice_name in readers and len(readers[slice_name]) > 0 

267 

268 if has_writers and not has_readers: 

269 unused[slice_name] = "write_only" 

270 elif has_readers and not has_writers: 

271 # Read-only is only a concern for non-input slices 

272 if slice_name != "request": 

273 unused[slice_name] = "read_only" 

274 

275 return unused