@@ -315,8 +315,43 @@ def unique_report_paths_by_content(report_paths: list[str | Path]) -> list[Path]
315315 return list (require_publish_candidates (None , report_paths ).selected_paths )
316316
317317
318- def preflight_publish_destinations (report_paths : list [str | Path ]) -> None :
319- plan = build_publication_plan (report_paths )
318+ RECOVERED_JSON_PATTERN = re .compile (
319+ r"^advisory_report_(?P<as_of>\d{4}-\d{2}-\d{2})(?P<variant>\.variant-(?P<digest>[0-9a-f]{12}))?\.json$"
320+ )
321+
322+
323+ def _recovered_public_identity (candidate : _ReportCandidate ) -> tuple [str , str , str , str , bool ]:
324+ assert candidate .report is not None and candidate .fingerprint is not None
325+ match = RECOVERED_JSON_PATTERN .fullmatch (candidate .path .name )
326+ if match is None or match .group ("as_of" ) != str (candidate .report .get ("as_of" , "" )):
327+ raise ValueError ("recovered_public_identity_invalid" )
328+ digest = match .group ("digest" )
329+ if digest is not None and digest != _variant_digest (candidate .fingerprint ):
330+ raise ValueError ("recovered_variant_digest_mismatch" )
331+ json_name = candidate .path .name
332+ html_name = report_filename (candidate .report )
333+ markdown_name = f"advisory_report_{ match .group ('as_of' )} .md"
334+ manifest_name = f"{ json_name } .manifest.json"
335+ if digest is not None :
336+ html_name = _variant_name (html_name , digest )
337+ markdown_name = _variant_name (markdown_name , digest )
338+ return html_name , json_name , markdown_name , manifest_name , digest is None
339+
340+
341+ def preflight_publish_destinations (
342+ report_paths : list [str | Path ],
343+ * ,
344+ mandatory_current : str | Path | None = None ,
345+ recovered_history : list [str | Path ] | None = None ,
346+ reject_invalid : bool = False ,
347+ publication_plan : PublicationPlan | None = None ,
348+ ) -> None :
349+ plan = publication_plan or build_publication_plan (
350+ report_paths ,
351+ mandatory_current = mandatory_current ,
352+ recovered_history = recovered_history ,
353+ reject_invalid = reject_invalid ,
354+ )
320355 preflight_publication_plan (plan )
321356
322357
@@ -362,25 +397,80 @@ def build_publication_plan(
362397 if reject_invalid and selection .quarantined :
363398 raise ValueError ("invalid_report_candidate" )
364399 selected_paths = list (selection .selected_paths )
365- artifact_ids = _relative_artifact_ids (selected_paths )
366- candidates = [
400+ mandatory_resolved = Path (mandatory_current ).resolve () if mandatory_current is not None else None
401+ recovered_paths = {
402+ path .resolve ()
403+ for path in _canonical_paths (list (recovered_history or []))
404+ if path .resolve () != mandatory_resolved
405+ }
406+ all_paths = _canonical_paths (selected_paths + list (recovered_paths ))
407+ artifact_ids = _relative_artifact_ids (all_paths )
408+ all_candidates = [
367409 _load_candidate (path , artifact_ids [path ], index )
368- for index , path in enumerate (selected_paths )
410+ for index , path in enumerate (all_paths )
369411 ]
370- valid_candidates = [candidate for candidate in candidates if candidate .report is not None ]
412+ candidates_by_path = {candidate .path .resolve (): candidate for candidate in all_candidates }
413+ recovered_candidates = [
414+ candidate
415+ for candidate in all_candidates
416+ if candidate .path .resolve () in recovered_paths and candidate .report is not None
417+ ]
418+ recovered_identities : dict [Path , tuple [str , str , str , str , bool ]] = {}
419+ identity_fingerprints : dict [str , str ] = {}
420+ recovered_canonical_names_by_period : dict [str , set [str ]] = {}
421+ for candidate in recovered_candidates :
422+ identity = _recovered_public_identity (candidate )
423+ recovered_identities [candidate .path .resolve ()] = identity
424+ if identity [4 ] and candidate .period is not None :
425+ recovered_canonical_names_by_period .setdefault (candidate .period .key , set ()).add (identity [1 ])
426+ for name in identity [:4 ]:
427+ previous = identity_fingerprints .get (name )
428+ if previous is not None and previous != candidate .fingerprint :
429+ raise ValueError ("recovered_public_identity_conflict" )
430+ identity_fingerprints [name ] = candidate .fingerprint or ""
431+ if any (len (names ) > 1 for names in recovered_canonical_names_by_period .values ()):
432+ raise ValueError ("recovered_public_identity_conflict" )
433+
434+ selected_candidates : list [_ReportCandidate ] = []
435+ for path in selected_paths :
436+ candidate = candidates_by_path [path .resolve ()]
437+ if candidate .report is None :
438+ continue
439+ if candidate .path .resolve () != mandatory_resolved :
440+ matches = [
441+ recovered
442+ for recovered in recovered_candidates
443+ if recovered .period == candidate .period and recovered .fingerprint == candidate .fingerprint
444+ ]
445+ if matches :
446+ candidate = sorted (matches , key = lambda item : item .artifact_id )[0 ]
447+ if candidate .path .resolve () not in {item .path .resolve () for item in selected_candidates }:
448+ selected_candidates .append (candidate )
449+ valid_candidates = selected_candidates
371450 groups : dict [str , list [_ReportCandidate ]] = {}
372451 for candidate in valid_candidates :
373452 assert candidate .period is not None
374453 groups .setdefault (candidate .period .key , []).append (candidate )
375- mandatory_resolved = Path (mandatory_current ).resolve () if mandatory_current is not None else None
376454 entries_by_path : dict [Path , PublicationEntry ] = {}
377455 ordered_groups : list [tuple [CanonicalPeriod , list [_ReportCandidate ]]] = []
378456 for group in groups .values ():
379457 ranked = _publication_rank (group )
380- owner = next (
381- (candidate for candidate in ranked if candidate .path .resolve () == mandatory_resolved ),
382- ranked [0 ],
458+ mandatory_owner = next (
459+ (candidate for candidate in ranked if candidate .path .resolve () == mandatory_resolved ), None
383460 )
461+ recovered_canonical = [
462+ candidate
463+ for candidate in group
464+ if recovered_identities .get (candidate .path .resolve (), ("" , "" , "" , "" , False ))[4 ]
465+ ]
466+ if mandatory_owner is not None :
467+ owner = mandatory_owner
468+ elif len ({recovered_identities [candidate .path .resolve ()][0 ] for candidate in recovered_canonical }) > 1 :
469+ raise ValueError ("recovered_public_identity_conflict" )
470+ elif recovered_canonical :
471+ owner = sorted (recovered_canonical , key = lambda item : item .artifact_id )[0 ]
472+ else :
473+ owner = ranked [0 ]
384474 assert owner .period is not None
385475 ordered_groups .append (
386476 (owner .period , [owner , * (candidate for candidate in ranked if candidate is not owner )])
@@ -399,22 +489,28 @@ def build_publication_plan(
399489 canonical_html = report_filename (candidate .report )
400490 canonical_json = f"advisory_report_{ as_of } .json"
401491 canonical_markdown = f"advisory_report_{ as_of } .md"
402- if candidate is owner :
492+ recovered_identity = recovered_identities .get (candidate .path .resolve ())
493+ if recovered_identity is not None and (not recovered_identity [4 ] or candidate is owner ):
494+ html_name , json_name , markdown_name , manifest_name , _ = recovered_identity
495+ canonical_owner = candidate is owner and recovered_identity [4 ]
496+ elif candidate is owner :
403497 html_name , json_name , markdown_name = canonical_html , canonical_json , canonical_markdown
498+ manifest_name = f"{ json_name } .manifest.json"
404499 canonical_owner = True
405500 else :
406501 suffix = _variant_digest (candidate .fingerprint )
407502 html_name = _variant_name (canonical_html , suffix )
408503 json_name = _variant_name (canonical_json , suffix )
409504 markdown_name = _variant_name (canonical_markdown , suffix )
505+ manifest_name = f"{ json_name } .manifest.json"
410506 canonical_owner = False
411507 entries_by_path [candidate .path ] = PublicationEntry (
412508 report = candidate .report ,
413509 source_path = candidate .path ,
414510 html_name = html_name ,
415511 json_name = json_name ,
416512 markdown_name = markdown_name ,
417- manifest_name = f" { json_name } .manifest.json" ,
513+ manifest_name = manifest_name ,
418514 fingerprint = candidate .fingerprint ,
419515 canonical_owner = canonical_owner ,
420516 generated_at = candidate .generated_at ,
0 commit comments