mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(core,cli): improve lint output - JSON flag, info/warning counts, severity display (#134)
## Summary - Respect `--json` flag on all lint exit paths so agents always get machine-readable output - Separate `infoCount` from `warningCount` in linter results (was conflated) - Display `info` vs `warning` severity distinctly in lint output
This commit is contained in:
+19
-9
@@ -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
|
||||
```
|
||||
</Step>
|
||||
<Step title="Render to MP4">
|
||||
@@ -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) |
|
||||
|
||||
The linter detects missing attributes, deprecated names, structural problems, and more. See [Common Mistakes](/guides/common-mistakes) for details on each rule.
|
||||
**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, missing adapter libraries (GSAP, Lottie, Three.js), structural problems, and more. See [Common Mistakes](/guides/common-mistakes) for details on each rule.
|
||||
</Tab>
|
||||
<Tab title="Build">
|
||||
### `render`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
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,
|
||||
verbose: args.verbose,
|
||||
});
|
||||
for (const line of lines) console.log(line);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const lines = formatLintFindings(lintResult, { showElementId: true, showSummary: true });
|
||||
for (const line of lines) console.log(line);
|
||||
|
||||
process.exit(lintResult.totalErrors > 0 ? 1 : 0);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 <topic> # reference docs in terminal
|
||||
```
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export type HyperframeLintResult = {
|
||||
ok: boolean;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
infoCount: number;
|
||||
findings: HyperframeLintFinding[];
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user