main
py 1,648 lines 70 KB
Raw
1 import json
2 import os
3 import tempfile
4 import unittest
5 from argparse import Namespace
6 from datetime import datetime
7 from pathlib import Path
8
9 import scripts.crawl as crawl
10 import scripts.publish_manifest as publish_manifest
11
12 RUN_ID = "123456"
13 CURRENT_DATETIME = "2026-05-18T08:00:00Z"
14 WEEK = "2026-W21"
15
16
17 def write_raw(
18 path: Path,
19 *,
20 week: str = WEEK,
21 crawled_at: str = CURRENT_DATETIME,
22 metadata: dict | None = None,
23 ) -> None:
24 path.parent.mkdir(parents=True, exist_ok=True)
25 path.write_text(
26 json.dumps(
27 {
28 "week": week,
29 "crawled_at": crawled_at,
30 "new_repos": [],
31 "trending_repos": [],
32 "metadata": metadata or {"same_day_reuse": "not_reused"},
33 }
34 ),
35 encoding="utf-8",
36 )
37
38
39 def write_summary(path: Path) -> None:
40 path.parent.mkdir(parents=True, exist_ok=True)
41 path.write_text('---\nweek: "2026-W21"\nquality_score: 75\n---\n\nbody\n', encoding="utf-8")
42
43
44 def write_good_summary(path: Path, *, quality_score: int = 90) -> None:
45 path.parent.mkdir(parents=True, exist_ok=True)
46 path.write_text(
47 f"""---
48 title: "Good AI Article"
49 date: {CURRENT_DATETIME}
50 week: "{WEEK}"
51 year: 2026
52 tags: [ai]
53 categories: [weekly]
54 repos_featured: 1
55 stars_tracked: 100
56 top_repo: "owner/good"
57 quality_score: {quality_score}
58 summary: "A good AI-authored weekly summary."
59 ---
60
61 ## This Week's Trends
62
63 Canonical good analysis.
64 """,
65 encoding="utf-8",
66 )
67
68
69 def write_no_ai_summary(path: Path, *, quality_score: int = 70) -> None:
70 path.parent.mkdir(parents=True, exist_ok=True)
71 path.write_text(
72 f"---\nweek: 2026-W21\nquality_score: {quality_score}\nsummary: fallback\n---\n\n"
73 "Automated data-only summary generated without AI assistance.\n",
74 encoding="utf-8",
75 )
76
77
78 def write_gate_report(path: Path, *, passed: bool = True, errors: list[str] | None = None) -> None:
79 path.parent.mkdir(parents=True, exist_ok=True)
80 gate_errors = errors or []
81 gates = {
82 "structural_schema": {"passed": passed, "errors": gate_errors if not passed else []},
83 "ai_provenance": {"passed": True, "errors": []},
84 "evidence_citation": {"passed": True, "errors": []},
85 "editorial_quality": {"passed": True, "errors": []},
86 }
87 path.write_text(
88 json.dumps(
89 {
90 "passed": passed,
91 "source": "copilot-cli",
92 "model": "copilot-default",
93 "failure_class": "passed" if passed else "structural_schema",
94 "errors_after_repair": gate_errors,
95 "repair_actions": [],
96 "gates": gates,
97 }
98 ),
99 encoding="utf-8",
100 )
101
102
103 def write_preflight(path: Path, *, degraded: bool = False, publish_eligible: bool = True) -> None:
104 path.parent.mkdir(parents=True, exist_ok=True)
105 path.write_text(
106 json.dumps(
107 {
108 "prompt_token_budget": 90000,
109 "prompt_tokens": 1200,
110 "prompt_bytes": 4800,
111 "prompt_checksum_sha256": "a" * 64,
112 "prompt_within_budget": True,
113 "degraded": degraded,
114 "publish_eligible": publish_eligible,
115 "promotion_policy": (
116 "normal-promotion"
117 if not degraded
118 else "staged/candidate-only by default; degraded compacted output requires an explicit future promotion policy."
119 ),
120 "degradation_reason": "Prompt was deterministically compacted."
121 if degraded
122 else None,
123 "fallback_policy": "copilot-only",
124 "components": [],
125 "deterministic_slices": [],
126 }
127 ),
128 encoding="utf-8",
129 )
130
131
132 def create_args(
133 base: Path,
134 raw: Path,
135 summary: Path,
136 manifest: Path,
137 *,
138 source: str = "copilot-cli",
139 model: str | None = "copilot-default",
140 gate_report: Path | None = None,
141 validation_status: str = "passed",
142 preflight: Path | None | bool = True,
143 run_mode: str = "normal",
144 source_run_id: str = "",
145 raw_store_manifest: Path | None = None,
146 synthesis_status: str | None = "available",
147 synthesis_file: Path | None = None,
148 ) -> list[str]:
149 args = [
150 "create",
151 "--week",
152 WEEK,
153 "--run-id",
154 RUN_ID,
155 "--current-datetime",
156 CURRENT_DATETIME,
157 "--root",
158 str(base),
159 "--summary",
160 str(summary),
161 "--published-summary",
162 str(base / "data/analyzed/2026-W21-summary.md"),
163 "--raw-json",
164 str(raw),
165 "--analysis-source",
166 source,
167 "--validation-status",
168 validation_status,
169 "--run-mode",
170 run_mode,
171 "--output",
172 str(manifest),
173 ]
174 if model is not None:
175 args.extend(["--analysis-model", model])
176 if gate_report is not None:
177 args.extend(["--gate-report", str(gate_report)])
178 if preflight is True and source == "copilot-cli":
179 preflight_path = manifest.parent / "diagnostics" / "analysis-preflight.json"
180 write_preflight(preflight_path)
181 args.extend(["--preflight-report", str(preflight_path)])
182 elif isinstance(preflight, Path):
183 args.extend(["--preflight-report", str(preflight)])
184 if synthesis_status is not None:
185 args.extend(["--synthesis-status", synthesis_status])
186 # When we claim synthesis is available we must back it with real provenance
187 # (a readable, non-empty file), matching how the workflow signals "available".
188 if synthesis_status == "available" and synthesis_file is None:
189 synthesis_file = manifest.parent / "diagnostics" / "synthesis-narrative.md"
190 synthesis_file.parent.mkdir(parents=True, exist_ok=True)
191 synthesis_file.write_text("Weekly synthesis narrative.\n", encoding="utf-8")
192 if synthesis_file is not None:
193 args.extend(["--synthesis-file", str(synthesis_file)])
194 if source_run_id:
195 args.extend(["--source-run-id", source_run_id])
196 if raw_store_manifest is not None:
197 args.extend(["--raw-store-manifest", str(raw_store_manifest)])
198 return args
199
200
201 def assert_eligible_from_root(root: Path, manifest: Path) -> int:
202 previous_cwd = Path.cwd()
203 try:
204 os.chdir(root)
205 return publish_manifest.main(["assert-eligible", "--manifest", str(manifest)])
206 finally:
207 os.chdir(previous_cwd)
208
209
210 class PublishManifestTests(unittest.TestCase):
211 def test_ai_candidate_with_fresh_sources_is_eligible(self) -> None:
212 tests_root = Path(__file__).resolve().parent
213 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
214 base = Path(tmpdir)
215 raw = base / "data/raw/2026-W21.json"
216 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
217 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
218 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
219 write_raw(raw)
220 write_summary(summary)
221 write_gate_report(gate_report)
222
223 exit_code = publish_manifest.main(
224 create_args(base, raw, summary, manifest, gate_report=gate_report)
225 )
226
227 self.assertEqual(exit_code, 0)
228 payload = json.loads(manifest.read_text(encoding="utf-8"))
229 self.assertEqual(payload["schema_version"], "publish_eligibility_v1")
230 self.assertEqual(payload["analysis"]["ai_status"], "ai")
231 self.assertEqual(payload["analysis"]["preflight"]["degraded"], False)
232 self.assertEqual(payload["analysis"]["preflight"]["publish_eligible"], True)
233 self.assertEqual(
234 payload["analysis"]["preflight"]["promotion_policy"], "normal-promotion"
235 )
236 self.assertTrue(payload["promotion"]["eligible"])
237 self.assertEqual(payload["promotion"]["decision"], "promote")
238 self.assertRegex(payload["candidate"]["summary_sha256"], r"^[0-9a-f]{64}$")
239 self.assertRegex(payload["source_artifacts"][0]["sha256"], r"^[0-9a-f]{64}$")
240 self.assertEqual(
241 payload["source_artifacts"][0]["provenance"]["sha256"],
242 payload["source_artifacts"][0]["sha256"],
243 )
244 self.assertEqual(
245 payload["source_artifacts"][0]["provenance"]["same_day_reuse"]["status"],
246 "not_reused",
247 )
248 self.assertEqual(assert_eligible_from_root(base, manifest), 0)
249
250 def test_restore_candidate_requires_verified_source_bound_raw_store(self) -> None:
251 tests_root = Path(__file__).resolve().parent
252 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
253 base = Path(tmpdir)
254 raw = base / "data/raw/2026-W21.json"
255 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
256 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
257 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
258 write_raw(raw)
259 write_summary(summary)
260 write_gate_report(gate_report)
261 publish_manifest.publish_safety.main(
262 [
263 "store-raw",
264 "--root",
265 str(base),
266 "--week",
267 WEEK,
268 "--source-run-id",
269 "26753498571",
270 "--source-artifact-id",
271 "7330965888",
272 "--source-head-sha",
273 "abc123",
274 "--path",
275 "data/raw/2026-W21.json",
276 ]
277 )
278 raw_store_manifest = base / "data/raw-store/2026-W21/26753498571/manifest.json"
279
280 publish_manifest.main(
281 create_args(
282 base,
283 raw,
284 summary,
285 manifest,
286 gate_report=gate_report,
287 run_mode="restore",
288 source_run_id="26753498571",
289 raw_store_manifest=raw_store_manifest,
290 )
291 )
292
293 payload = json.loads(manifest.read_text(encoding="utf-8"))
294 self.assertTrue(payload["promotion"]["eligible"])
295 self.assertTrue(payload["restore"]["verified"])
296 self.assertEqual(payload["restore"]["source_run_id"], "26753498571")
297 self.assertEqual(payload["restore"]["source_artifact"]["id"], "7330965888")
298 self.assertEqual(assert_eligible_from_root(base, manifest), 0)
299
300 def test_restore_candidate_rejects_hash_mismatch_before_acceptance(self) -> None:
301 tests_root = Path(__file__).resolve().parent
302 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
303 base = Path(tmpdir)
304 raw = base / "data/raw/2026-W21.json"
305 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
306 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
307 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
308 write_raw(raw)
309 write_summary(summary)
310 write_gate_report(gate_report)
311 publish_manifest.publish_safety.main(
312 [
313 "store-raw",
314 "--root",
315 str(base),
316 "--week",
317 WEEK,
318 "--source-run-id",
319 "26753498571",
320 "--source-artifact-id",
321 "7330965888",
322 "--source-head-sha",
323 "abc123",
324 "--path",
325 "data/raw/2026-W21.json",
326 ]
327 )
328 raw_store_manifest = base / "data/raw-store/2026-W21/26753498571/manifest.json"
329 raw.write_bytes(b"tampered restored input\n")
330
331 publish_manifest.main(
332 create_args(
333 base,
334 raw,
335 summary,
336 manifest,
337 gate_report=gate_report,
338 run_mode="restore",
339 source_run_id="26753498571",
340 raw_store_manifest=raw_store_manifest,
341 )
342 )
343
344 payload = json.loads(manifest.read_text(encoding="utf-8"))
345 self.assertFalse(payload["promotion"]["eligible"])
346 self.assertFalse(payload["restore"]["verified"])
347 self.assertTrue(
348 any(
349 "Restored raw input" in reason and "mismatch" in reason
350 for reason in payload["promotion"]["reasons"]
351 )
352 )
353 with self.assertRaises(SystemExit):
354 assert_eligible_from_root(base, manifest)
355
356 def test_no_ai_candidate_is_not_eligible(self) -> None:
357 tests_root = Path(__file__).resolve().parent
358 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
359 base = Path(tmpdir)
360 raw = base / "data/raw/2026-W21.json"
361 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
362 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
363 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
364 write_raw(raw)
365 write_summary(summary)
366 write_gate_report(gate_report)
367
368 publish_manifest.main(
369 create_args(
370 base,
371 raw,
372 summary,
373 manifest,
374 source="no-ai",
375 model="none",
376 gate_report=gate_report,
377 )
378 )
379
380 payload = json.loads(manifest.read_text(encoding="utf-8"))
381 self.assertFalse(payload["promotion"]["eligible"])
382 self.assertEqual(payload["promotion"]["decision"], "block")
383 self.assertEqual(payload["analysis"]["provenance"]["authorship"], "no-ai-fallback")
384 self.assertIn("fallback_reason is required", payload["promotion"]["reasons"][0])
385 with self.assertRaises(SystemExit):
386 assert_eligible_from_root(base, manifest)
387
388 def test_copilot_ai_candidate_requires_preflight_for_promotion(self) -> None:
389 tests_root = Path(__file__).resolve().parent
390 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
391 base = Path(tmpdir)
392 raw = base / "data/raw/2026-W21.json"
393 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
394 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
395 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
396 write_raw(raw)
397 write_summary(summary)
398 write_gate_report(gate_report)
399
400 publish_manifest.main(
401 create_args(base, raw, summary, manifest, gate_report=gate_report, preflight=False)
402 )
403
404 payload = json.loads(manifest.read_text(encoding="utf-8"))
405 self.assertEqual(payload["analysis"]["ai_status"], "ai")
406 self.assertFalse(payload["promotion"]["eligible"])
407 self.assertTrue(
408 any(
409 "preflight report is required" in reason
410 for reason in payload["promotion"]["reasons"]
411 )
412 )
413
414 def test_github_models_source_is_not_ai_publishable(self) -> None:
415 tests_root = Path(__file__).resolve().parent
416 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
417 base = Path(tmpdir)
418 raw = base / "data/raw/2026-W21.json"
419 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
420 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
421 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
422 write_raw(raw)
423 write_summary(summary)
424 write_gate_report(gate_report)
425
426 publish_manifest.main(
427 create_args(
428 base,
429 raw,
430 summary,
431 manifest,
432 source="github-models",
433 model="openai/gpt-4o",
434 gate_report=gate_report,
435 )
436 )
437
438 payload = json.loads(manifest.read_text(encoding="utf-8"))
439 self.assertEqual(payload["analysis"]["ai_status"], "unknown")
440 self.assertFalse(payload["promotion"]["eligible"])
441 self.assertTrue(
442 any(
443 "analysis source is not AI-publishable" in reason
444 for reason in payload["promotion"]["reasons"]
445 )
446 )
447
448 def test_degraded_preflight_candidate_is_staged_only_by_default(self) -> None:
449 tests_root = Path(__file__).resolve().parent
450 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
451 base = Path(tmpdir)
452 raw = base / "data/raw/2026-W21.json"
453 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
454 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
455 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
456 preflight = base / "data/candidates/2026-W21/123456/diagnostics/analysis-preflight.json"
457 write_raw(raw)
458 write_summary(summary)
459 write_gate_report(gate_report)
460 write_preflight(preflight, degraded=True, publish_eligible=False)
461
462 publish_manifest.main(
463 create_args(
464 base, raw, summary, manifest, gate_report=gate_report, preflight=preflight
465 )
466 )
467
468 payload = json.loads(manifest.read_text(encoding="utf-8"))
469 self.assertFalse(payload["promotion"]["eligible"])
470 self.assertEqual(payload["promotion"]["decision"], "block")
471 self.assertTrue(payload["analysis"]["preflight"]["degraded"])
472 self.assertFalse(payload["analysis"]["preflight"]["publish_eligible"])
473 self.assertIn(
474 "staged/candidate-only", payload["analysis"]["preflight"]["promotion_policy"]
475 )
476 self.assertTrue(
477 any("publish-ineligible" in reason for reason in payload["promotion"]["reasons"])
478 )
479 with self.assertRaises(SystemExit):
480 assert_eligible_from_root(base, manifest)
481
482 def test_degraded_but_publish_eligible_candidate_is_promotable(self) -> None:
483 """Post-compaction within budget: degraded=True + publish_eligible=True is promotable."""
484 tests_root = Path(__file__).resolve().parent
485 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
486 base = Path(tmpdir)
487 raw = base / "data/raw/2026-W21.json"
488 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
489 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
490 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
491 preflight = base / "data/candidates/2026-W21/123456/diagnostics/analysis-preflight.json"
492 write_raw(raw)
493 write_summary(summary)
494 write_gate_report(gate_report)
495 write_preflight(preflight, degraded=True, publish_eligible=True)
496
497 publish_manifest.main(
498 create_args(
499 base, raw, summary, manifest, gate_report=gate_report, preflight=preflight
500 )
501 )
502
503 payload = json.loads(manifest.read_text(encoding="utf-8"))
504 self.assertTrue(payload["promotion"]["eligible"])
505 self.assertEqual(payload["promotion"]["decision"], "promote")
506 self.assertTrue(payload["analysis"]["preflight"]["degraded"])
507 self.assertTrue(payload["analysis"]["preflight"]["publish_eligible"])
508 # Should not raise — degraded but eligible means promotable
509 assert_eligible_from_root(base, manifest)
510
511 def test_copilot_candidate_without_explicit_model_uses_publishable_default(self) -> None:
512 tests_root = Path(__file__).resolve().parent
513 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
514 base = Path(tmpdir)
515 raw = base / "data/raw/2026-W21.json"
516 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
517 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
518 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
519 write_raw(raw)
520 write_summary(summary)
521 write_gate_report(gate_report)
522
523 publish_manifest.main(
524 create_args(base, raw, summary, manifest, model=None, gate_report=gate_report)
525 )
526
527 payload = json.loads(manifest.read_text(encoding="utf-8"))
528 self.assertEqual(payload["analysis"]["model"], "copilot-default")
529 self.assertEqual(payload["analysis"]["model_status"], "available")
530 self.assertTrue(payload["promotion"]["eligible"])
531
532 def test_no_ai_default_cannot_replace_existing_good_ai_article(self) -> None:
533 tests_root = Path(__file__).resolve().parent
534 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
535 base = Path(tmpdir)
536 raw = base / "data/raw/2026-W21.json"
537 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
538 published = base / "data/analyzed/2026-W21-summary.md"
539 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
540 write_raw(raw)
541 write_no_ai_summary(summary)
542 write_good_summary(published)
543
544 publish_manifest.main(
545 [
546 "create",
547 "--week",
548 WEEK,
549 "--run-id",
550 RUN_ID,
551 "--current-datetime",
552 CURRENT_DATETIME,
553 "--summary",
554 str(summary),
555 "--published-summary",
556 str(published),
557 "--raw-json",
558 str(raw),
559 "--analysis-source",
560 "no-ai",
561 "--analysis-model",
562 "none",
563 "--validation-status",
564 "passed",
565 "--fallback-reason",
566 "copilot quality gate failed",
567 "--attempted-ai-path",
568 "provider=copilot-cli,model=copilot-default,status=failed",
569 "--output",
570 str(manifest),
571 ]
572 )
573
574 payload = json.loads(manifest.read_text(encoding="utf-8"))
575 self.assertFalse(payload["promotion"]["eligible"])
576 self.assertEqual(payload["promotion"]["decision"], "preserve")
577 self.assertTrue(payload["existing_article"]["good_ai_authored"])
578 self.assertIn(
579 "no-AI fallback is ineligible to replace", " ".join(payload["promotion"]["reasons"])
580 )
581
582 def test_no_ai_first_publish_requires_explicit_policy_and_quality_gate(self) -> None:
583 tests_root = Path(__file__).resolve().parent
584 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
585 base = Path(tmpdir)
586 raw = base / "data/raw/2026-W21.json"
587 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
588 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
589 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
590 write_raw(raw)
591 write_no_ai_summary(summary)
592 write_gate_report(gate_report)
593
594 publish_manifest.main(
595 [
596 "create",
597 "--week",
598 WEEK,
599 "--run-id",
600 RUN_ID,
601 "--current-datetime",
602 CURRENT_DATETIME,
603 "--summary",
604 str(summary),
605 "--published-summary",
606 str(base / "data/analyzed/2026-W21-summary.md"),
607 "--raw-json",
608 str(raw),
609 "--analysis-source",
610 "no-ai",
611 "--analysis-model",
612 "none",
613 "--validation-status",
614 "passed",
615 "--gate-report",
616 str(gate_report),
617 "--fallback-reason",
618 "copilot unavailable",
619 "--attempted-ai-path",
620 "provider=copilot-cli,model=copilot-default,status=failed",
621 "--publish-policy",
622 "allow-no-ai-first-publish",
623 "--actor",
624 "jmservera",
625 "--output",
626 str(manifest),
627 ]
628 )
629
630 payload = json.loads(manifest.read_text(encoding="utf-8"))
631 self.assertTrue(payload["promotion"]["eligible"])
632 self.assertEqual(payload["promotion"]["policy"], "allow-no-ai-first-publish")
633 self.assertEqual(assert_eligible_from_root(base, manifest), 0)
634
635 def test_no_ai_explicit_policy_requires_higher_fallback_quality_score(self) -> None:
636 tests_root = Path(__file__).resolve().parent
637 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
638 base = Path(tmpdir)
639 raw = base / "data/raw/2026-W21.json"
640 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
641 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
642 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
643 write_raw(raw)
644 write_no_ai_summary(summary, quality_score=69)
645 write_gate_report(gate_report)
646
647 publish_manifest.main(
648 [
649 "create",
650 "--week",
651 WEEK,
652 "--run-id",
653 RUN_ID,
654 "--current-datetime",
655 CURRENT_DATETIME,
656 "--summary",
657 str(summary),
658 "--published-summary",
659 str(base / "data/analyzed/2026-W21-summary.md"),
660 "--raw-json",
661 str(raw),
662 "--analysis-source",
663 "no-ai",
664 "--analysis-model",
665 "none",
666 "--validation-status",
667 "passed",
668 "--gate-report",
669 str(gate_report),
670 "--fallback-reason",
671 "copilot unavailable",
672 "--attempted-ai-path",
673 "provider=copilot-cli,model=copilot-default,status=failed",
674 "--publish-policy",
675 "allow-no-ai-first-publish",
676 "--output",
677 str(manifest),
678 ]
679 )
680
681 payload = json.loads(manifest.read_text(encoding="utf-8"))
682 self.assertFalse(payload["promotion"]["eligible"])
683 self.assertIn(
684 "quality_score must be at least 70", " ".join(payload["promotion"]["reasons"])
685 )
686
687 def test_force_replace_requires_audit_and_allows_no_ai_over_existing_good_article(self) -> None:
688 tests_root = Path(__file__).resolve().parent
689 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
690 base = Path(tmpdir)
691 raw = base / "data/raw/2026-W21.json"
692 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
693 published = base / "data/analyzed/2026-W21-summary.md"
694 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
695 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
696 write_raw(raw)
697 write_no_ai_summary(summary)
698 write_good_summary(published)
699 write_gate_report(gate_report)
700
701 publish_manifest.main(
702 [
703 "create",
704 "--week",
705 WEEK,
706 "--run-id",
707 RUN_ID,
708 "--current-datetime",
709 CURRENT_DATETIME,
710 "--summary",
711 str(summary),
712 "--published-summary",
713 str(published),
714 "--raw-json",
715 str(raw),
716 "--analysis-source",
717 "no-ai",
718 "--analysis-model",
719 "none",
720 "--validation-status",
721 "passed",
722 "--gate-report",
723 str(gate_report),
724 "--fallback-reason",
725 "copilot unavailable",
726 "--attempted-ai-path",
727 "provider=copilot-cli,model=copilot-default,status=failed",
728 "--publish-policy",
729 "force-replace",
730 "--force-reason",
731 "operator approved emergency publish",
732 "--actor",
733 "jmservera",
734 "--output",
735 str(manifest),
736 ]
737 )
738
739 payload = json.loads(manifest.read_text(encoding="utf-8"))
740 self.assertTrue(payload["promotion"]["eligible"])
741 self.assertEqual(payload["audit"]["mode"], "force-replace")
742 self.assertEqual(payload["audit"]["actor"], "jmservera")
743 self.assertEqual(assert_eligible_from_root(base, manifest), 0)
744
745 def test_force_replace_allows_lower_quality_ai_candidate_over_good_baseline(self) -> None:
746 tests_root = Path(__file__).resolve().parent
747 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
748 base = Path(tmpdir)
749 raw = base / "data/raw/2026-W21.json"
750 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
751 published = base / "data/analyzed/2026-W21-summary.md"
752 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
753 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
754 write_raw(raw)
755 write_good_summary(summary, quality_score=70)
756 write_good_summary(published, quality_score=90)
757 write_gate_report(gate_report)
758 args = create_args(base, raw, summary, manifest, gate_report=gate_report)
759 args.extend(
760 [
761 "--publish-policy",
762 "force-replace",
763 "--force-reason",
764 "operator approved W30 correction",
765 "--actor",
766 "jmservera",
767 ]
768 )
769
770 # jmservera/SquadScope#583: the W30 correction must bypass only the
771 # candidate-vs-published score comparison when explicitly audited.
772 self.assertEqual(publish_manifest.main(args), 0)
773
774 payload = json.loads(manifest.read_text(encoding="utf-8"))
775 self.assertTrue(payload["promotion"]["eligible"])
776 self.assertEqual(payload["promotion"]["decision"], "promote")
777 self.assertFalse(
778 any(
779 "lower than published good quality_score" in reason
780 for reason in payload["promotion"]["reasons"]
781 )
782 )
783 self.assertEqual(assert_eligible_from_root(base, manifest), 0)
784
785 def test_force_replace_ai_candidate_requires_reason_and_actor(self) -> None:
786 tests_root = Path(__file__).resolve().parent
787 for missing_flag, expected_reason in (
788 ("--force-reason", "force-replace requires force_reason"),
789 ("--actor", "force-replace requires actor"),
790 ):
791 with self.subTest(missing_flag=missing_flag):
792 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
793 base = Path(tmpdir)
794 raw = base / "data/raw/2026-W21.json"
795 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
796 published = base / "data/analyzed/2026-W21-summary.md"
797 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
798 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
799 write_raw(raw)
800 write_good_summary(summary, quality_score=70)
801 write_good_summary(published, quality_score=90)
802 write_gate_report(gate_report)
803 args = create_args(base, raw, summary, manifest, gate_report=gate_report)
804 audit_args = [
805 "--publish-policy",
806 "force-replace",
807 "--force-reason",
808 "operator approved correction",
809 "--actor",
810 "jmservera",
811 ]
812 missing_index = audit_args.index(missing_flag)
813 del audit_args[missing_index : missing_index + 2]
814 args.extend(audit_args)
815
816 self.assertEqual(publish_manifest.main(args), 0)
817
818 payload = json.loads(manifest.read_text(encoding="utf-8"))
819 self.assertFalse(payload["promotion"]["eligible"])
820 self.assertIn(expected_reason, payload["promotion"]["reasons"])
821
822 def test_force_replace_does_not_bypass_minimum_quality_score(self) -> None:
823 tests_root = Path(__file__).resolve().parent
824 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
825 base = Path(tmpdir)
826 raw = base / "data/raw/2026-W21.json"
827 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
828 published = base / "data/analyzed/2026-W21-summary.md"
829 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
830 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
831 write_raw(raw)
832 write_good_summary(summary, quality_score=59)
833 write_good_summary(published, quality_score=90)
834 write_gate_report(gate_report)
835 args = create_args(base, raw, summary, manifest, gate_report=gate_report)
836 args.extend(
837 [
838 "--publish-policy",
839 "force-replace",
840 "--force-reason",
841 "operator approved correction",
842 "--actor",
843 "jmservera",
844 ]
845 )
846
847 self.assertEqual(publish_manifest.main(args), 0)
848
849 payload = json.loads(manifest.read_text(encoding="utf-8"))
850 self.assertFalse(payload["promotion"]["eligible"])
851 self.assertIn("candidate quality_score below 60: 59", payload["promotion"]["reasons"])
852
853 def test_assert_eligible_force_replace_requires_complete_audit(self) -> None:
854 tests_root = Path(__file__).resolve().parent
855 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
856 base = Path(tmpdir)
857 raw = base / "data/raw/2026-W21.json"
858 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
859 published = base / "data/analyzed/2026-W21-summary.md"
860 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
861 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
862 write_raw(raw)
863 write_good_summary(summary, quality_score=70)
864 write_good_summary(published, quality_score=90)
865 write_gate_report(gate_report)
866 args = create_args(base, raw, summary, manifest, gate_report=gate_report)
867 args.extend(
868 [
869 "--publish-policy",
870 "force-replace",
871 "--force-reason",
872 "operator approved correction",
873 "--actor",
874 "jmservera",
875 ]
876 )
877 self.assertEqual(publish_manifest.main(args), 0)
878 valid_payload = json.loads(manifest.read_text(encoding="utf-8"))
879
880 for missing_field in ("actor", "reason"):
881 with self.subTest(missing_field=missing_field):
882 tampered = json.loads(json.dumps(valid_payload))
883 tampered["audit"][missing_field] = None
884 manifest.write_text(json.dumps(tampered), encoding="utf-8")
885 with self.assertRaisesRegex(
886 SystemExit,
887 "Force replacement requires actor and reason in manifest audit",
888 ):
889 assert_eligible_from_root(base, manifest)
890
891 manifest.write_text(json.dumps(valid_payload), encoding="utf-8")
892 self.assertEqual(assert_eligible_from_root(base, manifest), 0)
893
894 def test_missing_candidate_summary_only_reports_missing_summary(self) -> None:
895 tests_root = Path(__file__).resolve().parent
896 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
897 base = Path(tmpdir)
898 raw = base / "data/raw/2026-W21.json"
899 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
900 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
901 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
902 write_raw(raw)
903 write_gate_report(gate_report)
904
905 publish_manifest.main(
906 create_args(base, raw, summary, manifest, gate_report=gate_report)
907 )
908
909 reasons = json.loads(manifest.read_text(encoding="utf-8"))["promotion"]["reasons"]
910 self.assertTrue(
911 any(reason.startswith("candidate summary missing:") for reason in reasons)
912 )
913 self.assertFalse(any("quality_score" in reason for reason in reasons))
914
915 def test_stale_source_artifact_blocks_promotion_and_preserves_existing_good_summary(
916 self,
917 ) -> None:
918 tests_root = Path(__file__).resolve().parent
919 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
920 base = Path(tmpdir)
921 raw = base / "data/raw/2026-W21.json"
922 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
923 published = base / "data/analyzed/2026-W21-summary.md"
924 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
925 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
926 write_raw(raw, crawled_at="2026-05-11T08:00:00Z")
927 write_summary(summary)
928 write_good_summary(published)
929 write_gate_report(gate_report)
930
931 publish_manifest.main(
932 create_args(
933 base,
934 raw,
935 summary,
936 manifest,
937 source="github-models",
938 model="openai/gpt-4o",
939 gate_report=gate_report,
940 )
941 )
942
943 payload = json.loads(manifest.read_text(encoding="utf-8"))
944 self.assertFalse(payload["promotion"]["eligible"])
945 self.assertEqual(payload["promotion"]["decision"], "preserve")
946 self.assertTrue(payload["preservation"]["preserve_existing"])
947 self.assertEqual(payload["source_artifacts"][0]["generated_at"], "2026-05-11T08:00:00Z")
948 self.assertEqual(payload["source_artifacts"][0]["freshness"]["status"], "stale")
949 self.assertTrue(
950 any(
951 "timestamp week mismatch" in reason
952 for reason in payload["promotion"]["reasons"]
953 )
954 )
955
956 def test_payload_generated_at_takes_precedence_over_crawled_at(self) -> None:
957 tests_root = Path(__file__).resolve().parent
958 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
959 base = Path(tmpdir)
960 raw = base / "data/raw/2026-W21.json"
961 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
962 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
963 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
964 write_raw(raw, crawled_at="2026-05-18T07:00:00Z")
965 payload = json.loads(raw.read_text(encoding="utf-8"))
966 payload["generated_at"] = "2026-05-18T08:00:00Z"
967 raw.write_text(json.dumps(payload), encoding="utf-8")
968 write_summary(summary)
969 write_gate_report(gate_report)
970
971 publish_manifest.main(
972 create_args(base, raw, summary, manifest, gate_report=gate_report)
973 )
974
975 manifest_payload = json.loads(manifest.read_text(encoding="utf-8"))
976 self.assertEqual(
977 manifest_payload["source_artifacts"][0]["generated_at"], "2026-05-18T08:00:00Z"
978 )
979
980 def test_artifact_entry_handles_missing_or_malformed_json(self) -> None:
981 tests_root = Path(__file__).resolve().parent
982 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
983 base = Path(tmpdir)
984 missing = base / "data/raw/missing.json"
985 malformed = base / "data/raw/malformed.json"
986 malformed.parent.mkdir(parents=True, exist_ok=True)
987 malformed.write_text("{not json", encoding="utf-8")
988
989 missing_entry = publish_manifest.artifact_entry(
990 "raw_github", missing, WEEK, CURRENT_DATETIME
991 )
992 malformed_entry = publish_manifest.artifact_entry(
993 "raw_github", malformed, WEEK, CURRENT_DATETIME
994 )
995
996 self.assertEqual(missing_entry["generated_at"], CURRENT_DATETIME)
997 self.assertEqual(malformed_entry["generated_at"], CURRENT_DATETIME)
998 self.assertEqual(missing_entry["freshness"]["status"], "missing")
999 self.assertEqual(malformed_entry["freshness"]["status"], "missing")
1000
1001 def test_no_ai_candidate_preserves_existing_good_summary(self) -> None:
1002 tests_root = Path(__file__).resolve().parent
1003 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1004 base = Path(tmpdir)
1005 raw = base / "data/raw/2026-W21.json"
1006 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
1007 published = base / "data/analyzed/2026-W21-summary.md"
1008 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
1009 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
1010 write_raw(raw)
1011 write_summary(summary)
1012 write_good_summary(published)
1013 write_gate_report(gate_report)
1014
1015 publish_manifest.main(
1016 [
1017 "create",
1018 "--week",
1019 WEEK,
1020 "--run-id",
1021 RUN_ID,
1022 "--current-datetime",
1023 CURRENT_DATETIME,
1024 "--summary",
1025 str(summary),
1026 "--published-summary",
1027 str(published),
1028 "--raw-json",
1029 str(raw),
1030 "--analysis-source",
1031 "no-ai",
1032 "--analysis-model",
1033 "none",
1034 "--validation-status",
1035 "passed",
1036 "--gate-report",
1037 str(gate_report),
1038 "--output",
1039 str(manifest),
1040 ]
1041 )
1042
1043 payload = json.loads(manifest.read_text(encoding="utf-8"))
1044 self.assertFalse(payload["promotion"]["eligible"])
1045 self.assertEqual(payload["promotion"]["decision"], "preserve")
1046 self.assertTrue(payload["published"]["good"])
1047 self.assertTrue(payload["preservation"]["preserve_existing"])
1048 self.assertEqual(
1049 payload["preservation"]["preserved_summary_path"], published.as_posix()
1050 )
1051 self.assertEqual(payload["preservation"]["rejected_candidate_path"], summary.as_posix())
1052
1053 def test_lower_quality_candidate_preserves_existing_good_summary(self) -> None:
1054 tests_root = Path(__file__).resolve().parent
1055 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1056 base = Path(tmpdir)
1057 raw = base / "data/raw/2026-W21.json"
1058 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
1059 published = base / "data/analyzed/2026-W21-summary.md"
1060 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
1061 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
1062 write_raw(raw)
1063 write_good_summary(summary, quality_score=70)
1064 write_good_summary(published, quality_score=90)
1065 write_gate_report(gate_report)
1066
1067 publish_manifest.main(
1068 create_args(base, raw, summary, manifest, gate_report=gate_report)
1069 )
1070
1071 payload = json.loads(manifest.read_text(encoding="utf-8"))
1072 # The jmservera/SquadScope#583 override is force-replace-only; normal
1073 # publication must continue protecting the higher-quality baseline.
1074 self.assertFalse(payload["promotion"]["eligible"])
1075 self.assertEqual(payload["promotion"]["decision"], "preserve")
1076 self.assertTrue(
1077 any(
1078 "lower than published good quality_score" in reason
1079 for reason in payload["promotion"]["reasons"]
1080 )
1081 )
1082
1083 def test_structured_same_day_reuse_metadata_remains_machine_readable(self) -> None:
1084 tests_root = Path(__file__).resolve().parent
1085 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1086 base = Path(tmpdir)
1087 raw = base / "data/raw/2026-W21.json"
1088 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
1089 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
1090 reuse_metadata = {
1091 "same_day_reuse": {
1092 "status": "reused",
1093 "source": "github",
1094 "source_id": "github-search",
1095 "original_run_id": "111111",
1096 "original_crawled_at": "2026-05-18T06:00:00Z",
1097 "reused_at": CURRENT_DATETIME,
1098 "week": WEEK,
1099 "crawl_window": {"since": "2026-05-11", "until": "2026-05-18"},
1100 "crawl_config_checksum": "config-sha",
1101 "schema_checksum": "schema-sha",
1102 "content_checksum": "content-sha",
1103 },
1104 "artifact_checksum": "artifact-sha",
1105 }
1106 write_raw(raw, metadata=reuse_metadata)
1107 write_summary(summary)
1108
1109 publish_manifest.main(
1110 [
1111 "create",
1112 "--week",
1113 WEEK,
1114 "--run-id",
1115 RUN_ID,
1116 "--current-datetime",
1117 CURRENT_DATETIME,
1118 "--summary",
1119 str(summary),
1120 "--published-summary",
1121 str(base / "data/analyzed/2026-W21-summary.md"),
1122 "--raw-json",
1123 str(raw),
1124 "--analysis-source",
1125 "copilot-cli",
1126 "--analysis-model",
1127 "copilot-default",
1128 "--validation-status",
1129 "passed",
1130 "--output",
1131 str(manifest),
1132 ]
1133 )
1134
1135 reuse = json.loads(manifest.read_text(encoding="utf-8"))["source_artifacts"][0][
1136 "same_day_reuse"
1137 ]
1138 self.assertIsInstance(reuse, dict)
1139 self.assertEqual(reuse["status"], "reused")
1140 self.assertEqual(reuse["source"], "github")
1141 self.assertEqual(reuse["source_id"], "github-search")
1142 self.assertEqual(reuse["original_run_id"], "111111")
1143 self.assertEqual(reuse["original_crawled_at"], "2026-05-18T06:00:00Z")
1144 self.assertEqual(reuse["reused_at"], CURRENT_DATETIME)
1145 self.assertEqual(reuse["crawl_window"]["since"], "2026-05-11")
1146 self.assertEqual(reuse["crawl_config_checksum"], "config-sha")
1147 self.assertEqual(reuse["schema_checksum"], "schema-sha")
1148 self.assertEqual(reuse["content_checksum"], "content-sha")
1149 self.assertNotEqual(reuse["status"], str(dict(reuse)))
1150
1151 def test_manifest_preserves_source_id_from_crawl_reuse_metadata(self) -> None:
1152 tests_root = Path(__file__).resolve().parent
1153 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1154 base = Path(tmpdir)
1155 raw = base / "data/raw/2026-W21.json"
1156 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
1157 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
1158 since = datetime(2026, 5, 12, tzinfo=crawl.UTC)
1159 window_end = datetime(2026, 5, 19, tzinfo=crawl.UTC)
1160 original_crawled_at = datetime(2026, 5, 19, 8, 0, tzinfo=crawl.UTC)
1161 reused_at = datetime(2026, 5, 19, 10, 0, tzinfo=crawl.UTC)
1162 args = Namespace(
1163 since="2026-05-12",
1164 as_of="2026-05-19",
1165 max_results=25,
1166 output=str(raw),
1167 topic=None,
1168 config=None,
1169 )
1170 checksum = crawl.github_crawl_config_checksum(args, since, window_end, 25)
1171 payload = {
1172 "week": WEEK,
1173 "crawled_at": crawl.iso_timestamp(original_crawled_at),
1174 "new_repos": [],
1175 "trending_repos": [],
1176 "signals": {"top_topics": []},
1177 "metadata": {
1178 "api_calls_used": 1,
1179 "cache_hits": 0,
1180 "stale_cache_hits": 0,
1181 "rate_limit_limit": None,
1182 "rate_limit_remaining": None,
1183 "rate_limit_reset": None,
1184 "rate_limit_resource": None,
1185 "partial_failures": [],
1186 "run_id": "111111",
1187 "snapshot_path": "data/snapshots/2026-W21-stars.json",
1188 "crawl_window": {"since": "2026-05-12", "until": "2026-05-19"},
1189 "crawl_config_checksum": checksum,
1190 "schema_checksum": crawl.github_schema_checksum(),
1191 "same_day_reuse": {
1192 "status": "not_reused",
1193 "source": "github",
1194 "source_id": crawl.GITHUB_SOURCE_ID,
1195 },
1196 },
1197 }
1198 payload["metadata"]["artifact_checksum"] = crawl.github_artifact_checksum(payload)
1199 crawl.write_payload(raw, payload)
1200 reused = crawl.load_reusable_github_payload(
1201 raw,
1202 week=WEEK,
1203 crawled_at=reused_at,
1204 since=since,
1205 window_end=window_end,
1206 config_checksum=checksum,
1207 )
1208 self.assertIsNotNone(reused)
1209 crawl.write_payload(raw, reused)
1210 write_summary(summary)
1211
1212 publish_manifest.main(
1213 [
1214 "create",
1215 "--week",
1216 WEEK,
1217 "--run-id",
1218 RUN_ID,
1219 "--current-datetime",
1220 CURRENT_DATETIME,
1221 "--summary",
1222 str(summary),
1223 "--published-summary",
1224 str(base / "data/analyzed/2026-W21-summary.md"),
1225 "--raw-json",
1226 str(raw),
1227 "--analysis-source",
1228 "copilot-cli",
1229 "--analysis-model",
1230 "copilot-default",
1231 "--validation-status",
1232 "passed",
1233 "--output",
1234 str(manifest),
1235 ]
1236 )
1237
1238 reuse = json.loads(manifest.read_text(encoding="utf-8"))["source_artifacts"][0][
1239 "same_day_reuse"
1240 ]
1241 self.assertEqual(reuse["status"], "reused")
1242 self.assertEqual(reuse["source_id"], "github-search")
1243
1244 def test_same_week_wrong_day_source_blocks_normal_promotion(self) -> None:
1245 tests_root = Path(__file__).resolve().parent
1246 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1247 base = Path(tmpdir)
1248 raw = base / "data/raw/2026-W21.json"
1249 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
1250 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
1251 write_raw(raw, crawled_at="2026-05-19T08:00:00Z")
1252 write_summary(summary)
1253
1254 publish_manifest.main(
1255 [
1256 "create",
1257 "--week",
1258 WEEK,
1259 "--run-id",
1260 RUN_ID,
1261 "--current-datetime",
1262 "2026-05-20T08:00:00Z",
1263 "--summary",
1264 str(summary),
1265 "--published-summary",
1266 str(base / "data/analyzed/2026-W21-summary.md"),
1267 "--raw-json",
1268 str(raw),
1269 "--analysis-source",
1270 "copilot-cli",
1271 "--analysis-model",
1272 "copilot-default",
1273 "--validation-status",
1274 "passed",
1275 "--output",
1276 str(manifest),
1277 ]
1278 )
1279
1280 payload = json.loads(manifest.read_text(encoding="utf-8"))
1281 self.assertFalse(payload["promotion"]["eligible"])
1282 self.assertTrue(
1283 any("current UTC run date" in reason for reason in payload["promotion"]["reasons"])
1284 )
1285
1286 def test_invalid_current_datetime_fails_manifest_creation(self) -> None:
1287 tests_root = Path(__file__).resolve().parent
1288 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1289 base = Path(tmpdir)
1290 raw = base / "data/raw/2026-W21.json"
1291 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
1292 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
1293 write_raw(raw)
1294 write_summary(summary)
1295
1296 with self.assertRaises(SystemExit):
1297 publish_manifest.main(
1298 [
1299 "create",
1300 "--week",
1301 WEEK,
1302 "--run-id",
1303 RUN_ID,
1304 "--current-datetime",
1305 "not-a-date",
1306 "--summary",
1307 str(summary),
1308 "--published-summary",
1309 str(base / "data/analyzed/2026-W21-summary.md"),
1310 "--raw-json",
1311 str(raw),
1312 "--analysis-source",
1313 "copilot-cli",
1314 "--analysis-model",
1315 "copilot-default",
1316 "--validation-status",
1317 "passed",
1318 "--output",
1319 str(manifest),
1320 ]
1321 )
1322 self.assertFalse(manifest.exists())
1323
1324 def test_candidate_only_mode_blocks_promotion_even_with_valid_sources(self) -> None:
1325 tests_root = Path(__file__).resolve().parent
1326 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1327 base = Path(tmpdir)
1328 raw = base / "data/raw/2026-W21.json"
1329 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
1330 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
1331 write_raw(raw)
1332 write_summary(summary)
1333
1334 publish_manifest.main(
1335 [
1336 "create",
1337 "--week",
1338 WEEK,
1339 "--run-id",
1340 RUN_ID,
1341 "--current-datetime",
1342 CURRENT_DATETIME,
1343 "--summary",
1344 str(summary),
1345 "--published-summary",
1346 str(base / "data/analyzed/2026-W21-summary.md"),
1347 "--raw-json",
1348 str(raw),
1349 "--analysis-source",
1350 "copilot-cli",
1351 "--analysis-model",
1352 "copilot-default",
1353 "--validation-status",
1354 "passed",
1355 "--run-mode",
1356 "candidate-only",
1357 "--output",
1358 str(manifest),
1359 ]
1360 )
1361
1362 payload = json.loads(manifest.read_text(encoding="utf-8"))
1363 self.assertFalse(payload["promotion"]["eligible"])
1364 self.assertEqual(payload["run_mode"], "candidate-only")
1365 self.assertTrue(
1366 any("non-publishing" in reason for reason in payload["promotion"]["reasons"])
1367 )
1368
1369 def test_failed_gate_report_blocks_promotion(self) -> None:
1370 tests_root = Path(__file__).resolve().parent
1371 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1372 base = Path(tmpdir)
1373 raw = base / "data/raw/2026-W21.json"
1374 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
1375 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
1376 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
1377 write_raw(raw)
1378 write_summary(summary)
1379 write_gate_report(
1380 gate_report, passed=False, errors=["editorial_quality: low-quality summary"]
1381 )
1382
1383 publish_manifest.main(
1384 create_args(base, raw, summary, manifest, gate_report=gate_report)
1385 )
1386
1387 payload = json.loads(manifest.read_text(encoding="utf-8"))
1388 self.assertFalse(payload["promotion"]["eligible"])
1389 self.assertEqual(payload["validation"]["quality_gates"][0]["status"], "failed")
1390 self.assertTrue(
1391 any("low-quality summary" in reason for reason in payload["promotion"]["reasons"])
1392 )
1393
1394 def test_missing_required_gate_family_blocks_promotion(self) -> None:
1395 tests_root = Path(__file__).resolve().parent
1396 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1397 base = Path(tmpdir)
1398 raw = base / "data/raw/2026-W21.json"
1399 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
1400 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
1401 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
1402 write_raw(raw)
1403 write_summary(summary)
1404 write_gate_report(gate_report)
1405 payload = json.loads(gate_report.read_text(encoding="utf-8"))
1406 del payload["gates"]["evidence_citation"]
1407 gate_report.write_text(json.dumps(payload), encoding="utf-8")
1408
1409 publish_manifest.main(
1410 create_args(base, raw, summary, manifest, gate_report=gate_report)
1411 )
1412
1413 payload = json.loads(manifest.read_text(encoding="utf-8"))
1414 self.assertFalse(payload["promotion"]["eligible"])
1415 self.assertTrue(
1416 any(
1417 "evidence_citation gate missing" in reason
1418 for reason in payload["promotion"]["reasons"]
1419 )
1420 )
1421
1422
1423 class FailClosedSynthesisTests(unittest.TestCase):
1424 """Cover issue #571: required synthesis must fail closed for normal AI publication."""
1425
1426 def _prepare(self, base: Path) -> tuple[Path, Path, Path, Path]:
1427 raw = base / "data/raw/2026-W21.json"
1428 summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
1429 manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
1430 gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
1431 write_raw(raw)
1432 write_summary(summary)
1433 write_gate_report(gate_report)
1434 return raw, summary, manifest, gate_report
1435
1436 def _assert_synthesis_blocks(self, status: str) -> None:
1437 tests_root = Path(__file__).resolve().parent
1438 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1439 base = Path(tmpdir)
1440 raw, summary, manifest, gate_report = self._prepare(base)
1441
1442 publish_manifest.main(
1443 create_args(
1444 base,
1445 raw,
1446 summary,
1447 manifest,
1448 gate_report=gate_report,
1449 synthesis_status=status,
1450 )
1451 )
1452
1453 payload = json.loads(manifest.read_text(encoding="utf-8"))
1454 self.assertFalse(payload["promotion"]["eligible"])
1455 self.assertTrue(payload["synthesis"]["required"])
1456 self.assertEqual(payload["synthesis"]["status"], status)
1457 self.assertFalse(payload["synthesis"]["available"])
1458 self.assertTrue(
1459 any(
1460 f"required synthesis is {status}" in reason
1461 for reason in payload["promotion"]["reasons"]
1462 ),
1463 f"expected synthesis reason for status={status!r} in {payload['promotion']['reasons']}",
1464 )
1465 with self.assertRaises(SystemExit) as ctx:
1466 assert_eligible_from_root(base, manifest)
1467 self.assertIn("synthesis", str(ctx.exception).lower())
1468 self.assertIn(status, str(ctx.exception))
1469
1470 def test_missing_synthesis_blocks_normal_ai_publication(self) -> None:
1471 self._assert_synthesis_blocks("missing")
1472
1473 def test_empty_synthesis_blocks_normal_ai_publication(self) -> None:
1474 self._assert_synthesis_blocks("empty")
1475
1476 def test_failed_synthesis_blocks_normal_ai_publication(self) -> None:
1477 self._assert_synthesis_blocks("failed")
1478
1479 def test_available_synthesis_records_provenance_on_manifest(self) -> None:
1480 tests_root = Path(__file__).resolve().parent
1481 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1482 base = Path(tmpdir)
1483 raw, summary, manifest, gate_report = self._prepare(base)
1484 synthesis_file = base / "data/diagnostics/2026-W21/synthesis.md"
1485 synthesis_file.parent.mkdir(parents=True, exist_ok=True)
1486 synthesis_file.write_text("# Weekly synthesis narrative\n", encoding="utf-8")
1487
1488 args = create_args(
1489 base,
1490 raw,
1491 summary,
1492 manifest,
1493 gate_report=gate_report,
1494 synthesis_status="available",
1495 synthesis_file=synthesis_file,
1496 )
1497 self.assertEqual(publish_manifest.main(args), 0)
1498
1499 payload = json.loads(manifest.read_text(encoding="utf-8"))
1500 self.assertTrue(payload["promotion"]["eligible"])
1501 self.assertTrue(payload["synthesis"]["required"])
1502 self.assertEqual(payload["synthesis"]["status"], "available")
1503 self.assertTrue(payload["synthesis"]["available"])
1504 self.assertEqual(
1505 payload["synthesis"]["path"],
1506 synthesis_file.as_posix(),
1507 )
1508 self.assertRegex(payload["synthesis"]["sha256"], r"^[0-9a-f]{64}$")
1509 self.assertEqual(assert_eligible_from_root(base, manifest), 0)
1510
1511 def test_dry_run_mode_is_not_gated_by_synthesis(self) -> None:
1512 """Non-normal/debug modes remain isolated from the fail-closed gate."""
1513 tests_root = Path(__file__).resolve().parent
1514 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1515 base = Path(tmpdir)
1516 raw, summary, manifest, gate_report = self._prepare(base)
1517
1518 publish_manifest.main(
1519 create_args(
1520 base,
1521 raw,
1522 summary,
1523 manifest,
1524 gate_report=gate_report,
1525 synthesis_status="missing",
1526 run_mode="dry-run",
1527 )
1528 )
1529
1530 payload = json.loads(manifest.read_text(encoding="utf-8"))
1531 self.assertFalse(payload["synthesis"]["required"])
1532 self.assertFalse(
1533 any("required synthesis" in reason for reason in payload["promotion"]["reasons"]),
1534 f"dry-run must not emit synthesis reasons: {payload['promotion']['reasons']}",
1535 )
1536
1537 def test_no_ai_normal_mode_is_not_gated_by_synthesis(self) -> None:
1538 """no-ai fallback publication is governed by its own force-replace path, not synthesis."""
1539 tests_root = Path(__file__).resolve().parent
1540 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1541 base = Path(tmpdir)
1542 raw, summary, manifest, gate_report = self._prepare(base)
1543
1544 publish_manifest.main(
1545 create_args(
1546 base,
1547 raw,
1548 summary,
1549 manifest,
1550 source="no-ai",
1551 model="none",
1552 gate_report=gate_report,
1553 synthesis_status="missing",
1554 )
1555 )
1556
1557 payload = json.loads(manifest.read_text(encoding="utf-8"))
1558 self.assertFalse(payload["synthesis"]["required"])
1559 self.assertFalse(
1560 any("required synthesis" in reason for reason in payload["promotion"]["reasons"]),
1561 f"no-ai mode must not emit synthesis reasons: {payload['promotion']['reasons']}",
1562 )
1563
1564 def test_available_without_file_is_downgraded_and_fails_closed(self) -> None:
1565 """Claiming 'available' without provenance downgrades to missing and blocks promotion."""
1566 tests_root = Path(__file__).resolve().parent
1567 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1568 base = Path(tmpdir)
1569 raw, summary, manifest, gate_report = self._prepare(base)
1570
1571 # Pass status "available" but no synthesis file / provenance.
1572 args = create_args(
1573 base,
1574 raw,
1575 summary,
1576 manifest,
1577 gate_report=gate_report,
1578 synthesis_status=None,
1579 )
1580 args.extend(["--synthesis-status", "available"])
1581 self.assertEqual(publish_manifest.main(args), 0)
1582
1583 payload = json.loads(manifest.read_text(encoding="utf-8"))
1584 self.assertTrue(payload["synthesis"]["required"])
1585 self.assertEqual(payload["synthesis"]["status"], "missing")
1586 self.assertFalse(payload["synthesis"]["available"])
1587 self.assertIsNone(payload["synthesis"]["path"])
1588 self.assertIsNone(payload["synthesis"]["sha256"])
1589 self.assertFalse(payload["promotion"]["eligible"])
1590 self.assertTrue(
1591 any(
1592 "no readable, non-empty" in reason for reason in payload["synthesis"]["reasons"]
1593 ),
1594 payload["synthesis"]["reasons"],
1595 )
1596
1597 def test_available_with_empty_file_is_downgraded(self) -> None:
1598 """An empty synthesis file cannot back an 'available' claim."""
1599 tests_root = Path(__file__).resolve().parent
1600 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1601 base = Path(tmpdir)
1602 raw, summary, manifest, gate_report = self._prepare(base)
1603 empty = base / "diagnostics" / "synthesis-narrative.md"
1604 empty.parent.mkdir(parents=True, exist_ok=True)
1605 empty.write_text("", encoding="utf-8")
1606
1607 args = create_args(
1608 base,
1609 raw,
1610 summary,
1611 manifest,
1612 gate_report=gate_report,
1613 synthesis_status="available",
1614 synthesis_file=empty,
1615 )
1616 self.assertEqual(publish_manifest.main(args), 0)
1617
1618 payload = json.loads(manifest.read_text(encoding="utf-8"))
1619 self.assertEqual(payload["synthesis"]["status"], "missing")
1620 self.assertIsNone(payload["synthesis"]["path"])
1621 self.assertFalse(payload["promotion"]["eligible"])
1622
1623 def test_assert_eligible_requires_synthesis_provenance(self) -> None:
1624 """assert-eligible rejects a manifest that claims available without path/sha256."""
1625 tests_root = Path(__file__).resolve().parent
1626 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1627 base = Path(tmpdir)
1628 raw, summary, manifest, gate_report = self._prepare(base)
1629
1630 self.assertEqual(
1631 publish_manifest.main(
1632 create_args(base, raw, summary, manifest, gate_report=gate_report)
1633 ),
1634 0,
1635 )
1636 payload = json.loads(manifest.read_text(encoding="utf-8"))
1637 # Tamper: strip provenance while leaving status "available".
1638 payload["synthesis"]["path"] = None
1639 payload["synthesis"]["sha256"] = None
1640 manifest.write_text(json.dumps(payload), encoding="utf-8")
1641
1642 with self.assertRaises(SystemExit) as ctx:
1643 assert_eligible_from_root(base, manifest)
1644 self.assertIn("provenance", str(ctx.exception))
1645
1646
1647 if __name__ == "__main__":
1648 unittest.main()