From f3ae3e2dc8528d37772f7e95098d12e2ecff4eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 29 Mar 2026 11:02:34 +0000 Subject: [PATCH 1/6] fix(cli): respect --json flag on lint error paths When lint encountered an error (e.g., invalid directory), it output plain text even with --json, breaking JSON parsing pipelines for agents. Now wraps the entire lint command in try-catch that emits JSON errors when --json is set. Reproducer: npx hyperframes lint --json /nonexistent/path # Was: plain text "Not a directory: /nonexistent/path" # Now: {"ok": false, "error": "Not a directory: /nonexistent/path", ...} --- packages/cli/src/commands/lint.ts | 117 ++++++++++++++++++------------ 1 file changed, 70 insertions(+), 47 deletions(-) diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 8d4031f46..8a0266e2e 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -15,63 +15,86 @@ export default defineCommand({ json: { type: "boolean", description: "Output findings as JSON", default: false }, }, async run({ args }) { - const project = resolveProject(args.dir); - const htmlFiles = walkDir(project.dir).filter((f) => f.endsWith(".html")); + try { + const project = resolveProject(args.dir); + const htmlFiles = walkDir(project.dir).filter((f) => f.endsWith(".html")); - const allFindings: (HyperframeLintFinding & { file: string })[] = []; - let totalErrors = 0; - let totalWarnings = 0; + const allFindings: (HyperframeLintFinding & { file: string })[] = []; + let totalErrors = 0; + let totalWarnings = 0; - for (const file of htmlFiles) { - const html = readFileSync(join(project.dir, file), "utf-8"); - const result = lintHyperframeHtml(html, { filePath: file }); - for (const f of result.findings) { - allFindings.push({ ...f, file }); + for (const file of htmlFiles) { + const html = readFileSync(join(project.dir, file), "utf-8"); + const result = lintHyperframeHtml(html, { filePath: file }); + for (const f of result.findings) { + allFindings.push({ ...f, file }); + } + totalErrors += result.errorCount; + totalWarnings += result.warningCount; + } + + if (args.json) { + console.log( + JSON.stringify( + withMeta({ + ok: totalErrors === 0, + findings: allFindings, + errorCount: totalErrors, + warningCount: totalWarnings, + filesScanned: htmlFiles.length, + }), + null, + 2, + ), + ); + process.exit(totalErrors > 0 ? 1 : 0); } - totalErrors += result.errorCount; - totalWarnings += result.warningCount; - } - if (args.json) { console.log( - JSON.stringify( - withMeta({ - ok: totalErrors === 0, - findings: allFindings, - errorCount: totalErrors, - warningCount: totalWarnings, - filesScanned: htmlFiles.length, - }), - null, - 2, - ), + `${c.accent("◆")} Linting ${c.accent(project.name)} (${htmlFiles.length} HTML files)`, ); - process.exit(totalErrors > 0 ? 1 : 0); - } + console.log(); - console.log( - `${c.accent("◆")} Linting ${c.accent(project.name)} (${htmlFiles.length} HTML files)`, - ); - console.log(); + if (allFindings.length === 0) { + console.log(`${c.success("◇")} ${c.success("0 errors, 0 warnings")}`); + return; + } - if (allFindings.length === 0) { - console.log(`${c.success("◇")} ${c.success("0 errors, 0 warnings")}`); - return; - } + for (const finding of allFindings) { + const prefix = finding.severity === "error" ? c.error("✗") : c.warn("⚠"); + const loc = finding.elementId ? ` ${c.accent(`[${finding.elementId}]`)}` : ""; + console.log( + `${prefix} ${c.bold(finding.code)}${loc}: ${finding.message} ${c.dim(finding.file)}`, + ); + if (finding.fixHint) { + console.log(` ${c.dim(`Fix: ${finding.fixHint}`)}`); + } + } - for (const finding of allFindings) { - const prefix = finding.severity === "error" ? c.error("✗") : c.warn("⚠"); - const loc = finding.elementId ? ` ${c.accent(`[${finding.elementId}]`)}` : ""; - console.log( - `${prefix} ${c.bold(finding.code)}${loc}: ${finding.message} ${c.dim(finding.file)}`, - ); - if (finding.fixHint) { - console.log(` ${c.dim(`Fix: ${finding.fixHint}`)}`); + const summaryIcon = totalErrors > 0 ? c.error("◇") : c.success("◇"); + console.log(`\n${summaryIcon} ${totalErrors} error(s), ${totalWarnings} warning(s)`); + process.exit(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, + filesScanned: 0, + }), + null, + 2, + ), + ); + process.exit(1); } + console.error(message); + process.exit(1); } - - const summaryIcon = totalErrors > 0 ? c.error("◇") : c.success("◇"); - console.log(`\n${summaryIcon} ${totalErrors} error(s), ${totalWarnings} warning(s)`); - process.exit(totalErrors > 0 ? 1 : 0); }, }); From 8bc99ebd9d47317746b2961189f588ef46c15f3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 29 Mar 2026 11:02:50 +0000 Subject: [PATCH 2/6] fix(core): separate info count from warning count in linter results The linter was computing warningCount as findings.length - errorCount, which lumped info-severity findings into the warning count. Now counts warnings and infos separately, and adds a new infoCount field. Reproducer: # Create composition with timed element missing class="clip" npx hyperframes lint # Was: "0 errors, 1 warning(s)" npx hyperframes lint --json # Was: severity: "info" but warningCount: 1 # Now warningCount: 0, infoCount: 1 --- packages/core/src/lint/hyperframeLinter.ts | 4 +++- packages/core/src/lint/types.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/lint/hyperframeLinter.ts b/packages/core/src/lint/hyperframeLinter.ts index 57bf8ce27..9d6939ad8 100644 --- a/packages/core/src/lint/hyperframeLinter.ts +++ b/packages/core/src/lint/hyperframeLinter.ts @@ -636,12 +636,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[]; }; From 5f731892f187f3648a548e27b6d49cd3789cd889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 29 Mar 2026 11:03:39 +0000 Subject: [PATCH 3/6] fix(cli): distinguish info vs warning severity in lint output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lint command displayed info-level findings with the warning icon and counted them in the warning total, while JSON correctly reported severity as "info". Now info findings show a distinct icon (ℹ) and are counted separately in both human and JSON output. Reproducer: # Create a composition with a timed element missing class="clip" npx hyperframes lint # Was: "1 warning(s)" with ⚠ icon npx hyperframes lint --json # Was: severity: "info" but warningCount: 1 # Human and JSON output now agree --- packages/cli/src/commands/lint.ts | 17 +++++++++++++++-- .../core/scripts/check-hyperframe-static.ts | 15 ++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 8a0266e2e..b33dc3d4b 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -22,6 +22,7 @@ export default defineCommand({ const allFindings: (HyperframeLintFinding & { file: string })[] = []; let totalErrors = 0; let totalWarnings = 0; + let totalInfos = 0; for (const file of htmlFiles) { const html = readFileSync(join(project.dir, file), "utf-8"); @@ -31,6 +32,7 @@ export default defineCommand({ } totalErrors += result.errorCount; totalWarnings += result.warningCount; + totalInfos += result.infoCount; } if (args.json) { @@ -41,6 +43,7 @@ export default defineCommand({ findings: allFindings, errorCount: totalErrors, warningCount: totalWarnings, + infoCount: totalInfos, filesScanned: htmlFiles.length, }), null, @@ -61,7 +64,12 @@ export default defineCommand({ } for (const finding of allFindings) { - const prefix = finding.severity === "error" ? c.error("✗") : c.warn("⚠"); + const prefix = + finding.severity === "error" + ? c.error("✗") + : finding.severity === "warning" + ? c.warn("⚠") + : c.dim("ℹ"); const loc = finding.elementId ? ` ${c.accent(`[${finding.elementId}]`)}` : ""; console.log( `${prefix} ${c.bold(finding.code)}${loc}: ${finding.message} ${c.dim(finding.file)}`, @@ -72,7 +80,11 @@ export default defineCommand({ } const summaryIcon = totalErrors > 0 ? c.error("◇") : c.success("◇"); - console.log(`\n${summaryIcon} ${totalErrors} error(s), ${totalWarnings} warning(s)`); + const summaryParts = [`${totalErrors} error(s)`, `${totalWarnings} warning(s)`]; + if (totalInfos > 0) { + summaryParts.push(`${totalInfos} info(s)`); + } + console.log(`\n${summaryIcon} ${summaryParts.join(", ")}`); process.exit(totalErrors > 0 ? 1 : 0); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); @@ -85,6 +97,7 @@ export default defineCommand({ findings: [], errorCount: 0, warningCount: 0, + infoCount: 0, filesScanned: 0, }), null, 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) { From 7a228fcd40f87a449e262d4b4c942dacc0694ab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 02:05:40 +0200 Subject: [PATCH 4/6] fix(cli): hide info-level lint findings by default, show with --verbose Info findings are noisy for agents and day-to-day use. Only errors and warnings are shown by default. Pass --verbose to include info findings in both CLI and JSON output. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/lint.ts | 37 ++++++++++++++++++++++------ packages/cli/src/utils/lintFormat.ts | 13 ++++++++-- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 527e4a00e..3871d0afb 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -1,15 +1,31 @@ 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 }) { try { @@ -17,12 +33,13 @@ export default defineCommand({ const lintResult = lintProject(project); 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: lintResult.results.flatMap((r) => r.result.findings), + findings: args.verbose ? allFindings : allFindings.filter((f) => f.severity !== "info"), filesScanned: lintResult.results.length, }; console.log(JSON.stringify(withMeta(combined), null, 2)); @@ -32,7 +49,7 @@ export default defineCommand({ 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(`${c.accent("◆")} Linting ${c.accent(`${project.name}/${fileLabel}`)}`); console.log(); if (lintResult.totalErrors === 0 && lintResult.totalWarnings === 0) { @@ -40,7 +57,11 @@ export default defineCommand({ return; } - const lines = formatLintFindings(lintResult, { showElementId: true, showSummary: true }); + 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); diff --git a/packages/cli/src/utils/lintFormat.ts b/packages/cli/src/utils/lintFormat.ts index 65506ea9d..539c076ed 100644 --- a/packages/cli/src/utils/lintFormat.ts +++ b/packages/cli/src/utils/lintFormat.ts @@ -8,6 +8,8 @@ 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; } /** @@ -17,7 +19,12 @@ export function formatLintFindings( { 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,6 +32,7 @@ export function formatLintFindings( if (result.findings.length === 0) continue; const format = (finding: (typeof result.findings)[0]) => { + if (!verbose && finding.severity === "info") return; const prefix = finding.severity === "error" ? c.error("✗") @@ -41,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); } @@ -50,7 +59,7 @@ export function formatLintFindings( const icon = totalErrors > 0 ? c.error("◇") : c.success("◇"); lines.push(""); const summaryParts = [`${totalErrors} error(s)`, `${totalWarnings} warning(s)`]; - if (totalInfos > 0) summaryParts.push(`${totalInfos} info(s)`); + if (verbose && totalInfos > 0) summaryParts.push(`${totalInfos} info(s)`); lines.push(`${icon} ${summaryParts.join(", ")}`); } From 31c726e3fa18cc79f4fa117f7a32cfe57d5ec4ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 02:23:03 +0200 Subject: [PATCH 5/6] docs(cli): document --json and --verbose lint flags Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/README.md | 6 +++++- packages/cli/src/templates/_shared/CLAUDE.md | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) 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/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 ``` From 10e2811e52a7e03f3392e717ba15363eeddeefd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 02:25:59 +0200 Subject: [PATCH 6/6] docs(cli): update lint command docs with --verbose, --json flags and severity levels Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/packages/cli.mdx | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) 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`