Strengthen publish quality gates without regressing successful analysis (#278)

* fix: strengthen analysis publish gate (#255) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: calibrate publish gate for safe promotion (#255) Calibrate deterministic publish quality against known-good weekly outputs, preserve Copilot default-model provenance, and fail closed on missing or failed required gate families for promotion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: align publish manifest promotion contract (#255) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix publish manifest root gate for #255 Fail assert-eligible for manifests outside data/staging or data/candidates so it matches promotion_guard promotion checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address publish gate review comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix publish gate artifact timestamp handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 6, 2026 at 21:56 UTC bba85ba4c6641d4c6f664c1f22a6a5a6a10787af
9 files changed +1156 -194
.github/workflows/crawl-and-publish.yml
+16 -4
@@ -307,6 +307,7 @@ jobs:
307 candidate_dir = Path("data/candidates") / week / run_id
308 print(f"candidate_output_file={(candidate_dir / f'{week}-summary.md').as_posix()}")
309 print(f"publish_manifest_file={(candidate_dir / 'publish-manifest.json').as_posix()}")
310 + print(f"analysis_gate_report_file={(candidate_dir / 'analysis-gate-report.json').as_posix()}")
311 print(f"published_output_file=data/analyzed/{week}-summary.md")
312 PY
313 )
@@ -388,6 +389,8 @@ jobs:
389 WEEK_FILE="${{ steps.analysis-context.outputs.week_file }}"
390 WEEK="${{ steps.analysis-context.outputs.week }}"
391 CURRENT_DATETIME="${{ steps.analysis-context.outputs.current_datetime }}"
392 + MANIFEST_FILE="${{ steps.analysis-context.outputs.publish_manifest_file }}"
393 + PUBLISHED_SUMMARY="${{ steps.analysis-context.outputs.published_output_file }}"
394 PRESS_FILE="${{ steps.press-context.outputs.press_file }}"
395 ANALYSIS_STARTED=$(date +%s)
396 DIAGNOSTICS_DIR="$(dirname "$OUTPUT_FILE")/diagnostics"
@@ -415,8 +418,9 @@ jobs:
418 --raw-json "$WEEK_FILE" \
419 --current-datetime "$CURRENT_DATETIME" \
420 --source "$1" \
421 + --model "$2" \
422 --repair-safe \
419 - --report-json "$2"
423 + --report-json "$3"
424 }
425
426 # Retry loop: LLM output can be non-deterministically truncated,
@@ -497,8 +501,9 @@ jobs:
501
502 # Inline quality gate check (suppress step summary to avoid noise).
503 # The gate applies deterministic metadata/schema repairs and writes an auditable report.
500 - if run_quality_gate copilot-cli "$GATE_REPORT"; then
504 + if run_quality_gate copilot-cli copilot-default "$GATE_REPORT"; then
505 cp "$OUTPUT_FILE" "$CANDIDATE_SNAPSHOT" 2>/dev/null || true
506 + cp "$GATE_REPORT" "${{ steps.analysis-context.outputs.analysis_gate_report_file }}" 2>/dev/null || true
507 GATE_PASSED=true
508 FINAL_TRANSCRIPT="$TRANSCRIPT_FILE"
509 ANALYSIS_SOURCE="copilot-cli"
@@ -547,7 +552,8 @@ jobs:
552 --no-ai
553 sanitize_agent_output "$OUTPUT_FILE"
554 NO_AI_GATE_REPORT="$DIAGNOSTICS_DIR/gate-no-ai-attempt-0.json"
550 - run_quality_gate no-ai "$NO_AI_GATE_REPORT" || true
555 + run_quality_gate no-ai none "$NO_AI_GATE_REPORT" || true
556 + cp "$NO_AI_GATE_REPORT" "${{ steps.analysis-context.outputs.analysis_gate_report_file }}" 2>/dev/null || true
557 cp "$OUTPUT_FILE" "$DIAGNOSTICS_DIR/candidate-no-ai-attempt-0.md" 2>/dev/null || true
558 ANALYSIS_SOURCE="no-ai"
559 ANALYSIS_MODEL="none"
@@ -588,14 +594,18 @@ jobs:
594 env:
595 ANALYSIS_FILE: ${{ steps.analysis-context.outputs.candidate_output_file }}
596 ANALYSIS_SOURCE: ${{ steps.run-analysis.outputs.analysis_source }}
597 + ANALYSIS_MODEL: ${{ steps.run-analysis.outputs.analysis_model }}
598 RAW_JSON_FILE: ${{ steps.analysis-context.outputs.week_file }}
599 CURRENT_DATETIME: ${{ steps.analysis-context.outputs.current_datetime }}
600 + GATE_REPORT: ${{ steps.analysis-context.outputs.analysis_gate_report_file }}
601 run: |
602 python3 scripts/analysis_gate.py \
603 --analysis-file "$ANALYSIS_FILE" \
604 --raw-json "$RAW_JSON_FILE" \
605 --current-datetime "$CURRENT_DATETIME" \
598 - --source "$ANALYSIS_SOURCE"
606 + --source "$ANALYSIS_SOURCE" \
607 + --model "$ANALYSIS_MODEL" \
608 + --report-json "$GATE_REPORT"
609
610 - name: Emit publish eligibility manifest
611 env:
@@ -609,6 +619,7 @@ jobs:
619 ANALYSIS_MODEL: ${{ steps.run-analysis.outputs.analysis_model }}
620 VALIDATION_STATUS: ${{ steps.quality-check.outcome == 'success' && 'passed' || 'failed' }}
621 MANIFEST_FILE: ${{ steps.analysis-context.outputs.publish_manifest_file }}
622 + GATE_REPORT: ${{ steps.analysis-context.outputs.analysis_gate_report_file }}
623 run: |
624 set -euo pipefail
625 git fetch origin publish 2>/dev/null && git checkout origin/publish -- "$PUBLISHED_SUMMARY" 2>/dev/null || true
@@ -632,6 +643,7 @@ jobs:
643 --analysis-source "$ANALYSIS_SOURCE" \
644 --analysis-model "$ANALYSIS_MODEL" \
645 --validation-status "$VALIDATION_STATUS" \
646 + --gate-report "$GATE_REPORT" \
647 --output "$MANIFEST_FILE" \
648 "${ARTIFACT_ARGS[@]}"
649
docs/analysis-spec.md
+10 -1
@@ -326,7 +326,7 @@ Compare the current week to the prior week when a prior summary exists. Note con
326
327 ## Reviewer-Gate Expectations
328
329 -A weekly analysis is publishable only if all of the following are true:
329 +A weekly analysis is structurally valid only if all of the following are true:
330
331 - `quality_score >= 60`
332 - all required frontmatter fields are present,
@@ -335,6 +335,15 @@ A weekly analysis is publishable only if all of the following are true:
335 - body word count is at least 200,
336 - the prose contains no raw JSON, tool logs, or placeholder text.
337
338 +Publication also requires a structured gate report with four passing gate families:
339 +
340 +- `structural_schema` — frontmatter, schema, dates, headings, section order, and deterministic repair results.
341 +- `ai_provenance` — a publishable AI source and available model; `no-ai`, unknown, unavailable, or deterministic repair failure outputs are staged but not promoted.
342 +- `evidence_citation` — fresh raw evidence and markdown citations to raw-payload repositories where repository evidence exists.
343 +- `editorial_quality` — enough section depth, explanatory trend judgment, no contradictory claims, and no generic low-signal prose.
344 +
345 +The publish manifest records these gate outcomes and promotion must consume them before replacing a previously published AI-authored article.
346 +
347 ## Generator Handoff Rules
348
349 The generator may assume:
scripts/analysis_gate.py
+207 -1
@@ -43,6 +43,8 @@ REQUIRED_HEADINGS = [
43 "### Notable Projects",
44 "### Press & Industry",
45 ]
46 +PUBLISHABLE_AI_SOURCES = {"copilot-cli", "github-models"}
47 +UNPUBLISHABLE_MODEL_VALUES = {"", "unknown", "unavailable", "none", "no-ai"}
48 RAW_MARKERS = [
49 "```json",
50 '"week":',
@@ -65,6 +67,49 @@ GENERIC_TITLE_PATTERNS = [
67 re.compile(r"^Week\s+\d+.*Analysis$", re.IGNORECASE),
68 re.compile(r"^Week\s+\d+,\s*\d{4}$", re.IGNORECASE),
69 ]
70 +REPO_LINK_PATTERN = re.compile(r"\[([^/\]\s]+/[^/\]\s]+)\]\(https://github\.com/\1\)")
71 +SECTION_MIN_WORDS = {
72 + "## This Week's Trends": 60,
73 + "## Where Industry Meets Code": 40,
74 + "## Signal & Noise": 50,
75 + "## Blind Spots": 30,
76 + "## The Week Ahead": 30,
77 +}
78 +EDITORIAL_TERMS = {
79 + "signal",
80 + "noise",
81 + "gap",
82 + "gaps",
83 + "durable",
84 + "hype",
85 + "matters",
86 + "evidence",
87 + "trend",
88 + "trends",
89 + "blind",
90 + "missing",
91 + "practitioners",
92 + "ecosystem",
93 + "observability",
94 + "security",
95 + "testing",
96 +}
97 +EXPLANATORY_PATTERN = re.compile(
98 + r"\b(because|why|matters|signals|reveals|driven|shows|suggests|represents|means|confirms|indicates|constitutes)\b",
99 + re.IGNORECASE,
100 +)
101 +CONTRADICTION_PATTERNS = [
102 + (
103 + re.compile(r"no press data was provided", re.IGNORECASE),
104 + re.compile(r"\b(reported|techcrunch)\b", re.IGNORECASE),
105 + "claims no press data was provided while also describing press coverage.",
106 + ),
107 + (
108 + re.compile(r"no meaningful developer activity", re.IGNORECASE),
109 + REPO_LINK_PATTERN,
110 + "claims no meaningful developer activity while citing active repositories.",
111 + ),
112 +]
113
114
115 def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
@@ -73,6 +118,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
118 parser.add_argument("--raw-json", required=True, type=Path, help="Path to the raw weekly payload.")
119 parser.add_argument("--current-datetime", required=True, help="Current run timestamp in ISO 8601 format.")
120 parser.add_argument("--source", default="unknown", help="Analysis source label for summaries.")
121 + parser.add_argument("--model", default="copilot-default", help="AI model label for provenance validation.")
122 parser.add_argument(
123 "--repair-safe",
124 action="store_true",
@@ -424,6 +470,152 @@ def find_missing_headings(body: str) -> list[str]:
470 return missing
471
472
473 +def section_text(body: str, heading: str) -> str:
474 + heading_match = re.search(rf"(?m)^{re.escape(heading)}\s*$", body)
475 + if heading_match is None:
476 + return ""
477 + next_heading = re.search(r"(?m)^##\s+", body[heading_match.end() :])
478 + end = heading_match.end() + next_heading.start() if next_heading else len(body)
479 + return body[heading_match.end() : end].strip()
480 +
481 +
482 +def raw_repo_names(raw_payload: dict[str, Any]) -> set[str]:
483 + names: set[str] = set()
484 + for field in ("new_repos", "trending_repos"):
485 + repos = raw_payload.get(field)
486 + if not isinstance(repos, list):
487 + continue
488 + for repo in repos:
489 + if isinstance(repo, dict) and isinstance(repo.get("full_name"), str):
490 + names.add(repo["full_name"].strip())
491 + return {name for name in names if TOP_REPO_PATTERN.fullmatch(name)}
492 +
493 +
494 +def raw_artifact_week_errors(raw_payload: dict[str, Any], expected_week: Any) -> list[str]:
495 + if not isinstance(expected_week, str):
496 + return []
497 + timestamp = raw_payload.get("generated_at") or raw_payload.get("crawled_at")
498 + if not isinstance(timestamp, str) or not timestamp.strip():
499 + return []
500 + try:
501 + parsed = parse_datetime(timestamp)
502 + except (TypeError, ValueError) as exc:
503 + return [f"raw evidence timestamp is invalid: {exc}"]
504 + artifact_week = week_slug(parsed)
505 + if artifact_week != expected_week:
506 + return [f"raw evidence timestamp week mismatch: expected {expected_week}, found {artifact_week}."]
507 + return []
508 +
509 +
510 +def evidence_citation_errors(body: str, raw_payload: dict[str, Any]) -> list[str]:
511 + errors: list[str] = []
512 + repos = raw_repo_names(raw_payload)
513 + linked_repos = set(REPO_LINK_PATTERN.findall(body))
514 + if repos and not linked_repos.intersection(repos):
515 + errors.append("evidence citations must include at least one repository link from the raw payload.")
516 + if repos and "## Key References" in body:
517 + notable = section_text(body, "## Key References")
518 + notable_links = set(REPO_LINK_PATTERN.findall(notable))
519 + if not notable_links.intersection(repos):
520 + errors.append("Key References must cite at least one raw-payload repository link.")
521 + errors.extend(raw_artifact_week_errors(raw_payload, raw_payload.get("week")))
522 + return errors
523 +
524 +
525 +def editorial_quality_errors(body: str) -> list[str]:
526 + errors: list[str] = []
527 + prose = "\n".join(line for line in body.splitlines() if not line.lstrip().startswith("#"))
528 + lower_body = prose.lower()
529 + terms_found = {term for term in EDITORIAL_TERMS if re.search(rf"\b{re.escape(term)}\b", lower_body)}
530 + if len(terms_found) < 3:
531 + errors.append("editorial analysis must use trend/evidence judgment language, not generic summary prose.")
532 + for heading, minimum in SECTION_MIN_WORDS.items():
533 + text = section_text(body, heading)
534 + if not text:
535 + continue
536 + count = len(WORD_PATTERN.findall(text))
537 + if count < minimum:
538 + errors.append(f"{heading} section is too thin for publish-quality analysis; found {count} words, expected at least {minimum}.")
539 + for heading in ("## This Week's Trends", "## Signal & Noise", "## Blind Spots"):
540 + text = section_text(body, heading)
541 + linked_repos = REPO_LINK_PATTERN.findall(text)
542 + section_terms = {term for term in EDITORIAL_TERMS if re.search(rf"\b{re.escape(term)}\b", text.lower())}
543 + has_reasoning_or_evidence = EXPLANATORY_PATTERN.search(text) or linked_repos or len(section_terms) >= 2
544 + if text and not has_reasoning_or_evidence:
545 + errors.append(f"{heading} must explain why the pattern matters, not only name it.")
546 + return errors
547 +
548 +
549 +def contradiction_errors(body: str) -> list[str]:
550 + errors: list[str] = []
551 + for negative_pattern, positive_pattern, message in CONTRADICTION_PATTERNS:
552 + for match in negative_pattern.finditer(body):
553 + window_start = max(0, match.start() - 300)
554 + window_end = min(len(body), match.end() + 300)
555 + window = body[window_start:window_end]
556 + if positive_pattern.search(window.replace(match.group(0), "", 1)):
557 + errors.append(f"contradictory claim: {message}")
558 + break
559 + return errors
560 +
561 +
562 +def ai_provenance_errors(source: str, model: str) -> list[str]:
563 + errors: list[str] = []
564 + normalized_source = source.strip()
565 + normalized_model = model.strip()
566 + if normalized_source not in PUBLISHABLE_AI_SOURCES:
567 + errors.append(f"AI provenance source is not publishable: {normalized_source or 'unknown'}.")
568 + if normalized_model.lower() in UNPUBLISHABLE_MODEL_VALUES:
569 + errors.append(f"AI provenance model is not publishable: {normalized_model or 'unknown'}.")
570 + return errors
571 +
572 +
573 +def categorize_gate_error(error: str) -> str:
574 + if error.startswith("AI provenance"):
575 + return "ai_provenance"
576 + if error.startswith(("evidence citations", "Key References", "raw evidence")):
577 + return "evidence_citation"
578 + if error.startswith(("editorial analysis", "contradictory claim")) or "section is too thin" in error or "must explain why" in error:
579 + return "editorial_quality"
580 + if "quality_score" in error or "generic week/year" in error or "placeholder" in error:
581 + return "editorial_quality"
582 + return "structural_schema"
583 +
584 +
585 +def build_gate_results(errors: list[str]) -> dict[str, dict[str, Any]]:
586 + gates = {
587 + "structural_schema": {"passed": True, "errors": []},
588 + "ai_provenance": {"passed": True, "errors": []},
589 + "evidence_citation": {"passed": True, "errors": []},
590 + "editorial_quality": {"passed": True, "errors": []},
591 + }
592 + for error in errors:
593 + category = categorize_gate_error(error)
594 + gates[category]["passed"] = False
595 + gates[category]["errors"].append(error)
596 + return gates
597 +
598 +
599 +def validate_publish_quality(
600 + text: str,
601 + raw_payload: dict[str, Any],
602 + *,
603 + source: str,
604 + model: str,
605 +) -> tuple[list[str], dict[str, dict[str, Any]]]:
606 + try:
607 + _, body = extract_frontmatter(text)
608 + except ValueError:
609 + body = ""
610 + errors: list[str] = []
611 + errors.extend(ai_provenance_errors(source, model))
612 + if body:
613 + errors.extend(evidence_citation_errors(body, raw_payload))
614 + errors.extend(editorial_quality_errors(body))
615 + errors.extend(contradiction_errors(body))
616 + return errors, build_gate_results(errors)
617 +
618 +
619 def validate_analysis(text: str, raw_payload: dict[str, Any], current_datetime: str) -> tuple[list[str], int]:
620 errors: list[str] = []
621 try:
@@ -533,10 +725,12 @@ def write_gate_report(
725 *,
726 analysis_file: Path,
727 source: str,
728 + model: str,
729 errors_before: list[str],
730 errors_after: list[str],
731 repair_actions: list[str],
732 word_count: int,
733 + gate_results: dict[str, dict[str, Any]],
734 ) -> None:
735 if path is None:
736 return
@@ -544,8 +738,10 @@ def write_gate_report(
738 payload = {
739 "analysis_file": analysis_file.as_posix(),
740 "source": source,
741 + "model": model,
742 "passed": not errors_after,
743 "word_count": word_count,
744 + "gates": gate_results,
745 "errors_before_repair": errors_before,
746 "repair_actions": repair_actions,
747 "errors_after_repair": errors_after,
@@ -557,6 +753,9 @@ def write_gate_report(
753 def classify_gate_errors(errors: list[str]) -> str:
754 if not errors:
755 return "passed"
756 + categories = {categorize_gate_error(error) for error in errors}
757 + if len(categories) == 1:
758 + return next(iter(categories))
759 if all(
760 error.startswith(("date must", "week must", "year must", "repos_featured must", "stars_tracked must"))
761 or ".claim_type must" in error
@@ -591,6 +790,8 @@ def main(argv: list[str] | None = None) -> int:
790 text = args.analysis_file.read_text(encoding="utf-8")
791 raw_payload = load_json(args.raw_json)
792 errors_before, word_count = validate_analysis(text, raw_payload, args.current_datetime)
793 + publish_errors_before, _ = validate_publish_quality(text, raw_payload, source=args.source, model=args.model)
794 + combined_errors_before = errors_before + [error for error in publish_errors_before if error not in errors_before]
795 errors = errors_before
796 repair_actions: list[str] = []
797 if errors and args.repair_safe:
@@ -607,14 +808,19 @@ def main(argv: list[str] | None = None) -> int:
808 f"::notice::Analysis gate applied safe repairs: {', '.join(repair_actions)}",
809 file=sys.stderr,
810 )
811 + publish_errors, _ = validate_publish_quality(text, raw_payload, source=args.source, model=args.model)
812 + errors = errors + [error for error in publish_errors if error not in errors]
813 + gate_results = build_gate_results(errors)
814 write_gate_report(
815 args.report_json,
816 analysis_file=args.analysis_file,
817 source=args.source,
614 - errors_before=errors_before,
818 + model=args.model,
819 + errors_before=combined_errors_before,
820 errors_after=errors,
821 repair_actions=repair_actions,
822 word_count=word_count,
823 + gate_results=gate_results,
824 )
825 if errors:
826 fail(errors, summary_path)
scripts/promotion_guard.py
+103 -29
@@ -73,6 +73,82 @@ def _resolve_under_root(root: Path, value: Any, field: str, reasons: list[str])
73 return resolved_path
74
75
76 +def _manifest_candidate_path(manifest: dict[str, Any], legacy_key: str, nested_key: str) -> Any:
77 + candidate = manifest.get("candidate")
78 + if isinstance(candidate, dict) and nested_key in candidate:
79 + return candidate.get(nested_key)
80 + return manifest.get(legacy_key)
81 +
82 +
83 +def _manifest_candidate_content_path(manifest: dict[str, Any]) -> Any:
84 + content_path = _manifest_candidate_path(manifest, "candidate_content_path", "content_path")
85 + if content_path is not None:
86 + return content_path
87 + return _manifest_candidate_path(manifest, "candidate_summary_path", "summary_path")
88 +
89 +
90 +def _manifest_promotion_eligible(manifest: dict[str, Any]) -> bool:
91 + promotion = manifest.get("promotion")
92 + if isinstance(promotion, dict):
93 + return promotion.get("eligible") is True and promotion.get("decision") == "promote"
94 + return manifest.get("promotion_eligible") is True
95 +
96 +
97 +def _manifest_ai_provenance(manifest: dict[str, Any]) -> dict[str, Any] | None:
98 + ai_provenance = manifest.get("ai_provenance")
99 + if isinstance(ai_provenance, dict):
100 + return ai_provenance
101 + analysis = manifest.get("analysis")
102 + if isinstance(analysis, dict):
103 + provenance = analysis.get("provenance") if isinstance(analysis.get("provenance"), dict) else {}
104 + return {
105 + "source": analysis.get("source"),
106 + "model": analysis.get("model"),
107 + "degraded": analysis.get("ai_status") not in {"ai", "no-ai"}
108 + or (analysis.get("ai_status") == "ai" and analysis.get("model_status") != "available"),
109 + "authorship": provenance.get("authorship"),
110 + "fallback_reason": provenance.get("fallback_reason"),
111 + "attempted_ai_paths": provenance.get("attempted_ai_paths"),
112 + }
113 + return None
114 +
115 +
116 +def _manifest_gate_results(manifest: dict[str, Any]) -> dict[str, bool] | None:
117 + gate_results = manifest.get("gate_results")
118 + if isinstance(gate_results, dict):
119 + return {str(key): value is True for key, value in gate_results.items()}
120 + validation = manifest.get("validation")
121 + gate_report = validation.get("gate_report") if isinstance(validation, dict) else None
122 + gates = gate_report.get("gates") if isinstance(gate_report, dict) else None
123 + if isinstance(gates, dict):
124 + return {str(key): isinstance(value, dict) and value.get("passed") is True for key, value in gates.items()}
125 + return None
126 +
127 +
128 +def _manifest_gate_report(manifest: dict[str, Any]) -> dict[str, Any] | None:
129 + validation = manifest.get("validation")
130 + gate_report = validation.get("gate_report") if isinstance(validation, dict) else None
131 + return gate_report if isinstance(gate_report, dict) else None
132 +
133 +
134 +def _manifest_source_artifacts(manifest: dict[str, Any]) -> list[Any] | None:
135 + artifacts = manifest.get("source_artifacts")
136 + return artifacts if isinstance(artifacts, list) else None
137 +
138 +
139 +def _manifest_run_started_at(manifest: dict[str, Any]) -> Any:
140 + return manifest.get("run_started_at") or manifest.get("generated_at")
141 +
142 +
143 +def _artifact_reused_same_day(artifact: dict[str, Any]) -> bool:
144 + if artifact.get("reused_same_day") is True:
145 + return True
146 + same_day_reuse = artifact.get("same_day_reuse")
147 + if isinstance(same_day_reuse, dict):
148 + return str(same_day_reuse.get("status", "")).lower() in {"reused", "same_day_reuse", "same-day-reuse"}
149 + return str(same_day_reuse or "").lower() in {"reused", "same_day_reuse", "same-day-reuse"}
150 +
151 +
152 def _frontmatter(path: Path) -> dict[str, Any]:
153 if not path.exists() or not path.is_file():
154 return {}
@@ -139,29 +215,15 @@ def _validate_manifest(manifest: dict[str, Any], root: Path, manifest_path: Path
215 elif not WEEK_PATTERN.fullmatch(week):
216 reasons.append("week must use YYYY-WNN format.")
217
142 - promotion_eligible = manifest.get("promotion_eligible")
143 - if promotion_eligible is None and isinstance(manifest.get("promotion"), dict):
144 - promotion_eligible = manifest["promotion"].get("eligible")
145 - if promotion_eligible is not True:
218 + if not _manifest_promotion_eligible(manifest):
219 reasons.append("promotion_eligible must be true.")
220
148 - candidate_summary = _resolve_under_root(root, manifest.get("candidate_summary_path"), "candidate_summary_path", reasons)
149 - candidate_content = _resolve_under_root(root, manifest.get("candidate_content_path"), "candidate_content_path", reasons)
221 + candidate_summary = _resolve_under_root(root, _manifest_candidate_path(manifest, "candidate_summary_path", "summary_path"), "candidate_summary_path", reasons)
222 + candidate_content = _resolve_under_root(root, _manifest_candidate_content_path(manifest), "candidate_content_path", reasons)
223
224 policy = _promotion_policy(manifest)
225 policy_mode = str(policy.get("mode") or "default")
153 - ai_provenance = manifest.get("ai_provenance")
154 - if not isinstance(ai_provenance, dict) and isinstance(manifest.get("analysis"), dict):
155 - analysis = manifest["analysis"]
156 - provenance = analysis.get("provenance") if isinstance(analysis.get("provenance"), dict) else {}
157 - ai_provenance = {
158 - "source": analysis.get("source"),
159 - "model": analysis.get("model"),
160 - "degraded": analysis.get("ai_status") not in {"ai", "no-ai"},
161 - "authorship": provenance.get("authorship"),
162 - "fallback_reason": provenance.get("fallback_reason"),
163 - "attempted_ai_paths": provenance.get("attempted_ai_paths"),
164 - }
226 + ai_provenance = _manifest_ai_provenance(manifest)
227 if not isinstance(ai_provenance, dict):
228 reasons.append("ai_provenance is required.")
229 else:
@@ -194,19 +256,30 @@ def _validate_manifest(manifest: dict[str, Any], root: Path, manifest_path: Path
256 if ai_provenance.get("degraded") is True:
257 reasons.append("degraded AI provenance is not eligible for normal promotion.")
258
197 - gate_results = manifest.get("gate_results")
259 + gate_report = _manifest_gate_report(manifest)
260 + if gate_report is not None and gate_report.get("passed") is not True:
261 + reasons.append("validation.gate_report.passed must be true.")
262 +
263 + gate_results = _manifest_gate_results(manifest)
264 if not isinstance(gate_results, dict):
265 reasons.append("gate_results is required.")
266 else:
201 - for gate in ("analysis_gate", "editorial_quality_gate", "evidence_freshness_gate"):
202 - if gate_results.get(gate) is not True:
267 + for gate in ("structural_schema", "ai_provenance", "evidence_citation", "editorial_quality"):
268 + if gate not in gate_results:
269 + reasons.append(f"gate_results must include passing {gate}.")
270 + elif gate_results.get(gate) is not True:
271 reasons.append(f"{gate} must pass.")
272 + legacy_gates = ("analysis_gate", "editorial_quality_gate", "evidence_freshness_gate")
273 + if any(gate in gate_results for gate in legacy_gates):
274 + for gate in legacy_gates:
275 + if gate_results.get(gate) is not True:
276 + reasons.append(f"{gate} must pass.")
277
205 - run_date = _parse_date(manifest.get("run_started_at"))
278 + run_date = _parse_date(_manifest_run_started_at(manifest))
279 if run_date is None:
207 - reasons.append("run_started_at must be an ISO date or timestamp.")
280 + reasons.append("run_started_at/generated_at must be an ISO date or timestamp.")
281
209 - source_artifacts = manifest.get("source_artifacts")
282 + source_artifacts = _manifest_source_artifacts(manifest)
283 if not isinstance(source_artifacts, list) or not source_artifacts:
284 reasons.append("source_artifacts must include at least one artifact.")
285 else:
@@ -215,12 +288,13 @@ def _validate_manifest(manifest: dict[str, Any], root: Path, manifest_path: Path
288 if not isinstance(artifact, dict):
289 reasons.append(f"{prefix} must be an object.")
290 continue
218 - if artifact.get("stale") is True:
291 + freshness = artifact.get("freshness")
292 + if artifact.get("stale") is True or (isinstance(freshness, dict) and freshness.get("status") == "stale"):
293 reasons.append(f"{prefix} is stale.")
220 - generated_date = _parse_date(artifact.get("generated_at"))
294 + generated_date = _parse_date(artifact.get("generated_at") or artifact.get("crawled_at"))
295 if generated_date is None:
296 reasons.append(f"{prefix}.generated_at must be an ISO date or timestamp.")
223 - elif run_date is not None and generated_date != run_date and artifact.get("reused_same_day") is not True:
297 + elif run_date is not None and generated_date != run_date and not _artifact_reused_same_day(artifact):
298 reasons.append(f"{prefix} is not from the current run date or marked as same-day reuse.")
299 artifact_path = _resolve_under_root(root, artifact.get("path"), f"{prefix}.path", reasons)
300 if artifact_path is not None and not artifact_path.exists():
@@ -236,8 +310,8 @@ def _validate_manifest(manifest: dict[str, Any], root: Path, manifest_path: Path
310 except ValueError:
311 reasons.append("Publish manifest must be under the repository root.")
312 manifest_relative = Path()
239 - if manifest_relative.parts[:2] != ("data", "staging"):
240 - reasons.append("Publish manifest must live under data/staging/.")
313 + if manifest_relative.parts[:2] not in {("data", "staging"), ("data", "candidates")}:
314 + reasons.append("Publish manifest must live under data/staging/ or data/candidates/.")
315
316 return str(week), candidate_summary or root, candidate_content or root, reasons
317
scripts/publish_manifest.py
+149 -7
@@ -12,6 +12,8 @@ from typing import Any
12
13 SCHEMA_VERSION = "publish_eligibility_v1"
14 AI_SOURCES = {"copilot-cli", "github-models"}
15 +ALLOWED_PROMOTION_MANIFEST_ROOTS = {("data", "staging"), ("data", "candidates")}
16 +PROMOTION_MANIFEST_ROOT_ERROR = "Publish manifest must live under data/staging/ or data/candidates/."
17 NO_AI_SOURCE = "no-ai"
18 MIN_PUBLISH_QUALITY_SCORE = 60
19 FALLBACK_MIN_QUALITY_SCORE = 70
@@ -33,11 +35,13 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
35 create.add_argument("--run-id", required=True)
36 create.add_argument("--current-datetime", required=True)
37 create.add_argument("--summary", required=True, type=Path)
38 + create.add_argument("--content", type=Path, help="Rendered candidate content path. Defaults to --summary for legacy summary-only manifests.")
39 create.add_argument("--published-summary", required=True, type=Path)
40 create.add_argument("--raw-json", required=True, type=Path)
41 create.add_argument("--analysis-source", required=True)
39 - create.add_argument("--analysis-model", required=True)
42 + create.add_argument("--analysis-model", default="copilot-default")
43 create.add_argument("--validation-status", choices=["passed", "failed"], required=True)
44 + create.add_argument("--gate-report", type=Path, help="Structured analysis gate report emitted by analysis_gate.py.")
45 create.add_argument("--output", required=True, type=Path)
46 create.add_argument("--artifact", action="append", default=[], help="Additional source artifact as role=path.")
47 create.add_argument(
@@ -103,6 +107,16 @@ def load_json(path: Path) -> dict[str, Any] | None:
107 return payload if isinstance(payload, dict) else None
108
109
110 +def manifest_lives_under_allowed_promotion_root(manifest_path: Path, root: Path | None = None) -> bool:
111 + workspace = (root or Path.cwd()).resolve()
112 + resolved_manifest = manifest_path if manifest_path.is_absolute() else workspace / manifest_path
113 + try:
114 + manifest_relative = resolved_manifest.resolve().relative_to(workspace)
115 + except ValueError:
116 + return False
117 + return manifest_relative.parts[:2] in ALLOWED_PROMOTION_MANIFEST_ROOTS
118 +
119 +
120 def _parse_scalar(value: str) -> Any:
121 stripped = value.strip().strip('"\'')
122 if re.fullmatch(r"-?\d+", stripped):
@@ -284,9 +298,13 @@ def freshness_for_json_artifact(role: str, week: str, payload: dict[str, Any] |
298 return {"status": "fresh" if not reasons else "stale", "reasons": reasons}
299
300
287 -def artifact_entry(role: str, path: Path, week: str) -> dict[str, Any]:
301 +def artifact_entry(role: str, path: Path, week: str, generated_at: str | None = None) -> dict[str, Any]:
302 payload = load_json(path) if path.suffix == ".json" else None
303 metadata = payload.get("metadata", {}) if isinstance(payload, dict) and isinstance(payload.get("metadata"), dict) else {}
304 + if isinstance(payload, dict):
305 + artifact_generated_at = payload.get("generated_at") or payload.get("crawled_at") or generated_at
306 + else:
307 + artifact_generated_at = generated_at
308 entry: dict[str, Any] = {
309 "role": role,
310 "path": path.as_posix(),
@@ -296,6 +314,7 @@ def artifact_entry(role: str, path: Path, week: str) -> dict[str, Any]:
314 "artifact_checksum": metadata.get("artifact_checksum"),
315 "week": payload.get("week") if isinstance(payload, dict) else None,
316 "crawled_at": payload.get("crawled_at") if isinstance(payload, dict) else None,
317 + "generated_at": artifact_generated_at,
318 "same_day_reuse": same_day_reuse_status(payload),
319 "freshness": freshness_for_json_artifact(role, week, payload) if path.suffix == ".json" else {"status": "not_applicable", "reasons": []},
320 }
@@ -329,9 +348,82 @@ def parse_artifacts(values: list[str]) -> list[tuple[str, Path]]:
348 return artifacts
349
350
351 +def load_gate_report(path: Path | None) -> dict[str, Any]:
352 + if path is None:
353 + return {
354 + "path": None,
355 + "present": False,
356 + "passed": False,
357 + "gates": {},
358 + "errors": ["structured analysis gate report was not provided"],
359 + }
360 + payload = load_json(path)
361 + if payload is None:
362 + return {
363 + "path": path.as_posix(),
364 + "present": False,
365 + "passed": False,
366 + "gates": {},
367 + "errors": ["structured analysis gate report is missing or malformed"],
368 + }
369 + gates = payload.get("gates")
370 + if not isinstance(gates, dict):
371 + gates = {}
372 + errors = payload.get("errors_after_repair")
373 + if not isinstance(errors, list):
374 + errors = []
375 + report = {
376 + "path": path.as_posix(),
377 + "present": True,
378 + "passed": payload.get("passed") is True,
379 + "failure_class": payload.get("failure_class"),
380 + "source": payload.get("source"),
381 + "model": payload.get("model"),
382 + "repair_actions": payload.get("repair_actions") if isinstance(payload.get("repair_actions"), list) else [],
383 + "errors": [str(error) for error in errors],
384 + "gates": gates,
385 + "sha256": sha256_file(path),
386 + }
387 + for gate_name in ("structural_schema", "ai_provenance", "evidence_citation", "editorial_quality"):
388 + gate = gates.get(gate_name)
389 + if not isinstance(gate, dict) or gate.get("passed") is not True:
390 + report["passed"] = False
391 + return report
392 +
393 +
394 +def gate_reasons(report: dict[str, Any]) -> list[str]:
395 + reasons: list[str] = []
396 + if not report.get("present"):
397 + return [str(error) for error in report.get("errors", ["structured analysis gate report missing"])]
398 + gates = report.get("gates", {})
399 + if not isinstance(gates, dict):
400 + gates = {}
401 + for required_gate in ("structural_schema", "ai_provenance", "evidence_citation", "editorial_quality"):
402 + if required_gate not in gates:
403 + reasons.append(f"{required_gate} gate missing from structured analysis gate report")
404 + for name, gate in gates.items():
405 + if isinstance(gate, dict) and gate.get("passed") is not True:
406 + gate_errors = gate.get("errors") if isinstance(gate.get("errors"), list) else []
407 + if gate_errors:
408 + reasons.extend(f"{name}: {error}" for error in gate_errors)
409 + else:
410 + reasons.append(f"{name} gate did not pass")
411 + for error in report.get("errors", []):
412 + if not any(str(error) in reason for reason in reasons):
413 + reasons.append(str(error))
414 + return reasons
415 +
416 +
417 +def publishable_model_status(model: str) -> str:
418 + normalized = model.strip().lower()
419 + if normalized in {"", "unknown", "unavailable", "none", "no-ai"}:
420 + return "unavailable"
421 + return "available"
422 +
423 +
424 def create_manifest(args: argparse.Namespace) -> int:
425 artifacts = [("raw_github", args.raw_json), *parse_artifacts(args.artifact)]
334 - source_artifacts = [artifact_entry(role, path, args.week) for role, path in artifacts if path.exists() or role == "raw_github"]
426 + source_artifacts = [artifact_entry(role, path, args.week, args.current_datetime) for role, path in artifacts if path.exists() or role == "raw_github"]
427 artifact_reasons = [
428 f"{entry['role']}: {reason}"
429 for entry in source_artifacts
@@ -340,10 +432,15 @@ def create_manifest(args: argparse.Namespace) -> int:
432
433 analysis_source = args.analysis_source.strip()
434 ai_status = "ai" if analysis_source in AI_SOURCES else "no-ai" if analysis_source == NO_AI_SOURCE else "unknown"
435 + model_status = publishable_model_status(args.analysis_model)
436 + gate_report = load_gate_report(args.gate_report)
437 candidate_metadata = markdown_metadata(args.summary)
438 published_status = published_summary_status(args.published_summary, args.week)
439 candidate_exists = args.summary.exists()
440 + candidate_content = args.content or args.summary
441 + candidate_content_exists = candidate_content.exists()
442 validation_passed = args.validation_status == "passed"
443 + gates_passed = gate_report.get("present") is True and gate_report.get("passed") is True
444 candidate_quality = candidate_metadata.get("quality_score")
445 attempted_ai_paths = [path for path in args.attempted_ai_path if path.strip()]
446 force_replacing_no_ai = ai_status == "no-ai" and args.publish_policy == "force-replace"
@@ -390,21 +487,30 @@ def create_manifest(args: argparse.Namespace) -> int:
487
488 if not candidate_exists:
489 reasons.append(f"candidate summary missing: {args.summary}")
490 + if not candidate_content_exists:
491 + reasons.append(f"candidate content missing: {candidate_content}")
492 if not validation_passed:
493 reasons.append("analysis validation did not pass")
494 if ai_status not in {"ai", "no-ai"}:
495 reasons.append(f"analysis source is not AI-publishable: {analysis_source or 'unknown'}")
496 + if ai_status == "ai" and model_status != "available":
497 + reasons.append(f"analysis model is not AI-publishable: {args.analysis_model or 'unknown'}")
498 + if not gates_passed:
499 + reasons.extend(gate_reasons(gate_report))
500 reasons.extend(artifact_reasons)
501 reasons.extend(comparison_reasons)
502
503 eligible = (
504 candidate_exists
505 + and candidate_content_exists
506 and validation_passed
507 + and gates_passed
508 and not artifact_reasons
509 and not comparison_reasons
510 + and not reasons
511 and (
406 - ai_status == "ai"
407 - or (ai_status == "no-ai" and args.publish_policy in {"allow-no-ai-first-publish", "force-replace"} and not reasons)
512 + (ai_status == "ai" and model_status == "available")
513 + or (ai_status == "no-ai" and args.publish_policy in {"allow-no-ai-first-publish", "force-replace"})
514 )
515 )
516 preserve_existing = bool(published_status.get("good") and not eligible)
@@ -415,8 +521,13 @@ def create_manifest(args: argparse.Namespace) -> int:
521 "run_id": args.run_id,
522 "week": args.week,
523 "generated_at": args.current_datetime,
524 + "run_started_at": args.current_datetime,
525 + "candidate_summary_path": args.summary.as_posix(),
526 + "candidate_content_path": candidate_content.as_posix(),
527 + "promotion_eligible": eligible,
528 "candidate": {
529 "summary_path": args.summary.as_posix(),
530 + "content_path": candidate_content.as_posix(),
531 "published_summary_path": args.published_summary.as_posix(),
532 "summary_sha256": sha256_file(args.summary),
533 "quality_score": candidate_quality,
@@ -428,6 +539,7 @@ def create_manifest(args: argparse.Namespace) -> int:
539 "ai_status": ai_status,
540 "source": analysis_source,
541 "model": args.analysis_model,
542 + "model_status": model_status,
543 "provider": analysis_source,
544 "provenance": {
545 "run_id": args.run_id,
@@ -439,6 +551,18 @@ def create_manifest(args: argparse.Namespace) -> int:
551 "attempted_ai_paths": attempted_ai_paths,
552 },
553 },
554 + "ai_provenance": {
555 + "source": analysis_source,
556 + "model": args.analysis_model,
557 + "degraded": ai_status != "ai" or model_status != "available",
558 + "authorship": "ai-authored" if ai_status == "ai" else "no-ai-fallback" if ai_status == "no-ai" else "unknown",
559 + "fallback_reason": args.fallback_reason.strip() or None,
560 + "attempted_ai_paths": attempted_ai_paths,
561 + },
562 + "gate_results": {
563 + name: isinstance(gate, dict) and gate.get("passed") is True
564 + for name, gate in (gate_report.get("gates") if isinstance(gate_report.get("gates"), dict) else {}).items()
565 + },
566 "existing_article": {
567 "exists": published_status["exists"],
568 "path": published_status["path"],
@@ -454,11 +578,13 @@ def create_manifest(args: argparse.Namespace) -> int:
578 },
579 "validation": {
580 "status": args.validation_status,
581 + "gate_report": gate_report,
582 "quality_gates": [
583 {
584 "name": "analysis_gate",
460 - "status": args.validation_status,
585 + "status": "passed" if gates_passed else "failed",
586 "source": analysis_source,
587 + "report": gate_report.get("path"),
588 }
589 ],
590 },
@@ -500,10 +626,24 @@ def assert_eligible(args: argparse.Namespace) -> int:
626 payload = load_json(args.manifest)
627 if payload is None:
628 raise SystemExit(f"Publish manifest is missing or malformed: {args.manifest}")
629 + if not manifest_lives_under_allowed_promotion_root(args.manifest):
630 + raise SystemExit(PROMOTION_MANIFEST_ROOT_ERROR)
631 if payload.get("schema_version") != SCHEMA_VERSION:
632 raise SystemExit(f"Unsupported publish manifest schema: {payload.get('schema_version')!r}")
633 analysis = payload.get("analysis")
506 - ai_status = analysis.get("ai_status") if isinstance(analysis, dict) else None
634 + if not isinstance(analysis, dict):
635 + raise SystemExit("Manifest lacks publishable AI provenance.")
636 + ai_status = analysis.get("ai_status")
637 + if ai_status == "ai" and analysis.get("model_status") != "available":
638 + raise SystemExit("Manifest lacks an available AI model.")
639 + validation = payload.get("validation")
640 + gate_report = validation.get("gate_report") if isinstance(validation, dict) else None
641 + if not isinstance(gate_report, dict) or gate_report.get("present") is not True or gate_report.get("passed") is not True:
642 + raise SystemExit("Manifest lacks a passing structured analysis gate report.")
643 + for gate_name in ("structural_schema", "ai_provenance", "evidence_citation", "editorial_quality"):
644 + gate = gate_report.get("gates", {}).get(gate_name) if isinstance(gate_report.get("gates"), dict) else None
645 + if not isinstance(gate, dict) or gate.get("passed") is not True:
646 + raise SystemExit(f"Manifest analysis gate did not pass: {gate_name}")
647 promotion_policy = (payload.get("promotion") or {}).get("policy") if isinstance(payload.get("promotion"), dict) else None
648 if ai_status == "no-ai":
649 provenance = analysis.get("provenance") if isinstance(analysis, dict) else {}
@@ -517,6 +657,8 @@ def assert_eligible(args: argparse.Namespace) -> int:
657 audit = payload.get("audit")
658 if not isinstance(audit, dict) or not audit.get("actor") or not audit.get("reason"):
659 raise SystemExit("Force replacement requires actor and reason in manifest audit.")
660 + elif ai_status != "ai":
661 + raise SystemExit("Manifest lacks publishable AI provenance.")
662 promotion = payload.get("promotion")
663 if not isinstance(promotion, dict) or promotion.get("eligible") is not True or promotion.get("decision") != "promote":
664 reasons = promotion.get("reasons") if isinstance(promotion, dict) else ["missing promotion block"]
tests/test_analysis_gate.py
+214
@@ -10,6 +10,7 @@ import scripts.analysis_gate as analysis_gate
10 RAW_PAYLOAD = {"week": "2026-W23"}
11 RAW_PAYLOAD_WITH_REPOS = {
12 "week": "2026-W23",
13 + "crawled_at": "2026-06-01T00:00:00Z",
14 "new_repos": [{"full_name": "owner/repo", "stars": 1000}],
15 "trending_repos": [{"full_name": "owner/repo-b", "stars": 200}],
16 }
@@ -331,6 +332,219 @@ summary: "A grounded week focused on practical tools."'''.strip()
332 self.assertEqual(report["repair_actions"], ["repair skipped: boom"])
333 self.assertIn("predictions[1].repo must use owner/repo format.", report["errors_after_repair"])
334
335 + def test_gate_report_captures_pre_repair_publish_errors_from_original_text(self) -> None:
336 + tests_root = Path(__file__).resolve().parent
337 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
338 + workspace = Path(tmpdir)
339 + analysis_path = workspace / "candidate.md"
340 + raw_path = workspace / "raw.json"
341 + report_path = workspace / "report.json"
342 + original_text = make_analysis(
343 + VALID_FRONTMATTER.replace("date: 2026-06-01T00:00:00Z", "date: 2026-06-01T12:00:00Z"),
344 + make_body(),
345 + )
346 + analysis_path.write_text(original_text, encoding="utf-8")
347 + raw_path.write_text(json.dumps(RAW_PAYLOAD_WITH_REPOS), encoding="utf-8")
348 +
349 + def publish_quality_for(text: str, raw_payload: dict, *, source: str, model: str) -> tuple[list[str], dict]:
350 + if text == original_text:
351 + return ["pre-repair publish-quality failure"], analysis_gate.build_gate_results(
352 + ["pre-repair publish-quality failure"]
353 + )
354 + return [], analysis_gate.build_gate_results([])
355 +
356 + with mock.patch.object(analysis_gate, "validate_publish_quality", side_effect=publish_quality_for):
357 + self.assertEqual(
358 + analysis_gate.main(
359 + [
360 + "--analysis-file",
361 + str(analysis_path),
362 + "--raw-json",
363 + str(raw_path),
364 + "--current-datetime",
365 + CURRENT_DATETIME,
366 + "--repair-safe",
367 + "--report-json",
368 + str(report_path),
369 + ]
370 + ),
371 + 0,
372 + )
373 +
374 + report = analysis_gate.load_json(report_path)
375 + self.assertIn("pre-repair publish-quality failure", report["errors_before_repair"])
376 + self.assertNotIn("pre-repair publish-quality failure", report["errors_after_repair"])
377 +
378 + def test_publish_quality_gate_rejects_structurally_valid_low_quality_summary(self) -> None:
379 + generic = " ".join(["Projects were active this week and many updates appeared across the list."] * 12)
380 + low_quality = f"""
381 +## This Week's Trends
382 +
383 +{generic}
384 +
385 +## Where Industry Meets Code
386 +
387 +{generic}
388 +
389 +## Signal & Noise
390 +
391 +{generic}
392 +
393 +## Blind Spots
394 +
395 +{generic}
396 +
397 +## The Week Ahead
398 +
399 +{generic}
400 +
401 +## Key References
402 +
403 +### Notable Projects
404 +
405 +- [owner/repo-a](https://github.com/owner/repo-a) — appeared in the list.
406 +- [owner/repo-b](https://github.com/owner/repo-b) — appeared in the list.
407 +
408 +### Press & Industry
409 +
410 +No press data was provided this week.
411 +""".strip()
412 + errors, gates = analysis_gate.validate_publish_quality(
413 + make_analysis(VALID_FRONTMATTER, low_quality),
414 + RAW_PAYLOAD,
415 + source="copilot-cli",
416 + model="copilot-default",
417 + )
418 +
419 + self.assertTrue(any("editorial analysis" in error for error in errors))
420 + self.assertFalse(gates["editorial_quality"]["passed"])
421 +
422 + def test_publish_quality_gate_accepts_known_good_weekly_outputs(self) -> None:
423 + repo_root = Path(__file__).resolve().parent.parent
424 + fixtures = [
425 + (
426 + "2026-W22",
427 + "2026-05-25T11:56:08Z",
428 + "perplexityai/bumblebee",
429 + repo_root / "data/analyzed/2026-W22-summary.md",
430 + ),
431 + (
432 + "2026-W23",
433 + "2026-06-06T07:49:43Z",
434 + "pewdiepie-archdaemon/odysseus",
435 + repo_root / "data/analyzed/2026-W23-summary.md",
436 + ),
437 + ]
438 +
439 + for week, crawled_at, repo_name, summary_path in fixtures:
440 + with self.subTest(week=week):
441 + raw_payload = {
442 + "week": week,
443 + "crawled_at": crawled_at,
444 + "new_repos": [{"full_name": repo_name, "stars": 100}],
445 + "trending_repos": [],
446 + }
447 + text = summary_path.read_text(encoding="utf-8")
448 +
449 + structure_errors, word_count = analysis_gate.validate_analysis(text, raw_payload, crawled_at)
450 + publish_errors, gates = analysis_gate.validate_publish_quality(
451 + text,
452 + raw_payload,
453 + source="copilot-cli",
454 + model="copilot-default",
455 + )
456 +
457 + self.assertEqual(structure_errors, [])
458 + self.assertGreater(word_count, 200)
459 + self.assertEqual(publish_errors, [])
460 + self.assertTrue(all(gate["passed"] for gate in gates.values()))
461 +
462 + def test_copilot_source_without_explicit_model_uses_publishable_default(self) -> None:
463 + errors, gates = analysis_gate.validate_publish_quality(
464 + make_analysis(VALID_FRONTMATTER, make_body()),
465 + RAW_PAYLOAD_WITH_REPOS,
466 + source="copilot-cli",
467 + model=analysis_gate.parse_args(
468 + [
469 + "--analysis-file",
470 + "candidate.md",
471 + "--raw-json",
472 + "raw.json",
473 + "--current-datetime",
474 + CURRENT_DATETIME,
475 + "--source",
476 + "copilot-cli",
477 + ]
478 + ).model,
479 + )
480 +
481 + self.assertEqual(errors, [])
482 + self.assertTrue(gates["ai_provenance"]["passed"])
483 +
484 + def test_publish_quality_gate_rejects_missing_evidence_citations(self) -> None:
485 + body = make_body().replace("[owner/repo-b](https://github.com/owner/repo-b)", "owner/repo-b")
486 + errors, gates = analysis_gate.validate_publish_quality(
487 + make_analysis(VALID_FRONTMATTER, body),
488 + RAW_PAYLOAD_WITH_REPOS,
489 + source="copilot-cli",
490 + model="copilot-default",
491 + )
492 +
493 + self.assertIn("evidence citations must include at least one repository link from the raw payload.", errors)
494 + self.assertFalse(gates["evidence_citation"]["passed"])
495 +
496 + def test_publish_quality_gate_rejects_stale_evidence(self) -> None:
497 + stale_payload = dict(RAW_PAYLOAD_WITH_REPOS, crawled_at="2026-05-25T00:00:00Z")
498 + errors, gates = analysis_gate.validate_publish_quality(
499 + make_analysis(VALID_FRONTMATTER, make_body()),
500 + stale_payload,
501 + source="copilot-cli",
502 + model="copilot-default",
503 + )
504 +
505 + self.assertTrue(any("raw evidence timestamp week mismatch" in error for error in errors))
506 + self.assertFalse(gates["evidence_citation"]["passed"])
507 +
508 + def test_publish_quality_gate_prefers_generated_at_for_republished_evidence(self) -> None:
509 + republished_payload = dict(
510 + RAW_PAYLOAD_WITH_REPOS,
511 + crawled_at="2026-05-25T00:00:00Z",
512 + generated_at="2026-06-01T00:00:00Z",
513 + )
514 + errors, gates = analysis_gate.validate_publish_quality(
515 + make_analysis(VALID_FRONTMATTER, make_body()),
516 + republished_payload,
517 + source="copilot-cli",
518 + model="copilot-default",
519 + )
520 +
521 + self.assertEqual(errors, [])
522 + self.assertTrue(gates["evidence_citation"]["passed"])
523 +
524 + def test_publish_quality_gate_rejects_no_ai_provenance(self) -> None:
525 + errors, gates = analysis_gate.validate_publish_quality(
526 + make_analysis(VALID_FRONTMATTER, make_body()),
527 + RAW_PAYLOAD,
528 + source="no-ai",
529 + model="none",
530 + )
531 +
532 + self.assertIn("AI provenance source is not publishable: no-ai.", errors)
533 + self.assertIn("AI provenance model is not publishable: none.", errors)
534 + self.assertFalse(gates["ai_provenance"]["passed"])
535 +
536 + def test_publish_quality_gate_rejects_contradictory_press_claims(self) -> None:
537 + body = make_body() + "\n\nNo press data was provided this week, but TechCrunch reported a major launch."
538 + errors, gates = analysis_gate.validate_publish_quality(
539 + make_analysis(VALID_FRONTMATTER, body),
540 + RAW_PAYLOAD,
541 + source="copilot-cli",
542 + model="copilot-default",
543 + )
544 +
545 + self.assertTrue(any("contradictory claim" in error for error in errors))
546 + self.assertFalse(gates["editorial_quality"]["passed"])
547 +
548
549 if __name__ == "__main__":
550 unittest.main()
tests/test_promotion_guard.py
+251 -5
@@ -1,8 +1,10 @@
1 import json
2 +import os
3 import tempfile
4 import unittest
5 from pathlib import Path
6
7 +import scripts.publish_manifest as publish_manifest
8 from scripts import promotion_guard
9
10
@@ -69,6 +71,92 @@ def write_source_artifact(root: Path, name: str = "raw") -> Path:
71 return write_file(root, f"data/raw/{WEEK}-{name}.json", json.dumps({"week": WEEK}) + "\n")
72
73
74 +def write_publish_raw(root: Path) -> Path:
75 + return write_file(
76 + root,
77 + f"data/raw/{WEEK}.json",
78 + json.dumps({"week": WEEK, "crawled_at": RUN_STARTED_AT, "metadata": {"same_day_reuse": "not_reused"}}) + "\n",
79 + )
80 +
81 +
82 +def write_gate_report(root: Path, path: Path, *, passed: bool = True) -> None:
83 + gates = {
84 + "structural_schema": {"passed": True, "errors": []},
85 + "ai_provenance": {"passed": True, "errors": []},
86 + "evidence_citation": {"passed": passed, "errors": [] if passed else ["missing evidence"]},
87 + "editorial_quality": {"passed": True, "errors": []},
88 + }
89 + write_file(
90 + root,
91 + path.as_posix(),
92 + json.dumps(
93 + {
94 + "passed": passed,
95 + "source": "copilot-cli",
96 + "model": "copilot-default",
97 + "failure_class": "passed" if passed else "evidence_citation",
98 + "errors_after_repair": [] if passed else ["missing evidence"],
99 + "repair_actions": [],
100 + "gates": gates,
101 + }
102 + )
103 + + "\n",
104 + )
105 +
106 +
107 +def create_publish_manifest(root: Path, name: str, *, source: str = "copilot-cli", model: str = "copilot-default", gate_passed: bool = True) -> Path:
108 + candidate_dir = Path("data/candidates") / WEEK / name
109 + summary_path = candidate_dir / f"{WEEK}-summary.md"
110 + manifest_path = candidate_dir / "publish-manifest.json"
111 + gate_report = candidate_dir / "analysis-gate-report.json"
112 + write_file(root, summary_path.as_posix(), VALID_REPLACEMENT_SUMMARY)
113 + write_publish_raw(root)
114 + write_gate_report(root, gate_report, passed=gate_passed)
115 +
116 + previous_cwd = Path.cwd()
117 + try:
118 + os.chdir(root)
119 + publish_manifest.main(
120 + [
121 + "create",
122 + "--week",
123 + WEEK,
124 + "--run-id",
125 + name,
126 + "--current-datetime",
127 + RUN_STARTED_AT,
128 + "--summary",
129 + summary_path.as_posix(),
130 + "--published-summary",
131 + f"data/analyzed/{WEEK}-summary.md",
132 + "--raw-json",
133 + f"data/raw/{WEEK}.json",
134 + "--analysis-source",
135 + source,
136 + "--analysis-model",
137 + model,
138 + "--validation-status",
139 + "passed" if gate_passed else "failed",
140 + "--gate-report",
141 + gate_report.as_posix(),
142 + "--output",
143 + manifest_path.as_posix(),
144 + ]
145 + )
146 + finally:
147 + os.chdir(previous_cwd)
148 + return root / manifest_path
149 +
150 +
151 +def assert_eligible_from_root(root: Path, manifest_path: Path) -> int:
152 + previous_cwd = Path.cwd()
153 + try:
154 + os.chdir(root)
155 + return publish_manifest.main(["assert-eligible", "--manifest", str(manifest_path)])
156 + finally:
157 + os.chdir(previous_cwd)
158 +
159 +
160 def manifest_for(root: Path, name: str, **overrides) -> Path:
161 summary_path, content_path = write_candidate(
162 root,
@@ -91,9 +179,10 @@ def manifest_for(root: Path, name: str, **overrides) -> Path:
179 "degraded": False,
180 },
181 "gate_results": {
94 - "analysis_gate": True,
95 - "editorial_quality_gate": True,
96 - "evidence_freshness_gate": True,
182 + "structural_schema": True,
183 + "ai_provenance": True,
184 + "evidence_citation": True,
185 + "editorial_quality": True,
186 },
187 "source_artifacts": [
188 {
@@ -112,6 +201,59 @@ def manifest_for(root: Path, name: str, **overrides) -> Path:
201 return manifest_path
202
203
204 +def nested_manifest_for(root: Path, name: str, **overrides) -> Path:
205 + summary_path, content_path = write_candidate(
206 + root,
207 + name,
208 + overrides.pop("summary", VALID_REPLACEMENT_SUMMARY),
209 + overrides.pop("content", VALID_REPLACEMENT_CONTENT),
210 + )
211 + source_artifact = write_source_artifact(root, name)
212 + manifest = {
213 + "schema_version": "publish_eligibility_v1",
214 + "week": WEEK,
215 + "run_id": f"{WEEK}-{name}",
216 + "run_started_at": RUN_STARTED_AT,
217 + "candidate": {
218 + "summary_path": summary_path.relative_to(root).as_posix(),
219 + "content_path": content_path.relative_to(root).as_posix(),
220 + "summary_sha256": "sha256:test",
221 + },
222 + "analysis": {
223 + "ai_status": "ai",
224 + "source": "copilot-cli",
225 + "model": "copilot-default",
226 + "model_status": "available",
227 + },
228 + "validation": {
229 + "gate_report": {
230 + "present": True,
231 + "passed": True,
232 + "gates": {
233 + "structural_schema": {"passed": True, "errors": []},
234 + "ai_provenance": {"passed": True, "errors": []},
235 + "evidence_citation": {"passed": True, "errors": []},
236 + "editorial_quality": {"passed": True, "errors": []},
237 + },
238 + },
239 + },
240 + "promotion": {"eligible": True, "decision": "promote", "reasons": []},
241 + "source_artifacts": [
242 + {
243 + "path": source_artifact.relative_to(root).as_posix(),
244 + "sha256": "test",
245 + "crawled_at": RUN_STARTED_AT,
246 + "freshness": {"status": "fresh", "reasons": []},
247 + }
248 + ],
249 + }
250 + for key, value in overrides.items():
251 + manifest[key] = value
252 + manifest_path = root / "data" / "staging" / WEEK / name / "publish-manifest.json"
253 + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
254 + return manifest_path
255 +
256 +
257 def no_ai_manifest_for(root: Path, name: str, *, policy: dict | None = None, quality_score: int = 70) -> Path:
258 summary = VALID_REPLACEMENT_SUMMARY.replace("quality_score: 90", f"quality_score: {quality_score}").replace(
259 "Better candidate analysis.", "Automated data-only summary generated without AI assistance."
@@ -308,7 +450,7 @@ class PromotionGuardTests(unittest.TestCase):
450 outside_summary.unlink(missing_ok=True)
451 outside_content.unlink(missing_ok=True)
452
311 - def test_manifest_path_must_be_directly_under_data_staging(self) -> None:
453 + def test_manifest_path_must_be_under_allowed_data_manifest_roots(self) -> None:
454 tests_root = Path(__file__).resolve().parent
455 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
456 root = Path(tmpdir)
@@ -321,7 +463,111 @@ class PromotionGuardTests(unittest.TestCase):
463 with self.assertRaises(promotion_guard.PromotionBlocked) as blocked:
464 promotion_guard.promote_candidate(misplaced_manifest, root=root)
465
324 - self.assertIn("Publish manifest must live under data/staging/.", blocked.exception.reasons)
466 + self.assertIn("Publish manifest must live under data/staging/ or data/candidates/.", blocked.exception.reasons)
467 +
468 + def test_publish_manifest_outside_allowed_roots_is_rejected_by_both_gates(self) -> None:
469 + tests_root = Path(__file__).resolve().parent
470 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
471 + root = Path(tmpdir)
472 + install_existing_good_article(root)
473 + valid_manifest = create_publish_manifest(root, "outside-root")
474 + misplaced_manifest = root / "other" / "data" / "candidates" / WEEK / "outside-root" / "publish-manifest.json"
475 + misplaced_manifest.parent.mkdir(parents=True, exist_ok=True)
476 + misplaced_manifest.write_text(valid_manifest.read_text(encoding="utf-8"), encoding="utf-8")
477 +
478 + with self.assertRaises(SystemExit) as assert_blocked:
479 + assert_eligible_from_root(root, misplaced_manifest)
480 + with self.assertRaises(promotion_guard.PromotionBlocked) as promote_blocked:
481 + promotion_guard.promote_candidate(misplaced_manifest, root=root)
482 +
483 + self.assertEqual(str(assert_blocked.exception), "Publish manifest must live under data/staging/ or data/candidates/.")
484 + self.assertIn("Publish manifest must live under data/staging/ or data/candidates/.", promote_blocked.exception.reasons)
485 +
486 + def test_publish_manifest_created_candidate_is_accepted_by_promotion_guard(self) -> None:
487 + tests_root = Path(__file__).resolve().parent
488 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
489 + root = Path(tmpdir)
490 + install_existing_good_article(root)
491 + manifest_path = create_publish_manifest(root, "publish-compatible")
492 +
493 + self.assertEqual(assert_eligible_from_root(root, manifest_path), 0)
494 + summary_path, content_path = promotion_guard.promote_candidate(manifest_path, root=root)
495 +
496 + self.assertIn("Better AI Article", summary_path.read_text(encoding="utf-8"))
497 + self.assertIn("Better AI Article", content_path.read_text(encoding="utf-8"))
498 +
499 + def test_publish_manifest_rejected_candidate_is_rejected_by_both_gates(self) -> None:
500 + tests_root = Path(__file__).resolve().parent
501 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
502 + root = Path(tmpdir)
503 + canonical_summary, canonical_content = install_existing_good_article(root)
504 + original_summary = canonical_summary.read_text(encoding="utf-8")
505 + original_content = canonical_content.read_text(encoding="utf-8")
506 + manifest_path = create_publish_manifest(root, "publish-rejected", source="no-ai", model="none")
507 +
508 + with self.assertRaises(SystemExit):
509 + assert_eligible_from_root(root, manifest_path)
510 + with self.assertRaises(promotion_guard.PromotionBlocked):
511 + promotion_guard.promote_candidate(manifest_path, root=root)
512 +
513 + self.assertEqual(canonical_summary.read_text(encoding="utf-8"), original_summary)
514 + self.assertEqual(canonical_content.read_text(encoding="utf-8"), original_content)
515 +
516 + def test_nested_manifest_gate_decisions_are_consumed(self) -> None:
517 + tests_root = Path(__file__).resolve().parent
518 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
519 + root = Path(tmpdir)
520 + install_existing_good_article(root)
521 + blocked = nested_manifest_for(
522 + root,
523 + "nested-blocked",
524 + validation={
525 + "gate_report": {
526 + "present": True,
527 + "passed": False,
528 + "gates": {
529 + "structural_schema": {"passed": True, "errors": []},
530 + "ai_provenance": {"passed": True, "errors": []},
531 + "evidence_citation": {"passed": False, "errors": ["missing evidence"]},
532 + "editorial_quality": {"passed": True, "errors": []},
533 + },
534 + }
535 + },
536 + promotion={"eligible": False, "decision": "block", "reasons": ["missing evidence"]},
537 + )
538 +
539 + with self.assertRaises(promotion_guard.PromotionBlocked) as raised:
540 + promotion_guard.promote_candidate(blocked, root=root)
541 +
542 + self.assertIn("promotion_eligible must be true.", raised.exception.reasons)
543 + self.assertIn("validation.gate_report.passed must be true.", raised.exception.reasons)
544 + self.assertIn("evidence_citation must pass.", raised.exception.reasons)
545 +
546 + def test_nested_manifest_missing_required_gate_family_blocks_promotion(self) -> None:
547 + tests_root = Path(__file__).resolve().parent
548 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
549 + root = Path(tmpdir)
550 + install_existing_good_article(root)
551 + manifest_path = nested_manifest_for(
552 + root,
553 + "missing-gate-family",
554 + validation={
555 + "gate_report": {
556 + "present": True,
557 + "passed": True,
558 + "gates": {
559 + "structural_schema": {"passed": True, "errors": []},
560 + "ai_provenance": {"passed": True, "errors": []},
561 + "editorial_quality": {"passed": True, "errors": []},
562 + },
563 + }
564 + },
565 + )
566 +
567 + with self.assertRaises(promotion_guard.PromotionBlocked) as raised:
568 + promotion_guard.promote_candidate(manifest_path, root=root)
569 +
570 + self.assertIn("gate_results must include passing evidence_citation.", raised.exception.reasons)
571
572
573 if __name__ == "__main__":
tests/test_publish_manifest.py
+203 -144
@@ -1,4 +1,5 @@
1 import json
2 +import os
3 import tempfile
4 import unittest
5 from argparse import Namespace
@@ -75,6 +76,69 @@ def write_no_ai_summary(path: Path, *, quality_score: int = 70) -> None:
76 )
77
78
79 +def write_gate_report(path: Path, *, passed: bool = True, errors: list[str] | None = None) -> None:
80 + path.parent.mkdir(parents=True, exist_ok=True)
81 + gate_errors = errors or []
82 + gates = {
83 + "structural_schema": {"passed": passed, "errors": gate_errors if not passed else []},
84 + "ai_provenance": {"passed": True, "errors": []},
85 + "evidence_citation": {"passed": True, "errors": []},
86 + "editorial_quality": {"passed": True, "errors": []},
87 + }
88 + path.write_text(
89 + json.dumps(
90 + {
91 + "passed": passed,
92 + "source": "copilot-cli",
93 + "model": "copilot-default",
94 + "failure_class": "passed" if passed else "structural_schema",
95 + "errors_after_repair": gate_errors,
96 + "repair_actions": [],
97 + "gates": gates,
98 + }
99 + ),
100 + encoding="utf-8",
101 + )
102 +
103 +
104 +def create_args(base: Path, raw: Path, summary: Path, manifest: Path, *, source: str = "copilot-cli", model: str | None = "copilot-default", gate_report: Path | None = None, validation_status: str = "passed") -> list[str]:
105 + args = [
106 + "create",
107 + "--week",
108 + WEEK,
109 + "--run-id",
110 + RUN_ID,
111 + "--current-datetime",
112 + CURRENT_DATETIME,
113 + "--summary",
114 + str(summary),
115 + "--published-summary",
116 + str(base / "data/analyzed/2026-W21-summary.md"),
117 + "--raw-json",
118 + str(raw),
119 + "--analysis-source",
120 + source,
121 + "--validation-status",
122 + validation_status,
123 + "--output",
124 + str(manifest),
125 + ]
126 + if model is not None:
127 + args.extend(["--analysis-model", model])
128 + if gate_report is not None:
129 + args.extend(["--gate-report", str(gate_report)])
130 + return args
131 +
132 +
133 +def assert_eligible_from_root(root: Path, manifest: Path) -> int:
134 + previous_cwd = Path.cwd()
135 + try:
136 + os.chdir(root)
137 + return publish_manifest.main(["assert-eligible", "--manifest", str(manifest)])
138 + finally:
139 + os.chdir(previous_cwd)
140 +
141 +
142 class PublishManifestTests(unittest.TestCase):
143 def test_ai_candidate_with_fresh_sources_is_eligible(self) -> None:
144 tests_root = Path(__file__).resolve().parent
@@ -83,33 +147,13 @@ class PublishManifestTests(unittest.TestCase):
147 raw = base / "data/raw/2026-W21.json"
148 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
149 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
150 + gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
151 write_raw(raw)
152 write_summary(summary)
153 + write_gate_report(gate_report)
154
155 exit_code = publish_manifest.main(
90 - [
91 - "create",
92 - "--week",
93 - WEEK,
94 - "--run-id",
95 - RUN_ID,
96 - "--current-datetime",
97 - CURRENT_DATETIME,
98 - "--summary",
99 - str(summary),
100 - "--published-summary",
101 - str(base / "data/analyzed/2026-W21-summary.md"),
102 - "--raw-json",
103 - str(raw),
104 - "--analysis-source",
105 - "copilot-cli",
106 - "--analysis-model",
107 - "copilot-default",
108 - "--validation-status",
109 - "passed",
110 - "--output",
111 - str(manifest),
112 - ]
156 + create_args(base, raw, summary, manifest, gate_report=gate_report)
157 )
158
159 self.assertEqual(exit_code, 0)
@@ -120,7 +164,7 @@ class PublishManifestTests(unittest.TestCase):
164 self.assertEqual(payload["promotion"]["decision"], "promote")
165 self.assertRegex(payload["candidate"]["summary_sha256"], r"^[0-9a-f]{64}$")
166 self.assertRegex(payload["source_artifacts"][0]["sha256"], r"^[0-9a-f]{64}$")
123 - self.assertEqual(publish_manifest.main(["assert-eligible", "--manifest", str(manifest)]), 0)
167 + self.assertEqual(assert_eligible_from_root(base, manifest), 0)
168
169 def test_no_ai_candidate_is_not_eligible(self) -> None:
170 tests_root = Path(__file__).resolve().parent
@@ -129,33 +173,13 @@ class PublishManifestTests(unittest.TestCase):
173 raw = base / "data/raw/2026-W21.json"
174 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
175 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
176 + gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
177 write_raw(raw)
178 write_summary(summary)
179 + write_gate_report(gate_report)
180
181 publish_manifest.main(
136 - [
137 - "create",
138 - "--week",
139 - WEEK,
140 - "--run-id",
141 - RUN_ID,
142 - "--current-datetime",
143 - CURRENT_DATETIME,
144 - "--summary",
145 - str(summary),
146 - "--published-summary",
147 - str(base / "data/analyzed/2026-W21-summary.md"),
148 - "--raw-json",
149 - str(raw),
150 - "--analysis-source",
151 - "no-ai",
152 - "--analysis-model",
153 - "none",
154 - "--validation-status",
155 - "passed",
156 - "--output",
157 - str(manifest),
158 - ]
182 + create_args(base, raw, summary, manifest, source="no-ai", model="none", gate_report=gate_report)
183 )
184
185 payload = json.loads(manifest.read_text(encoding="utf-8"))
@@ -164,7 +188,26 @@ class PublishManifestTests(unittest.TestCase):
188 self.assertEqual(payload["analysis"]["provenance"]["authorship"], "no-ai-fallback")
189 self.assertIn("fallback_reason is required", payload["promotion"]["reasons"][0])
190 with self.assertRaises(SystemExit):
167 - publish_manifest.main(["assert-eligible", "--manifest", str(manifest)])
191 + assert_eligible_from_root(base, manifest)
192 +
193 + def test_copilot_candidate_without_explicit_model_uses_publishable_default(self) -> None:
194 + tests_root = Path(__file__).resolve().parent
195 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
196 + base = Path(tmpdir)
197 + raw = base / "data/raw/2026-W21.json"
198 + summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
199 + manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
200 + gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
201 + write_raw(raw)
202 + write_summary(summary)
203 + write_gate_report(gate_report)
204 +
205 + publish_manifest.main(create_args(base, raw, summary, manifest, model=None, gate_report=gate_report))
206 +
207 + payload = json.loads(manifest.read_text(encoding="utf-8"))
208 + self.assertEqual(payload["analysis"]["model"], "copilot-default")
209 + self.assertEqual(payload["analysis"]["model_status"], "available")
210 + self.assertTrue(payload["promotion"]["eligible"])
211
212 def test_no_ai_default_cannot_replace_existing_good_ai_article(self) -> None:
213 tests_root = Path(__file__).resolve().parent
@@ -209,8 +252,10 @@ class PublishManifestTests(unittest.TestCase):
252 raw = base / "data/raw/2026-W21.json"
253 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
254 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
255 + gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
256 write_raw(raw)
257 write_no_ai_summary(summary)
258 + write_gate_report(gate_report)
259
260 publish_manifest.main(
261 [
@@ -224,6 +269,7 @@ class PublishManifestTests(unittest.TestCase):
269 "--analysis-source", "no-ai",
270 "--analysis-model", "none",
271 "--validation-status", "passed",
272 + "--gate-report", str(gate_report),
273 "--fallback-reason", "copilot unavailable",
274 "--attempted-ai-path", "provider=copilot-cli,model=copilot-default,status=failed",
275 "--publish-policy", "allow-no-ai-first-publish",
@@ -235,7 +281,7 @@ class PublishManifestTests(unittest.TestCase):
281 payload = json.loads(manifest.read_text(encoding="utf-8"))
282 self.assertTrue(payload["promotion"]["eligible"])
283 self.assertEqual(payload["promotion"]["policy"], "allow-no-ai-first-publish")
238 - self.assertEqual(publish_manifest.main(["assert-eligible", "--manifest", str(manifest)]), 0)
284 + self.assertEqual(assert_eligible_from_root(base, manifest), 0)
285
286 def test_no_ai_explicit_policy_requires_higher_fallback_quality_score(self) -> None:
287 tests_root = Path(__file__).resolve().parent
@@ -244,8 +290,10 @@ class PublishManifestTests(unittest.TestCase):
290 raw = base / "data/raw/2026-W21.json"
291 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
292 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
293 + gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
294 write_raw(raw)
295 write_no_ai_summary(summary, quality_score=69)
296 + write_gate_report(gate_report)
297
298 publish_manifest.main(
299 [
@@ -259,6 +307,7 @@ class PublishManifestTests(unittest.TestCase):
307 "--analysis-source", "no-ai",
308 "--analysis-model", "none",
309 "--validation-status", "passed",
310 + "--gate-report", str(gate_report),
311 "--fallback-reason", "copilot unavailable",
312 "--attempted-ai-path", "provider=copilot-cli,model=copilot-default,status=failed",
313 "--publish-policy", "allow-no-ai-first-publish",
@@ -278,9 +327,11 @@ class PublishManifestTests(unittest.TestCase):
327 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
328 published = base / "data/analyzed/2026-W21-summary.md"
329 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
330 + gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
331 write_raw(raw)
332 write_no_ai_summary(summary)
333 write_good_summary(published)
334 + write_gate_report(gate_report)
335
336 publish_manifest.main(
337 [
@@ -294,6 +345,7 @@ class PublishManifestTests(unittest.TestCase):
345 "--analysis-source", "no-ai",
346 "--analysis-model", "none",
347 "--validation-status", "passed",
348 + "--gate-report", str(gate_report),
349 "--fallback-reason", "copilot unavailable",
350 "--attempted-ai-path", "provider=copilot-cli,model=copilot-default,status=failed",
351 "--publish-policy", "force-replace",
@@ -307,7 +359,7 @@ class PublishManifestTests(unittest.TestCase):
359 self.assertTrue(payload["promotion"]["eligible"])
360 self.assertEqual(payload["audit"]["mode"], "force-replace")
361 self.assertEqual(payload["audit"]["actor"], "jmservera")
310 - self.assertEqual(publish_manifest.main(["assert-eligible", "--manifest", str(manifest)]), 0)
362 + self.assertEqual(assert_eligible_from_root(base, manifest), 0)
363
364 def test_missing_candidate_summary_only_reports_missing_summary(self) -> None:
365 tests_root = Path(__file__).resolve().parent
@@ -316,39 +368,17 @@ class PublishManifestTests(unittest.TestCase):
368 raw = base / "data/raw/2026-W21.json"
369 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
370 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
371 + gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
372 write_raw(raw)
373 + write_gate_report(gate_report)
374
321 - publish_manifest.main(
322 - [
323 - "create",
324 - "--week",
325 - WEEK,
326 - "--run-id",
327 - RUN_ID,
328 - "--current-datetime",
329 - CURRENT_DATETIME,
330 - "--summary",
331 - str(summary),
332 - "--published-summary",
333 - str(base / "data/analyzed/2026-W21-summary.md"),
334 - "--raw-json",
335 - str(raw),
336 - "--analysis-source",
337 - "copilot-cli",
338 - "--analysis-model",
339 - "copilot-default",
340 - "--validation-status",
341 - "passed",
342 - "--output",
343 - str(manifest),
344 - ]
345 - )
375 + publish_manifest.main(create_args(base, raw, summary, manifest, gate_report=gate_report))
376
377 reasons = json.loads(manifest.read_text(encoding="utf-8"))["promotion"]["reasons"]
378 self.assertTrue(any(reason.startswith("candidate summary missing:") for reason in reasons))
379 self.assertFalse(any("quality_score" in reason for reason in reasons))
380
351 - def test_stale_source_artifact_blocks_promotion(self) -> None:
381 + def test_stale_source_artifact_blocks_promotion_and_preserves_existing_good_summary(self) -> None:
382 tests_root = Path(__file__).resolve().parent
383 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
384 base = Path(tmpdir)
@@ -356,43 +386,61 @@ class PublishManifestTests(unittest.TestCase):
386 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
387 published = base / "data/analyzed/2026-W21-summary.md"
388 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
389 + gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
390 write_raw(raw, crawled_at="2026-05-11T08:00:00Z")
391 write_summary(summary)
392 write_good_summary(published)
393 + write_gate_report(gate_report)
394
395 publish_manifest.main(
364 - [
365 - "create",
366 - "--week",
367 - WEEK,
368 - "--run-id",
369 - RUN_ID,
370 - "--current-datetime",
371 - CURRENT_DATETIME,
372 - "--summary",
373 - str(summary),
374 - "--published-summary",
375 - str(published),
376 - "--raw-json",
377 - str(raw),
378 - "--analysis-source",
379 - "github-models",
380 - "--analysis-model",
381 - "openai/gpt-4o",
382 - "--validation-status",
383 - "passed",
384 - "--output",
385 - str(manifest),
386 - ]
396 + create_args(base, raw, summary, manifest, source="github-models", model="openai/gpt-4o", gate_report=gate_report)
397 )
398
399 payload = json.loads(manifest.read_text(encoding="utf-8"))
400 self.assertFalse(payload["promotion"]["eligible"])
401 self.assertEqual(payload["promotion"]["decision"], "preserve")
402 self.assertTrue(payload["preservation"]["preserve_existing"])
403 + self.assertEqual(payload["source_artifacts"][0]["generated_at"], "2026-05-11T08:00:00Z")
404 self.assertEqual(payload["source_artifacts"][0]["freshness"]["status"], "stale")
405 self.assertTrue(any("timestamp week mismatch" in reason for reason in payload["promotion"]["reasons"]))
406
407 + def test_payload_generated_at_takes_precedence_over_crawled_at(self) -> None:
408 + tests_root = Path(__file__).resolve().parent
409 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
410 + base = Path(tmpdir)
411 + raw = base / "data/raw/2026-W21.json"
412 + summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
413 + manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
414 + gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
415 + write_raw(raw, crawled_at="2026-05-18T07:00:00Z")
416 + payload = json.loads(raw.read_text(encoding="utf-8"))
417 + payload["generated_at"] = "2026-05-18T08:00:00Z"
418 + raw.write_text(json.dumps(payload), encoding="utf-8")
419 + write_summary(summary)
420 + write_gate_report(gate_report)
421 +
422 + publish_manifest.main(create_args(base, raw, summary, manifest, gate_report=gate_report))
423 +
424 + manifest_payload = json.loads(manifest.read_text(encoding="utf-8"))
425 + self.assertEqual(manifest_payload["source_artifacts"][0]["generated_at"], "2026-05-18T08:00:00Z")
426 +
427 + def test_artifact_entry_handles_missing_or_malformed_json(self) -> None:
428 + tests_root = Path(__file__).resolve().parent
429 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
430 + base = Path(tmpdir)
431 + missing = base / "data/raw/missing.json"
432 + malformed = base / "data/raw/malformed.json"
433 + malformed.parent.mkdir(parents=True, exist_ok=True)
434 + malformed.write_text("{not json", encoding="utf-8")
435 +
436 + missing_entry = publish_manifest.artifact_entry("raw_github", missing, WEEK, CURRENT_DATETIME)
437 + malformed_entry = publish_manifest.artifact_entry("raw_github", malformed, WEEK, CURRENT_DATETIME)
438 +
439 + self.assertEqual(missing_entry["generated_at"], CURRENT_DATETIME)
440 + self.assertEqual(malformed_entry["generated_at"], CURRENT_DATETIME)
441 + self.assertEqual(missing_entry["freshness"]["status"], "missing")
442 + self.assertEqual(malformed_entry["freshness"]["status"], "missing")
443 +
444 def test_no_ai_candidate_preserves_existing_good_summary(self) -> None:
445 tests_root = Path(__file__).resolve().parent
446 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
@@ -401,33 +449,26 @@ class PublishManifestTests(unittest.TestCase):
449 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
450 published = base / "data/analyzed/2026-W21-summary.md"
451 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
452 + gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
453 write_raw(raw)
454 write_summary(summary)
455 write_good_summary(published)
456 + write_gate_report(gate_report)
457
458 publish_manifest.main(
459 [
460 "create",
411 - "--week",
412 - WEEK,
413 - "--run-id",
414 - RUN_ID,
415 - "--current-datetime",
416 - CURRENT_DATETIME,
417 - "--summary",
418 - str(summary),
419 - "--published-summary",
420 - str(published),
421 - "--raw-json",
422 - str(raw),
423 - "--analysis-source",
424 - "no-ai",
425 - "--analysis-model",
426 - "none",
427 - "--validation-status",
428 - "passed",
429 - "--output",
430 - str(manifest),
461 + "--week", WEEK,
462 + "--run-id", RUN_ID,
463 + "--current-datetime", CURRENT_DATETIME,
464 + "--summary", str(summary),
465 + "--published-summary", str(published),
466 + "--raw-json", str(raw),
467 + "--analysis-source", "no-ai",
468 + "--analysis-model", "none",
469 + "--validation-status", "passed",
470 + "--gate-report", str(gate_report),
471 + "--output", str(manifest),
472 ]
473 )
474
@@ -447,35 +488,13 @@ class PublishManifestTests(unittest.TestCase):
488 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
489 published = base / "data/analyzed/2026-W21-summary.md"
490 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
491 + gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
492 write_raw(raw)
493 write_good_summary(summary, quality_score=70)
494 write_good_summary(published, quality_score=90)
495 + write_gate_report(gate_report)
496
454 - publish_manifest.main(
455 - [
456 - "create",
457 - "--week",
458 - WEEK,
459 - "--run-id",
460 - RUN_ID,
461 - "--current-datetime",
462 - CURRENT_DATETIME,
463 - "--summary",
464 - str(summary),
465 - "--published-summary",
466 - str(published),
467 - "--raw-json",
468 - str(raw),
469 - "--analysis-source",
470 - "copilot-cli",
471 - "--analysis-model",
472 - "copilot-default",
473 - "--validation-status",
474 - "passed",
475 - "--output",
476 - str(manifest),
477 - ]
478 - )
497 + publish_manifest.main(create_args(base, raw, summary, manifest, gate_report=gate_report))
498
499 payload = json.loads(manifest.read_text(encoding="utf-8"))
500 self.assertEqual(payload["promotion"]["decision"], "preserve")
@@ -629,6 +648,46 @@ class PublishManifestTests(unittest.TestCase):
648 self.assertEqual(reuse["status"], "reused")
649 self.assertEqual(reuse["source_id"], "github-search")
650
651 + def test_failed_gate_report_blocks_promotion(self) -> None:
652 + tests_root = Path(__file__).resolve().parent
653 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
654 + base = Path(tmpdir)
655 + raw = base / "data/raw/2026-W21.json"
656 + summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
657 + manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
658 + gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
659 + write_raw(raw)
660 + write_summary(summary)
661 + write_gate_report(gate_report, passed=False, errors=["editorial_quality: low-quality summary"])
662 +
663 + publish_manifest.main(create_args(base, raw, summary, manifest, gate_report=gate_report))
664 +
665 + payload = json.loads(manifest.read_text(encoding="utf-8"))
666 + self.assertFalse(payload["promotion"]["eligible"])
667 + self.assertEqual(payload["validation"]["quality_gates"][0]["status"], "failed")
668 + self.assertTrue(any("low-quality summary" in reason for reason in payload["promotion"]["reasons"]))
669 +
670 + def test_missing_required_gate_family_blocks_promotion(self) -> None:
671 + tests_root = Path(__file__).resolve().parent
672 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
673 + base = Path(tmpdir)
674 + raw = base / "data/raw/2026-W21.json"
675 + summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
676 + manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
677 + gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
678 + write_raw(raw)
679 + write_summary(summary)
680 + write_gate_report(gate_report)
681 + payload = json.loads(gate_report.read_text(encoding="utf-8"))
682 + del payload["gates"]["evidence_citation"]
683 + gate_report.write_text(json.dumps(payload), encoding="utf-8")
684 +
685 + publish_manifest.main(create_args(base, raw, summary, manifest, gate_report=gate_report))
686 +
687 + payload = json.loads(manifest.read_text(encoding="utf-8"))
688 + self.assertFalse(payload["promotion"]["eligible"])
689 + self.assertTrue(any("evidence_citation gate missing" in reason for reason in payload["promotion"]["reasons"]))
690 +
691
692 if __name__ == "__main__":
693 unittest.main()
tests/test_validate_predictions.py
+3 -3
@@ -110,9 +110,9 @@ def test_infer_predictions_from_current_summary_patterns() -> None:
110 predictions = validate_predictions.load_summary_predictions(summary_path)
111 claims = {(prediction.claim, prediction.repo) for prediction in predictions}
112
113 - assert ("signal", "op7418/guizang-social-card-skill") in claims
114 - assert ("noise", "Signal-Trade-Core/weather-prediction-bot") in claims
115 - assert ("gap", "ssreeni1/tracebase") in claims
113 + assert ("signal", "duncatzat/vigils") in claims
114 + assert ("signal", "openai/role-specific-plugins") in claims
115 + assert ("noise", "pewdiepie-archdaemon/odysseus") in claims
116
117
118 def test_frontmatter_predictions_use_explicit_claim_type() -> None: