main
py 1,100 lines 42.1 KB
Raw
1 #!/usr/bin/env python3
2 from __future__ import annotations
3
4 import argparse
5 import hashlib
6 import json
7 import os
8 import re
9 import sys
10 from datetime import UTC, datetime
11 from pathlib import Path
12 from typing import Any
13
14 from scripts.render_press_context import NO_PRESS_SENTINEL_MARKER
15
16 try: # pragma: no cover - optional dependency on runners
17 import yaml
18 except ImportError: # pragma: no cover - exercised via fallback parser
19 yaml = None
20
21 REQUIRED_FIELDS = [
22 "title",
23 "date",
24 "week",
25 "year",
26 "tags",
27 "categories",
28 "repos_featured",
29 "stars_tracked",
30 "top_repo",
31 "quality_score",
32 "summary",
33 ]
34 OPTIONAL_FIELDS = ["predictions"]
35 PREDICTION_DIRECTIONS = {"up", "flat", "down"}
36 PREDICTION_CLAIM_TYPES = {"signal", "noise", "gap"}
37 PREDICTION_FIELDS = {"repo", "claim_type", "direction", "confidence"}
38 REQUIRED_HEADINGS = [
39 "## This Week's Trends",
40 "## Where Industry Meets Code",
41 "## Signal & Noise",
42 "## Blind Spots",
43 "## The Week Ahead",
44 "## Key References",
45 "### Notable Projects",
46 "### Press & Industry",
47 ]
48 PUBLISHABLE_AI_SOURCES = {"copilot-cli"}
49 UNPUBLISHABLE_MODEL_VALUES = {"", "unknown", "unavailable", "none", "no-ai"}
50 RAW_MARKERS = [
51 "```json",
52 '"week":',
53 '"new_repos"',
54 '"trending_repos"',
55 "traceback (most recent call last)",
56 ]
57 PLACEHOLDER_PATTERNS = [
58 (re.compile(r"(?mi)^\s*(?:[-*]\s*)?todo\s*[:\-]"), "TODO placeholder marker"),
59 (re.compile(r"(?mi)^\s*(?:[-*]\s*)?tbd\s*[:\-]"), "TBD placeholder marker"),
60 (re.compile(r"(?i)\bplaceholder text\b"), "placeholder text"),
61 (re.compile(r"(?i)\byour analysis here\b"), "placeholder instruction"),
62 ]
63 WEEK_PATTERN = re.compile(r"^(?P<year>\d{4})-W(?P<week>\d{2})$")
64 FRONTMATTER_PATTERN = re.compile(r"^---\n(.*?)\n---\n(.*)\Z", re.DOTALL)
65 HEADING_PATTERN = re.compile(r"(?m)^(#{2,3})\s+(.+?)\s*$")
66 WORD_PATTERN = re.compile(r"\b[\w'-]+\b")
67 TOP_REPO_PATTERN = re.compile(r"^[^/\s]+/[^/\s]+$")
68 GENERIC_TITLE_PATTERNS = [
69 re.compile(r"^Week\s+\d+.*Analysis$", re.IGNORECASE),
70 re.compile(r"^Week\s+\d+,\s*\d{4}$", re.IGNORECASE),
71 ]
72 REPO_LINK_PATTERN = re.compile(r"\[([^/\]\s]+/[^/\]\s]+)\]\(https://github\.com/\1\)")
73 SECTION_MIN_WORDS = {
74 "## This Week's Trends": 60,
75 "## Where Industry Meets Code": 40,
76 "## Signal & Noise": 50,
77 "## Blind Spots": 30,
78 "## The Week Ahead": 30,
79 }
80 EDITORIAL_TERMS = {
81 "signal",
82 "noise",
83 "gap",
84 "gaps",
85 "durable",
86 "hype",
87 "matters",
88 "evidence",
89 "trend",
90 "trends",
91 "blind",
92 "missing",
93 "practitioners",
94 "ecosystem",
95 "observability",
96 "security",
97 "testing",
98 }
99 EXPLANATORY_PATTERN = re.compile(
100 r"\b(because|why|matters|signals|reveals|driven|shows|suggests|represents|means|confirms|indicates|constitutes)\b",
101 re.IGNORECASE,
102 )
103 CONTRADICTION_PATTERNS = [
104 (
105 re.compile(r"no press data was provided", re.IGNORECASE),
106 re.compile(r"\b(reported|techcrunch)\b", re.IGNORECASE),
107 "claims no press data was provided while also describing press coverage.",
108 ),
109 (
110 re.compile(r"no meaningful developer activity", re.IGNORECASE),
111 REPO_LINK_PATTERN,
112 "claims no meaningful developer activity while citing active repositories.",
113 ),
114 ]
115 # "No press data" style statements that must NOT appear in the published body when
116 # the week's press context is actually populated. Unlike CONTRADICTION_PATTERNS these
117 # fire even when the body does not self-contradict (no other press discussion present) —
118 # they catch the false-negative where a populated press-context.md is silently dropped.
119 STALE_PRESS_CLAIM_PATTERNS = [
120 (
121 re.compile(r"no industry press data was available", re.IGNORECASE),
122 'body states "No industry press data was available for this week\'s analysis." '
123 "while a populated press context exists for this week.",
124 ),
125 (
126 re.compile(r"no press data was provided this week", re.IGNORECASE),
127 'Key References state "No press data was provided this week." '
128 "while a populated press context exists for this week.",
129 ),
130 ]
131
132
133 def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
134 parser = argparse.ArgumentParser(
135 description="Validate weekly analysis output against the analysis spec."
136 )
137 parser.add_argument(
138 "--analysis-file", required=True, type=Path, help="Path to the rendered markdown summary."
139 )
140 parser.add_argument(
141 "--raw-json", required=True, type=Path, help="Path to the raw weekly payload."
142 )
143 parser.add_argument(
144 "--current-datetime", required=True, help="Current run timestamp in ISO 8601 format."
145 )
146 parser.add_argument("--source", default="unknown", help="Analysis source label for summaries.")
147 parser.add_argument(
148 "--model", default="copilot-default", help="AI model label for provenance validation."
149 )
150 parser.add_argument(
151 "--repair-safe",
152 action="store_true",
153 help="Apply deterministic frontmatter/schema repairs before final validation.",
154 )
155 parser.add_argument("--report-json", type=Path, help="Write a machine-readable gate report.")
156 parser.add_argument(
157 "--press-context-path",
158 type=Path,
159 default=None,
160 help="Path to the week's rendered press-context.md. When populated, the gate fails "
161 'analyses that still claim "no press data".',
162 )
163 parser.add_argument(
164 "--press-token-estimate",
165 type=int,
166 default=None,
167 help="Optional token estimate for the week's press context; a value > 0 marks the "
168 "press context as populated even without the rendered file.",
169 )
170 return parser.parse_args(argv)
171
172
173 def parse_datetime(value: str | datetime) -> datetime:
174 if isinstance(value, datetime):
175 parsed = value
176 elif isinstance(value, str):
177 candidate = value.strip()
178 if candidate.endswith("Z"):
179 candidate = f"{candidate[:-1]}+00:00"
180 parsed = datetime.fromisoformat(candidate)
181 else: # pragma: no cover - guarded by callers
182 raise TypeError(f"Unsupported datetime value: {value!r}")
183 return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
184
185
186 def week_slug(value: datetime) -> str:
187 year, week, _ = value.astimezone(UTC).isocalendar()
188 return f"{year}-W{week:02d}"
189
190
191 def load_json(path: Path) -> dict[str, Any]:
192 payload = json.loads(path.read_text(encoding="utf-8"))
193 if not isinstance(payload, dict):
194 raise ValueError(f"Raw payload must be an object: {path}")
195 return payload
196
197
198 def strip_quotes(value: str) -> str:
199 stripped = value.strip()
200 if len(stripped) >= 2 and stripped[0] == stripped[-1] and stripped[0] in {'"', "'"}:
201 return stripped[1:-1]
202 return stripped
203
204
205 def parse_inline_list(value: str) -> list[str]:
206 inner = value.strip()[1:-1].strip()
207 if not inner:
208 return []
209 items: list[str] = []
210 for part in inner.split(","):
211 item = strip_quotes(part)
212 if not item:
213 raise ValueError(f"Malformed YAML list entry: {value}")
214 items.append(item)
215 return items
216
217
218 def parse_scalar(value: str) -> Any:
219 scalar = strip_quotes(value)
220 if re.fullmatch(r"-?\d+", scalar):
221 return int(scalar)
222 if re.fullmatch(r"-?\d+\.\d+", scalar):
223 return float(scalar)
224 return scalar
225
226
227 def parse_frontmatter_fallback(text: str) -> dict[str, Any]:
228 frontmatter: dict[str, Any] = {}
229 lines = text.splitlines()
230 index = 0
231 while index < len(lines):
232 line = lines[index]
233 if not line.strip():
234 index += 1
235 continue
236 if line.startswith((" ", "\t")):
237 raise ValueError(f"Unexpected indentation in frontmatter: {line}")
238 if ":" not in line:
239 raise ValueError(f"Malformed frontmatter line: {line}")
240 key, raw_value = line.split(":", 1)
241 key = key.strip()
242 value = raw_value.strip()
243 if not key:
244 raise ValueError(f"Malformed frontmatter key: {line}")
245 if value == "":
246 items: list[Any] = []
247 index += 1
248 while index < len(lines):
249 candidate = lines[index]
250 if not candidate.strip():
251 index += 1
252 continue
253 candidate_indent = len(candidate) - len(candidate.lstrip(" \t"))
254 if candidate_indent == 0:
255 break
256 stripped = candidate.strip()
257 if not stripped.startswith("- "):
258 raise ValueError(
259 f"Unsupported multiline frontmatter value for {key}: {candidate}"
260 )
261 item_value = stripped[2:].strip()
262 if ":" in item_value:
263 item: dict[str, Any] = {}
264 item_key, item_raw_value = item_value.split(":", 1)
265 item[item_key.strip()] = parse_scalar(item_raw_value.strip())
266 index += 1
267 while index < len(lines):
268 nested = lines[index]
269 if not nested.strip():
270 index += 1
271 continue
272 nested_indent = len(nested) - len(nested.lstrip(" \t"))
273 if nested_indent <= candidate_indent:
274 break
275 if ":" not in nested:
276 raise ValueError(
277 f"Unsupported multiline frontmatter value for {key}: {nested}"
278 )
279 nested_key, nested_raw_value = nested.strip().split(":", 1)
280 item[nested_key.strip()] = parse_scalar(nested_raw_value.strip())
281 index += 1
282 items.append(item)
283 continue
284 items.append(parse_scalar(item_value))
285 index += 1
286 frontmatter[key] = items
287 continue
288 if value.startswith("[") and value.endswith("]"):
289 frontmatter[key] = parse_inline_list(value)
290 else:
291 frontmatter[key] = parse_scalar(value)
292 index += 1
293 return frontmatter
294
295
296 def parse_frontmatter(text: str) -> dict[str, Any]:
297 if yaml is not None:
298 try:
299 data = yaml.safe_load(text) or {}
300 except Exception:
301 data = parse_frontmatter_fallback(text)
302 else:
303 if not isinstance(data, dict):
304 raise ValueError("YAML frontmatter must be a mapping.")
305 return data
306 return parse_frontmatter_fallback(text)
307
308
309 def dump_frontmatter(data: dict[str, Any]) -> str:
310 if yaml is not None:
311 dumped = yaml.safe_dump(data, sort_keys=False, allow_unicode=True).strip()
312 return re.sub(r"^(date): '([^']+)'$", r"\1: \2", dumped, flags=re.MULTILINE)
313
314 def format_scalar(value: Any) -> str:
315 if isinstance(value, str):
316 return json.dumps(value)
317 if isinstance(value, (int, float)):
318 return str(value)
319 raise TypeError(f"Unsupported frontmatter value for fallback dump: {value!r}")
320
321 lines: list[str] = []
322 for key, value in data.items():
323 if isinstance(value, list):
324 if not value:
325 lines.append(f"{key}: []")
326 continue
327 lines.append(f"{key}:")
328 for item in value:
329 if isinstance(item, dict):
330 if not item:
331 lines.append(" - {}")
332 continue
333 item_fields = list(item.items())
334 first_key, first_value = item_fields[0]
335 lines.append(f" - {first_key}: {format_scalar(first_value)}")
336 for nested_key, nested_value in item_fields[1:]:
337 lines.append(f" {nested_key}: {format_scalar(nested_value)}")
338 else:
339 lines.append(f" - {format_scalar(item)}")
340 else:
341 lines.append(f"{key}: {format_scalar(value)}")
342 return "\n".join(lines)
343
344
345 def extract_frontmatter(text: str) -> tuple[dict[str, Any], str]:
346 match = FRONTMATTER_PATTERN.match(text)
347 if not match:
348 raise ValueError("Analysis output is missing YAML frontmatter.")
349 frontmatter_text, body = match.groups()
350 return parse_frontmatter(frontmatter_text), body
351
352
353 def render_analysis(frontmatter: dict[str, Any], body: str) -> str:
354 return f"---\n{dump_frontmatter(frontmatter)}\n---\n{body}"
355
356
357 def expected_repo_counts(raw_payload: dict[str, Any]) -> tuple[int, int]:
358 repos: list[dict[str, Any]] = []
359 for field in ("new_repos", "trending_repos"):
360 value = raw_payload.get(field)
361 if isinstance(value, list):
362 repos.extend(item for item in value if isinstance(item, dict))
363 stars = sum(
364 star
365 for repo in repos
366 if isinstance((star := repo.get("stars")), int) and not isinstance(star, bool)
367 )
368 return len(repos), stars
369
370
371 def repair_analysis(
372 text: str,
373 raw_payload: dict[str, Any],
374 current_datetime: str,
375 ) -> tuple[str, list[str]]:
376 frontmatter, body = extract_frontmatter(text)
377 repaired = dict(frontmatter)
378 actions: list[str] = []
379
380 expected_week = raw_payload.get("week")
381 week_match = WEEK_PATTERN.fullmatch(expected_week) if isinstance(expected_week, str) else None
382 if isinstance(expected_week, str) and repaired.get("week") != expected_week:
383 repaired["week"] = expected_week
384 actions.append(f"set week from raw payload ({expected_week})")
385 if week_match:
386 expected_year = int(week_match.group("year"))
387 if repaired.get("year") != expected_year:
388 repaired["year"] = expected_year
389 actions.append(f"set year from raw payload week ({expected_year})")
390 if repaired.get("date") != current_datetime:
391 repaired["date"] = current_datetime
392 actions.append("set date from current run timestamp")
393
394 repos_featured, stars_tracked = expected_repo_counts(raw_payload)
395 if repaired.get("repos_featured") != repos_featured:
396 repaired["repos_featured"] = repos_featured
397 actions.append(f"set repos_featured from raw repo counts ({repos_featured})")
398 if repaired.get("stars_tracked") != stars_tracked:
399 repaired["stars_tracked"] = stars_tracked
400 actions.append(f"set stars_tracked from raw repo stars ({stars_tracked})")
401
402 predictions = repaired.get("predictions")
403 if isinstance(predictions, list):
404 repaired_predictions = []
405 changed_predictions = False
406 for index, prediction in enumerate(predictions, start=1):
407 if not isinstance(prediction, dict):
408 repaired_predictions.append(prediction)
409 continue
410 repaired_prediction = dict(prediction)
411 if "claim_type" not in repaired_prediction:
412 for alias in ("claim", "claimType", "type", "kind"):
413 alias_value = repaired_prediction.get(alias)
414 if (
415 isinstance(alias_value, str)
416 and alias_value.strip().lower() in PREDICTION_CLAIM_TYPES
417 ):
418 repaired_prediction["claim_type"] = alias_value.strip().lower()
419 del repaired_prediction[alias]
420 changed_predictions = True
421 actions.append(f"set predictions[{index}].claim_type from {alias}")
422 break
423 claim_type = repaired_prediction.get("claim_type")
424 if (
425 isinstance(claim_type, str)
426 and claim_type.strip().lower() in PREDICTION_CLAIM_TYPES
427 and claim_type != claim_type.strip().lower()
428 ):
429 repaired_prediction["claim_type"] = claim_type.strip().lower()
430 changed_predictions = True
431 actions.append(f"normalized predictions[{index}].claim_type")
432 direction = repaired_prediction.get("direction")
433 if (
434 isinstance(direction, str)
435 and direction.strip().lower() in PREDICTION_DIRECTIONS
436 and direction != direction.strip().lower()
437 ):
438 repaired_prediction["direction"] = direction.strip().lower()
439 changed_predictions = True
440 actions.append(f"normalized predictions[{index}].direction")
441 repaired_predictions.append(repaired_prediction)
442 if changed_predictions:
443 repaired["predictions"] = repaired_predictions
444
445 if not actions:
446 return text, actions
447 return render_analysis(repaired, body), actions
448
449
450 def validate_string_field(frontmatter: dict[str, Any], field: str, errors: list[str]) -> None:
451 value = frontmatter.get(field)
452 if value is None:
453 return
454 if not isinstance(value, str) or not value.strip():
455 errors.append(f"{field} must be a non-empty string.")
456
457
458 def validate_integer_field(
459 frontmatter: dict[str, Any], field: str, errors: list[str], *, minimum: int = 0
460 ) -> None:
461 value = frontmatter.get(field)
462 if value is None:
463 return
464 if isinstance(value, bool) or not isinstance(value, int):
465 errors.append(f"{field} must be an integer.")
466 return
467 if value < minimum:
468 errors.append(f"{field} must be at least {minimum}.")
469
470
471 def validate_string_list(
472 frontmatter: dict[str, Any],
473 field: str,
474 errors: list[str],
475 *,
476 minimum: int | None = None,
477 maximum: int | None = None,
478 includes: str | None = None,
479 ) -> None:
480 value = frontmatter.get(field)
481 if value is None:
482 return
483 if not isinstance(value, list) or any(
484 not isinstance(item, str) or not item.strip() for item in value
485 ):
486 errors.append(f"{field} must be an array of strings.")
487 return
488 if minimum is not None and len(value) < minimum:
489 errors.append(f"{field} must contain at least {minimum} items.")
490 if maximum is not None and len(value) > maximum:
491 errors.append(f"{field} must contain at most {maximum} items.")
492 if includes is not None and includes not in value:
493 errors.append(f"{field} must include {includes!r}.")
494
495
496 def validate_predictions(frontmatter: dict[str, Any], errors: list[str]) -> None:
497 predictions = frontmatter.get("predictions")
498 if predictions is None:
499 return
500 if not isinstance(predictions, list):
501 errors.append("predictions must be an array.")
502 return
503 for index, prediction in enumerate(predictions, start=1):
504 if not isinstance(prediction, dict):
505 errors.append(f"predictions[{index}] must be an object.")
506 continue
507 repo = prediction.get("repo")
508 claim_type = prediction.get("claim_type")
509 direction = prediction.get("direction")
510 confidence = prediction.get("confidence")
511 if not isinstance(repo, str) or not TOP_REPO_PATTERN.fullmatch(repo.strip()):
512 errors.append(f"predictions[{index}].repo must use owner/repo format.")
513 if (
514 not isinstance(claim_type, str)
515 or claim_type.strip().lower() not in PREDICTION_CLAIM_TYPES
516 ):
517 errors.append(f"predictions[{index}].claim_type must be one of signal, noise, gap.")
518 if not isinstance(direction, str) or direction.strip().lower() not in PREDICTION_DIRECTIONS:
519 errors.append(f"predictions[{index}].direction must be one of up, flat, down.")
520 if isinstance(confidence, bool) or not isinstance(confidence, (int, float)):
521 errors.append(f"predictions[{index}].confidence must be numeric.")
522 elif not 0 <= float(confidence) <= 1:
523 errors.append(f"predictions[{index}].confidence must be between 0 and 1.")
524 extra_fields = sorted(set(prediction) - PREDICTION_FIELDS)
525 if extra_fields:
526 errors.append(f"predictions[{index}] has unexpected fields: {', '.join(extra_fields)}")
527
528
529 def find_missing_headings(body: str) -> list[str]:
530 headings = [f"{level} {title.strip()}" for level, title in HEADING_PATTERN.findall(body)]
531 missing: list[str] = []
532 position = -1
533 for required in REQUIRED_HEADINGS:
534 try:
535 position = headings.index(required, position + 1)
536 except ValueError:
537 missing.append(required)
538 return missing
539
540
541 def section_text(body: str, heading: str) -> str:
542 heading_match = re.search(rf"(?m)^{re.escape(heading)}\s*$", body)
543 if heading_match is None:
544 return ""
545 next_heading = re.search(r"(?m)^##\s+", body[heading_match.end() :])
546 end = heading_match.end() + next_heading.start() if next_heading else len(body)
547 return body[heading_match.end() : end].strip()
548
549
550 def raw_repo_names(raw_payload: dict[str, Any]) -> set[str]:
551 names: set[str] = set()
552 for field in ("new_repos", "trending_repos"):
553 repos = raw_payload.get(field)
554 if not isinstance(repos, list):
555 continue
556 for repo in repos:
557 if isinstance(repo, dict) and isinstance(repo.get("full_name"), str):
558 names.add(repo["full_name"].strip())
559 return {name for name in names if TOP_REPO_PATTERN.fullmatch(name)}
560
561
562 def compute_objective_quality(
563 text: str, raw_payload: dict, press_context_available: bool
564 ) -> tuple[int, dict]:
565 _, body = extract_frontmatter(text)
566 words = len(WORD_PATTERN.findall(body))
567 depth = round(min(15, max(0, (words - 200) / 1000 * 15)))
568
569 available_repos = raw_repo_names(raw_payload)
570 cited_repos = set(REPO_LINK_PATTERN.findall(body)).intersection(available_repos)
571 evidence_target = min(10, len(available_repos))
572 evidence = (
573 0 if evidence_target == 0 else round(min(10, len(cited_repos) / evidence_target * 10))
574 )
575
576 press_citations = 0
577 press = 0
578 if press_context_available:
579 key_references = section_text(body, "## Key References")
580 press_section = section_text(key_references, "### Press & Industry")
581 # section_text() only terminates on the next level-2 heading, so trim at the next
582 # level-3 subsection to avoid counting URLs from later ### blocks as press citations.
583 next_subsection = re.search(r"(?m)^###\s+", press_section)
584 if next_subsection:
585 press_section = press_section[: next_subsection.start()]
586 urls = set(re.findall(r"https?://[^\s)\]]+", press_section))
587 external_urls = {
588 url
589 for url in urls
590 if not re.match(
591 r"https?://(?:[^/\s]+\.)?(?:github\.com|githubusercontent\.com)(?:[/:?#]|$)",
592 url,
593 re.IGNORECASE,
594 )
595 }
596 press_citations = len(external_urls)
597 press = round(min(15, press_citations / 3 * 15))
598
599 # Identical content with cited press must score strictly above its press-less variant.
600 score = min(100, 60 + depth + evidence + press)
601 return score, {
602 "base": 60,
603 "depth": depth,
604 "evidence": evidence,
605 "press": press,
606 "words": words,
607 "repo_citations": len(cited_repos),
608 "press_citations": press_citations,
609 "press_available": press_context_available,
610 }
611
612
613 def set_frontmatter_quality_score(text: str, score: int) -> str:
614 match = FRONTMATTER_PATTERN.match(text)
615 if not match:
616 raise ValueError("Analysis output is missing YAML frontmatter.")
617 frontmatter_text, body = match.groups()
618 rewritten_frontmatter, replacements = re.subn(
619 r"(?m)^quality_score:.*$",
620 f"quality_score: {score}",
621 frontmatter_text,
622 count=1,
623 )
624 if replacements == 0:
625 rewritten_frontmatter = f"{frontmatter_text}\nquality_score: {score}"
626 return f"---\n{rewritten_frontmatter}\n---\n{body}"
627
628
629 def raw_artifact_week_errors(raw_payload: dict[str, Any], expected_week: Any) -> list[str]:
630 if not isinstance(expected_week, str):
631 return []
632 timestamp = raw_payload.get("generated_at") or raw_payload.get("crawled_at")
633 if not isinstance(timestamp, str) or not timestamp.strip():
634 return []
635 try:
636 parsed = parse_datetime(timestamp)
637 except (TypeError, ValueError) as exc:
638 return [f"raw evidence timestamp is invalid: {exc}"]
639 artifact_week = week_slug(parsed)
640 if artifact_week != expected_week:
641 return [
642 f"raw evidence timestamp week mismatch: expected {expected_week}, found {artifact_week}."
643 ]
644 return []
645
646
647 def evidence_citation_errors(body: str, raw_payload: dict[str, Any]) -> list[str]:
648 errors: list[str] = []
649 repos = raw_repo_names(raw_payload)
650 linked_repos = set(REPO_LINK_PATTERN.findall(body))
651 if repos and not linked_repos.intersection(repos):
652 errors.append(
653 "evidence citations must include at least one repository link from the raw payload."
654 )
655 unresolved_links = sorted(linked_repos - repos) if repos else []
656 if unresolved_links:
657 preview = ", ".join(unresolved_links[:10])
658 suffix = f" (+{len(unresolved_links) - 10} more)" if len(unresolved_links) > 10 else ""
659 errors.append(
660 f"repository links must resolve to the current raw evidence inventory: {preview}{suffix}."
661 )
662 if repos and "## Key References" in body:
663 notable = section_text(body, "## Key References")
664 notable_links = set(REPO_LINK_PATTERN.findall(notable))
665 if not notable_links.intersection(repos):
666 errors.append("Key References must cite at least one raw-payload repository link.")
667 errors.extend(raw_artifact_week_errors(raw_payload, raw_payload.get("week")))
668 return errors
669
670
671 def editorial_quality_errors(body: str) -> list[str]:
672 errors: list[str] = []
673 prose = "\n".join(line for line in body.splitlines() if not line.lstrip().startswith("#"))
674 lower_body = prose.lower()
675 terms_found = {
676 term for term in EDITORIAL_TERMS if re.search(rf"\b{re.escape(term)}\b", lower_body)
677 }
678 if len(terms_found) < 3:
679 errors.append(
680 "editorial analysis must use trend/evidence judgment language, not generic summary prose."
681 )
682 for heading, minimum in SECTION_MIN_WORDS.items():
683 text = section_text(body, heading)
684 if not text:
685 continue
686 count = len(WORD_PATTERN.findall(text))
687 if count < minimum:
688 errors.append(
689 f"{heading} section is too thin for publish-quality analysis; found {count} words, expected at least {minimum}."
690 )
691 for heading in ("## This Week's Trends", "## Signal & Noise", "## Blind Spots"):
692 text = section_text(body, heading)
693 linked_repos = REPO_LINK_PATTERN.findall(text)
694 section_terms = {
695 term for term in EDITORIAL_TERMS if re.search(rf"\b{re.escape(term)}\b", text.lower())
696 }
697 has_reasoning_or_evidence = (
698 EXPLANATORY_PATTERN.search(text) or linked_repos or len(section_terms) >= 2
699 )
700 if text and not has_reasoning_or_evidence:
701 errors.append(f"{heading} must explain why the pattern matters, not only name it.")
702 return errors
703
704
705 def contradiction_errors(body: str) -> list[str]:
706 errors: list[str] = []
707 for negative_pattern, positive_pattern, message in CONTRADICTION_PATTERNS:
708 for match in negative_pattern.finditer(body):
709 window_start = max(0, match.start() - 300)
710 window_end = min(len(body), match.end() + 300)
711 window = body[window_start:window_end]
712 if positive_pattern.search(window.replace(match.group(0), "", 1)):
713 errors.append(f"contradictory claim: {message}")
714 break
715 return errors
716
717
718 def press_context_is_populated(
719 press_context_path: Path | None, token_estimate: int | None = None
720 ) -> bool:
721 """Return True when the week has real press context to write from.
722
723 render_press_context.py emits a non-empty "No press data available for this week."
724 sentinel when press is absent, so a bare size/non-empty check is insufficient: the
725 sentinel is treated as an empty press context.
726
727 When a press_context_path is provided its content is authoritative: sentinel
728 content wins over a positive token_estimate, so a genuinely press-less week is
729 classified as *not* populated even if token_estimate > 0. The token_estimate
730 fallback only applies when no usable path is provided (path is None, missing,
731 empty, or unreadable).
732 """
733 if press_context_path is not None:
734 try:
735 if press_context_path.exists() and press_context_path.stat().st_size > 0:
736 content = press_context_path.read_text(encoding="utf-8").strip()
737 if content:
738 return not NO_PRESS_SENTINEL_MARKER.search(content)
739 except OSError:
740 pass
741 return token_estimate is not None and token_estimate > 0
742
743
744 def stale_press_claim_errors(body: str, *, press_context_available: bool) -> list[str]:
745 """Fail analyses that claim "no press data" while press context is populated.
746
747 This is defense-in-depth against the 2026-W30 regression where a populated
748 press-context.md was silently dropped and the body shipped
749 "No industry press data was available for this week's analysis.".
750 """
751 if not press_context_available:
752 return []
753 errors: list[str] = []
754 for pattern, message in STALE_PRESS_CLAIM_PATTERNS:
755 if pattern.search(body):
756 errors.append(f"stale press claim: {message}")
757 return errors
758
759
760 def ai_provenance_errors(source: str, model: str) -> list[str]:
761 errors: list[str] = []
762 normalized_source = source.strip()
763 normalized_model = model.strip()
764 if normalized_source not in PUBLISHABLE_AI_SOURCES:
765 errors.append(f"AI provenance source is not publishable: {normalized_source or 'unknown'}.")
766 if normalized_model.lower() in UNPUBLISHABLE_MODEL_VALUES:
767 errors.append(f"AI provenance model is not publishable: {normalized_model or 'unknown'}.")
768 return errors
769
770
771 def categorize_gate_error(error: str) -> str:
772 if error.startswith("AI provenance"):
773 return "ai_provenance"
774 if error.startswith(
775 ("evidence citations", "Key References", "raw evidence", "repository links")
776 ):
777 return "evidence_citation"
778 if (
779 error.startswith(("editorial analysis", "contradictory claim", "stale press claim"))
780 or "section is too thin" in error
781 or "must explain why" in error
782 ):
783 return "editorial_quality"
784 if "quality_score" in error or "generic week/year" in error or "placeholder" in error:
785 return "editorial_quality"
786 return "structural_schema"
787
788
789 def build_gate_results(errors: list[str]) -> dict[str, dict[str, Any]]:
790 gates = {
791 "structural_schema": {"passed": True, "errors": []},
792 "ai_provenance": {"passed": True, "errors": []},
793 "evidence_citation": {"passed": True, "errors": []},
794 "editorial_quality": {"passed": True, "errors": []},
795 }
796 for error in errors:
797 category = categorize_gate_error(error)
798 gates[category]["passed"] = False
799 gates[category]["errors"].append(error)
800 return gates
801
802
803 def validate_publish_quality(
804 text: str,
805 raw_payload: dict[str, Any],
806 *,
807 source: str,
808 model: str,
809 press_context_available: bool = False,
810 ) -> tuple[list[str], dict[str, dict[str, Any]]]:
811 try:
812 _, body = extract_frontmatter(text)
813 except ValueError:
814 body = ""
815 errors: list[str] = []
816 errors.extend(ai_provenance_errors(source, model))
817 if body:
818 errors.extend(evidence_citation_errors(body, raw_payload))
819 errors.extend(editorial_quality_errors(body))
820 errors.extend(contradiction_errors(body))
821 errors.extend(
822 stale_press_claim_errors(body, press_context_available=press_context_available)
823 )
824 return errors, build_gate_results(errors)
825
826
827 def validate_analysis(
828 text: str, raw_payload: dict[str, Any], current_datetime: str
829 ) -> tuple[list[str], int]:
830 errors: list[str] = []
831 try:
832 frontmatter, body = extract_frontmatter(text)
833 except ValueError as exc:
834 return [str(exc)], 0
835
836 missing_fields = [field for field in REQUIRED_FIELDS if field not in frontmatter]
837 if missing_fields:
838 errors.append(f"Missing frontmatter fields: {', '.join(missing_fields)}")
839
840 extra_fields = sorted(set(frontmatter) - set(REQUIRED_FIELDS) - set(OPTIONAL_FIELDS))
841 if extra_fields:
842 errors.append(f"Unexpected frontmatter fields: {', '.join(extra_fields)}")
843
844 validate_string_field(frontmatter, "title", errors)
845 title = frontmatter.get("title")
846 if isinstance(title, str) and title.strip():
847 if any(pattern.fullmatch(title.strip()) for pattern in GENERIC_TITLE_PATTERNS):
848 errors.append("title must not use a generic week/year placeholder format.")
849 validate_string_field(frontmatter, "week", errors)
850 validate_string_field(frontmatter, "top_repo", errors)
851 validate_string_field(frontmatter, "summary", errors)
852 validate_string_list(frontmatter, "tags", errors, minimum=3, maximum=8)
853 validate_string_list(frontmatter, "categories", errors, includes="weekly")
854 validate_integer_field(frontmatter, "year", errors)
855 validate_integer_field(frontmatter, "repos_featured", errors)
856 validate_integer_field(frontmatter, "stars_tracked", errors)
857 validate_integer_field(frontmatter, "quality_score", errors)
858 validate_predictions(frontmatter, errors)
859
860 quality_score = frontmatter.get("quality_score")
861 if isinstance(quality_score, int) and quality_score < 60:
862 errors.append("quality_score must be at least 60.")
863
864 expected_week = raw_payload.get("week")
865 week_match = WEEK_PATTERN.fullmatch(expected_week) if isinstance(expected_week, str) else None
866 expected_year = int(week_match.group("year")) if week_match else None
867 if frontmatter.get("week") != expected_week:
868 errors.append(f"week must match raw payload week {expected_week!r}.")
869 if expected_year is not None and frontmatter.get("year") != expected_year:
870 errors.append(f"year must match the raw payload week year ({expected_year}).")
871
872 date_value = frontmatter.get("date")
873 if date_value is None:
874 pass
875 else:
876 try:
877 analysis_datetime = parse_datetime(date_value)
878 run_datetime = parse_datetime(current_datetime)
879 except (TypeError, ValueError) as exc:
880 errors.append(f"date must be a valid ISO 8601 timestamp: {exc}")
881 else:
882 if analysis_datetime.astimezone(UTC) != run_datetime.astimezone(UTC):
883 errors.append("date must match the current run timestamp.")
884 if isinstance(expected_week, str) and week_slug(analysis_datetime) != expected_week:
885 errors.append(f"date must fall within raw payload week {expected_week}.")
886
887 top_repo = frontmatter.get("top_repo")
888 if isinstance(top_repo, str) and top_repo and not TOP_REPO_PATTERN.fullmatch(top_repo):
889 errors.append("top_repo must use owner/repo format.")
890
891 missing_headings = find_missing_headings(body)
892 if missing_headings:
893 for heading in missing_headings:
894 errors.append(f"Missing required section heading: {heading}")
895
896 word_count = len(WORD_PATTERN.findall(body))
897 if word_count < 200:
898 errors.append(f"Analysis body must be at least 200 words; found {word_count}.")
899
900 lower_body = body.lower()
901 for marker in RAW_MARKERS:
902 if marker in lower_body:
903 errors.append(f"Analysis body contains prohibited marker: {marker}")
904 for pattern, description in PLACEHOLDER_PATTERNS:
905 if pattern.search(body):
906 errors.append(f"Analysis body contains prohibited placeholder marker: {description}")
907
908 return errors, word_count
909
910
911 def fail(errors: list[str], summary_path: str | None) -> None:
912 if summary_path:
913 with open(summary_path, "a", encoding="utf-8") as handle:
914 handle.write("## Analysis quality gate failed\n")
915 for error in errors:
916 handle.write(f"- {error}\n")
917 for error in errors:
918 print(error, file=sys.stderr)
919 raise SystemExit(1)
920
921
922 def report_success(path: Path, source: str, word_count: int, summary_path: str | None) -> None:
923 message = f"✅ Analysis quality gate passed for {path.name} via {source} ({word_count} words)."
924 print(message)
925 if summary_path:
926 with open(summary_path, "a", encoding="utf-8") as handle:
927 handle.write("## Analysis quality gate passed\n")
928 handle.write(f"- File: `{path}`\n")
929 handle.write(f"- Source: `{source}`\n")
930 handle.write(f"- Word count: `{word_count}`\n")
931
932
933 def write_gate_report(
934 path: Path | None,
935 *,
936 analysis_file: Path,
937 source: str,
938 model: str,
939 errors_before: list[str],
940 errors_after: list[str],
941 repair_actions: list[str],
942 word_count: int,
943 gate_results: dict[str, dict[str, Any]],
944 quality_breakdown: dict[str, Any] | None = None,
945 ) -> None:
946 if path is None:
947 return
948 path.parent.mkdir(parents=True, exist_ok=True)
949 payload = {
950 "analysis_file": analysis_file.as_posix(),
951 "source": source,
952 "model": model,
953 "passed": not errors_after,
954 "word_count": word_count,
955 "quality_breakdown": quality_breakdown,
956 "gates": gate_results,
957 "failure_summary": build_failure_summary(errors_after, gate_results),
958 "errors_before_repair": errors_before,
959 "repair_actions": repair_actions,
960 "errors_after_repair": errors_after,
961 "failure_class": classify_gate_errors(errors_after),
962 }
963 path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
964
965
966 def build_failure_summary(
967 errors: list[str], gate_results: dict[str, dict[str, Any]]
968 ) -> dict[str, Any]:
969 categories = sorted(
970 category for category, result in gate_results.items() if not result.get("passed")
971 )
972 return {
973 "failure_class": classify_gate_errors(errors),
974 "failure_categories": categories,
975 "error_count": len(errors),
976 "retryable": bool(errors)
977 and not any(category == "ai_provenance" for category in categories),
978 }
979
980
981 def classify_gate_errors(errors: list[str]) -> str:
982 if not errors:
983 return "passed"
984 categories = {categorize_gate_error(error) for error in errors}
985 if len(categories) == 1:
986 return next(iter(categories))
987 if all(
988 error.startswith(
989 ("date must", "week must", "year must", "repos_featured must", "stars_tracked must")
990 )
991 or ".claim_type must" in error
992 for error in errors
993 ):
994 return "metadata_schema"
995 if any(
996 error.startswith("Missing required section heading") or "body" in error for error in errors
997 ):
998 return "content_structure"
999 return "quality_gate"
1000
1001
1002 def gate_report_fingerprint(path: Path) -> str:
1003 try:
1004 report = json.loads(path.read_text(encoding="utf-8"))
1005 except (OSError, json.JSONDecodeError):
1006 return ""
1007 if not isinstance(report, dict):
1008 return ""
1009 errors = report.get("errors_after_repair") or report.get("errors_before_repair") or []
1010 if not isinstance(errors, list):
1011 return ""
1012 return hashlib.sha256(json.dumps(errors, sort_keys=True).encode("utf-8")).hexdigest()
1013
1014
1015 def main(argv: list[str] | None = None) -> int:
1016 args = parse_args(argv)
1017 summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
1018
1019 if not args.analysis_file.exists():
1020 fail([f"Missing analysis output: {args.analysis_file}"], summary_path)
1021
1022 text = args.analysis_file.read_text(encoding="utf-8")
1023 raw_payload = load_json(args.raw_json)
1024 press_context_available = press_context_is_populated(
1025 args.press_context_path, args.press_token_estimate
1026 )
1027 errors_before, word_count = validate_analysis(text, raw_payload, args.current_datetime)
1028 publish_errors_before, _ = validate_publish_quality(
1029 text,
1030 raw_payload,
1031 source=args.source,
1032 model=args.model,
1033 press_context_available=press_context_available,
1034 )
1035 combined_errors_before = errors_before + [
1036 error for error in publish_errors_before if error not in errors_before
1037 ]
1038 errors = errors_before
1039 repair_actions: list[str] = []
1040 if errors and args.repair_safe:
1041 try:
1042 repaired_text, repair_actions = repair_analysis(
1043 text, raw_payload, args.current_datetime
1044 )
1045 except Exception as exc: # noqa: BLE001 - repair is best-effort; validation/reporting must continue.
1046 repair_actions = [f"repair skipped: {exc}"]
1047 else:
1048 if repair_actions and repaired_text != text:
1049 args.analysis_file.write_text(repaired_text, encoding="utf-8")
1050 text = repaired_text
1051 errors, word_count = validate_analysis(text, raw_payload, args.current_datetime)
1052 print(
1053 f"::notice::Analysis gate applied safe repairs: {', '.join(repair_actions)}",
1054 file=sys.stderr,
1055 )
1056 objective_score: int | None = None
1057 quality_breakdown: dict | None = None
1058 try:
1059 objective_score, quality_breakdown = compute_objective_quality(
1060 text, raw_payload, press_context_available
1061 )
1062 rewritten = set_frontmatter_quality_score(text, objective_score)
1063 except ValueError:
1064 # Missing/invalid frontmatter is already reported by validate_analysis(); let the
1065 # gate fail cleanly with those errors instead of raising an uncaught exception.
1066 rewritten = text
1067 if rewritten != text:
1068 args.analysis_file.write_text(rewritten, encoding="utf-8")
1069 text = rewritten
1070 errors, word_count = validate_analysis(text, raw_payload, args.current_datetime)
1071 publish_errors, _ = validate_publish_quality(
1072 text,
1073 raw_payload,
1074 source=args.source,
1075 model=args.model,
1076 press_context_available=press_context_available,
1077 )
1078 errors = errors + [error for error in publish_errors if error not in errors]
1079 gate_results = build_gate_results(errors)
1080 write_gate_report(
1081 args.report_json,
1082 analysis_file=args.analysis_file,
1083 source=args.source,
1084 model=args.model,
1085 errors_before=combined_errors_before,
1086 errors_after=errors,
1087 repair_actions=repair_actions,
1088 word_count=word_count,
1089 gate_results=gate_results,
1090 quality_breakdown=quality_breakdown,
1091 )
1092 if errors:
1093 fail(errors, summary_path)
1094
1095 report_success(args.analysis_file, args.source, word_count, summary_path)
1096 return 0
1097
1098
1099 if __name__ == "__main__":
1100 raise SystemExit(main())