diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 0df3029fe..9ab8ac448 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -116,8 +116,9 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ npx hyperframes lint ``` ``` - Linting index.html... - No issues found. + ◆ Linting my-project/index.html + + ◇ 0 errors, 0 warnings ``` @@ -213,22 +214,31 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ ```bash npx hyperframes lint [dir] + npx hyperframes lint [dir] --verbose # include info-level findings + npx hyperframes lint [dir] --json # machine-readable JSON output ``` ``` - Linting index.html... + ◆ Linting my-project/index.html - WARNING unmuted-video - Video element 'clip-1' should have the 'muted' attribute for reliable autoplay. - at index.html:5 + ✗ missing_gsap_script: Composition uses GSAP but no GSAP script is loaded. + ⚠ unmuted-video [clip-1]: Video should have the 'muted' attribute for reliable autoplay. - 1 issue found (0 errors, 1 warning) + ◇ 1 error(s), 1 warning(s) ``` + By default only **errors** and **warnings** are printed. Info-level findings (e.g., external script dependency notices) are hidden to keep output clean for agents and CI. Use `--verbose` to include them. + | Flag | Description | |------|-------------| - | `--json` | Output findings as JSON | + | `--json` | Output findings as JSON (includes `errorCount`, `warningCount`, `infoCount`, and `findings` array) | + | `--verbose` | Include info-level findings in output (hidden by default) | + + **Severity levels:** + - **Error** (`✗`) — must fix before rendering (e.g., missing adapter library, invalid attributes) + - **Warning** (`⚠`) — likely issues that may cause unexpected behavior + - **Info** (`ℹ`) — informational notices, shown only with `--verbose` - The linter detects missing attributes, deprecated names, structural problems, and more. See [Common Mistakes](/guides/common-mistakes) for details on each rule. + The linter detects missing attributes, missing adapter libraries (GSAP, Lottie, Three.js), structural problems, and more. See [Common Mistakes](/guides/common-mistakes) for details on each rule. ### `render` diff --git a/packages/cli/README.md b/packages/cli/README.md index c95e77ec2..24a2ff175 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -51,9 +51,13 @@ npx hyperframes render ./my-composition.html -o output.mp4 Validate your Hyperframes HTML: ```bash -npx hyperframes lint ./my-composition.html +npx hyperframes lint ./my-composition +npx hyperframes lint ./my-composition --json # JSON output for CI/tooling +npx hyperframes lint ./my-composition --verbose # Include info-level findings ``` +By default only errors and warnings are shown. Use `--verbose` to also display informational findings (e.g., external script dependency notices). Use `--json` for machine-readable output with `errorCount`, `warningCount`, `infoCount`, and a `findings` array. + ### `compositions` List compositions found in the current project: diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 1d10bce3b..3871d0afb 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -1,45 +1,92 @@ import { defineCommand } from "citty"; import { c } from "../ui/colors.js"; -import { resolveProject } from "../utils/project.js"; -import { lintProject } from "../utils/lintProject.js"; import { formatLintFindings } from "../utils/lintFormat.js"; +import { lintProject } from "../utils/lintProject.js"; +import { resolveProject } from "../utils/project.js"; import { withMeta } from "../utils/updateCheck.js"; export default defineCommand({ - meta: { name: "lint", description: "Validate a composition for common mistakes" }, + meta: { + name: "lint", + description: "Validate a composition for common mistakes", + }, args: { - dir: { type: "positional", description: "Project directory", required: false }, - json: { type: "boolean", description: "Output findings as JSON", default: false }, + dir: { + type: "positional", + description: "Project directory", + required: false, + }, + json: { + type: "boolean", + description: "Output findings as JSON", + default: false, + }, + verbose: { + type: "boolean", + description: "Show info-level findings (hidden by default)", + default: false, + }, }, async run({ args }) { - const project = resolveProject(args.dir); - const lintResult = lintProject(project); + try { + const project = resolveProject(args.dir); + const lintResult = lintProject(project); - if (args.json) { - const combined = { - ok: lintResult.totalErrors === 0, - errorCount: lintResult.totalErrors, - warningCount: lintResult.totalWarnings, - findings: lintResult.results.flatMap((r) => r.result.findings), - }; - console.log(JSON.stringify(withMeta(combined), null, 2)); - process.exit(combined.ok ? 0 : 1); - } + if (args.json) { + const allFindings = lintResult.results.flatMap((r) => r.result.findings); + const combined = { + ok: lintResult.totalErrors === 0, + errorCount: lintResult.totalErrors, + warningCount: lintResult.totalWarnings, + infoCount: lintResult.totalInfos, + findings: args.verbose ? allFindings : allFindings.filter((f) => f.severity !== "info"), + filesScanned: lintResult.results.length, + }; + console.log(JSON.stringify(withMeta(combined), null, 2)); + process.exit(combined.ok ? 0 : 1); + } - const fileCount = lintResult.results.length; - const fileLabel = - fileCount === 1 ? (lintResult.results[0]?.file ?? "index.html") : `${fileCount} files`; - console.log(`${c.accent("◆")} Linting ${c.accent(project.name + "/" + fileLabel)}`); - console.log(); + const fileCount = lintResult.results.length; + const fileLabel = + fileCount === 1 ? (lintResult.results[0]?.file ?? "index.html") : `${fileCount} files`; + console.log(`${c.accent("◆")} Linting ${c.accent(`${project.name}/${fileLabel}`)}`); + console.log(); - if (lintResult.totalErrors === 0 && lintResult.totalWarnings === 0) { - console.log(`${c.success("◇")} ${c.success("0 errors, 0 warnings")}`); - return; - } + if (lintResult.totalErrors === 0 && lintResult.totalWarnings === 0) { + console.log(`${c.success("◇")} ${c.success("0 errors, 0 warnings")}`); + return; + } - const lines = formatLintFindings(lintResult, { showElementId: true, showSummary: true }); - for (const line of lines) console.log(line); + const lines = formatLintFindings(lintResult, { + showElementId: true, + showSummary: true, + verbose: args.verbose, + }); + for (const line of lines) console.log(line); - process.exit(lintResult.totalErrors > 0 ? 1 : 0); + process.exit(lintResult.totalErrors > 0 ? 1 : 0); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + if (args.json) { + console.log( + JSON.stringify( + withMeta({ + ok: false, + error: message, + findings: [], + errorCount: 0, + warningCount: 0, + infoCount: 0, + filesScanned: 0, + }), + null, + 2, + ), + ); + process.exit(1); + } + console.error(message); + process.exit(1); + } }, }); diff --git a/packages/cli/src/templates/_shared/CLAUDE.md b/packages/cli/src/templates/_shared/CLAUDE.md index ada92b7a2..1c710c0c1 100644 --- a/packages/cli/src/templates/_shared/CLAUDE.md +++ b/packages/cli/src/templates/_shared/CLAUDE.md @@ -21,7 +21,9 @@ ```bash npx hyperframes dev # preview in browser (studio editor) npx hyperframes render # render to MP4 -npx hyperframes lint # validate compositions +npx hyperframes lint # validate compositions (errors + warnings) +npx hyperframes lint --verbose # include info-level findings +npx hyperframes lint --json # machine-readable output for CI npx hyperframes docs # reference docs in terminal ``` diff --git a/packages/cli/src/utils/lintFormat.ts b/packages/cli/src/utils/lintFormat.ts index 7410fc508..539c076ed 100644 --- a/packages/cli/src/utils/lintFormat.ts +++ b/packages/cli/src/utils/lintFormat.ts @@ -8,16 +8,23 @@ export interface LintFormatOptions { showSummary?: boolean; /** Group errors before warnings per file (default: false — interleaved) */ errorsFirst?: boolean; + /** Include info-level findings in output (default: false — only errors/warnings) */ + verbose?: boolean; } /** * Format lint findings for console output. Used by lint, render, and dev commands. */ export function formatLintFindings( - { results, totalErrors, totalWarnings }: ProjectLintResult, + { results, totalErrors, totalWarnings, totalInfos }: ProjectLintResult, options: LintFormatOptions = {}, ): string[] { - const { showElementId = true, showSummary = false, errorsFirst = false } = options; + const { + showElementId = true, + showSummary = false, + errorsFirst = false, + verbose = false, + } = options; const lines: string[] = []; const multiFile = results.length > 1; @@ -25,7 +32,13 @@ export function formatLintFindings( if (result.findings.length === 0) continue; const format = (finding: (typeof result.findings)[0]) => { - const prefix = finding.severity === "error" ? c.error("✗") : c.warn("⚠"); + if (!verbose && finding.severity === "info") return; + const prefix = + finding.severity === "error" + ? c.error("✗") + : finding.severity === "warning" + ? c.warn("⚠") + : c.dim("ℹ"); const fileLabel = multiFile ? c.dim(`[${file}] `) : ""; const loc = showElementId && finding.elementId ? ` ${c.accent(`[${finding.elementId}]`)}` : ""; @@ -36,6 +49,7 @@ export function formatLintFindings( if (errorsFirst) { for (const f of result.findings) if (f.severity === "error") format(f); for (const f of result.findings) if (f.severity === "warning") format(f); + if (verbose) for (const f of result.findings) if (f.severity === "info") format(f); } else { for (const f of result.findings) format(f); } @@ -44,7 +58,9 @@ export function formatLintFindings( if (showSummary) { const icon = totalErrors > 0 ? c.error("◇") : c.success("◇"); lines.push(""); - lines.push(`${icon} ${totalErrors} error(s), ${totalWarnings} warning(s)`); + const summaryParts = [`${totalErrors} error(s)`, `${totalWarnings} warning(s)`]; + if (verbose && totalInfos > 0) summaryParts.push(`${totalInfos} info(s)`); + lines.push(`${icon} ${summaryParts.join(", ")}`); } return lines; diff --git a/packages/cli/src/utils/lintProject.ts b/packages/cli/src/utils/lintProject.ts index c612f33f8..cb08dd7c9 100644 --- a/packages/cli/src/utils/lintProject.ts +++ b/packages/cli/src/utils/lintProject.ts @@ -7,6 +7,7 @@ export interface ProjectLintResult { results: Array<{ file: string; result: HyperframeLintResult }>; totalErrors: number; totalWarnings: number; + totalInfos: number; } /** @@ -17,6 +18,7 @@ export function lintProject(project: ProjectDir): ProjectLintResult { const results: Array<{ file: string; result: HyperframeLintResult }> = []; let totalErrors = 0; let totalWarnings = 0; + let totalInfos = 0; // Lint root composition const rootHtml = readFileSync(project.indexPath, "utf-8"); @@ -24,6 +26,7 @@ export function lintProject(project: ProjectDir): ProjectLintResult { results.push({ file: "index.html", result: rootResult }); totalErrors += rootResult.errorCount; totalWarnings += rootResult.warningCount; + totalInfos += rootResult.infoCount; // Lint sub-compositions in compositions/ directory const compositionsDir = resolve(project.dir, "compositions"); @@ -36,10 +39,11 @@ export function lintProject(project: ProjectDir): ProjectLintResult { results.push({ file: `compositions/${file}`, result }); totalErrors += result.errorCount; totalWarnings += result.warningCount; + totalInfos += result.infoCount; } } - return { results, totalErrors, totalWarnings }; + return { results, totalErrors, totalWarnings, totalInfos }; } /** diff --git a/packages/core/scripts/check-hyperframe-static.ts b/packages/core/scripts/check-hyperframe-static.ts index 0d1d7d851..4c37d10da 100644 --- a/packages/core/scripts/check-hyperframe-static.ts +++ b/packages/core/scripts/check-hyperframe-static.ts @@ -3,11 +3,20 @@ import path from "node:path"; import { lintHyperframeHtml } from "../src/lint/hyperframeLinter"; import type { HyperframeLintResult } from "../src/lint/types"; +function formatCounts(result: HyperframeLintResult): string { + const parts = [`${result.warningCount} warning${result.warningCount === 1 ? "" : "s"}`]; + if (result.infoCount > 0) { + parts.push(`${result.infoCount} info${result.infoCount === 1 ? "" : "s"}`); + } + return parts.join(", "); +} + function formatHumanOutput(result: HyperframeLintResult, resolvedPath: string): string { + const counts = result.ok + ? formatCounts(result) + : `${result.errorCount} error${result.errorCount === 1 ? "" : "s"}, ${formatCounts(result)}`; const lines = [ - result.ok - ? `PASS ${resolvedPath} (${result.warningCount} warning${result.warningCount === 1 ? "" : "s"})` - : `FAIL ${resolvedPath} (${result.errorCount} error${result.errorCount === 1 ? "" : "s"}, ${result.warningCount} warning${result.warningCount === 1 ? "" : "s"})`, + result.ok ? `PASS ${resolvedPath} (${counts})` : `FAIL ${resolvedPath} (${counts})`, ]; for (const finding of result.findings) { diff --git a/packages/core/src/lint/hyperframeLinter.ts b/packages/core/src/lint/hyperframeLinter.ts index ef50a9e14..db878ccb9 100644 --- a/packages/core/src/lint/hyperframeLinter.ts +++ b/packages/core/src/lint/hyperframeLinter.ts @@ -737,12 +737,14 @@ export function lintHyperframeHtml( } const errorCount = findings.filter((finding) => finding.severity === "error").length; - const warningCount = findings.length - errorCount; + const warningCount = findings.filter((finding) => finding.severity === "warning").length; + const infoCount = findings.filter((finding) => finding.severity === "info").length; return { ok: errorCount === 0, errorCount, warningCount, + infoCount, findings, }; } diff --git a/packages/core/src/lint/types.ts b/packages/core/src/lint/types.ts index 30f85fbb9..075919c15 100644 --- a/packages/core/src/lint/types.ts +++ b/packages/core/src/lint/types.ts @@ -15,6 +15,7 @@ export type HyperframeLintResult = { ok: boolean; errorCount: number; warningCount: number; + infoCount: number; findings: HyperframeLintFinding[]; };