diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index b5a86f73ff..541423d877 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -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 } { const runBrowserCheck = vi.fn( @@ -273,7 +275,11 @@ function dependencies( motion: MotionSpecResolution, ): Promise => { 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", @@ -814,6 +830,10 @@ function reportWithFindings(overrides: Partial = {}): CheckReport { ok: true, strict: false, lint: { ...emptySection(), filesScanned: 0 }, + // `reached: true` with no findings is the clean-project shape. The other + // value means bundling never ran, which is "unknown" rather than "none", + // and no test here is about that case. + compile: { ...emptySection(), reached: true }, runtime: emptySection(), layout: { ...emptySection(), @@ -1526,3 +1546,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"); + }); +}); diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index f90b494398..087b98d8f3 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -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)); diff --git a/packages/cli/src/utils/bundleWithLocalizedFonts.ts b/packages/cli/src/utils/bundleWithLocalizedFonts.ts index 1e71e1119b..f2223b2ca6 100644 --- a/packages/cli/src/utils/bundleWithLocalizedFonts.ts +++ b/packages/cli/src/utils/bundleWithLocalizedFonts.ts @@ -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 = 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 { const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); - const html = await bundleToSingleHtml(projectDir); + const html = await bundleToSingleHtml(projectDir, onDiagnostic ? { onDiagnostic } : undefined); return localizeFonts(html); } diff --git a/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts index 8a6a4920ab..3c970a0ef7 100644 --- a/packages/cli/src/utils/checkBrowser.test.ts +++ b/packages/cli/src/utils/checkBrowser.test.ts @@ -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", diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 012708b217..a759f9f1e2 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -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 { 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); diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index c984f7e52d..e8780e4dd6 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -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"); } diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index 5baa31916f..67cc4f3d96 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -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 & { duration: number; diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index b820d536fa..e2e4fafd67 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseHTML } from "linkedom"; import { describe, it, expect, vi } from "vitest"; -import { bundleToSingleHtml } from "./htmlBundler"; +import { bundleToSingleHtml, type BundleDiagnostic } from "./htmlBundler"; import { getHyperframeRuntimeScript } from "../generated/runtime-inline"; function makeTempProject(files: Record): string { @@ -1388,3 +1388,91 @@ describe("bundleToSingleHtml", () => { } }); }); + +// --------------------------------------------------------------------------- +// Compile diagnostics: the sink that lets `hyperframes check` report what used +// to vanish into console.warn. +// --------------------------------------------------------------------------- + +/** One project that trips all three diagnostic sites at once: + * - no `window.__timelines` registration -> StaticGuard contract breach + * - `data-composition-src` at a file that does not exist -> skipped sub-comp + * - `data-color-grading` LUT at a file that does not exist -> un-inlinable LUT + */ +function makeAllDiagnosticsProject(): string { + return makeTempProject({ + "index.html": ` + +
+
+ +
+`, + }); +} + +describe("bundleToSingleHtml compile diagnostics", () => { + it("routes every compile-time diagnostic to the sink instead of console.warn", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const dir = makeAllDiagnosticsProject(); + const diagnostics: BundleDiagnostic[] = []; + + await bundleToSingleHtml(dir, { onDiagnostic: (d) => diagnostics.push(d) }); + + expect(diagnostics.map((d) => d.code).sort()).toEqual([ + "color_grading_lut_not_inlined", + "static_guard_contract", + "sub_composition_skipped", + ]); + expect(diagnostics.every((d) => d.severity === "warning")).toBe(true); + // A sink takes ownership: nothing leaks to the console behind its back. + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); + + it("carries the message and the source each diagnostic already knows", async () => { + const dir = makeAllDiagnosticsProject(); + const diagnostics: BundleDiagnostic[] = []; + + await bundleToSingleHtml(dir, { onDiagnostic: (d) => diagnostics.push(d) }); + + const byCode = new Map(diagnostics.map((d) => [d.code, d])); + expect(byCode.get("sub_composition_skipped")?.source).toBe("scenes/gone.html"); + expect(byCode.get("sub_composition_skipped")?.message).toContain( + 'Skipping sub-composition "scenes/gone.html"', + ); + expect(byCode.get("color_grading_lut_not_inlined")?.source).toBe("assets/luts/gone.cube"); + expect(byCode.get("color_grading_lut_not_inlined")?.message).toContain( + 'Could not inline color grading LUT "assets/luts/gone.cube"', + ); + expect(byCode.get("static_guard_contract")?.source).toBe("index.html"); + expect(byCode.get("static_guard_contract")?.message).toContain("[StaticGuard]"); + }); + + it("still console.warns, with byte-identical output, when no sink is supplied", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const dir = makeAllDiagnosticsProject(); + const diagnostics: BundleDiagnostic[] = []; + + const withSink = await bundleToSingleHtml(dir, { + onDiagnostic: (d) => diagnostics.push(d), + }); + expect(warnSpy).not.toHaveBeenCalled(); + + const withoutSink = await bundleToSingleHtml(dir); + + // The sink changes reporting, never the bundle. + expect(withoutSink).toBe(withSink); + // Every diagnostic reaches console.warn with exactly its message, one + // argument, same as before the sink existed. + expect(warnSpy.mock.calls.map((call) => call[0])).toEqual(diagnostics.map((d) => d.message)); + expect(warnSpy.mock.calls.every((call) => call.length === 1)).toBe(true); + } finally { + warnSpy.mockRestore(); + } + }); +}); diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index d7f32b77e8..92b786b2a4 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -320,16 +320,23 @@ function isExternalSvgFragmentUse(el: Element, attr: string, urlValue: string): return pathBeforeFragment.toLowerCase().endsWith(".svg"); } -function warnColorGradingLutNotInlined(lutSrc: string): void { +function warnColorGradingLutNotInlined(lutSrc: string, emit: BundleDiagnosticSink): void { const trimmed = lutSrc.trim(); if (!isRelativeUrl(trimmed)) return; - console.warn( - `[HyperFrames] Could not inline color grading LUT "${trimmed}". The rendered bundle may not be self-contained.`, - ); + emit({ + code: "color_grading_lut_not_inlined", + severity: "warning", + message: `[HyperFrames] Could not inline color grading LUT "${trimmed}". The rendered bundle may not be self-contained.`, + source: trimmed, + }); } // fallow-ignore-next-line complexity -function rewriteColorGradingLutWithInlinedAssets(value: string, projectDir: string): string { +function rewriteColorGradingLutWithInlinedAssets( + value: string, + projectDir: string, + emit: BundleDiagnosticSink, +): string { if (!value.trim().startsWith("{")) return value; let parsed: unknown; try { @@ -343,7 +350,7 @@ function rewriteColorGradingLutWithInlinedAssets(value: string, projectDir: stri if (typeof lut === "string") { const inlined = maybeInlineRelativeAssetUrl(lut, projectDir); if (!inlined) { - warnColorGradingLutNotInlined(lut); + warnColorGradingLutNotInlined(lut, emit); return value; } Reflect.set(parsed, "lut", inlined); @@ -354,7 +361,7 @@ function rewriteColorGradingLutWithInlinedAssets(value: string, projectDir: stri if (typeof lutSrc !== "string") return value; const inlined = maybeInlineRelativeAssetUrl(lutSrc, projectDir); if (!inlined) { - warnColorGradingLutNotInlined(lutSrc); + warnColorGradingLutNotInlined(lutSrc, emit); return value; } Reflect.set(lut, "src", inlined); @@ -673,9 +680,46 @@ function stripJsCommentsParserSafe(source: string): string { } } +/** + * A compile-time diagnostic raised while bundling — the class of problem that + * used to reach only `console.warn` and was therefore invisible to any + * programmatic consumer (`hyperframes check --json`, studio, CI logs). + * + * Severity is `"warning"` for every code emitted today: the bundler is + * deliberately tolerant here (it skips the broken piece and keeps going) and + * nothing about this envelope changes that. Escalating any of these to an + * error is a separate, gating decision. + */ +export interface BundleDiagnostic { + /** Stable machine-readable identifier — safe to match on in tooling. */ + code: "static_guard_contract" | "color_grading_lut_not_inlined" | "sub_composition_skipped"; + severity: "warning"; + /** + * Human-readable text. Byte-identical to the `console.warn` line this + * replaces, so terminal output is unchanged when no sink is supplied. + */ + message: string; + /** + * What the diagnostic is about, when the emitter knows: the entry file, the + * sub-composition `src`, or the LUT URL. Not a line/column — none of these + * sites carry source positions, and inventing one would be a lie. + */ + source?: string; +} + +export type BundleDiagnosticSink = (diagnostic: BundleDiagnostic) => void; + export interface BundleOptions { /** Project-relative HTML entry to bundle. Defaults to `index.html`. */ entryFile?: string; + /** + * Collect compile-time diagnostics instead of logging them. Follows the same + * shape as `inlineSubCompositions`'s `onMissingComposition`: optional, and + * when omitted every diagnostic still goes to `console.warn` with exactly the + * text it has today. Producer, studio, vite preview and render all rely on + * that default. + */ + onDiagnostic?: BundleDiagnosticSink; /** Optional media duration prober (e.g., ffprobe). If omitted, media durations are not resolved. */ probeMediaDuration?: MediaDurationProber; /** @@ -798,14 +842,20 @@ export async function bundleToSingleHtml( return isSafePath(projectDir, resolved) ? resolved : null; }; + const emit: BundleDiagnosticSink = + options?.onDiagnostic ?? ((diagnostic) => console.warn(diagnostic.message)); + const rawHtml = readFileSync(indexPath, "utf-8"); const compiled = await compileHtml(rawHtml, sourceDir, options?.probeMediaDuration); const staticGuard = await validateHyperframeHtmlContract(compiled); if (!staticGuard.isValid) { - console.warn( - `[StaticGuard] Invalid HyperFrame contract: ${staticGuard.missingKeys.join("; ")}`, - ); + emit({ + code: "static_guard_contract", + severity: "warning", + message: `[StaticGuard] Invalid HyperFrame contract: ${staticGuard.missingKeys.join("; ")}`, + source: entryFile, + }); } const withInterceptor = injectInterceptor(compiled, options?.runtime ?? "inline"); @@ -911,9 +961,12 @@ export async function bundleToSingleHtml( buildScopeSelector: (compId: string) => cssAttributeSelector("data-composition-id", compId), scriptErrorLabel: "[HyperFrames] composition script error:", onMissingComposition: (srcPath: string, reason?: string) => { - console.warn( - `[Bundler] Skipping sub-composition "${srcPath}": ${reason ?? "the file could not be found"}.`, - ); + emit({ + code: "sub_composition_skipped", + severity: "warning", + message: `[Bundler] Skipping sub-composition "${srcPath}": ${reason ?? "the file could not be found"}.`, + source: srcPath, + }); }, }); const compStyleChunks: string[] = [...subCompResult.styles]; @@ -1119,7 +1172,7 @@ export async function bundleToSingleHtml( if (value) { el.setAttribute( HF_COLOR_GRADING_ATTR, - rewriteColorGradingLutWithInlinedAssets(value, projectDir), + rewriteColorGradingLutWithInlinedAssets(value, projectDir, emit), ); } } diff --git a/packages/core/src/compiler/index.ts b/packages/core/src/compiler/index.ts index be63bebcd5..31b6bcd82c 100644 --- a/packages/core/src/compiler/index.ts +++ b/packages/core/src/compiler/index.ts @@ -32,6 +32,8 @@ export { assignBundledRuntimeCompositionIds, type BundledHostCompositionIdentity, bundleToSingleHtml, + type BundleDiagnostic, + type BundleDiagnosticSink, type BundleOptions, prepareFlattenedInnerRoot, FLATTENED_INNER_ROOT_STRIP_ATTRS,