| 1 | import json |
| 2 | import tempfile |
| 3 | import unittest |
| 4 | from pathlib import Path |
| 5 | from unittest import mock |
| 6 | |
| 7 | import scripts.analysis_gate as analysis_gate |
| 8 | from scripts.render_press_context import NO_PRESS_SENTINEL, press_token_estimate |
| 9 | |
| 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": [ |
| 16 | {"full_name": "owner/repo-a", "stars": 300}, |
| 17 | {"full_name": "owner/repo-b", "stars": 200}, |
| 18 | ], |
| 19 | } |
| 20 | CURRENT_DATETIME = "2026-06-01T00:00:00Z" |
| 21 | |
| 22 | |
| 23 | def make_body( |
| 24 | *, alternate_heading: str = "## Where Industry Meets Code", include_todo_app: bool = False |
| 25 | ) -> str: |
| 26 | trends = " ".join( |
| 27 | [ |
| 28 | "This section names the macro trends of the week, explaining what is driving each pattern and why it matters to practitioners tracking real engineering movement." |
| 29 | ] |
| 30 | * 4 |
| 31 | ) |
| 32 | industry = " ".join( |
| 33 | [ |
| 34 | "Developer activity and press coverage aligned around practical tooling, but the narrative reveals where media attention diverged from what engineers are actually building." |
| 35 | ] |
| 36 | * 3 |
| 37 | ) |
| 38 | signal_noise = " ".join( |
| 39 | [ |
| 40 | "The durable pattern is disciplined infrastructure work and credible developer experience improvements. The weak pattern is wrapper churn, shallow agent branding, and launches that borrow attention without demonstrating technical substance or ecosystem fit." |
| 41 | ] |
| 42 | * 3 |
| 43 | ) |
| 44 | blind_spots = " ".join( |
| 45 | [ |
| 46 | "What is missing is more progress on observability, testing ergonomics, and dependable security tooling for smaller teams that still need production discipline." |
| 47 | ] |
| 48 | * 3 |
| 49 | ) |
| 50 | week_ahead = " ".join( |
| 51 | [ |
| 52 | "The week matters because it shows teams rewarding grounded software that reduces toil, while hype-heavy experiments still struggle to prove lasting value." |
| 53 | ] |
| 54 | * 2 |
| 55 | ) |
| 56 | if include_todo_app: |
| 57 | week_ahead += " Several repositories mention todo apps as legitimate examples rather than placeholder notes." |
| 58 | return f""" |
| 59 | ## This Week's Trends |
| 60 | |
| 61 | {trends} |
| 62 | |
| 63 | {alternate_heading} |
| 64 | |
| 65 | {industry} |
| 66 | |
| 67 | ## Signal & Noise |
| 68 | |
| 69 | {signal_noise} |
| 70 | |
| 71 | ## Blind Spots |
| 72 | |
| 73 | {blind_spots} |
| 74 | |
| 75 | ## The Week Ahead |
| 76 | |
| 77 | {week_ahead} |
| 78 | |
| 79 | ## Key References |
| 80 | |
| 81 | ### Notable Projects |
| 82 | |
| 83 | - [owner/repo-a](https://github.com/owner/repo-a) — anchors the automation trend with practical defaults. |
| 84 | - [owner/repo-b](https://github.com/owner/repo-b) — observability tooling for smaller teams. |
| 85 | |
| 86 | ### Press & Industry |
| 87 | |
| 88 | No press data was provided this week. |
| 89 | """.strip() |
| 90 | |
| 91 | |
| 92 | def make_analysis(frontmatter: str, body: str) -> str: |
| 93 | return f"---\n{frontmatter}\n---\n\n{body}\n" |
| 94 | |
| 95 | |
| 96 | VALID_FRONTMATTER = '''title: "The Week Local Models Went Mainstream" |
| 97 | date: 2026-06-01T00:00:00Z |
| 98 | week: 2026-W23 |
| 99 | year: 2026 |
| 100 | tags: |
| 101 | - ai |
| 102 | - agents |
| 103 | - infrastructure |
| 104 | categories: |
| 105 | - weekly |
| 106 | repos_featured: 9 |
| 107 | stars_tracked: 1200 |
| 108 | top_repo: owner/repo |
| 109 | quality_score: 82 |
| 110 | summary: "A grounded week focused on practical tools."'''.strip() |
| 111 | |
| 112 | |
| 113 | class AnalysisGateTests(unittest.TestCase): |
| 114 | def test_objective_quality_press_included_never_scores_below_press_less(self) -> None: |
| 115 | body = make_body().replace( |
| 116 | "No press data was provided this week.", |
| 117 | "- [Industry report](https://example.com/industry-report) — confirms the trend.", |
| 118 | ) |
| 119 | analysis = make_analysis(VALID_FRONTMATTER, body) |
| 120 | |
| 121 | press_score, press_breakdown = analysis_gate.compute_objective_quality( |
| 122 | analysis, RAW_PAYLOAD_WITH_REPOS, press_context_available=True |
| 123 | ) |
| 124 | press_less_score, press_less_breakdown = analysis_gate.compute_objective_quality( |
| 125 | analysis, RAW_PAYLOAD_WITH_REPOS, press_context_available=False |
| 126 | ) |
| 127 | |
| 128 | # jmservera/SquadScope#583: prevent the W30 paradox where adding real press |
| 129 | # evidence made an otherwise identical summary score lower. |
| 130 | self.assertGreaterEqual(press_score, press_less_score) |
| 131 | self.assertGreater(press_score, press_less_score) |
| 132 | self.assertEqual(press_breakdown["words"], press_less_breakdown["words"]) |
| 133 | self.assertEqual(press_breakdown["repo_citations"], press_less_breakdown["repo_citations"]) |
| 134 | self.assertEqual(press_breakdown["press_citations"], 1) |
| 135 | self.assertEqual(press_less_breakdown["press"], 0) |
| 136 | |
| 137 | def test_objective_quality_no_press_week_retains_publishable_base_score(self) -> None: |
| 138 | score, breakdown = analysis_gate.compute_objective_quality( |
| 139 | make_analysis("week: 2026-W23", "A short press-less summary."), |
| 140 | RAW_PAYLOAD, |
| 141 | press_context_available=False, |
| 142 | ) |
| 143 | |
| 144 | self.assertGreaterEqual(score, 60) |
| 145 | self.assertEqual(breakdown["base"], 60) |
| 146 | self.assertEqual(breakdown["press"], 0) |
| 147 | self.assertFalse(breakdown["press_available"]) |
| 148 | |
| 149 | def test_objective_quality_depth_and_evidence_scale_and_cap(self) -> None: |
| 150 | short_text = make_analysis("week: 2026-W23", "word " * 200) |
| 151 | medium_text = make_analysis("week: 2026-W23", "word " * 700) |
| 152 | long_text = make_analysis("week: 2026-W23", "word " * 1400) |
| 153 | |
| 154 | _, short = analysis_gate.compute_objective_quality(short_text, {}, False) |
| 155 | _, medium = analysis_gate.compute_objective_quality(medium_text, {}, False) |
| 156 | _, long = analysis_gate.compute_objective_quality(long_text, {}, False) |
| 157 | |
| 158 | self.assertEqual(short["depth"], 0) |
| 159 | self.assertGreater(medium["depth"], short["depth"]) |
| 160 | self.assertEqual(long["depth"], 15) |
| 161 | |
| 162 | repos = [f"owner/repo-{index}" for index in range(12)] |
| 163 | raw_payload = { |
| 164 | "new_repos": [{"full_name": repo} for repo in repos[:6]], |
| 165 | "trending_repos": [{"full_name": repo} for repo in repos[6:]], |
| 166 | } |
| 167 | |
| 168 | def cited_analysis(count: int) -> str: |
| 169 | links = "\n".join(f"- [{repo}](https://github.com/{repo})" for repo in repos[:count]) |
| 170 | return make_analysis("week: 2026-W23", links) |
| 171 | |
| 172 | _, none = analysis_gate.compute_objective_quality(cited_analysis(0), raw_payload, False) |
| 173 | _, half = analysis_gate.compute_objective_quality(cited_analysis(5), raw_payload, False) |
| 174 | _, full = analysis_gate.compute_objective_quality(cited_analysis(12), raw_payload, False) |
| 175 | _, no_inventory = analysis_gate.compute_objective_quality(cited_analysis(1), {}, False) |
| 176 | |
| 177 | self.assertEqual(none["evidence"], 0) |
| 178 | self.assertEqual(half["evidence"], 5) |
| 179 | self.assertEqual(full["evidence"], 10) |
| 180 | self.assertEqual(full["repo_citations"], 12) |
| 181 | self.assertEqual(no_inventory["evidence"], 0) |
| 182 | self.assertEqual(no_inventory["repo_citations"], 0) |
| 183 | |
| 184 | def test_objective_quality_press_bonus_caps_and_requires_press_context(self) -> None: |
| 185 | body = """## Key References |
| 186 | |
| 187 | ### Notable Projects |
| 188 | |
| 189 | - [owner/repo-a](https://github.com/owner/repo-a) |
| 190 | |
| 191 | ### Press & Industry |
| 192 | |
| 193 | - [One](https://one.example/article) |
| 194 | - [Two](https://two.example/article) |
| 195 | - [Three](https://three.example/article) |
| 196 | - [Four](https://four.example/article) |
| 197 | - [GitHub](https://github.com/owner/repo-a) |
| 198 | """ |
| 199 | analysis = make_analysis("week: 2026-W23", body) |
| 200 | |
| 201 | _, available = analysis_gate.compute_objective_quality( |
| 202 | analysis, RAW_PAYLOAD_WITH_REPOS, True |
| 203 | ) |
| 204 | _, unavailable = analysis_gate.compute_objective_quality( |
| 205 | analysis, RAW_PAYLOAD_WITH_REPOS, False |
| 206 | ) |
| 207 | |
| 208 | self.assertEqual(available["press"], 15) |
| 209 | self.assertEqual(available["press_citations"], 4) |
| 210 | self.assertEqual(unavailable["press"], 0) |
| 211 | self.assertEqual(unavailable["press_citations"], 0) |
| 212 | |
| 213 | def test_objective_quality_excludes_github_owned_hosts_from_press(self) -> None: |
| 214 | body = """## Key References |
| 215 | |
| 216 | ### Notable Projects |
| 217 | |
| 218 | - [owner/repo-a](https://github.com/owner/repo-a) |
| 219 | |
| 220 | ### Press & Industry |
| 221 | |
| 222 | - [Real press](https://press.example/article) |
| 223 | - [Gist](https://gist.github.com/owner/abc123) |
| 224 | - [Raw](https://raw.githubusercontent.com/owner/repo-a/main/README.md) |
| 225 | - [Sub](https://api.github.com/repos/owner/repo-a) |
| 226 | """ |
| 227 | analysis = make_analysis("week: 2026-W23", body) |
| 228 | |
| 229 | _, breakdown = analysis_gate.compute_objective_quality( |
| 230 | analysis, RAW_PAYLOAD_WITH_REPOS, True |
| 231 | ) |
| 232 | |
| 233 | self.assertEqual(breakdown["press_citations"], 1) |
| 234 | |
| 235 | def test_objective_quality_ignores_urls_after_press_subsection(self) -> None: |
| 236 | body = """## Key References |
| 237 | |
| 238 | ### Press & Industry |
| 239 | |
| 240 | - [Real press](https://press.example/article) |
| 241 | |
| 242 | ### Further Reading |
| 243 | |
| 244 | - [Not press](https://blog.example/post) |
| 245 | - [Also not](https://docs.example/guide) |
| 246 | """ |
| 247 | analysis = make_analysis("week: 2026-W23", body) |
| 248 | |
| 249 | _, breakdown = analysis_gate.compute_objective_quality( |
| 250 | analysis, RAW_PAYLOAD_WITH_REPOS, True |
| 251 | ) |
| 252 | |
| 253 | self.assertEqual(breakdown["press_citations"], 1) |
| 254 | |
| 255 | def test_set_frontmatter_quality_score_replaces_inserts_and_preserves_body(self) -> None: |
| 256 | body = "Body with quality_score: 999 that must remain untouched.\n" |
| 257 | existing = make_analysis("week: 2026-W23\nquality_score: 99", body) |
| 258 | missing = make_analysis("week: 2026-W23", body) |
| 259 | |
| 260 | replaced = analysis_gate.set_frontmatter_quality_score(existing, 72) |
| 261 | inserted = analysis_gate.set_frontmatter_quality_score(missing, 68) |
| 262 | |
| 263 | self.assertIn("\nquality_score: 72\n---", replaced) |
| 264 | self.assertEqual(replaced.split("---\n", 2)[2], f"\n{body}\n") |
| 265 | self.assertIn("\nquality_score: 68\n---", inserted) |
| 266 | self.assertEqual(inserted.split("---\n", 2)[2], f"\n{body}\n") |
| 267 | with self.assertRaisesRegex(ValueError, "missing YAML frontmatter"): |
| 268 | analysis_gate.set_frontmatter_quality_score(body, 70) |
| 269 | |
| 270 | def test_main_overwrites_llm_quality_score_and_reports_objective_breakdown(self) -> None: |
| 271 | tests_root = Path(__file__).resolve().parent |
| 272 | with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir: |
| 273 | workspace = Path(tmpdir) |
| 274 | analysis_path = workspace / "candidate.md" |
| 275 | raw_path = workspace / "raw.json" |
| 276 | report_path = workspace / "report.json" |
| 277 | original = make_analysis( |
| 278 | VALID_FRONTMATTER.replace("quality_score: 82", "quality_score: 99"), |
| 279 | make_body(), |
| 280 | ) |
| 281 | expected_score, expected_breakdown = analysis_gate.compute_objective_quality( |
| 282 | original, RAW_PAYLOAD_WITH_REPOS, False |
| 283 | ) |
| 284 | analysis_path.write_text(original, encoding="utf-8") |
| 285 | raw_path.write_text(json.dumps(RAW_PAYLOAD_WITH_REPOS), encoding="utf-8") |
| 286 | |
| 287 | self.assertEqual( |
| 288 | analysis_gate.main( |
| 289 | [ |
| 290 | "--analysis-file", |
| 291 | str(analysis_path), |
| 292 | "--raw-json", |
| 293 | str(raw_path), |
| 294 | "--current-datetime", |
| 295 | CURRENT_DATETIME, |
| 296 | "--source", |
| 297 | "copilot-cli", |
| 298 | "--model", |
| 299 | "copilot-default", |
| 300 | "--report-json", |
| 301 | str(report_path), |
| 302 | ] |
| 303 | ), |
| 304 | 0, |
| 305 | ) |
| 306 | |
| 307 | frontmatter, _ = analysis_gate.extract_frontmatter( |
| 308 | analysis_path.read_text(encoding="utf-8") |
| 309 | ) |
| 310 | report = json.loads(report_path.read_text(encoding="utf-8")) |
| 311 | self.assertEqual(frontmatter["quality_score"], expected_score) |
| 312 | self.assertNotEqual(frontmatter["quality_score"], 99) |
| 313 | self.assertEqual(report["quality_breakdown"], expected_breakdown) |
| 314 | |
| 315 | def test_validate_analysis_accepts_block_style_lists(self) -> None: |
| 316 | errors, word_count = analysis_gate.validate_analysis( |
| 317 | make_analysis(VALID_FRONTMATTER, make_body()), |
| 318 | RAW_PAYLOAD, |
| 319 | CURRENT_DATETIME, |
| 320 | ) |
| 321 | |
| 322 | self.assertEqual(errors, []) |
| 323 | self.assertGreaterEqual(word_count, 200) |
| 324 | |
| 325 | def test_validate_analysis_rejects_wrong_week_date_and_types(self) -> None: |
| 326 | invalid_frontmatter = '''title: "The Week Local Models Went Mainstream" |
| 327 | date: 2026-06-01T12:00:00Z |
| 328 | week: 2026-W22 |
| 329 | year: "2026" |
| 330 | tags: weekly |
| 331 | categories: |
| 332 | - analysis |
| 333 | repos_featured: 9 |
| 334 | stars_tracked: 1200 |
| 335 | top_repo: owner/repo |
| 336 | quality_score: 82 |
| 337 | summary: "A grounded week focused on practical tools."'''.strip() |
| 338 | |
| 339 | errors, _ = analysis_gate.validate_analysis( |
| 340 | make_analysis(invalid_frontmatter, make_body()), |
| 341 | RAW_PAYLOAD, |
| 342 | CURRENT_DATETIME, |
| 343 | ) |
| 344 | |
| 345 | self.assertIn("week must match raw payload week '2026-W23'.", errors) |
| 346 | self.assertIn("year must be an integer.", errors) |
| 347 | self.assertIn("tags must be an array of strings.", errors) |
| 348 | self.assertIn("categories must include 'weekly'.", errors) |
| 349 | self.assertIn("date must match the current run timestamp.", errors) |
| 350 | |
| 351 | def test_validate_analysis_requires_real_heading_lines(self) -> None: |
| 352 | body = make_body( |
| 353 | alternate_heading="The prose references ## Where Industry Meets Code without creating a heading line.", |
| 354 | ) |
| 355 | errors, _ = analysis_gate.validate_analysis( |
| 356 | make_analysis(VALID_FRONTMATTER, body), |
| 357 | RAW_PAYLOAD, |
| 358 | CURRENT_DATETIME, |
| 359 | ) |
| 360 | |
| 361 | self.assertIn("Missing required section heading: ## Where Industry Meets Code", errors) |
| 362 | |
| 363 | def test_validate_analysis_allows_legitimate_todo_mentions(self) -> None: |
| 364 | errors, _ = analysis_gate.validate_analysis( |
| 365 | make_analysis(VALID_FRONTMATTER, make_body(include_todo_app=True)), |
| 366 | RAW_PAYLOAD, |
| 367 | CURRENT_DATETIME, |
| 368 | ) |
| 369 | |
| 370 | self.assertEqual(errors, []) |
| 371 | |
| 372 | def test_validate_analysis_rejects_todo_placeholders(self) -> None: |
| 373 | body = make_body() + "\n\nTODO: replace this closing note.\n" |
| 374 | errors, _ = analysis_gate.validate_analysis( |
| 375 | make_analysis(VALID_FRONTMATTER, body), |
| 376 | RAW_PAYLOAD, |
| 377 | CURRENT_DATETIME, |
| 378 | ) |
| 379 | |
| 380 | self.assertIn( |
| 381 | "Analysis body contains prohibited placeholder marker: TODO placeholder marker", errors |
| 382 | ) |
| 383 | |
| 384 | def test_validate_analysis_rejects_generic_week_analysis_title(self) -> None: |
| 385 | frontmatter = VALID_FRONTMATTER.replace( |
| 386 | 'title: "The Week Local Models Went Mainstream"', |
| 387 | 'title: "Week 21, 2026 Analysis"', |
| 388 | ) |
| 389 | errors, _ = analysis_gate.validate_analysis( |
| 390 | make_analysis(frontmatter, make_body()), |
| 391 | RAW_PAYLOAD, |
| 392 | CURRENT_DATETIME, |
| 393 | ) |
| 394 | |
| 395 | self.assertIn("title must not use a generic week/year placeholder format.", errors) |
| 396 | |
| 397 | def test_validate_analysis_rejects_generic_week_year_title(self) -> None: |
| 398 | frontmatter = VALID_FRONTMATTER.replace( |
| 399 | 'title: "The Week Local Models Went Mainstream"', |
| 400 | 'title: "Week 21, 2026"', |
| 401 | ) |
| 402 | errors, _ = analysis_gate.validate_analysis( |
| 403 | make_analysis(frontmatter, make_body()), |
| 404 | RAW_PAYLOAD, |
| 405 | CURRENT_DATETIME, |
| 406 | ) |
| 407 | |
| 408 | self.assertIn("title must not use a generic week/year placeholder format.", errors) |
| 409 | |
| 410 | def test_validate_analysis_accepts_prediction_registry(self) -> None: |
| 411 | frontmatter = ( |
| 412 | VALID_FRONTMATTER |
| 413 | + "\npredictions:\n - repo: owner/repo\n claim_type: signal\n direction: up\n confidence: 0.7" |
| 414 | ) |
| 415 | |
| 416 | errors, _ = analysis_gate.validate_analysis( |
| 417 | make_analysis(frontmatter, make_body()), |
| 418 | RAW_PAYLOAD, |
| 419 | CURRENT_DATETIME, |
| 420 | ) |
| 421 | |
| 422 | self.assertEqual(errors, []) |
| 423 | |
| 424 | def test_repair_analysis_refuses_to_guess_legacy_prediction_claim_type(self) -> None: |
| 425 | frontmatter = ( |
| 426 | VALID_FRONTMATTER.replace( |
| 427 | "date: 2026-06-01T00:00:00Z", |
| 428 | "date: 2026-06-01T12:00:00Z", |
| 429 | ) |
| 430 | + "\npredictions:\n - repo: owner/repo\n direction: up\n confidence: 0.7" |
| 431 | ) |
| 432 | |
| 433 | repaired_text, actions = analysis_gate.repair_analysis( |
| 434 | make_analysis(frontmatter, make_body()), |
| 435 | RAW_PAYLOAD_WITH_REPOS, |
| 436 | CURRENT_DATETIME, |
| 437 | ) |
| 438 | errors, _ = analysis_gate.validate_analysis( |
| 439 | repaired_text, RAW_PAYLOAD_WITH_REPOS, CURRENT_DATETIME |
| 440 | ) |
| 441 | frontmatter_after, _ = analysis_gate.extract_frontmatter(repaired_text) |
| 442 | |
| 443 | self.assertEqual(errors, ["predictions[1].claim_type must be one of signal, noise, gap."]) |
| 444 | self.assertIn("set date from current run timestamp", actions) |
| 445 | self.assertNotIn("claim_type", frontmatter_after["predictions"][0]) |
| 446 | self.assertEqual(frontmatter_after["repos_featured"], 3) |
| 447 | self.assertEqual(frontmatter_after["stars_tracked"], 1500) |
| 448 | |
| 449 | def test_repair_analysis_normalizes_safe_prediction_claim_alias(self) -> None: |
| 450 | frontmatter = ( |
| 451 | VALID_FRONTMATTER |
| 452 | + "\npredictions:\n - repo: owner/repo\n claim: Signal\n direction: UP\n confidence: 0.7" |
| 453 | ) |
| 454 | |
| 455 | repaired_text, actions = analysis_gate.repair_analysis( |
| 456 | make_analysis(frontmatter, make_body()), |
| 457 | RAW_PAYLOAD, |
| 458 | CURRENT_DATETIME, |
| 459 | ) |
| 460 | errors, _ = analysis_gate.validate_analysis(repaired_text, RAW_PAYLOAD, CURRENT_DATETIME) |
| 461 | frontmatter_after, _ = analysis_gate.extract_frontmatter(repaired_text) |
| 462 | |
| 463 | self.assertEqual(errors, []) |
| 464 | self.assertIn("set predictions[1].claim_type from claim", actions) |
| 465 | self.assertEqual(frontmatter_after["predictions"][0]["claim_type"], "signal") |
| 466 | self.assertNotIn("claim", frontmatter_after["predictions"][0]) |
| 467 | |
| 468 | def test_validate_analysis_rejects_invalid_prediction_registry(self) -> None: |
| 469 | frontmatter = ( |
| 470 | VALID_FRONTMATTER |
| 471 | + "\npredictions:\n - repo: bad repo\n claim_type: maybe\n direction: sideways\n confidence: 1.3\n note: nope" |
| 472 | ) |
| 473 | |
| 474 | errors, _ = analysis_gate.validate_analysis( |
| 475 | make_analysis(frontmatter, make_body()), |
| 476 | RAW_PAYLOAD, |
| 477 | CURRENT_DATETIME, |
| 478 | ) |
| 479 | |
| 480 | self.assertIn("predictions[1].repo must use owner/repo format.", errors) |
| 481 | self.assertIn("predictions[1].claim_type must be one of signal, noise, gap.", errors) |
| 482 | self.assertIn("predictions[1].direction must be one of up, flat, down.", errors) |
| 483 | self.assertIn("predictions[1].confidence must be between 0 and 1.", errors) |
| 484 | self.assertIn("predictions[1] has unexpected fields: note", errors) |
| 485 | |
| 486 | def test_prediction_contract_examples_stay_aligned_with_gate(self) -> None: |
| 487 | repo_root = Path(__file__).resolve().parent.parent |
| 488 | docs = (repo_root / "docs" / "analysis-spec.md").read_text(encoding="utf-8") |
| 489 | prompt = (repo_root / "prompts" / "analyze-weekly.md").read_text(encoding="utf-8") |
| 490 | |
| 491 | for content in (docs, prompt): |
| 492 | self.assertIn("{repo, claim_type, direction, confidence}", content) |
| 493 | self.assertIn("signal|noise|gap", content) |
| 494 | self.assertNotIn("{repo, direction, confidence}", content) |
| 495 | |
| 496 | def test_gate_report_fingerprint_tolerates_missing_or_invalid_reports(self) -> None: |
| 497 | tests_root = Path(__file__).resolve().parent |
| 498 | with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir: |
| 499 | workspace = Path(tmpdir) |
| 500 | missing = workspace / "missing.json" |
| 501 | invalid = workspace / "invalid.json" |
| 502 | report = workspace / "report.json" |
| 503 | invalid.write_text("not json", encoding="utf-8") |
| 504 | report.write_text( |
| 505 | json.dumps({"errors_after_repair": ["date must match the current run timestamp."]}), |
| 506 | encoding="utf-8", |
| 507 | ) |
| 508 | |
| 509 | self.assertEqual(analysis_gate.gate_report_fingerprint(missing), "") |
| 510 | self.assertEqual(analysis_gate.gate_report_fingerprint(invalid), "") |
| 511 | self.assertRegex(analysis_gate.gate_report_fingerprint(report), r"^[0-9a-f]{64}$") |
| 512 | |
| 513 | def test_fallback_frontmatter_dump_handles_empty_dict_list_items(self) -> None: |
| 514 | original_yaml = analysis_gate.yaml |
| 515 | try: |
| 516 | analysis_gate.yaml = None |
| 517 | dumped = analysis_gate.dump_frontmatter({"predictions": [{}]}) |
| 518 | finally: |
| 519 | analysis_gate.yaml = original_yaml |
| 520 | |
| 521 | self.assertIn(" - {}", dumped) |
| 522 | |
| 523 | def test_repair_exception_still_writes_gate_report(self) -> None: |
| 524 | tests_root = Path(__file__).resolve().parent |
| 525 | with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir: |
| 526 | workspace = Path(tmpdir) |
| 527 | analysis_path = workspace / "candidate.md" |
| 528 | raw_path = workspace / "raw.json" |
| 529 | report_path = workspace / "report.json" |
| 530 | analysis_path.write_text( |
| 531 | make_analysis(VALID_FRONTMATTER + "\npredictions:\n - {}", make_body()), |
| 532 | encoding="utf-8", |
| 533 | ) |
| 534 | raw_path.write_text('{"week": "2026-W23"}', encoding="utf-8") |
| 535 | |
| 536 | with mock.patch.object( |
| 537 | analysis_gate, "repair_analysis", side_effect=RuntimeError("boom") |
| 538 | ): |
| 539 | with self.assertRaises(SystemExit) as raised: |
| 540 | analysis_gate.main( |
| 541 | [ |
| 542 | "--analysis-file", |
| 543 | str(analysis_path), |
| 544 | "--raw-json", |
| 545 | str(raw_path), |
| 546 | "--current-datetime", |
| 547 | CURRENT_DATETIME, |
| 548 | "--repair-safe", |
| 549 | "--report-json", |
| 550 | str(report_path), |
| 551 | ] |
| 552 | ) |
| 553 | |
| 554 | self.assertEqual(raised.exception.code, 1) |
| 555 | report = analysis_gate.load_json(report_path) |
| 556 | self.assertEqual(report["repair_actions"], ["repair skipped: boom"]) |
| 557 | self.assertIn( |
| 558 | "predictions[1].repo must use owner/repo format.", report["errors_after_repair"] |
| 559 | ) |
| 560 | |
| 561 | def test_gate_report_captures_pre_repair_publish_errors_from_original_text(self) -> None: |
| 562 | tests_root = Path(__file__).resolve().parent |
| 563 | with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir: |
| 564 | workspace = Path(tmpdir) |
| 565 | analysis_path = workspace / "candidate.md" |
| 566 | raw_path = workspace / "raw.json" |
| 567 | report_path = workspace / "report.json" |
| 568 | original_text = make_analysis( |
| 569 | VALID_FRONTMATTER.replace( |
| 570 | "date: 2026-06-01T00:00:00Z", "date: 2026-06-01T12:00:00Z" |
| 571 | ), |
| 572 | make_body(), |
| 573 | ) |
| 574 | analysis_path.write_text(original_text, encoding="utf-8") |
| 575 | raw_path.write_text(json.dumps(RAW_PAYLOAD_WITH_REPOS), encoding="utf-8") |
| 576 | |
| 577 | def publish_quality_for( |
| 578 | text: str, |
| 579 | raw_payload: dict, |
| 580 | *, |
| 581 | source: str, |
| 582 | model: str, |
| 583 | press_context_available: bool = False, |
| 584 | ) -> tuple[list[str], dict]: |
| 585 | if text == original_text: |
| 586 | return ["pre-repair publish-quality failure"], analysis_gate.build_gate_results( |
| 587 | ["pre-repair publish-quality failure"] |
| 588 | ) |
| 589 | return [], analysis_gate.build_gate_results([]) |
| 590 | |
| 591 | with mock.patch.object( |
| 592 | analysis_gate, "validate_publish_quality", side_effect=publish_quality_for |
| 593 | ): |
| 594 | self.assertEqual( |
| 595 | analysis_gate.main( |
| 596 | [ |
| 597 | "--analysis-file", |
| 598 | str(analysis_path), |
| 599 | "--raw-json", |
| 600 | str(raw_path), |
| 601 | "--current-datetime", |
| 602 | CURRENT_DATETIME, |
| 603 | "--repair-safe", |
| 604 | "--report-json", |
| 605 | str(report_path), |
| 606 | ] |
| 607 | ), |
| 608 | 0, |
| 609 | ) |
| 610 | |
| 611 | report = analysis_gate.load_json(report_path) |
| 612 | self.assertIn("pre-repair publish-quality failure", report["errors_before_repair"]) |
| 613 | self.assertNotIn("pre-repair publish-quality failure", report["errors_after_repair"]) |
| 614 | |
| 615 | def test_publish_quality_gate_rejects_structurally_valid_low_quality_summary(self) -> None: |
| 616 | generic = " ".join( |
| 617 | ["Projects were active this week and many updates appeared across the list."] * 12 |
| 618 | ) |
| 619 | low_quality = f""" |
| 620 | ## This Week's Trends |
| 621 | |
| 622 | {generic} |
| 623 | |
| 624 | ## Where Industry Meets Code |
| 625 | |
| 626 | {generic} |
| 627 | |
| 628 | ## Signal & Noise |
| 629 | |
| 630 | {generic} |
| 631 | |
| 632 | ## Blind Spots |
| 633 | |
| 634 | {generic} |
| 635 | |
| 636 | ## The Week Ahead |
| 637 | |
| 638 | {generic} |
| 639 | |
| 640 | ## Key References |
| 641 | |
| 642 | ### Notable Projects |
| 643 | |
| 644 | - [owner/repo-a](https://github.com/owner/repo-a) — appeared in the list. |
| 645 | - [owner/repo-b](https://github.com/owner/repo-b) — appeared in the list. |
| 646 | |
| 647 | ### Press & Industry |
| 648 | |
| 649 | No press data was provided this week. |
| 650 | """.strip() |
| 651 | errors, gates = analysis_gate.validate_publish_quality( |
| 652 | make_analysis(VALID_FRONTMATTER, low_quality), |
| 653 | RAW_PAYLOAD, |
| 654 | source="copilot-cli", |
| 655 | model="copilot-default", |
| 656 | ) |
| 657 | |
| 658 | self.assertTrue(any("editorial analysis" in error for error in errors)) |
| 659 | self.assertFalse(gates["editorial_quality"]["passed"]) |
| 660 | |
| 661 | def test_publish_quality_gate_accepts_known_good_weekly_outputs(self) -> None: |
| 662 | repo_root = Path(__file__).resolve().parent.parent |
| 663 | fixtures = [ |
| 664 | ( |
| 665 | "2026-W22", |
| 666 | "2026-05-25T11:56:08Z", |
| 667 | "perplexityai/bumblebee", |
| 668 | repo_root / "data/analyzed/2026-W22-summary.md", |
| 669 | ), |
| 670 | ( |
| 671 | "2026-W23", |
| 672 | "2026-06-06T07:49:43Z", |
| 673 | "pewdiepie-archdaemon/odysseus", |
| 674 | repo_root / "data/analyzed/2026-W23-summary.md", |
| 675 | ), |
| 676 | ] |
| 677 | |
| 678 | for week, crawled_at, repo_name, summary_path in fixtures: |
| 679 | with self.subTest(week=week): |
| 680 | raw_payload = { |
| 681 | "week": week, |
| 682 | "crawled_at": crawled_at, |
| 683 | "new_repos": [{"full_name": repo_name, "stars": 100}], |
| 684 | "trending_repos": [], |
| 685 | } |
| 686 | text = summary_path.read_text(encoding="utf-8") |
| 687 | linked_repos = sorted(analysis_gate.REPO_LINK_PATTERN.findall(text)) |
| 688 | raw_payload["new_repos"].extend( |
| 689 | {"full_name": name, "stars": 100} for name in linked_repos if name != repo_name |
| 690 | ) |
| 691 | |
| 692 | structure_errors, word_count = analysis_gate.validate_analysis( |
| 693 | text, raw_payload, crawled_at |
| 694 | ) |
| 695 | publish_errors, gates = analysis_gate.validate_publish_quality( |
| 696 | text, |
| 697 | raw_payload, |
| 698 | source="copilot-cli", |
| 699 | model="copilot-default", |
| 700 | ) |
| 701 | |
| 702 | self.assertEqual(structure_errors, []) |
| 703 | self.assertGreater(word_count, 200) |
| 704 | self.assertEqual(publish_errors, []) |
| 705 | self.assertTrue(all(gate["passed"] for gate in gates.values())) |
| 706 | |
| 707 | def test_copilot_source_without_explicit_model_uses_publishable_default(self) -> None: |
| 708 | errors, gates = analysis_gate.validate_publish_quality( |
| 709 | make_analysis(VALID_FRONTMATTER, make_body()), |
| 710 | RAW_PAYLOAD_WITH_REPOS, |
| 711 | source="copilot-cli", |
| 712 | model=analysis_gate.parse_args( |
| 713 | [ |
| 714 | "--analysis-file", |
| 715 | "candidate.md", |
| 716 | "--raw-json", |
| 717 | "raw.json", |
| 718 | "--current-datetime", |
| 719 | CURRENT_DATETIME, |
| 720 | "--source", |
| 721 | "copilot-cli", |
| 722 | ] |
| 723 | ).model, |
| 724 | ) |
| 725 | |
| 726 | self.assertEqual(errors, []) |
| 727 | self.assertTrue(gates["ai_provenance"]["passed"]) |
| 728 | |
| 729 | def test_publish_quality_gate_rejects_missing_evidence_citations(self) -> None: |
| 730 | body = ( |
| 731 | make_body() |
| 732 | .replace("[owner/repo-a](https://github.com/owner/repo-a)", "owner/repo-a") |
| 733 | .replace( |
| 734 | "[owner/repo-b](https://github.com/owner/repo-b)", |
| 735 | "owner/repo-b", |
| 736 | ) |
| 737 | ) |
| 738 | errors, gates = analysis_gate.validate_publish_quality( |
| 739 | make_analysis(VALID_FRONTMATTER, body), |
| 740 | RAW_PAYLOAD_WITH_REPOS, |
| 741 | source="copilot-cli", |
| 742 | model="copilot-default", |
| 743 | ) |
| 744 | |
| 745 | self.assertIn( |
| 746 | "evidence citations must include at least one repository link from the raw payload.", |
| 747 | errors, |
| 748 | ) |
| 749 | self.assertFalse(gates["evidence_citation"]["passed"]) |
| 750 | |
| 751 | def test_publish_quality_gate_rejects_repo_links_outside_current_inventory(self) -> None: |
| 752 | body = make_body().replace( |
| 753 | "[owner/repo-a](https://github.com/owner/repo-a)", |
| 754 | "[other/repo](https://github.com/other/repo)", |
| 755 | ) |
| 756 | errors, gates = analysis_gate.validate_publish_quality( |
| 757 | make_analysis(VALID_FRONTMATTER, body), |
| 758 | RAW_PAYLOAD_WITH_REPOS, |
| 759 | source="copilot-cli", |
| 760 | model="copilot-default", |
| 761 | ) |
| 762 | |
| 763 | self.assertIn( |
| 764 | "repository links must resolve to the current raw evidence inventory: other/repo.", |
| 765 | errors, |
| 766 | ) |
| 767 | self.assertFalse(gates["evidence_citation"]["passed"]) |
| 768 | |
| 769 | def test_publish_quality_gate_rejects_stale_evidence(self) -> None: |
| 770 | stale_payload = dict(RAW_PAYLOAD_WITH_REPOS, crawled_at="2026-05-25T00:00:00Z") |
| 771 | errors, gates = analysis_gate.validate_publish_quality( |
| 772 | make_analysis(VALID_FRONTMATTER, make_body()), |
| 773 | stale_payload, |
| 774 | source="copilot-cli", |
| 775 | model="copilot-default", |
| 776 | ) |
| 777 | |
| 778 | self.assertTrue(any("raw evidence timestamp week mismatch" in error for error in errors)) |
| 779 | self.assertFalse(gates["evidence_citation"]["passed"]) |
| 780 | |
| 781 | def test_publish_quality_gate_prefers_generated_at_for_republished_evidence(self) -> None: |
| 782 | republished_payload = dict( |
| 783 | RAW_PAYLOAD_WITH_REPOS, |
| 784 | crawled_at="2026-05-25T00:00:00Z", |
| 785 | generated_at="2026-06-01T00:00:00Z", |
| 786 | ) |
| 787 | errors, gates = analysis_gate.validate_publish_quality( |
| 788 | make_analysis(VALID_FRONTMATTER, make_body()), |
| 789 | republished_payload, |
| 790 | source="copilot-cli", |
| 791 | model="copilot-default", |
| 792 | ) |
| 793 | |
| 794 | self.assertEqual(errors, []) |
| 795 | self.assertTrue(gates["evidence_citation"]["passed"]) |
| 796 | |
| 797 | def test_publish_quality_gate_rejects_no_ai_provenance(self) -> None: |
| 798 | errors, gates = analysis_gate.validate_publish_quality( |
| 799 | make_analysis(VALID_FRONTMATTER, make_body()), |
| 800 | RAW_PAYLOAD, |
| 801 | source="no-ai", |
| 802 | model="none", |
| 803 | ) |
| 804 | |
| 805 | self.assertIn("AI provenance source is not publishable: no-ai.", errors) |
| 806 | self.assertIn("AI provenance model is not publishable: none.", errors) |
| 807 | self.assertFalse(gates["ai_provenance"]["passed"]) |
| 808 | |
| 809 | def test_publish_quality_gate_rejects_github_models_provenance(self) -> None: |
| 810 | errors, gates = analysis_gate.validate_publish_quality( |
| 811 | make_analysis(VALID_FRONTMATTER, make_body()), |
| 812 | RAW_PAYLOAD, |
| 813 | source="github-models", |
| 814 | model="openai/gpt-4o", |
| 815 | ) |
| 816 | |
| 817 | self.assertIn("AI provenance source is not publishable: github-models.", errors) |
| 818 | self.assertFalse(gates["ai_provenance"]["passed"]) |
| 819 | |
| 820 | def test_gate_report_includes_structured_failure_summary(self) -> None: |
| 821 | errors = [ |
| 822 | "repository links must resolve to the current raw evidence inventory: other/repo." |
| 823 | ] |
| 824 | gates = analysis_gate.build_gate_results(errors) |
| 825 | summary = analysis_gate.build_failure_summary(errors, gates) |
| 826 | |
| 827 | self.assertEqual(summary["failure_class"], "evidence_citation") |
| 828 | self.assertEqual(summary["failure_categories"], ["evidence_citation"]) |
| 829 | self.assertEqual(summary["error_count"], 1) |
| 830 | |
| 831 | def test_publish_quality_gate_rejects_contradictory_press_claims(self) -> None: |
| 832 | body = ( |
| 833 | make_body() |
| 834 | + "\n\nNo press data was provided this week, but TechCrunch reported a major launch." |
| 835 | ) |
| 836 | errors, gates = analysis_gate.validate_publish_quality( |
| 837 | make_analysis(VALID_FRONTMATTER, body), |
| 838 | RAW_PAYLOAD, |
| 839 | source="copilot-cli", |
| 840 | model="copilot-default", |
| 841 | ) |
| 842 | |
| 843 | self.assertTrue(any("contradictory claim" in error for error in errors)) |
| 844 | self.assertFalse(gates["editorial_quality"]["passed"]) |
| 845 | |
| 846 | def test_stale_press_claim_fails_when_press_context_available(self) -> None: |
| 847 | """Regression (2026-W30): body claims no press data while a populated press |
| 848 | context exists → gate must fail with a 'stale press claim:' error.""" |
| 849 | body = "No industry press data was available for this week's analysis." |
| 850 | errors = analysis_gate.stale_press_claim_errors(body, press_context_available=True) |
| 851 | self.assertTrue(any(error.startswith("stale press claim:") for error in errors)) |
| 852 | |
| 853 | def test_stale_press_claim_ignored_when_no_press_context(self) -> None: |
| 854 | """Legitimately press-less weeks must NOT false-positive: the same body with |
| 855 | press_context_available=False produces no stale-press error.""" |
| 856 | body = "No industry press data was available for this week's analysis." |
| 857 | errors = analysis_gate.stale_press_claim_errors(body, press_context_available=False) |
| 858 | self.assertEqual(errors, []) |
| 859 | |
| 860 | def test_stale_press_claim_catches_key_references_variant(self) -> None: |
| 861 | """The 'No press data was provided this week.' Key References phrasing is also |
| 862 | caught when a populated press context exists.""" |
| 863 | body = "### Press & Industry\n\nNo press data was provided this week." |
| 864 | errors = analysis_gate.stale_press_claim_errors(body, press_context_available=True) |
| 865 | self.assertTrue(any(error.startswith("stale press claim:") for error in errors)) |
| 866 | errors_no_press = analysis_gate.stale_press_claim_errors( |
| 867 | body, press_context_available=False |
| 868 | ) |
| 869 | self.assertEqual(errors_no_press, []) |
| 870 | |
| 871 | def test_publish_quality_gate_fails_on_stale_press_claim_with_press_available(self) -> None: |
| 872 | """End-to-end: validate_publish_quality wires stale_press_claim_errors into the |
| 873 | editorial_quality gate when press context is available.""" |
| 874 | body = make_body() + "\n\nNo industry press data was available for this week's analysis." |
| 875 | errors, gates = analysis_gate.validate_publish_quality( |
| 876 | make_analysis(VALID_FRONTMATTER, body), |
| 877 | RAW_PAYLOAD, |
| 878 | source="copilot-cli", |
| 879 | model="copilot-default", |
| 880 | press_context_available=True, |
| 881 | ) |
| 882 | self.assertTrue(any(error.startswith("stale press claim:") for error in errors)) |
| 883 | self.assertFalse(gates["editorial_quality"]["passed"]) |
| 884 | |
| 885 | def test_publish_quality_gate_allows_no_press_body_when_press_absent(self) -> None: |
| 886 | """The same body passes the stale-press rule when there is genuinely no press |
| 887 | context (the default press_context_available=False).""" |
| 888 | body = make_body() + "\n\nNo industry press data was available for this week's analysis." |
| 889 | errors, gates = analysis_gate.validate_publish_quality( |
| 890 | make_analysis(VALID_FRONTMATTER, body), |
| 891 | RAW_PAYLOAD, |
| 892 | source="copilot-cli", |
| 893 | model="copilot-default", |
| 894 | press_context_available=False, |
| 895 | ) |
| 896 | self.assertFalse(any(error.startswith("stale press claim:") for error in errors)) |
| 897 | |
| 898 | def test_press_context_is_populated_detects_real_and_empty_context(self) -> None: |
| 899 | """The helper treats missing/empty files and the render sentinel as empty, but |
| 900 | real content (or a positive token estimate) as populated.""" |
| 901 | self.assertFalse(analysis_gate.press_context_is_populated(None)) |
| 902 | tests_root = Path(__file__).resolve().parent |
| 903 | with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir: |
| 904 | base = Path(tmpdir) |
| 905 | |
| 906 | missing = base / "missing-press-context.md" |
| 907 | self.assertFalse(analysis_gate.press_context_is_populated(missing)) |
| 908 | |
| 909 | empty = base / "empty-press-context.md" |
| 910 | empty.write_text("", encoding="utf-8") |
| 911 | self.assertFalse(analysis_gate.press_context_is_populated(empty)) |
| 912 | |
| 913 | sentinel = base / "sentinel-press-context.md" |
| 914 | sentinel.write_text("No press data available for this week.", encoding="utf-8") |
| 915 | self.assertFalse(analysis_gate.press_context_is_populated(sentinel)) |
| 916 | |
| 917 | real = base / "real-press-context.md" |
| 918 | real.write_text( |
| 919 | "## Press Context\n\n22 relevant articles about AI agents.", |
| 920 | encoding="utf-8", |
| 921 | ) |
| 922 | self.assertTrue(analysis_gate.press_context_is_populated(real)) |
| 923 | |
| 924 | # A positive token estimate short-circuits to populated even without a file. |
| 925 | self.assertTrue(analysis_gate.press_context_is_populated(missing, token_estimate=42)) |
| 926 | |
| 927 | def test_press_context_is_populated_sentinel_wins_over_token_estimate(self) -> None: |
| 928 | """Regression (2026-W30 press-less path): a provided path is authoritative, so a |
| 929 | sentinel-only press file is *not* populated even when token_estimate > 0, while a |
| 930 | real press file is. The token_estimate fallback only applies when no usable path |
| 931 | is provided.""" |
| 932 | tests_root = Path(__file__).resolve().parent |
| 933 | with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir: |
| 934 | base = Path(tmpdir) |
| 935 | |
| 936 | sentinel = base / "sentinel-press-context.md" |
| 937 | sentinel.write_text(NO_PRESS_SENTINEL, encoding="utf-8") |
| 938 | self.assertFalse(analysis_gate.press_context_is_populated(sentinel, token_estimate=500)) |
| 939 | |
| 940 | real = base / "real-press-context.md" |
| 941 | real.write_text( |
| 942 | "## Press Context\n\n22 relevant articles about AI agents.", |
| 943 | encoding="utf-8", |
| 944 | ) |
| 945 | self.assertTrue(analysis_gate.press_context_is_populated(real, token_estimate=500)) |
| 946 | |
| 947 | # token_estimate fallback applies only when no usable path is provided. |
| 948 | self.assertTrue(analysis_gate.press_context_is_populated(None, token_estimate=500)) |
| 949 | self.assertFalse(analysis_gate.press_context_is_populated(None, 0)) |
| 950 | self.assertFalse(analysis_gate.press_context_is_populated(None, None)) |
| 951 | |
| 952 | def test_press_context_token_estimate_is_fallback_only(self) -> None: |
| 953 | """Positive token estimates populate only when no readable authoritative content exists.""" |
| 954 | self.assertTrue(analysis_gate.press_context_is_populated(None, token_estimate=1)) |
| 955 | self.assertFalse(analysis_gate.press_context_is_populated(None, token_estimate=0)) |
| 956 | self.assertFalse(analysis_gate.press_context_is_populated(None, token_estimate=None)) |
| 957 | |
| 958 | tests_root = Path(__file__).resolve().parent |
| 959 | with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir: |
| 960 | base = Path(tmpdir) |
| 961 | |
| 962 | missing = base / "missing-press-context.md" |
| 963 | self.assertTrue(analysis_gate.press_context_is_populated(missing, token_estimate=1)) |
| 964 | self.assertFalse(analysis_gate.press_context_is_populated(missing, token_estimate=0)) |
| 965 | self.assertFalse(analysis_gate.press_context_is_populated(missing, token_estimate=None)) |
| 966 | |
| 967 | empty = base / "empty-press-context.md" |
| 968 | empty.write_text("", encoding="utf-8") |
| 969 | self.assertTrue(analysis_gate.press_context_is_populated(empty, token_estimate=1)) |
| 970 | self.assertFalse(analysis_gate.press_context_is_populated(empty, token_estimate=0)) |
| 971 | |
| 972 | unreadable = base / "unreadable-press-context.md" |
| 973 | unreadable.write_text("content that cannot be read", encoding="utf-8") |
| 974 | with mock.patch.object(Path, "read_text", side_effect=OSError("unreadable")): |
| 975 | self.assertTrue( |
| 976 | analysis_gate.press_context_is_populated(unreadable, token_estimate=1) |
| 977 | ) |
| 978 | |
| 979 | sentinel = base / "sentinel-press-context.md" |
| 980 | sentinel.write_text(NO_PRESS_SENTINEL, encoding="utf-8") |
| 981 | self.assertFalse( |
| 982 | analysis_gate.press_context_is_populated(sentinel, token_estimate=9999) |
| 983 | ) |
| 984 | |
| 985 | real = base / "real-press-context.md" |
| 986 | real.write_text( |
| 987 | "## Press Context\n\n22 relevant articles about AI agents.", |
| 988 | encoding="utf-8", |
| 989 | ) |
| 990 | self.assertTrue(analysis_gate.press_context_is_populated(real, token_estimate=0)) |
| 991 | |
| 992 | def test_press_context_fallback_uses_rendered_content_token_estimate(self) -> None: |
| 993 | """The gate fallback consumes render_press_context.press_token_estimate, where |
| 994 | empty/whitespace/sentinel content maps to 0 and real press content maps positive.""" |
| 995 | self.assertFalse(analysis_gate.press_context_is_populated(None, press_token_estimate(""))) |
| 996 | self.assertFalse( |
| 997 | analysis_gate.press_context_is_populated(None, press_token_estimate(" \n\t ")) |
| 998 | ) |
| 999 | self.assertFalse( |
| 1000 | analysis_gate.press_context_is_populated(None, press_token_estimate(NO_PRESS_SENTINEL)) |
| 1001 | ) |
| 1002 | self.assertTrue( |
| 1003 | analysis_gate.press_context_is_populated( |
| 1004 | None, |
| 1005 | press_token_estimate("## Press Context\n\nA real article about AI agents."), |
| 1006 | ) |
| 1007 | ) |
| 1008 | |
| 1009 | def test_stale_press_gate_end_to_end_for_sentinel_pressless_week(self) -> None: |
| 1010 | """End-to-end press-less path: a body that legitimately states press was absent |
| 1011 | must NOT trip the stale-press rule when the week's press file is the sentinel |
| 1012 | (even with a positive token estimate); the identical body with a real press file |
| 1013 | still trips it, confirming the W30 regression stays caught.""" |
| 1014 | body = "No industry press data was available for this week's analysis." |
| 1015 | tests_root = Path(__file__).resolve().parent |
| 1016 | with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir: |
| 1017 | base = Path(tmpdir) |
| 1018 | token_estimate = 500 |
| 1019 | |
| 1020 | sentinel = base / "sentinel-press-context.md" |
| 1021 | sentinel.write_text(NO_PRESS_SENTINEL, encoding="utf-8") |
| 1022 | sentinel_available = analysis_gate.press_context_is_populated( |
| 1023 | sentinel, token_estimate=token_estimate |
| 1024 | ) |
| 1025 | self.assertFalse(sentinel_available) |
| 1026 | self.assertEqual( |
| 1027 | analysis_gate.stale_press_claim_errors( |
| 1028 | body, press_context_available=sentinel_available |
| 1029 | ), |
| 1030 | [], |
| 1031 | ) |
| 1032 | |
| 1033 | real = base / "real-press-context.md" |
| 1034 | real.write_text( |
| 1035 | "## Press Context\n\n22 relevant articles about AI agents.", |
| 1036 | encoding="utf-8", |
| 1037 | ) |
| 1038 | real_available = analysis_gate.press_context_is_populated( |
| 1039 | real, token_estimate=token_estimate |
| 1040 | ) |
| 1041 | self.assertTrue(real_available) |
| 1042 | errors = analysis_gate.stale_press_claim_errors( |
| 1043 | body, press_context_available=real_available |
| 1044 | ) |
| 1045 | self.assertTrue(any(error.startswith("stale press claim:") for error in errors)) |
| 1046 | |
| 1047 | |
| 1048 | if __name__ == "__main__": |
| 1049 | unittest.main() |