diff --git a/README.md b/README.md index 34a837e..f605eb5 100644 --- a/README.md +++ b/README.md @@ -139,3 +139,7 @@ maintenance changes are in source and are not in the npm 0.1.0/GitHub v0.1.4 rel - [CI](./docs/ci.md) - [Runner](./docs/runner-contract.md) - [Full design](./SkillSync-Complete-Design.md) + +### Offline verification review + +Build the source checkout, then run `skillsync verify --path ./skills --format html > verification.html`. Open the report locally to review severity groups, causes, and next steps. Summary counts cover all findings; details are capped at 256 deduplicated issues. JSON retains complete evidence. No scripts, network assets, Skill execution, or automatic fixes are added. Inspired by [MLflow's reviewable tracking UI](https://mlflow.org/docs/latest/ml/tracking/). This option is source functionality until a new package is published. diff --git a/src/cli/index.ts b/src/cli/index.ts index 69e2147..ccbce4c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -58,6 +58,7 @@ import { import { renderJson } from "../reporters/json.js"; import { renderSarif } from "../reporters/sarif.js"; import { renderText } from "../reporters/text.js"; +import { renderVerificationHtml } from "../reporters/verification-html.js"; import { parseOutputFormat } from "./output.js"; import { VERSION } from "../version.js"; @@ -286,7 +287,7 @@ export function createCli(io: CliIO = defaultCliIO): Command { .option("--policy ", "YAML or JSON policy file") .option("--profile-dir ", "Additional directory containing external profile YAML") .option("--follow-symlinks", "Follow only symlinks that remain inside the Skill root") - .option("--format ", "Output format: text, json, or sarif", "text") + .option("--format ", "Output format: text, json, sarif, or offline html", "text") .action(async (options: VerifyCliOptions) => { const report = await runVerification({ paths: options.path ?? [], @@ -295,8 +296,10 @@ export function createCli(io: CliIO = defaultCliIO): Command { profileDir: options.profileDir, followSymlinks: options.followSymlinks, }); - const format = parseOutputFormat(options.format); - const output = format === "json" + const format = options.format === "html" ? "html" : parseOutputFormat(options.format); + const output = format === "html" + ? renderVerificationHtml(report) + : format === "json" ? renderJson(report) : format === "sarif" ? renderSarif(report) diff --git a/src/reporters/verification-html.ts b/src/reporters/verification-html.ts new file mode 100644 index 0000000..95338e3 --- /dev/null +++ b/src/reporters/verification-html.ts @@ -0,0 +1,42 @@ +import type { VerificationReport } from "../cli/commands/verify.js"; +import { toIssues } from "../domain/issue.js"; +import { redactLocalPaths } from "./local-paths.js"; + +const DETAIL_LIMIT = 256; +function text(value: unknown): string { + const valueText = String(value ?? "Not reported"); + return (valueText.length > 600 ? valueText.slice(0, 600) + "…" : valueText) + .replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">") + .replaceAll('"', """).replaceAll("'", "'"); +} + +/** A bounded, offline review of existing evidence; no scripts or automatic fixes. */ +export function renderVerificationHtml(input: VerificationReport): string { + const report = redactLocalPaths(input); + const issues = report.issues?.length ? report.issues : toIssues(report.findings); + const counts = report.summary; + let remaining = DETAIL_LIMIT; + const groups = (["critical", "error", "warn", "info"] as const).map(severity => { + const all = issues.filter(issue => issue.finding.severity === severity); + const shown = all.slice(0, remaining); + remaining -= shown.length; + return `
${severity.toUpperCase()} · ${all.length} issues${shown.map((issue, index) => { + const f = issue.finding; + const resolution = issue.resolutions[0]; + const steps = resolution?.kind === "manual" ? resolution.steps.slice(0, 8) : []; + return `

${text(f.code)} ${text(f.status)}

+

Skill: ${text(f.skill)} · Target: ${text(f.target ?? "All")}

+

${text(issue.explanation.cause)}

Next step: ${text(resolution?.title ?? f.remediation ?? issue.explanation.impact)}

+ ${steps.length ? `
    ${steps.map(step => `
  1. ${text(step)}
  2. `).join("")}
` : ""} +
Issue reference${text(issue.id)}

${text(issue.location?.path ?? "Location not included")}

`; + }).join("")}${all.length > shown.length ? `

Showing ${shown.length} of ${all.length} issues in this group. Use JSON for complete evidence.

` : ""}${all.length ? "" : "

No issues in this group.

"}
`; + }).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 ` +SkillSync · ${title} + +
SkillSync · Offline verification review
+

${title}

Read the cause and next step for each issue. These deterministic checks do not establish runtime safety, authenticated provenance, or approval.

${(["pass", "fail", "warn", "unknown"] as const).map(key => `
${text(counts[key])}${key}
`).join("")}

${text(counts.total)} findings · ${text(issues.length)} deduplicated issues · exit code ${report.exitCode}

+

Issues and next steps

Groups are ordered by severity. At most ${DETAIL_LIMIT} issues are shown. Expand information groups to review successful checks.

${groups}
+

Scope and evidence

${report.targets.length} selected targets. ${text(report.generated_at)}

Local paths and raw evidence contents are excluded. No Skill was executed and no fix was applied by this report.

Generated from local verification metadata. No scripts, external assets, or network requests. Keep the JSON report for complete machine-readable evidence.
\n`; +} diff --git a/tests/cli/verify.test.ts b/tests/cli/verify.test.ts index e5c94e3..82d8809 100644 --- a/tests/cli/verify.test.ts +++ b/tests/cli/verify.test.ts @@ -7,6 +7,17 @@ import { describe, expect, it } from "vitest"; import { runCli } from "../../src/cli/index"; describe("skillsync verify", () => { + it("exports an offline HTML review with actionable findings", async () => { + const root = await mkdtemp(join(tmpdir(), "skillsync-html-")); + await writeFile(join(root, "SKILL.md"), "---\nname: review\ndescription: Review\n---\n[missing](missing.md)\n"); + const result = await runCli(["verify", "--path", root, "--target", "codex", "--format", "html"]); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain(""); + expect(result.stdout).toContain("Add the referenced file"); + expect(result.stdout).toContain("Content-Security-Policy"); + expect(result.stdout).not.toContain(root); + }); + it("aggregates structure, compatibility, and provenance findings without executing scripts", async () => { const root = await mkdtemp(join(tmpdir(), "skillsync-verify-")); const skillRoot = join(root, "review"); diff --git a/tests/reporters/verification-html.test.ts b/tests/reporters/verification-html.test.ts new file mode 100644 index 0000000..d25d6b0 --- /dev/null +++ b/tests/reporters/verification-html.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from "vitest"; +import { toIssues } from "../../src/domain/issue"; +import type { VerificationReport } from "../../src/cli/commands/verify"; +import { renderVerificationHtml } from "../../src/reporters/verification-html"; + +it("escapes metadata, hides raw evidence, and caps details while retaining totals", () => { + const finding = { level: 1 as const, severity: "error" as const, status: "fail" as const, code: "test", skill: "", message: "Read /private/work/file.md", evidence: [{ content: "RAW_SECRET", path: "/private/work/file.md" }], remediation: "Review " }; + const issues = Array.from({ length: 270 }, () => toIssues([finding])[0]!); + const report: VerificationReport = { schema_version: 1, generated_at: "now", targets: [], findings: [finding], issues, summary: { total: 270, pass: 0, fail: 270, warn: 0, unknown: 0 }, exitCode: 1 }; + const html = renderVerificationHtml(report); + expect(html).toContain("Showing 256 of 270"); + expect(html.match(/
"); + expect(html).not.toContain("RAW_SECRET"); + expect(html).not.toContain("/private/work"); + expect(html).toContain("<local-path>"); +});