import { formatDeckHtmlContractReport, validateDeckHtmlContract } from "../deck-html/contract" import { activeDesign, extractDesignComponentContracts } from "../design/designs" import type { DesignClassVocabulary, DesignComponentContract } from "../design/designs" import { formatReport, runQA } from "./index" import { runComplianceQA } from "./compliance" import { runComponentContractQA } from "./component-contracts" import { formatPageTemplateContractReport, validatePageTemplateContracts } from "../page-templates" import type { QAReport } from "./checks" import { existsSync, readFileSync } from "fs" import { basename, dirname, resolve } from "path" export interface ArtifactQAReport { file: string passed: boolean hardErrorCount: number warningCount: number sections: string[] } function hardErrors(report: QAReport): number { return report.slides.reduce((sum, slide) => sum + slide.issues.filter((issue) => issue.severity === "error").length, 0) } function warnings(report: QAReport): number { return report.slides.reduce((sum, slide) => sum + slide.issues.filter((issue) => issue.severity === "warning").length, 0) } export async function runArtifactQA(input: { workspaceRoot: string filePath: string vocabulary?: DesignClassVocabulary componentContracts?: DesignComponentContract[] }): Promise { const sections: string[] = [] let hardErrorCount = 0 let warningCount = 0 const contract = validateDeckHtmlContract(input.workspaceRoot, input.filePath) if (contract.status === "invalid") { hardErrorCount += contract.issues.filter((issue) => issue.severity === "error").length warningCount += contract.warnings.length sections.push("**[deck HTML contract]**\n\n" + formatDeckHtmlContractReport(contract)) } else if (contract.warnings.length > 0) { warningCount += contract.warnings.length sections.push("**[deck HTML contract]**\n\n" + formatDeckHtmlContractReport(contract)) } const designCss = validateLinkedDesignCss(input.filePath) if (designCss.errors.length > 0 || designCss.warnings.length > 0) { hardErrorCount += designCss.errors.length warningCount += designCss.warnings.length sections.push("**[design CSS snapshot]**\n\n" + [ ...designCss.errors.map((message) => `- ERROR: ${message}`), ...designCss.warnings.map((message) => `- WARNING: ${message}`), ].join("\n")) } if (shouldRunArtifactCompliance(input.filePath)) { const compliance = runComplianceQA(input.filePath, input.vocabulary) const complianceErrors = hardErrors(compliance) if (compliance.totalIssues > 0) { hardErrorCount += complianceErrors warningCount += warnings(compliance) sections.push("**[design class compliance]**\n\n" + formatReport(compliance)) } } const componentContracts = input.componentContracts ?? componentContractsForArtifact(input.filePath) if (componentContracts.length > 0) { const componentContractReport = runComponentContractQA(input.filePath, componentContracts) const contractErrors = hardErrors(componentContractReport) if (componentContractReport.totalIssues > 0) { hardErrorCount += contractErrors warningCount += warnings(componentContractReport) sections.push("**[component structure contracts]**\n\n" + formatReport(componentContractReport)) } } const templateContracts = validatePageTemplateContracts(input.filePath) if (templateContracts.issues.length > 0) { hardErrorCount += templateContracts.issues.filter((issue) => issue.severity === "error").length warningCount += templateContracts.issues.filter((issue) => issue.severity === "warning").length sections.push("**[page template contracts]**\n\n" + formatPageTemplateContractReport(templateContracts)) } try { const browser = await runQA(input.filePath) const browserErrors = hardErrors(browser) if (browser.totalIssues > 0) { hardErrorCount += browserErrors warningCount += warnings(browser) sections.push("**[browser artifact QA]**\n\n" + formatReport(browser)) } } catch (e) { hardErrorCount += 1 sections.push("**[browser artifact QA]**\n\nError running browser QA: " + (e instanceof Error ? e.message : String(e))) } return { file: input.filePath, passed: hardErrorCount === 0, hardErrorCount, warningCount, sections, } } function validateLinkedDesignCss(filePath: string): { errors: string[]; warnings: string[] } { if (isDesignPreviewFile(filePath)) return { errors: [], warnings: [] } const errors: string[] = [] const warnings: string[] = [] let html = "" try { html = readFileSync(filePath, "utf-8") } catch { return { errors, warnings } } const hrefs = [...html.matchAll(/]*rel=["']stylesheet["'][^>]*href=["']([^"']*design\.css)["'][^>]*>/gi)].map((match) => match[1]) if (hrefs.length === 0) { warnings.push("Deck does not reference a design.css snapshot.") return { errors, warnings } } for (const href of hrefs) { if (/^[a-z][a-z0-9+.-]*:/i.test(href)) continue const cssPath = resolve(dirname(filePath), href) if (!existsSync(cssPath)) { errors.push(`Linked design CSS is missing: ${href}`) continue } const css = readFileSync(cssPath, "utf-8") for (const asset of cssAssetUrls(css)) { const assetPath = resolve(dirname(cssPath), asset) if (!existsSync(assetPath)) errors.push(`Linked design CSS references missing asset: ${href} -> ${asset}`) } } return { errors, warnings } } function cssAssetUrls(css: string): string[] { const urls: string[] = [] const seen = new Set() const urlRe = /url\(\s*["']?([^"')]+)["']?\s*\)/gi let match: RegExpExecArray | null while ((match = urlRe.exec(css)) !== null) { const raw = match[1].trim() if (!raw || raw.startsWith("data:") || /^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith("#")) continue if (seen.has(raw)) continue seen.add(raw) urls.push(raw) } return urls } function componentContractsForArtifact(filePath: string): DesignComponentContract[] { const designName = designNameFromPreviewPath(filePath) try { return extractDesignComponentContracts(designName || activeDesign()) } catch { return [] } } export function shouldRunArtifactCompliance(filePath: string): boolean { return !isDesignPreviewFile(filePath) } function isDesignPreviewFile(filePath: string): boolean { const normalizedPath = filePath.replace(/\\/g, "/") if (basename(normalizedPath) !== "preview.html") return false const parts = dirname(normalizedPath).split("/") return parts.length >= 2 && parts[parts.length - 2] === "designs" } function designNameFromPreviewPath(filePath: string): string | undefined { const normalizedPath = filePath.replace(/\\/g, "/") if (basename(normalizedPath) !== "preview.html") return undefined const parts = dirname(normalizedPath).split("/") if (parts.length >= 2 && parts[parts.length - 2] === "designs") return parts[parts.length - 1] return undefined } export function formatArtifactQAReport(report: ArtifactQAReport): string { const heading = report.passed ? "Artifact QA: PASSED" : "Artifact QA: FAILED" const summary = `**File:** \`${report.file}\`\n\n**Hard errors:** ${report.hardErrorCount}\n**Warnings:** ${report.warningCount}` if (report.sections.length === 0) return `## ${heading}\n\n${summary}\n\nAll artifact QA checks passed.` return `## ${heading}\n\n${summary}\n\n${report.sections.join("\n\n---\n\n")}` }