1
import json
2
+import os
3
import tempfile
4
import unittest
5
from argparse import Namespace
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
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)
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
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"))
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
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
[
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",
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
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
[
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",
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
[
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",
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
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)
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:
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
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")
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()