2
from __future__ import annotations
3
4
import argparse
5
+import hashlib
6
import json
7
import os
8
import re
31
]
32
OPTIONAL_FIELDS = ["predictions"]
33
PREDICTION_DIRECTIONS = {"up", "flat", "down"}
34
+PREDICTION_CLAIM_TYPES = {"signal", "noise", "gap"}
35
+PREDICTION_FIELDS = {"repo", "claim_type", "direction", "confidence"}
36
REQUIRED_HEADINGS = [
37
"## This Week's Trends",
38
"## Where Industry Meets Code",
73
parser.add_argument("--raw-json", required=True, type=Path, help="Path to the raw weekly payload.")
74
parser.add_argument("--current-datetime", required=True, help="Current run timestamp in ISO 8601 format.")
75
parser.add_argument("--source", default="unknown", help="Analysis source label for summaries.")
76
+ parser.add_argument(
77
+ "--repair-safe",
78
+ action="store_true",
79
+ help="Apply deterministic frontmatter/schema repairs before final validation.",
80
+ )
81
+ parser.add_argument("--report-json", type=Path, help="Write a machine-readable gate report.")
82
return parser.parse_args(argv)
83
84
214
return parse_frontmatter_fallback(text)
215
216
217
+def dump_frontmatter(data: dict[str, Any]) -> str:
218
+ if yaml is not None:
219
+ dumped = yaml.safe_dump(data, sort_keys=False, allow_unicode=True).strip()
220
+ return re.sub(r"^(date): '([^']+)'$", r"\1: \2", dumped, flags=re.MULTILINE)
221
+
222
+ def format_scalar(value: Any) -> str:
223
+ if isinstance(value, str):
224
+ return json.dumps(value)
225
+ if isinstance(value, (int, float)):
226
+ return str(value)
227
+ raise TypeError(f"Unsupported frontmatter value for fallback dump: {value!r}")
228
+
229
+ lines: list[str] = []
230
+ for key, value in data.items():
231
+ if isinstance(value, list):
232
+ if not value:
233
+ lines.append(f"{key}: []")
234
+ continue
235
+ lines.append(f"{key}:")
236
+ for item in value:
237
+ if isinstance(item, dict):
238
+ if not item:
239
+ lines.append(" - {}")
240
+ continue
241
+ item_fields = list(item.items())
242
+ first_key, first_value = item_fields[0]
243
+ lines.append(f" - {first_key}: {format_scalar(first_value)}")
244
+ for nested_key, nested_value in item_fields[1:]:
245
+ lines.append(f" {nested_key}: {format_scalar(nested_value)}")
246
+ else:
247
+ lines.append(f" - {format_scalar(item)}")
248
+ else:
249
+ lines.append(f"{key}: {format_scalar(value)}")
250
+ return "\n".join(lines)
251
+
252
+
253
def extract_frontmatter(text: str) -> tuple[dict[str, Any], str]:
254
match = FRONTMATTER_PATTERN.match(text)
255
if not match:
258
return parse_frontmatter(frontmatter_text), body
259
260
261
+def render_analysis(frontmatter: dict[str, Any], body: str) -> str:
262
+ return f"---\n{dump_frontmatter(frontmatter)}\n---\n{body}"
263
+
264
+
265
+def expected_repo_counts(raw_payload: dict[str, Any]) -> tuple[int, int]:
266
+ repos: list[dict[str, Any]] = []
267
+ for field in ("new_repos", "trending_repos"):
268
+ value = raw_payload.get(field)
269
+ if isinstance(value, list):
270
+ repos.extend(item for item in value if isinstance(item, dict))
271
+ stars = sum(star for repo in repos if isinstance((star := repo.get("stars")), int) and not isinstance(star, bool))
272
+ return len(repos), stars
273
+
274
+
275
+def repair_analysis(
276
+ text: str,
277
+ raw_payload: dict[str, Any],
278
+ current_datetime: str,
279
+) -> tuple[str, list[str]]:
280
+ frontmatter, body = extract_frontmatter(text)
281
+ repaired = dict(frontmatter)
282
+ actions: list[str] = []
283
+
284
+ expected_week = raw_payload.get("week")
285
+ week_match = WEEK_PATTERN.fullmatch(expected_week) if isinstance(expected_week, str) else None
286
+ if isinstance(expected_week, str) and repaired.get("week") != expected_week:
287
+ repaired["week"] = expected_week
288
+ actions.append(f"set week from raw payload ({expected_week})")
289
+ if week_match:
290
+ expected_year = int(week_match.group("year"))
291
+ if repaired.get("year") != expected_year:
292
+ repaired["year"] = expected_year
293
+ actions.append(f"set year from raw payload week ({expected_year})")
294
+ if repaired.get("date") != current_datetime:
295
+ repaired["date"] = current_datetime
296
+ actions.append("set date from current run timestamp")
297
+
298
+ repos_featured, stars_tracked = expected_repo_counts(raw_payload)
299
+ if repaired.get("repos_featured") != repos_featured:
300
+ repaired["repos_featured"] = repos_featured
301
+ actions.append(f"set repos_featured from raw repo counts ({repos_featured})")
302
+ if repaired.get("stars_tracked") != stars_tracked:
303
+ repaired["stars_tracked"] = stars_tracked
304
+ actions.append(f"set stars_tracked from raw repo stars ({stars_tracked})")
305
+
306
+ predictions = repaired.get("predictions")
307
+ if isinstance(predictions, list):
308
+ repaired_predictions = []
309
+ changed_predictions = False
310
+ for index, prediction in enumerate(predictions, start=1):
311
+ if not isinstance(prediction, dict):
312
+ repaired_predictions.append(prediction)
313
+ continue
314
+ repaired_prediction = dict(prediction)
315
+ if "claim_type" not in repaired_prediction:
316
+ for alias in ("claim", "claimType", "type", "kind"):
317
+ alias_value = repaired_prediction.get(alias)
318
+ if isinstance(alias_value, str) and alias_value.strip().lower() in PREDICTION_CLAIM_TYPES:
319
+ repaired_prediction["claim_type"] = alias_value.strip().lower()
320
+ del repaired_prediction[alias]
321
+ changed_predictions = True
322
+ actions.append(f"set predictions[{index}].claim_type from {alias}")
323
+ break
324
+ claim_type = repaired_prediction.get("claim_type")
325
+ if isinstance(claim_type, str) and claim_type.strip().lower() in PREDICTION_CLAIM_TYPES and claim_type != claim_type.strip().lower():
326
+ repaired_prediction["claim_type"] = claim_type.strip().lower()
327
+ changed_predictions = True
328
+ actions.append(f"normalized predictions[{index}].claim_type")
329
+ direction = repaired_prediction.get("direction")
330
+ if isinstance(direction, str) and direction.strip().lower() in PREDICTION_DIRECTIONS and direction != direction.strip().lower():
331
+ repaired_prediction["direction"] = direction.strip().lower()
332
+ changed_predictions = True
333
+ actions.append(f"normalized predictions[{index}].direction")
334
+ repaired_predictions.append(repaired_prediction)
335
+ if changed_predictions:
336
+ repaired["predictions"] = repaired_predictions
337
+
338
+ if not actions:
339
+ return text, actions
340
+ return render_analysis(repaired, body), actions
341
+
342
+
343
def validate_string_field(frontmatter: dict[str, Any], field: str, errors: list[str]) -> None:
344
value = frontmatter.get(field)
345
if value is None:
399
confidence = prediction.get("confidence")
400
if not isinstance(repo, str) or not TOP_REPO_PATTERN.fullmatch(repo.strip()):
401
errors.append(f"predictions[{index}].repo must use owner/repo format.")
275
- if not isinstance(claim_type, str) or claim_type.strip().lower() not in {"signal", "noise", "gap"}:
402
+ if not isinstance(claim_type, str) or claim_type.strip().lower() not in PREDICTION_CLAIM_TYPES:
403
errors.append(f"predictions[{index}].claim_type must be one of signal, noise, gap.")
404
if not isinstance(direction, str) or direction.strip().lower() not in PREDICTION_DIRECTIONS:
405
errors.append(f"predictions[{index}].direction must be one of up, flat, down.")
407
errors.append(f"predictions[{index}].confidence must be numeric.")
408
elif not 0 <= float(confidence) <= 1:
409
errors.append(f"predictions[{index}].confidence must be between 0 and 1.")
283
- extra_fields = sorted(set(prediction) - {"repo", "claim_type", "direction", "confidence"})
410
+ extra_fields = sorted(set(prediction) - PREDICTION_FIELDS)
411
if extra_fields:
412
errors.append(f"predictions[{index}] has unexpected fields: {', '.join(extra_fields)}")
413
528
handle.write(f"- Word count: `{word_count}`\n")
529
530
531
+def write_gate_report(
532
+ path: Path | None,
533
+ *,
534
+ analysis_file: Path,
535
+ source: str,
536
+ errors_before: list[str],
537
+ errors_after: list[str],
538
+ repair_actions: list[str],
539
+ word_count: int,
540
+) -> None:
541
+ if path is None:
542
+ return
543
+ path.parent.mkdir(parents=True, exist_ok=True)
544
+ payload = {
545
+ "analysis_file": analysis_file.as_posix(),
546
+ "source": source,
547
+ "passed": not errors_after,
548
+ "word_count": word_count,
549
+ "errors_before_repair": errors_before,
550
+ "repair_actions": repair_actions,
551
+ "errors_after_repair": errors_after,
552
+ "failure_class": classify_gate_errors(errors_after),
553
+ }
554
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
555
+
556
+
557
+def classify_gate_errors(errors: list[str]) -> str:
558
+ if not errors:
559
+ return "passed"
560
+ if all(
561
+ error.startswith(("date must", "week must", "year must", "repos_featured must", "stars_tracked must"))
562
+ or ".claim_type must" in error
563
+ for error in errors
564
+ ):
565
+ return "metadata_schema"
566
+ if any(error.startswith("Missing required section heading") or "body" in error for error in errors):
567
+ return "content_structure"
568
+ return "quality_gate"
569
+
570
+
571
+def gate_report_fingerprint(path: Path) -> str:
572
+ try:
573
+ report = json.loads(path.read_text(encoding="utf-8"))
574
+ except (OSError, json.JSONDecodeError):
575
+ return ""
576
+ if not isinstance(report, dict):
577
+ return ""
578
+ errors = report.get("errors_after_repair") or report.get("errors_before_repair") or []
579
+ if not isinstance(errors, list):
580
+ return ""
581
+ return hashlib.sha256(json.dumps(errors, sort_keys=True).encode("utf-8")).hexdigest()
582
+
583
+
584
def main(argv: list[str] | None = None) -> int:
585
args = parse_args(argv)
586
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
590
591
text = args.analysis_file.read_text(encoding="utf-8")
592
raw_payload = load_json(args.raw_json)
413
- errors, word_count = validate_analysis(text, raw_payload, args.current_datetime)
593
+ errors_before, word_count = validate_analysis(text, raw_payload, args.current_datetime)
594
+ errors = errors_before
595
+ repair_actions: list[str] = []
596
+ if errors and args.repair_safe:
597
+ try:
598
+ repaired_text, repair_actions = repair_analysis(text, raw_payload, args.current_datetime)
599
+ except Exception as exc: # noqa: BLE001 - repair is best-effort; validation/reporting must continue.
600
+ repair_actions = [f"repair skipped: {exc}"]
601
+ else:
602
+ if repair_actions and repaired_text != text:
603
+ args.analysis_file.write_text(repaired_text, encoding="utf-8")
604
+ text = repaired_text
605
+ errors, word_count = validate_analysis(text, raw_payload, args.current_datetime)
606
+ print(
607
+ f"::notice::Analysis gate applied safe repairs: {', '.join(repair_actions)}",
608
+ file=sys.stderr,
609
+ )
610
+ write_gate_report(
611
+ args.report_json,
612
+ analysis_file=args.analysis_file,
613
+ source=args.source,
614
+ errors_before=errors_before,
615
+ errors_after=errors,
616
+ repair_actions=repair_actions,
617
+ word_count=word_count,
618
+ )
619
if errors:
620
fail(errors, summary_path)
621