Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased source - 2026-10-21

- Open offline verification reports at the first priority needing review and
jump directly to other severity groups, including without JavaScript.
- Keep complete counts and the existing detail budget; omit empty groups and
use readable status labels. Redaction and verification outcomes are unchanged.

## 0.3.0 - 2026-10-20

- Review all validated cross-file declarations and inspection limits in offline HTML, text and JSON reports, including CSV summaries, reference metadata and keyed integer allocations.
Expand Down
23 changes: 15 additions & 8 deletions src/reporters/verification-html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,19 @@ export function renderVerificationHtml(input: VerificationReport): string {
const report = redactLocalPaths(input);
const issues = report.issues?.length ? report.issues : toIssues(report.findings);
const counts = report.summary;
const priorities = [
{ severity: "critical", label: "Critical issues", help: "Resolve these issues before reusing this Skill." },
{ severity: "error", label: "Errors", help: "Fix the failed checks, then verify again." },
{ severity: "warn", label: "Warnings", help: "Review these warnings against your intended use." },
{ severity: "info", label: "Information", help: "Review the recorded checks and notes." },
].map(priority => ({ ...priority, issues: issues.filter(issue => issue.finding.severity === priority.severity) }))
.filter(priority => priority.issues.length > 0);
const links = priorities.length ? `<nav class="priorities" aria-label="Issue priorities">${priorities.map(priority => `<a href="#issues-${priority.severity}-start">${priority.label} <strong>${priority.issues.length}</strong></a>`).join("")}</nav>` : "";
let remaining = DETAIL_LIMIT;
const groups = (["critical", "error", "warn", "info"] as const).map(severity => {
const all = issues.filter(issue => issue.finding.severity === severity);
const groups = priorities.map(({ severity, label, help, issues: all }, groupIndex) => {
const shown = all.slice(0, remaining);
remaining -= shown.length;
return `<details ${severity === "info" ? "" : "open"}><summary>${severity.toUpperCase()} · ${all.length} issues</summary>${shown.map((issue, index) => {
return `<details class="issue-group" id="issues-${severity}"${groupIndex === 0 ? " open" : ""}><summary>${label} · ${all.length} ${all.length === 1 ? "issue" : "issues"}</summary><p id="issues-${severity}-start" tabindex="-1">${help}</p>${shown.map((issue, index) => {
const f = issue.finding;
const resolution = issue.resolutions[0];
const steps = resolution?.kind === "manual" ? resolution.steps.slice(0, 8) : [];
Expand All @@ -29,14 +36,14 @@ export function renderVerificationHtml(input: VerificationReport): string {
<p>${text(issue.explanation.cause)}</p><p><strong>Next step:</strong> ${text(resolution?.title ?? f.remediation ?? issue.explanation.impact)}</p>
${steps.length ? `<ol>${steps.map(step => `<li>${text(step)}</li>`).join("")}</ol>` : ""}
<details><summary>Issue reference</summary><code>${text(issue.id)}</code><p>${text(issue.location?.path ?? "Location not included")}</p></details></article>`;
}).join("")}${all.length > shown.length ? `<p>Showing ${shown.length} of ${all.length} issues in this group. Use JSON for complete evidence.</p>` : ""}${all.length ? "" : "<p>No issues in this group.</p>"}</details>`;
}).join("")}${all.length > shown.length ? `<p>Showing ${shown.length} of ${all.length} issues in this group. Use JSON for complete evidence.</p>` : ""}</details>`;
}).join("");
const title = report.exitCode === 2 ? "Verification setup required" : counts.fail ? "Changes require attention" : counts.unknown || counts.warn ? "Review incomplete evidence" : "Declared checks passed";
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"><title>SkillSync · ${title}</title>
<style>:root{font-family:system-ui,sans-serif;color:#19343c;background:#f1f5f6;line-height:1.6}*{box-sizing:border-box}body{margin:0}header,main,footer{max-width:960px;margin:auto;padding:24px}a{color:#066d78}:focus-visible{outline:3px solid #18849b;outline-offset:4px}nav{display:flex;flex-wrap:wrap;gap:18px}h1{font-size:clamp(28px,4vw,42px);line-height:1.2}.hero,article,details{background:white;border:1px solid #cddce0;border-radius:12px;padding:20px;margin:16px 0}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.metrics strong{display:block;font-size:28px}summary{cursor:pointer;font-weight:700}code{overflow-wrap:anywhere}small{font-size:13px}footer{color:#4e6871;font-size:13px}@media(max-width:600px){.metrics{grid-template-columns:1fr 1fr}header,main,footer{padding:16px}}@media print{body{background:white}nav{display:none}article{break-inside:avoid}details{border-radius:0}}</style></head>
<style>:root{font-family:system-ui,sans-serif;color:#19343c;background:#f1f5f6;line-height:1.6}*{box-sizing:border-box}body{margin:0}header,main,footer{max-width:960px;margin:auto;padding:24px}a{color:#066d78}:focus-visible{outline:3px solid #18849b;outline-offset:4px}nav{display:flex;flex-wrap:wrap;gap:18px}h1{font-size:clamp(28px,4vw,42px);line-height:1.2}.hero,.issue-group{background:white;border:1px solid #cddce0;border-radius:8px;padding:20px;margin:16px 0}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;border-block:1px solid #cddce0;padding:16px 0;font-variant-numeric:tabular-nums}.metrics strong{display:block;font-size:28px}.priorities{gap:8px;margin-top:20px}.priorities a{display:flex;align-items:center;gap:12px;min-height:44px;padding:8px 12px;border:1px solid #9db7bd;border-radius:4px;text-decoration:none}.priorities a:hover{background:#e8f3f4}summary{cursor:pointer;font-weight:700;min-height:44px;padding-block:8px}article{padding:16px 0;border-top:1px solid #cddce0}article h3{margin:0}article details{margin-top:12px}code,p{overflow-wrap:anywhere}[id]{scroll-margin-top:16px}small{font-size:13px}footer{color:#4e6871;font-size:13px}@media(max-width:600px){.metrics{grid-template-columns:1fr 1fr}header,main,footer{padding:16px}}@media print{body{background:white}nav{display:none}article{break-inside:avoid}details{border-radius:0}}</style></head>
<body><header><strong>SkillSync · Offline verification review</strong><nav aria-label="Report sections"><a href="#overview">Overview</a><a href="#issues">Issues and next steps</a><a href="#scope">Scope</a></nav></header><main>
<section class="hero" id="overview"><h1>${title}</h1><p>Read the cause and next step for each issue. These deterministic checks do not establish runtime safety, authenticated provenance, or approval.</p><div class="metrics">${(["pass", "fail", "warn", "unknown"] as const).map(key => `<div><strong>${text(counts[key])}</strong>${key}</div>`).join("")}</div><p>${text(counts.total)} findings · ${text(issues.length)} deduplicated issues · exit code ${report.exitCode}</p></section>
<section id="issues"><h2>Issues and next steps</h2><p>Groups are ordered by severity. At most ${DETAIL_LIMIT} issues are shown. Expand information groups to review successful checks.</p>${groups}</section>
<section id="scope"><h2>Scope and evidence</h2><p>${report.targets.length} selected targets. ${text(report.generated_at)}</p><p>Local paths and raw evidence contents are excluded. No Skill was executed and no fix was applied by this report.</p></section></main><footer>Generated from local verification metadata. No scripts, external assets, or network requests. Keep the JSON report for complete machine-readable evidence.</footer></body></html>\n`;
<section class="hero" id="overview"><h1>${title}</h1><p>These checks read declared metadata. They do not establish runtime safety, authenticated provenance, or approval.</p><div class="metrics">${([{ key: "pass", label: "Passed" }, { key: "fail", label: "Failed" }, { key: "warn", label: "Warnings" }, { key: "unknown", label: "Unverified" }] as const).map(({ key, label }) => `<div><strong>${text(counts[key])}</strong>${label}</div>`).join("")}</div><p>${text(counts.total)} findings · ${text(issues.length)} deduplicated issues · exit code ${report.exitCode}</p>${links}</section>
<section id="issues"><h2>Issues and next steps</h2>${priorities.length ? `<p>The first group is open. Choose a priority above to open another group. At most ${DETAIL_LIMIT} issue details are included; use JSON for the full report.</p>${groups}` : "<p>No issues were reported. Review the scope and selected targets below.</p>"}</section>
<section id="scope"><h2>Scope and evidence</h2><p>${report.targets.length} selected ${report.targets.length === 1 ? "target" : "targets"}. ${text(report.generated_at)}</p><p>Local paths and raw evidence contents are excluded. No Skill was executed and no fix was applied by this report.</p></section></main><footer>Generated from local verification metadata. No scripts, external assets, or network requests. Keep the JSON report for complete machine-readable evidence.</footer></body></html>\n`;
}
24 changes: 24 additions & 0 deletions tests/reporters/verification-html.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,27 @@ it("escapes metadata, hides raw evidence, and caps details while retaining total
expect(html).not.toContain("/private/work");
expect(html).toContain("&lt;local-path&gt;");
});

it("links directly to severity groups and opens the first group needing review", () => {
const findings = (["warn", "critical", "error", "info"] as const).map(severity => ({ level: 1 as const, severity, status: "fail" as const, code: `test-${severity}`, skill: "fixture", message: "Review this check", remediation: "Check the saved input", evidence: [] }));
const report: VerificationReport = { schema_version: 1, generated_at: "now", targets: [], findings, issues: toIssues(findings), summary: { total: 4, pass: 0, fail: 4, warn: 0, unknown: 0 }, exitCode: 1 };
const html = renderVerificationHtml(report);
expect(html).toContain('aria-label="Issue priorities"');
expect(html).toContain('href="#issues-critical-start"');
expect(html).toContain('id="issues-critical" open');
expect(html).not.toContain('id="issues-error" open');
for (const severity of ["critical", "error", "warn", "info"]) {
expect(html).toContain(`id="issues-${severity}-start"`);
}
expect(html).toContain("Unverified");
expect(html).not.toContain("CRITICAL ·");
expect(html).not.toContain("1 issues");
});

it("does not fill an empty report with four empty severity panels", () => {
const report: VerificationReport = { schema_version: 1, generated_at: "now", targets: [], findings: [], issues: [], summary: { total: 0, pass: 0, fail: 0, warn: 0, unknown: 0 }, exitCode: 0 };
const html = renderVerificationHtml(report);
expect(html).toContain("No issues were reported.");
expect(html).not.toContain('class="issue-group"');
expect(html).not.toContain('aria-label="Issue priorities"');
});
Loading