fix(cli): report compile diagnostics in the check report

The bundler emits three warnings -- an un-inlinable color-grading LUT, a
StaticGuard contract breach, and a skipped sub-composition -- as bare
console.warn calls. They never reached the structured check report, so
--json consumers could not see them at all and a terminal reader had to
catch them scrolling past above the report they were reading.

bundleToSingleHtml now takes an optional diagnostics sink, following the
onMissingComposition hook inlineSubCompositions already uses. Without a
sink the calls console.warn exactly as before, with byte-identical text
and a byte-identical bundle; a test pins both. check passes one and
renders a Compile section carrying the same CheckFinding shape lint and
runtime already use.

Two deliberate limits. Compile counts stay out of the aggregate totals,
so --strict fails nothing it passes today: this changes what you can see,
not what passes. And the section carries a reached flag, because an empty
findings list is otherwise ambiguous -- runCheckPipeline short-circuits
before bundling whenever lint has errors, and clean and never-ran must
not print the same. It reports not reached (fix lint errors first)
instead, and is also false when the browser check throws after bundling.

Verified on a lint-clean probe: the LUT warning appears in the Compile
section and in the --json envelope. Removing data-width from the same
probe makes lint fail and the section correctly reads not reached.

Left as follow-ups: sub_composition_skipped is arguably an error for a
render, since you ship a bundle silently missing a scene; and
static_guard_contract fires only when lint has errors, so it is
structurally unreachable through check and surfaces for studio, preview
and render callers only.
This commit is contained in:
Miguel Ángel
2026-08-09 20:14:16 +00:00
parent adb13ce125
commit ebcee69dcc
10 changed files with 348 additions and 20 deletions
+107 -1
View File
@@ -32,6 +32,7 @@ import {
} from "../utils/checkPipeline.js";
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
import { consumeCommandResult } from "../utils/commandResult.js";
import type { BundleDiagnostic } from "@hyperframes/core/compiler";
import type { ProjectLintResult } from "../utils/lintProject.js";
import type {
LayoutIssue,
@@ -264,6 +265,7 @@ function dependencies(
runtime?: CheckFinding[];
writeSnapshot?: CheckDependencies["writeSnapshot"];
captureFindingCrops?: CheckDependencies["captureFindingCrops"];
compile?: BundleDiagnostic[];
} = {},
): { deps: CheckDependencies; runBrowserCheck: ReturnType<typeof vi.fn> } {
const runBrowserCheck = vi.fn(
@@ -273,7 +275,11 @@ function dependencies(
motion: MotionSpecResolution,
): Promise<CheckBrowserResult> => {
const result = await runAuditGrid(driver, checkOptions, motion);
return { ...result, runtimeFindings: options.runtime ?? [] };
return {
...result,
runtimeFindings: options.runtime ?? [],
compileDiagnostics: options.compile ?? [],
};
},
);
const deps: CheckDependencies = {
@@ -306,6 +312,16 @@ async function runScenario(
return { report, deps, browser: runBrowserCheck };
}
function lutDiagnostic(): BundleDiagnostic {
return {
code: "color_grading_lut_not_inlined",
severity: "warning",
message:
'[HyperFrames] Could not inline color grading LUT "assets/luts/gone.cube". The rendered bundle may not be self-contained.',
source: "assets/luts/gone.cube",
};
}
function runtimeError(): CheckFinding {
return {
code: "console_error",
@@ -1526,3 +1542,93 @@ describe("dense motion-overlap re-sampling", () => {
expect(report.layout.findings.some((f) => f.code === "content_overlap")).toBe(true);
});
});
describe("compile diagnostics section", () => {
afterEach(() => {
consumeCommandResult();
vi.restoreAllMocks();
});
it("surfaces bundler diagnostics as findings and marks the section reached", async () => {
const { report } = await runScenario(fakeDriver(), {}, { compile: [lutDiagnostic()] });
expect(report.compile.reached).toBe(true);
expect(report.compile.findings).toHaveLength(1);
expect(report.compile.findings[0]).toMatchObject({
code: "color_grading_lut_not_inlined",
severity: "warning",
sourceFile: "assets/luts/gone.cube",
});
expect(report.compile.warningCount).toBe(1);
expect(report.compile.errorCount).toBe(0);
});
it("keeps compile warnings out of the pass/fail gate, even under --strict", async () => {
const { report } = await runScenario(
fakeDriver(),
{ strict: true },
{ compile: [lutDiagnostic()] },
);
expect(report.ok).toBe(true);
expect(checkExitCode(report)).toBe(0);
});
it("reports an empty but reached compile section for a clean project", async () => {
const { report } = await runScenario(fakeDriver());
expect(report.compile.reached).toBe(true);
expect(report.compile.findings).toEqual([]);
expect(report.compile.ok).toBe(true);
});
it("marks compile not-reached when a lint error short-circuits before bundling", async () => {
const { report, browser } = await runScenario(
fakeDriver(),
{},
{ lint: lintWith("error", "missing_composition_id", "boom"), compile: [lutDiagnostic()] },
);
expect(browser).not.toHaveBeenCalled();
expect(report.compile.reached).toBe(false);
expect(report.compile.findings).toEqual([]);
});
it("carries the compile section into the --json envelope", async () => {
const { report } = await runScenario(fakeDriver(), {}, { compile: [lutDiagnostic()] });
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
const command = createCheckCommand({
resolveProject: () => PROJECT,
runPipeline: vi.fn(async () => report),
withMeta: (value) => ({ ...value, _meta: { version: "test" } }),
});
await runCommand(command, { rawArgs: ["--json"] });
const output = log.mock.calls[0]?.[0];
if (typeof output !== "string") throw new Error("expected JSON output");
const envelope = JSON.parse(output) as CheckReport;
expect(envelope.compile).toMatchObject({
ok: true,
reached: true,
warningCount: 1,
findings: [{ code: "color_grading_lut_not_inlined", severity: "warning" }],
});
});
it("prints the compile section in the human report", async () => {
const { report } = await runScenario(fakeDriver(), {}, { compile: [lutDiagnostic()] });
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
const command = createCheckCommand({
resolveProject: () => PROJECT,
runPipeline: vi.fn(async () => report),
withMeta: (value) => value,
});
await runCommand(command, { rawArgs: [] });
const printed = log.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
expect(printed).toContain("Compile");
expect(printed).toContain("color_grading_lut_not_inlined");
});
});
+19
View File
@@ -405,6 +405,7 @@ function nonNegativeNumber(value: unknown, fallback: number): number {
function printHumanReport(report: CheckReport): void {
printSection("Lint", report.lint);
printCompileSection(report);
printSection("Runtime", report.runtime);
printLayoutSection("Layout", report.layout);
printSection("Motion", report.motion);
@@ -415,6 +416,24 @@ function printHumanReport(report: CheckReport): void {
console.log(`${report.ok ? c.success("◇") : c.error("◇")} ${label}`);
}
/** Compile-time bundler diagnostics. `reached: false` means the pipeline
* short-circuited before bundling (any lint error does this), so "0 findings"
* would be a lie — say "not reached" instead. */
function printCompileSection(report: CheckReport): void {
console.log();
console.log(c.bold("Compile"));
if (!report.compile.reached) {
console.log(` ${c.dim("◇")} not reached (fix lint errors first)`);
return;
}
if (report.compile.findings.length === 0) {
console.log(` ${c.success("◇")} 0 errors, 0 warnings`);
return;
}
for (const finding of report.compile.findings) printFinding(finding);
printCounts(report.compile);
}
function printSection(title: string, section: CheckSection): void {
console.log();
console.log(c.bold(title));
@@ -1,3 +1,4 @@
import type { BundleDiagnosticSink } from "@hyperframes/core/compiler";
import { normalizeErrorMessage } from "./errorMessage.js";
import { c } from "../ui/colors.js";
@@ -20,9 +21,12 @@ export async function bundleWithLocalizedFonts(
// Injectable for tests. Production callers omit it and get the producer
// font-localization pass (see localizeWithProducer).
localizeFonts: (html: string) => Promise<string> = localizeWithProducer,
// Optional compile-diagnostic sink, threaded straight through to the bundler.
// Omit it and the bundler keeps console.warn-ing exactly as before.
onDiagnostic?: BundleDiagnosticSink,
): Promise<string> {
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const html = await bundleToSingleHtml(projectDir);
const html = await bundleToSingleHtml(projectDir, onDiagnostic ? { onDiagnostic } : undefined);
return localizeFonts(html);
}
+5 -1
View File
@@ -138,7 +138,11 @@ it("carries raw browser geometry through the page driver and pipeline", async ()
runAuditGrid,
);
expect(mocks.bundleWithLocalizedFonts).toHaveBeenCalledWith(PROJECT.dir);
expect(mocks.bundleWithLocalizedFonts).toHaveBeenCalledWith(
PROJECT.dir,
undefined,
expect.any(Function),
);
expect(result.layoutIssues).toEqual([
expect.objectContaining({
code: "frame_out_of_frame",
+9 -1
View File
@@ -1,6 +1,7 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import type { Page } from "puppeteer-core";
import type { BundleDiagnostic } from "@hyperframes/core/compiler";
import {
AUDIT_SEEK_OPTIONS,
DENSE_GEOMETRY_SEEK_OPTIONS,
@@ -150,7 +151,13 @@ export async function runBrowserCheck(
runGrid: RunAuditGrid,
): Promise<CheckBrowserResult> {
const { bundleWithLocalizedFonts } = await import("./bundleWithLocalizedFonts.js");
const html = await bundleWithLocalizedFonts(project.dir);
// Compile diagnostics are captured here rather than console.warn-ed, so they
// land in the check report. captureFindingCrops re-bundles the same project
// and deliberately does NOT collect them again (they would be duplicates).
const compileDiagnostics: BundleDiagnostic[] = [];
const html = await bundleWithLocalizedFonts(project.dir, undefined, (diagnostic) =>
compileDiagnostics.push(diagnostic),
);
await preResolveHostileMediaProxies(project.dir, html, options.autoProxy);
const server = await serveStaticProjectHtml(
project.dir,
@@ -195,6 +202,7 @@ export async function runBrowserCheck(
...result,
timings: { ...result.timings, launchSettleMs },
runtimeFindings: drafts.map((draft) => runtimeFinding(draft, rootAnchor)),
compileDiagnostics,
};
} finally {
await chromeBrowser?.close().catch(() => undefined);
+28 -1
View File
@@ -21,6 +21,7 @@ import {
type MotionFrame,
} from "./motionAudit.js";
import { findMotionSpec, readMotionSpec } from "./motionSpec.js";
import type { BundleDiagnostic } from "@hyperframes/core/compiler";
import { normalizeErrorMessage } from "./errorMessage.js";
import {
parseColorRGBA,
@@ -1097,6 +1098,8 @@ export async function runAuditGrid(
contrastPassed: contrast.passed,
screenshots: collected.screenshots,
timings: { launchSettleMs: 0, seekLoopMs, contrastMs: collected.contrastMs },
// The grid never bundles; runBrowserCheck owns bundling and overwrites this.
compileDiagnostics: [],
};
}
@@ -1132,17 +1135,22 @@ export async function runCheckPipeline(
}
let browser: CheckBrowserResult;
// Bundling happens inside runBrowserCheck, so a throw from there (a Chrome
// launch failure, a bundler error) leaves the compile pass unobserved rather
// than clean. Track that instead of letting an empty section read as a pass.
let bundlingReached = true;
try {
browser = await dependencies.runBrowserCheck(project, options, motion);
} catch (error) {
browser = emptyBrowserResult();
browser.runtimeFindings.push(runtimeFailure(error));
bundlingReached = false;
}
const snapshotFiles = options.snapshots
? await writeContrastSnapshots(dependencies, project.dir, browser)
: [];
const report = buildReport(options, lint, browser, motion, [], snapshotFiles);
const report = buildReport(options, lint, browser, motion, [], snapshotFiles, bundlingReached);
return options.snapshots
? await withFindingCrops(dependencies, project, options, report)
: report;
@@ -1322,7 +1330,12 @@ function buildReport(
motion: MotionSpecResolution,
extraMotionFindings: CheckFinding[],
snapshotFiles: string[],
bundlingReached = false,
): CheckReport {
const compile = {
...section(browser.compileDiagnostics.map(compileFinding)),
reached: bundlingReached,
};
const layout = shapeLayoutSection(browser.layoutIssues, browser, options);
const shapedMotion = shapeLayoutFindings(browser.motionIssues, options);
const motionFindings: CheckFinding[] = [...shapedMotion.findings, ...extraMotionFindings];
@@ -1345,6 +1358,7 @@ function buildReport(
ok: errorCount === 0 && (!options.strict || warningCount === 0),
strict: options.strict,
lint,
compile,
runtime,
layout,
motion: {
@@ -1468,9 +1482,22 @@ function emptyBrowserResult(): CheckBrowserResult {
contrastPassed: 0,
screenshots: [],
timings: { launchSettleMs: 0, seekLoopMs: 0, contrastMs: 0 },
compileDiagnostics: [],
};
}
/** A compile diagnostic as a report finding. The bundler has no source
* positions at these sites, so it anchors at the composition root like the
* lint and motion-spec findings do. */
function compileFinding(diagnostic: BundleDiagnostic): CheckFinding {
return findingAtRoot(
diagnostic.code,
diagnostic.severity,
diagnostic.message,
diagnostic.source ?? "index.html",
);
}
function runtimeFailure(error: unknown, code = "check_runtime_failure"): CheckFinding {
return findingAtRoot(code, "error", normalizeErrorMessage(error), "index.html");
}
+17
View File
@@ -1,3 +1,4 @@
import type { BundleDiagnostic } from "@hyperframes/core/compiler";
import type { ProjectLintResult } from "./lintProject.js";
import type { LayoutIssue, LayoutOverflow, LayoutRect } from "./layoutAudit.js";
import type { Canvas, MotionFrame } from "./motionAudit.js";
@@ -242,6 +243,10 @@ export interface CheckBrowserResult {
contrastPassed: number;
screenshots: CheckScreenshot[];
timings: CheckTimings;
/** Compile-time diagnostics collected while bundling the project for the
* browser pass. Empty whenever bundling never ran (lint-error short-circuit,
* a browser-launch failure) — absence is "not observed", not "clean". */
compileDiagnostics: BundleDiagnostic[];
}
/** The seek-grid audit loop, injected into checkBrowser so it never imports checkPipeline back. */
@@ -263,6 +268,18 @@ export interface CheckReport {
ok: boolean;
strict: boolean;
lint: CheckSection & { filesScanned: number };
/**
* Compile-time bundler diagnostics, surfaced as findings so agents and
* `--json` consumers see them instead of losing them to stdout.
*
* Deliberately NOT folded into the report's aggregate error/warning counts:
* every code here is a warning today, and rolling them into the aggregate
* would make `--strict` start failing projects it passes now. Visibility
* first; gating is a separate decision. `reached` says whether bundling
* actually ran — `runCheckPipeline` short-circuits before the bundler when
* lint has any error, so `reached: false` means "unknown", not "none".
*/
compile: CheckSection & { reached: boolean };
runtime: CheckSection;
layout: CheckSection<AnchoredLayoutIssue> & {
duration: number;