|
2 | 2 |
|
3 | 3 | import copy |
4 | 4 | import json |
| 5 | +import subprocess |
| 6 | +from pathlib import Path |
5 | 7 |
|
6 | 8 | if __package__: |
7 | 9 | from .common import atomic_write_text |
@@ -185,6 +187,195 @@ def add_case(row): |
185 | 187 | return [selected[record_id] for record_id in sorted(selected)] |
186 | 188 |
|
187 | 189 |
|
| 190 | +def _read_json_array(path, label): |
| 191 | + path = Path(path) |
| 192 | + with path.open(encoding="utf-8") as input_file: |
| 193 | + value = json.load(input_file) |
| 194 | + if not isinstance(value, list): |
| 195 | + raise ValueError(f"{label} must contain a JSON array") |
| 196 | + return value |
| 197 | + |
| 198 | + |
| 199 | +def parse_witnesses_with_runner( |
| 200 | + sql_path, |
| 201 | + runner_path, |
| 202 | + *, |
| 203 | + branch, |
| 204 | + commit, |
| 205 | + timeout_seconds=30, |
| 206 | +): |
| 207 | + result = subprocess.run( |
| 208 | + [ |
| 209 | + str(runner_path), |
| 210 | + "--input", |
| 211 | + str(sql_path), |
| 212 | + "--branch", |
| 213 | + branch, |
| 214 | + "--commit", |
| 215 | + commit, |
| 216 | + ], |
| 217 | + capture_output=True, |
| 218 | + text=True, |
| 219 | + timeout=timeout_seconds, |
| 220 | + check=False, |
| 221 | + ) |
| 222 | + if result.returncode != 0: |
| 223 | + raise RuntimeError( |
| 224 | + "witness runner failed: " |
| 225 | + f"{result.stderr.strip() or result.stdout.strip()}" |
| 226 | + ) |
| 227 | + |
| 228 | + rows = [] |
| 229 | + for line_number, line in enumerate(result.stdout.splitlines(), start=1): |
| 230 | + if not line.strip(): |
| 231 | + continue |
| 232 | + try: |
| 233 | + rows.append(json.loads(line)) |
| 234 | + except json.JSONDecodeError as error: |
| 235 | + raise ValueError( |
| 236 | + f"witness runner stdout line {line_number}: invalid JSON: " |
| 237 | + f"{error.msg}" |
| 238 | + ) from error |
| 239 | + return rows |
| 240 | + |
| 241 | + |
| 242 | +def _validate_metadata_row(row, index, target_major): |
| 243 | + if not isinstance(row, dict): |
| 244 | + raise ValueError(f"witness metadata row {index}: expected a JSON object") |
| 245 | + |
| 246 | + witness_id = row.get("id") |
| 247 | + if not isinstance(witness_id, str) or not witness_id: |
| 248 | + raise ValueError( |
| 249 | + f"witness metadata row {index}: field 'id' must be a " |
| 250 | + "non-empty string" |
| 251 | + ) |
| 252 | + |
| 253 | + first_major = row.get("first_postgresql_major") |
| 254 | + if ( |
| 255 | + isinstance(first_major, bool) |
| 256 | + or not isinstance(first_major, int) |
| 257 | + or first_major < 1 |
| 258 | + or first_major > target_major |
| 259 | + ): |
| 260 | + raise ValueError( |
| 261 | + f"witness metadata row {index}: field 'first_postgresql_major' " |
| 262 | + f"must be an integer from 1 to {target_major}" |
| 263 | + ) |
| 264 | + |
| 265 | + expected_node = row.get("expected_oracle_node") |
| 266 | + if not isinstance(expected_node, str) or not expected_node: |
| 267 | + raise ValueError( |
| 268 | + f"witness metadata row {index}: field 'expected_oracle_node' " |
| 269 | + "must be a non-empty string" |
| 270 | + ) |
| 271 | + |
| 272 | + feature_ids = row.get("structural_feature_ids") |
| 273 | + if not isinstance(feature_ids, list): |
| 274 | + raise ValueError( |
| 275 | + f"witness metadata row {index}: field 'structural_feature_ids' " |
| 276 | + "must be a list" |
| 277 | + ) |
| 278 | + for feature_index, feature_id in enumerate(feature_ids): |
| 279 | + if not isinstance(feature_id, str) or not feature_id: |
| 280 | + raise ValueError( |
| 281 | + f"witness metadata row {index}: structural_feature_ids" |
| 282 | + f"[{feature_index}] must be a non-empty string" |
| 283 | + ) |
| 284 | + |
| 285 | + note = row.get("note") |
| 286 | + if note is not None and not isinstance(note, str): |
| 287 | + raise ValueError( |
| 288 | + f"witness metadata row {index}: field 'note' must be a string" |
| 289 | + ) |
| 290 | + |
| 291 | + |
| 292 | +def _validate_oracle_witness_row(row, index): |
| 293 | + if not isinstance(row, dict): |
| 294 | + raise ValueError(f"witness oracle row {index}: expected a JSON object") |
| 295 | + for field in ("oracle_node", "result", "sql"): |
| 296 | + value = row.get(field) |
| 297 | + if not isinstance(value, str) or not value: |
| 298 | + raise ValueError( |
| 299 | + f"witness oracle row {index}: field {field!r} must be a " |
| 300 | + "non-empty string" |
| 301 | + ) |
| 302 | + if row["result"] == "ORACLE_REJECTED": |
| 303 | + raise ValueError( |
| 304 | + f"witness oracle row {index}: statement must be oracle accepted" |
| 305 | + ) |
| 306 | + |
| 307 | + |
| 308 | +def validate_witnesses(oracle_rows, metadata_rows, *, target_major): |
| 309 | + if isinstance(target_major, bool) or not isinstance(target_major, int): |
| 310 | + raise ValueError("target_major must be an integer") |
| 311 | + if target_major < 1: |
| 312 | + raise ValueError("target_major must be positive") |
| 313 | + |
| 314 | + if len(oracle_rows) != len(metadata_rows): |
| 315 | + raise ValueError( |
| 316 | + f"witness metadata count {len(metadata_rows)} does not match " |
| 317 | + f"{len(oracle_rows)} parsed SQL statement(s)" |
| 318 | + ) |
| 319 | + |
| 320 | + witnesses = [] |
| 321 | + unlinked_witnesses = [] |
| 322 | + seen_ids = set() |
| 323 | + for index, (oracle_row, metadata_row) in enumerate( |
| 324 | + zip(oracle_rows, metadata_rows) |
| 325 | + ): |
| 326 | + _validate_oracle_witness_row(oracle_row, index) |
| 327 | + _validate_metadata_row(metadata_row, index, target_major) |
| 328 | + |
| 329 | + witness_id = metadata_row["id"] |
| 330 | + if witness_id in seen_ids: |
| 331 | + raise ValueError(f"duplicate witness ID {witness_id!r}") |
| 332 | + seen_ids.add(witness_id) |
| 333 | + |
| 334 | + expected_node = metadata_row["expected_oracle_node"] |
| 335 | + actual_node = oracle_row["oracle_node"] |
| 336 | + if actual_node != expected_node: |
| 337 | + raise ValueError( |
| 338 | + f"witness {witness_id!r}: oracle node differs from " |
| 339 | + f"expected {expected_node!r}: {actual_node!r}" |
| 340 | + ) |
| 341 | + |
| 342 | + witness = copy.deepcopy(oracle_row) |
| 343 | + witness.update(copy.deepcopy(metadata_row)) |
| 344 | + witness["oracle_node"] = actual_node |
| 345 | + witness["result"] = oracle_row["result"] |
| 346 | + witnesses.append(witness) |
| 347 | + if not witness["structural_feature_ids"]: |
| 348 | + unlinked_witnesses.append(witness_id) |
| 349 | + |
| 350 | + return { |
| 351 | + "witnesses": witnesses, |
| 352 | + "unlinked_witnesses": sorted(unlinked_witnesses), |
| 353 | + } |
| 354 | + |
| 355 | + |
| 356 | +def load_reviewed_witnesses( |
| 357 | + sql_path, |
| 358 | + metadata_path, |
| 359 | + runner_path, |
| 360 | + *, |
| 361 | + target_major, |
| 362 | + branch, |
| 363 | + commit, |
| 364 | +): |
| 365 | + oracle_rows = parse_witnesses_with_runner( |
| 366 | + sql_path, |
| 367 | + runner_path, |
| 368 | + branch=branch, |
| 369 | + commit=commit, |
| 370 | + ) |
| 371 | + metadata_rows = _read_json_array(metadata_path, "witness metadata") |
| 372 | + return validate_witnesses( |
| 373 | + oracle_rows, |
| 374 | + metadata_rows, |
| 375 | + target_major=target_major, |
| 376 | + ) |
| 377 | + |
| 378 | + |
188 | 379 | def write_ci_cases(path, cases): |
189 | 380 | records = _records_by_id(cases, "CI case", require_sql=True) |
190 | 381 | output = [] |
|
0 commit comments