A fork of mtelver's day10 project

Fix four issues in failure classification and history recording

1. Extract compiler version from layer.json deps field instead of using
empty string - searches for ocaml-base-compiler or ocaml-variants
2. Record dependency_failure entries for packages that have no build
layer by comparing solutions against scanned layers
3. Deduplicate history entries by checking for existing run_id +
build_hash before appending
4. Record doc success entries for blessed packages, not just failures

Also extract matches_any helper to clean up classify_build_failure and
doc category detection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+87 -22
+87 -22
bin/main.ml
··· 267 267 268 268 let record_build_result ~packages_dir ~run_id ~pkg_str ~build_hash 269 269 ~compiler ~blessed ~status ~category ?error ?failed_dep ?failed_dep_hash () = 270 - let entry : Day10_lib.History.entry = { 271 - ts = Day10_lib.Run_log.format_time (Unix.gettimeofday ()); 272 - run = run_id; 273 - build_hash; 274 - status; 275 - category; 276 - compiler; 277 - blessed; 278 - error; 279 - failed_dep; 280 - failed_dep_hash; 281 - } in 282 - Day10_lib.History.append ~packages_dir ~pkg_str entry 270 + (* Skip if already recorded for this run and build_hash *) 271 + let existing = Day10_lib.History.read ~packages_dir ~pkg_str in 272 + let already_recorded = List.exists (fun (e : Day10_lib.History.entry) -> 273 + e.run = run_id && e.build_hash = build_hash 274 + ) existing in 275 + if already_recorded then () 276 + else begin 277 + let entry : Day10_lib.History.entry = { 278 + ts = Day10_lib.Run_log.format_time (Unix.gettimeofday ()); 279 + run = run_id; 280 + build_hash; 281 + status; 282 + category; 283 + compiler; 284 + blessed; 285 + error; 286 + failed_dep; 287 + failed_dep_hash; 288 + } in 289 + Day10_lib.History.append ~packages_dir ~pkg_str entry 290 + end 291 + 292 + (** Check if any pattern in the list matches the given text (case-insensitive). *) 293 + let matches_any patterns text = 294 + List.exists (fun pat -> 295 + try ignore (Str.search_forward (Str.regexp_case_fold pat) text 0); true 296 + with Not_found -> false 297 + ) patterns 298 + 299 + (** Extract the compiler version from a layer.json's deps list. 300 + Looks for packages starting with "ocaml-base-compiler" or "ocaml-variants". *) 301 + let extract_compiler_from_deps json = 302 + let open Yojson.Safe.Util in 303 + let deps = try json |> member "deps" |> to_list |> List.map to_string with _ -> [] in 304 + let compiler_pkg = List.find_opt (fun dep -> 305 + let name = try String.sub dep 0 (String.index dep '.') with Not_found -> dep in 306 + name = "ocaml-base-compiler" || name = "ocaml-variants" 307 + ) deps in 308 + match compiler_pkg with 309 + | Some pkg -> 310 + (try String.sub pkg (String.index pkg '.' + 1) (String.length pkg - String.index pkg '.' - 1) 311 + with Not_found -> pkg) 312 + | None -> "" 283 313 284 314 (** Classify a build failure by scanning the build log for known patterns. *) 285 315 let classify_build_failure build_log_path = ··· 300 330 "unmet dependencies"; 301 331 "dpkg: dependency problems"; 302 332 ] in 303 - if List.exists (fun pat -> try ignore (Str.search_forward (Str.regexp_case_fold pat) log_content 0); true with Not_found -> false) transient_patterns then 333 + if matches_any transient_patterns log_content then 304 334 ("failure", "transient_failure", Some "Transient infrastructure failure detected in build log") 305 - else if List.exists (fun pat -> try ignore (Str.search_forward (Str.regexp_case_fold pat) log_content 0); true with Not_found -> false) depext_patterns then 335 + else if matches_any depext_patterns log_content then 306 336 ("failure", "depext_unavailable", Some "Missing system dependency detected in build log") 307 337 else 308 338 ("failure", "build_failure", None) ··· 1313 1343 let doc_success = ref 0 in 1314 1344 let doc_fail = ref 0 in 1315 1345 let failures = ref [] in 1346 + (* Track which packages have build layers, for detecting dependency failures *) 1347 + let built_packages = Hashtbl.create 64 in 1348 + (* Track per-package build layer exit status and compiler, for dep failure reporting *) 1349 + let build_layer_info = Hashtbl.create 64 in 1316 1350 let () = 1317 1351 try 1318 1352 Sys.readdir layer_dir |> Array.iter (fun name -> ··· 1325 1359 (* Build layer *) 1326 1360 let pkg_name = json |> member "package" |> to_string in 1327 1361 let exit_status = json |> member "exit_status" |> to_int_option |> Option.value ~default:(-1) in 1362 + let compiler = extract_compiler_from_deps json in 1328 1363 (* Check if this build is blessed *) 1329 1364 let blessed_build_link = Path.(packages_dir / pkg_name / "blessed-build") in 1330 1365 let is_blessed = try 1331 1366 let target = Unix.readlink blessed_build_link in 1332 1367 Filename.basename target = name 1333 1368 with _ -> false in 1369 + Hashtbl.replace built_packages pkg_name true; 1370 + Hashtbl.replace build_layer_info pkg_name (name, exit_status, compiler); 1334 1371 if exit_status = 0 then begin 1335 1372 incr build_success; 1336 1373 (* Add build log to run *) ··· 1338 1375 Day10_lib.Run_log.add_build_log run_info ~package:pkg_name ~source_log:build_log; 1339 1376 (* Record success in history *) 1340 1377 record_build_result ~packages_dir ~run_id ~pkg_str:pkg_name 1341 - ~build_hash:name ~compiler:"" ~blessed:is_blessed 1378 + ~build_hash:name ~compiler ~blessed:is_blessed 1342 1379 ~status:"success" ~category:"success" () 1343 1380 end else begin 1344 1381 incr build_fail; ··· 1348 1385 (* Classify and record build failure in history *) 1349 1386 let (status, category, error) = classify_build_failure build_log in 1350 1387 record_build_result ~packages_dir ~run_id ~pkg_str:pkg_name 1351 - ~build_hash:name ~compiler:"" ~blessed:is_blessed 1388 + ~build_hash:name ~compiler ~blessed:is_blessed 1352 1389 ~status ~category ?error () 1353 1390 end 1354 1391 end else if String.length name > 4 && String.sub name 0 4 = "doc-" then begin ··· 1364 1401 Day10_lib.Run_log.add_doc_log run_info ~package:pkg_name ~source_log:doc_log ~layer_hash (); 1365 1402 (* Only count blessed docs in summary stats *) 1366 1403 if blessed then begin 1367 - if status = "success" then 1368 - incr doc_success 1369 - else begin 1404 + if status = "success" then begin 1405 + incr doc_success; 1406 + (* Record doc success for blessed packages *) 1407 + record_build_result ~packages_dir ~run_id ~pkg_str:pkg_name 1408 + ~build_hash:name ~compiler:"" ~blessed:true 1409 + ~status:"success" ~category:"success" () 1410 + end else begin 1370 1411 incr doc_fail; 1371 1412 let error_msg = doc |> member "error" |> to_string_option |> Option.value ~default:"unknown error" in 1372 1413 failures := (pkg_name, Printf.sprintf "doc: %s" error_msg) :: !failures; 1373 1414 (* Record blessed doc failure in history *) 1374 1415 let doc_category = 1375 - let lower = String.lowercase_ascii error_msg in 1376 - if try ignore (Str.search_forward (Str.regexp_string "link") lower 0); true with Not_found -> false then 1416 + if matches_any ["link"] (String.lowercase_ascii error_msg) then 1377 1417 "doc_link_failure" 1378 1418 else 1379 1419 "doc_compile_failure" ··· 1389 1429 ) 1390 1430 with _ -> () 1391 1431 in 1432 + (* Record dependency failures: packages in solutions that have no build layer *) 1433 + List.iter (fun (_target, solution) -> 1434 + OpamPackage.Map.iter (fun pkg deps -> 1435 + let pkg_str = OpamPackage.to_string pkg in 1436 + if not (Hashtbl.mem built_packages pkg_str) then begin 1437 + (* Find which dep failed by checking build_layer_info *) 1438 + let dep_pkgs = OpamPackage.Set.elements deps in 1439 + let failed_dep_info = List.find_map (fun dep -> 1440 + let dep_str = OpamPackage.to_string dep in 1441 + match Hashtbl.find_opt build_layer_info dep_str with 1442 + | Some (hash, exit_status, _) when exit_status <> 0 -> 1443 + Some (dep_str, hash) 1444 + | _ -> None 1445 + ) dep_pkgs in 1446 + let failed_dep, failed_dep_hash = match failed_dep_info with 1447 + | Some (dep, hash) -> (Some dep, Some hash) 1448 + | None -> (None, None) 1449 + in 1450 + record_build_result ~packages_dir ~run_id ~pkg_str 1451 + ~build_hash:"none" ~compiler:"" ~blessed:false 1452 + ~status:"failure" ~category:"dependency_failure" 1453 + ?failed_dep ?failed_dep_hash () 1454 + end 1455 + ) solution 1456 + ) solutions; 1392 1457 let html_versions = match config.html_output with 1393 1458 | None -> 0 1394 1459 | Some html_dir ->