#!/usr/bin/env node /** * Mechanical PR evidence gate for the evidence rows in the pull request * template. The template keeps stable HTML markers above each required row so * this checker can ignore row prose churn while still failing closed when a row * is blank, checkbox-only, or removed. */ import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; export const REQUIRED_EVIDENCE_ROWS = [ { id: "before-screenshots", label: "Before screenshots" }, { id: "after-screenshots", label: "After screenshots" }, { id: "walkthrough-video", label: "Walkthrough video" }, { id: "backend-logs", label: "Backend logs" }, { id: "frontend-logs", label: "Frontend console/network logs" }, { id: "llm-trajectory", label: "Real-LLM trajectory" }, { id: "domain-artifacts", label: "Domain artifacts" }, ]; export const SURFACE_EVIDENCE_LABELS = ["ui", "frontend", "native"]; export const SURFACE_ARTIFACT_ROW_IDS = [ "before-screenshots", "after-screenshots", "walkthrough-video", ]; export const SURFACE_OCR_EVIDENCE_ROW = { id: "ocr-review", label: "OCR visual text review", }; /** * A changed file forces surface artifacts when it is a rendered-UI source file * — labels are advisory and agents routinely omit them, so the gate cannot rely * on them alone. Detection is deliberately narrow: a visual EXTENSION (`.tsx`, * CSS family, `.svg`, `.html`, `.vue`) under a UI-bearing PACKAGE, excluding * test/story/fixture files that render nothing a user sees. This is why editing * a real component in `packages/ui` or `packages/app` demands screenshots while * editing its `*.test.tsx` or `*.stories.tsx` does not. */ const SURFACE_PATH_RE = /(^|\/)(packages\/(app|ui|tui|homepage)|apps\/app|packages\/cloud\/frontend|packages\/os\/landing)\//i; const SURFACE_VISUAL_EXT_RE = /\.(tsx|jsx|css|scss|sass|less|svg|html|vue)$/i; const SURFACE_NON_VISUAL_RE = /(\.(test|spec|stories|story|bench)\.|\.d\.ts$|(^|\/)(__tests__|__e2e__|__mocks__|__fixtures__|test|tests|e2e|stories)\/)/i; /** * True when any changed file is a rendered-UI source file (see `SURFACE_PATH_RE` * rationale). Backslash paths from a Windows runner are normalized so the same * diff classifies identically on either OS. */ export function requiresSurfaceArtifactsFromFiles(files) { return surfaceFiles(files).length > 0; } /** The rendered-UI source files within a changed-file list. */ export function surfaceFiles(files) { return parseChangedFiles(files) .map((raw) => raw.replaceAll("\\", "/")) .filter( (file) => SURFACE_PATH_RE.test(file) && SURFACE_VISUAL_EXT_RE.test(file) && !SURFACE_NON_VISUAL_RE.test(file), ); } /** * A "before" screenshot is impossible when the ENTIRE touched UI surface is new * — every rendered-UI file in the diff was ADDED, none modified. In that case * the before-screenshots row may be an honest `N/A - ` instead of * media. Determined mechanically from the added-files list (`git diff * --diff-filter=A`), never from the reason text, so it cannot be gamed by * prose. */ export function beforeScreenshotImpossible(changedFiles, addedFiles) { const surface = surfaceFiles(changedFiles); if (surface.length === 0) return false; const added = new Set(surfaceFiles(addedFiles)); return surface.every((file) => added.has(file)); } const OCR_EVIDENCE_RE = /\bOCR\b|ocr-triage|mvp:visual-verify|audit:app:verify|tesseract|text readout/i; const MARKER_RE = //gi; const RETIRED_REPO_EVIDENCE_PATH = [ ".github", ["issue", "evidence"].join("-"), ].join("/"); const RETIRED_REPO_EVIDENCE_RE = new RegExp( `${RETIRED_REPO_EVIDENCE_PATH.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\/\\S+`, "i", ); export function parseLabels(value) { if (Array.isArray(value)) { return value .flatMap((label) => parseLabels(label)) .filter((label, index, labels) => labels.indexOf(label) === index); } return String(value ?? "") .split(/[\n,]/) .map((label) => label.trim().toLowerCase()) .filter(Boolean); } export function requiresSurfaceArtifacts(labels) { const labelSet = new Set(parseLabels(labels)); return SURFACE_EVIDENCE_LABELS.some((label) => labelSet.has(label)); } export function hasOcrEvidenceReference(rows) { for (const rowText of rows.values()) { // OCR proof must reference real evidence, not a page link: either an // actual media artifact or a linked report/log file next to OCR keywords. if ( OCR_EVIDENCE_RE.test(rowText) && (hasVisualArtifactReference(rowText) || hasEvidenceFileReference(rowText)) ) { return true; } } return false; } // Linked non-media evidence file (an OCR report/JSON/log) — still stricter // than "any URL": the link target must look like a file, not a web page. const EVIDENCE_FILE_RE = /\.(json|txt|log|csv|md)(\?\S*)?(\s|$|\)|"|')/i; export function hasEvidenceFileReference(text) { const value = String(text ?? ""); return ( (EVIDENCE_FILE_RE.test(value) || GITHUB_ATTACHMENT_RE.test(value)) && !RETIRED_REPO_EVIDENCE_RE.test(value) ); } export function hasNaWithReason(text) { const match = text.match(/\bN\/?A\b\s*[-:\u2013\u2014]\s*(\S[\s\S]*?)$/im); if (!match) return false; const reason = match[1].trim(); if (reason.length < 3) return false; const stripped = reason.replace(/[`*_]+/g, "").trim(); return !/^<[^>]*>[.\s]*$/.test(stripped); } export function hasArtifactReference(text) { const markdownLinks = [ ...String(text ?? "").matchAll(/\[[^\]]+\]\(\s*(\S+)\s*\)/g), ]; if (markdownLinks.some((match) => !RETIRED_REPO_EVIDENCE_RE.test(match[1]))) { return true; } if (/https?:\/\/\S+/i.test(text)) return true; if ( /user-images\.githubusercontent\.com|github\.com\/[^)\s]+\/assets\//i.test( text, ) ) { return true; } return false; } // A GitHub-uploaded attachment (drag-and-drop) lands on one of these hosts; // these URLs are the unforgeable signal that a real image/video is embedded. const GITHUB_ATTACHMENT_RE = /(https?:\/\/)?(github\.com\/user-attachments\/assets\/|user-images\.githubusercontent\.com\/|github\.com\/[^/\s)]+\/[^/\s)]+\/assets\/)/i; // A URL/path whose tail is an image or video file — a directly-linked media file. const MEDIA_EXT_RE = /\.(png|jpe?g|gif|webp|apng|avif|bmp|svg|mp4|mov|webm|m4v|ogg)(\?\S*)?(\s|$|\)|"|')/i; /** * Strict media check for the VISUAL evidence rows (screenshots, walkthrough * video, OCR readout). Unlike `hasArtifactReference`, a bare link to a page — * the PR itself, a `/checks` tab, a commit, a job log — does NOT count: those * are how an author games the loose check while attaching no pixels. Only a * real embedded/linked image or video satisfies it: a GitHub attachment-host * URL, a markdown image embed `![](…)`, an ``/`