From a9ea07edde6648463e3970a572dc42f4007a3d7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 21 Aug 2026 11:00:43 -0400 Subject: [PATCH] fix(cli): reject blank default composition entries (#3392) * fix(cli): reject blank default composition entry * fix(cli): complete blank entry safeguards --- packages/cli/src/commands/publish.test.ts | 48 ++++++++- packages/cli/src/commands/publish.ts | 9 +- packages/cli/src/commands/render.test.ts | 42 ++++++++ packages/cli/src/commands/render.ts | 4 +- packages/cli/src/commands/render/execute.ts | 48 ++++++--- packages/cli/src/commands/snapshot.test.ts | 63 +++++++++++- packages/cli/src/commands/snapshot.ts | 18 ++++ packages/cli/src/utils/lintProject.test.ts | 47 ++++++++- packages/cli/src/utils/lintProject.ts | 12 ++- packages/lint/src/project.test.ts | 106 ++++++++++++++++++++ packages/lint/src/project.ts | 41 ++++++++ 11 files changed, 412 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/commands/publish.test.ts b/packages/cli/src/commands/publish.test.ts index 0d41515c9..a91893510 100644 --- a/packages/cli/src/commands/publish.test.ts +++ b/packages/cli/src/commands/publish.test.ts @@ -1,6 +1,16 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; -import { parseUpdateTarget } from "./publish.js"; +const publishState = vi.hoisted(() => ({ publish: vi.fn() })); + +vi.mock("../utils/publishProject.js", async (importOriginal) => ({ + ...(await importOriginal()), + publishProjectArchive: publishState.publish, +})); + +import publishCommand, { parseUpdateTarget } from "./publish.js"; describe("parseUpdateTarget", () => { it("extracts the id from a full published URL", () => { @@ -25,3 +35,37 @@ describe("parseUpdateTarget", () => { expect(parseUpdateTarget("https://example.com/foo/hfp_abc123")).toBe("hfp_abc123"); }); }); + +describe("publish default-entry preflight", () => { + it("rejects the real fixture before creating or uploading an archive", async () => { + const project = mkdtempSync(join(tmpdir(), "hf-publish-entry-mismatch-")); + const compositions = join(project, "compositions"); + mkdirSync(compositions); + writeFileSync( + join(project, "index.html"), + `
`, + ); + writeFileSync( + join(compositions, "index.html"), + `
Visible
`, + ); + publishState.publish.mockReset(); + publishState.publish.mockResolvedValue({ + title: "test", + fileCount: 2, + claimed: true, + projectId: "project-id", + url: "https://hyperframes.dev/p/project-id", + claimToken: "", + }); + + try { + await expect( + publishCommand.run?.({ args: { dir: project, yes: true, proxy: false } } as never), + ).rejects.toMatchObject({ name: "CliRuntimeError" }); + expect(publishState.publish).not.toHaveBeenCalled(); + } finally { + rmSync(project, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 586203738..115bb6807 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -1,12 +1,12 @@ import { join, relative, resolve } from "node:path"; -import { setCommandExitCode } from "../utils/commandResult.js"; +import { failCommand, setCommandExitCode } from "../utils/commandResult.js"; import { existsSync } from "node:fs"; import { defineCommand } from "citty"; import * as clack from "@clack/prompts"; import type { Example } from "./_examples.js"; import { c } from "../ui/colors.js"; -import { lintProject } from "../utils/lintProject.js"; +import { hasDefinitiveEntryMismatch, lintProject } from "../utils/lintProject.js"; import { formatLintFindings } from "../utils/lintFormat.js"; import { buildPublishFileMap, @@ -92,6 +92,11 @@ export default defineCommand({ for (const line of formatLintFindings(lintResult)) console.log(line); console.log(); } + if (hasDefinitiveEntryMismatch(lintResult)) { + console.log(c.error(" Aborting publish because the default index.html entry is blank.")); + console.log(); + failCommand(); + } } if (args.yes !== true) { diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index 77d0049f3..35f4760a9 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -222,6 +222,7 @@ describe("renderLocal browser GPU config", () => { renderLocal, resolveBrowserGpuForCli, renderLintContinuationHint, + runRenderLint, __resetDeParallelRouterTrialStateForTests: resetTrialState, } = renderModule; @@ -234,6 +235,47 @@ describe("renderLocal browser GPU config", () => { expect(renderLintContinuationHint(false)).toContain("Use --strict to block errors"); }); + it("aborts the real render lint preflight on a default-entry mismatch without --strict", async () => { + const lintResult = { + results: [ + { + file: "index.html", + contentHash: "abc", + result: { + ok: false, + errorCount: 1, + warningCount: 0, + infoCount: 0, + findings: [ + { + code: "blank_root_with_standalone_composition", + severity: "error" as const, + message: "wrong entry", + }, + ], + }, + }, + ], + totalErrors: 1, + totalWarnings: 0, + totalInfos: 0, + }; + + await expect( + runRenderLint( + { + project: { dir: "/tmp/project" }, + entryFile: undefined, + renderTarget: "/tmp/project/index.html", + strictErrors: false, + strictAll: false, + effectiveQuiet: true, + } as never, + async () => lintResult, + ), + ).rejects.toMatchObject({ name: "CliRuntimeError" }); + }); + function setEnv(key: string, value: string) { if (!savedEnv.has(key)) savedEnv.set(key, process.env[key]); process.env[key] = value; diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index bb3307d43..570fc02fe 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -5,9 +5,9 @@ import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync import { createRenderPlan, resolveBrowserGpuForCli, type RenderFormat } from "./render/plan.js"; import { seedProjectAuthoringSkill } from "../utils/projectConfig.js"; import { presentRenderPlan } from "./render/present.js"; -import { executeRenderPlan, renderLintContinuationHint } from "./render/execute.js"; +import { executeRenderPlan, renderLintContinuationHint, runRenderLint } from "./render/execute.js"; // Test-only seams retained at the command boundary for render behavior tests. -export { resolveBrowserGpuForCli, renderLintContinuationHint }; +export { resolveBrowserGpuForCli, renderLintContinuationHint, runRenderLint }; export const examples: Example[] = [ ["Render to MP4", "hyperframes render --output output.mp4"], diff --git a/packages/cli/src/commands/render/execute.ts b/packages/cli/src/commands/render/execute.ts index a2a3b4482..163c849f2 100644 --- a/packages/cli/src/commands/render/execute.ts +++ b/packages/cli/src/commands/render/execute.ts @@ -3,7 +3,12 @@ import type { CanvasResolution, OutputResolutionIssueKind } from "@hyperframes/c import { c } from "../../ui/colors.js"; import { errorBox, formatBytes } from "../../ui/format.js"; import { formatLintFindings } from "../../utils/lintFormat.js"; -import { lintProject, shouldBlockRender } from "../../utils/lintProject.js"; +import { + hasDefinitiveEntryMismatch, + lintProject, + shouldBlockRender, + type ProjectLintResult, +} from "../../utils/lintProject.js"; import { normalizeErrorMessage } from "../../utils/errorMessage.js"; import { failCommand, setCommandExitCode } from "../../utils/commandResult.js"; import { @@ -40,6 +45,17 @@ export function renderLintContinuationHint(strictErrors: boolean): string { : " Continuing render despite lint issues. Use --strict to block errors."; } +function renderLintShouldAbort( + strictErrors: boolean, + strictAll: boolean, + lintResult: ProjectLintResult, +): boolean { + return ( + hasDefinitiveEntryMismatch(lintResult) || + shouldBlockRender(strictErrors, strictAll, lintResult.totalErrors, lintResult.totalWarnings) + ); +} + /** Execute a validated plan. Output and process lifecycle stay outside parsing. */ export async function executeRenderPlan( plan: RenderPlan, @@ -158,22 +174,19 @@ async function ensureRenderBrowser(plan: RenderPlan): Promise { } // fallow-ignore-next-line complexity -async function runRenderLint(plan: RenderPlan): Promise { +export async function runRenderLint( + plan: RenderPlan, + runLint: (projectDir: string, entryFile?: string) => Promise = lintProject, +): Promise { // lintProject's explicit-entry contract is an absolute source path; // entryFile remains project-relative for the producer. const explicitEntry = plan.entryFile ? plan.renderTarget : undefined; - const lintResult = await lintProject(plan.project.dir, explicitEntry); + const lintResult = await runLint(plan.project.dir, explicitEntry); if (lintResult.totalErrors === 0 && lintResult.totalWarnings === 0) return; presentRenderLintFindings(lintResult, plan.effectiveQuiet); - if ( - shouldBlockRender( - plan.strictErrors, - plan.strictAll, - lintResult.totalErrors, - lintResult.totalWarnings, - ) - ) { - presentRenderLintAbort(plan); + const definitiveEntryMismatch = hasDefinitiveEntryMismatch(lintResult); + if (renderLintShouldAbort(plan.strictErrors, plan.strictAll, lintResult)) { + presentRenderLintAbort(plan, definitiveEntryMismatch); failCommand(); } presentRenderLintContinuation(plan); @@ -188,11 +201,16 @@ function presentRenderLintFindings( for (const line of formatLintFindings(lintResult, { errorsFirst: true })) console.log(line); } -function presentRenderLintAbort(plan: RenderPlan): void { +function presentRenderLintAbort(plan: RenderPlan, definitiveEntryMismatch: boolean): void { if (plan.effectiveQuiet) return; - const mode = plan.strictAll ? "--strict-all" : "--strict"; console.log(""); - console.log(c.error(` Aborting render due to lint issues (${mode} mode).`)); + console.log( + c.error( + definitiveEntryMismatch + ? " Aborting render because the default index.html entry is blank." + : ` Aborting render due to lint issues (${plan.strictAll ? "--strict-all" : "--strict"} mode).`, + ), + ); console.log(""); } diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts index d2db7c3c0..4cd4a7032 100644 --- a/packages/cli/src/commands/snapshot.test.ts +++ b/packages/cli/src/commands/snapshot.test.ts @@ -1,6 +1,28 @@ -import { describe, expect, it } from "vitest"; -import { readFileSync } from "node:fs"; -import { +import { describe, expect, it, vi } from "vitest"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const snapshotState = vi.hoisted(() => ({ + openSettledPage: vi.fn(async () => { + throw new Error("browser capture reached"); + }), + closeServer: vi.fn(async () => undefined), +})); + +vi.mock("../capture/captureCompositionFrame.js", async (importOriginal) => ({ + ...(await importOriginal()), + openSettledCompositionPage: snapshotState.openSettledPage, +})); + +vi.mock("../utils/staticProjectServer.js", () => ({ + serveStaticProjectHtml: vi.fn(async () => ({ + url: "http://127.0.0.1:1", + close: snapshotState.closeServer, + })), +})); + +import snapshotCommand, { computeSnapshotTimes, formatSnapshotTimestamp, parseZoomScale, @@ -62,6 +84,41 @@ describe("transparent snapshot capture", () => { }); }); +describe("snapshot lint preflight", () => { + it("rejects the real fixture before invoking browser capture", async () => { + const project = mkdtempSync(join(tmpdir(), "hf-snapshot-entry-mismatch-")); + const compositions = join(project, "compositions"); + mkdirSync(compositions); + writeFileSync( + join(project, "index.html"), + `
`, + ); + writeFileSync( + join(compositions, "index.html"), + `
Visible
`, + ); + snapshotState.openSettledPage.mockClear(); + const lines: string[] = []; + const log = vi.spyOn(console, "log").mockImplementation((...parts: unknown[]) => { + lines.push(parts.map(String).join(" ")); + }); + + try { + await expect( + snapshotCommand.run?.({ args: { dir: project } } as never), + ).rejects.toMatchObject({ + name: "CliRuntimeError", + }); + expect(snapshotState.openSettledPage).not.toHaveBeenCalled(); + expect(lines.join("\n")).toContain("hyperframes snapshot"); + expect(lines.join("\n")).toContain("compositions"); + } finally { + log.mockRestore(); + rmSync(project, { recursive: true, force: true }); + } + }); +}); + describe("resolveSnapshotVideoFrameTime", () => { it("keeps media active at the inclusive clip end and samples its last decodable frame", () => { expect( diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index b10096d26..775d25ba6 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -15,6 +15,8 @@ import { type ZoomTarget, } from "../capture/captureCompositionFrame.js"; import { resolveProject } from "../utils/project.js"; +import { hasDefinitiveEntryMismatch, lintProject } from "../utils/lintProject.js"; +import { formatLintFindings } from "../utils/lintFormat.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js"; import { serveStaticProjectHtml } from "../utils/staticProjectServer.js"; import { c } from "../ui/colors.js"; @@ -650,6 +652,22 @@ export default defineCommand({ }, async run({ args }) { const project = resolveProject(args.dir); + const lintResult = await lintProject(project.dir); + if (hasDefinitiveEntryMismatch(lintResult)) { + console.log(""); + for (const line of formatLintFindings(lintResult, { errorsFirst: true })) { + console.log(line); + } + console.log(""); + console.log(c.error(" Aborting snapshot because the default index.html entry is blank.")); + console.log( + c.dim( + " Move or mount the authored file, or snapshot its directory directly: hyperframes snapshot /compositions", + ), + ); + console.log(""); + failCommand(); + } const frames = parseInt(args.frames as string, 10) || 5; const timeout = parseInt(args.timeout as string, 10) || 5000; const atTimestamps = args.at diff --git a/packages/cli/src/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts index 41b59fb3a..ba4fd8dd0 100644 --- a/packages/cli/src/utils/lintProject.test.ts +++ b/packages/cli/src/utils/lintProject.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import type { HyperframeLintFinding } from "@hyperframes/core/lint"; -import { lintProject, shouldBlockRender } from "./lintProject.js"; +import { hasDefinitiveEntryMismatch, lintProject, shouldBlockRender } from "./lintProject.js"; function tmpProject(name: string): string { return mkdtempSync(join(tmpdir(), `hf-test-${name}-`)); @@ -249,6 +249,51 @@ describe("lintProject", () => { }); }); +describe("hasDefinitiveEntryMismatch", () => { + it("distinguishes the blank-default-entry failure from ordinary lint errors", () => { + const result = { + results: [ + { + file: "index.html", + contentHash: "abc", + result: { + ok: false, + errorCount: 1, + warningCount: 0, + infoCount: 0, + findings: [ + { + code: "blank_root_with_standalone_composition", + severity: "error" as const, + message: "wrong entry", + }, + ], + }, + }, + ], + totalErrors: 1, + totalWarnings: 0, + totalInfos: 0, + }; + + expect(hasDefinitiveEntryMismatch(result)).toBe(true); + expect( + hasDefinitiveEntryMismatch({ + ...result, + results: [ + { + ...result.results[0]!, + result: { + ...result.results[0]!.result, + findings: [{ code: "media_missing_id", severity: "error", message: "missing" }], + }, + }, + ], + }), + ).toBe(false); + }); +}); + function validHtmlWithAudio(compId = "main"): string { return `
diff --git a/packages/cli/src/utils/lintProject.ts b/packages/cli/src/utils/lintProject.ts index f63ce1260..5a6f4897d 100644 --- a/packages/cli/src/utils/lintProject.ts +++ b/packages/cli/src/utils/lintProject.ts @@ -1,3 +1,13 @@ -// ponytail: thin re-export — lintProject lives in @hyperframes/lint so it's usable without the CLI +// CLI facade: the linter stays reusable without the CLI, while command-specific gates live here. export { lintProject, shouldBlockRender } from "@hyperframes/lint"; export type { ProjectLintResult } from "@hyperframes/lint"; + +import type { ProjectLintResult } from "@hyperframes/lint"; + +export function hasDefinitiveEntryMismatch(result: ProjectLintResult): boolean { + return result.results.some((entry) => + entry.result.findings.some( + (finding) => finding.code === "blank_root_with_standalone_composition", + ), + ); +} diff --git a/packages/lint/src/project.test.ts b/packages/lint/src/project.test.ts index 440974e09..2d35fa5d5 100644 --- a/packages/lint/src/project.test.ts +++ b/packages/lint/src/project.test.ts @@ -69,6 +69,112 @@ describe("external symlink assets", () => { }); }); +describe("blank_root_with_standalone_composition", () => { + it("errors when the default entry is blank but an authored standalone composition lives under compositions", async () => { + const project = makeProject(validHtml(), { + "index.html": ` +
+
BONA
+
+ +`, + }); + + const { results, totalErrors } = await lintProject(project); + const finding = results + .flatMap((result) => result.result.findings) + .find((item) => item.code === "blank_root_with_standalone_composition"); + + expect(totalErrors).toBeGreaterThan(0); + expect(finding?.severity).toBe("error"); + expect(finding?.message).toContain("compositions/index.html"); + expect(finding?.message).toContain("index.html"); + expect(finding?.fixHint).toContain("data-composition-src"); + }); + + it("treats non-rendering script, style, link, meta, and template children as blank", async () => { + const shellOnlyRoot = validHtml().replace( + "
", + ` + + + + + `, + ); + const project = makeProject(shellOnlyRoot, { + "authored.html": ` +
+
Visible
+
+`, + }); + + const { results } = await lintProject(project); + const finding = results + .flatMap((result) => result.result.findings) + .find((item) => item.code === "blank_root_with_standalone_composition"); + + expect(finding).toBeDefined(); + }); + + it("does not fire when index.html already contains authored clip content", async () => { + const authoredRoot = validHtml().replace( + "", + '
Master content
', + ); + const project = makeProject(authoredRoot, { + "alternate.html": ` +
+
Alternate
+
+`, + }); + + const { results } = await lintProject(project); + const finding = results + .flatMap((result) => result.result.findings) + .find((item) => item.code === "blank_root_with_standalone_composition"); + + expect(finding).toBeUndefined(); + }); + + it("does not treat a template-wrapped sub-composition as a misplaced standalone entry", async () => { + const project = makeProject(validHtml(), { + "scene.html": ``, + }); + + const { results } = await lintProject(project); + const finding = results + .flatMap((result) => result.result.findings) + .find((item) => item.code === "blank_root_with_standalone_composition"); + + expect(finding).toBeUndefined(); + }); + + it("still catches a standalone composition that contains an unrelated nested template", async () => { + const project = makeProject(validHtml(), { + "card.html": ` +
+
Card
+ +
+`, + }); + + const { results } = await lintProject(project); + const finding = results + .flatMap((result) => result.result.findings) + .find((item) => item.code === "blank_root_with_standalone_composition"); + + expect(finding).toBeDefined(); + }); +}); + describe("missing_or_empty_sub_composition", () => { function htmlWithSubComp(srcPath: string): string { return ` diff --git a/packages/lint/src/project.ts b/packages/lint/src/project.ts index 5a2d2b9a6..a4f522488 100644 --- a/packages/lint/src/project.ts +++ b/packages/lint/src/project.ts @@ -230,6 +230,7 @@ export async function lintProject( ...lintMissingLocalAsset(projectDir, allHtmlSources), ...lintTextureMaskAssetNotFound(projectDir, allHtmlSources), ...(!entryFile ? lintMultipleRootCompositions(projectDir) : []), + ...(!entryFile ? lintBlankRootWithStandaloneComposition(rootHtml, allHtmlSources) : []), ...lintDuplicateAudioTracks(allHtmlSources), ...lintMissingOrEmptySubComposition(projectDir, rootHtml), ...(await lintHevcPreviewCodec(collectLocalVideoCandidates(projectDir, allHtmlSources))), @@ -254,6 +255,46 @@ export async function lintProject( return { results, totalErrors, totalWarnings, totalInfos }; } +function lintBlankRootWithStandaloneComposition( + rootHtml: string, + htmlSources: HtmlSource[], +): HyperframeLintFinding[] { + const { document: rootDocument } = parseHTML(rootHtml); + const root = rootDocument.querySelector("body [data-composition-id]"); + // A no-media scaffold has no rendered descendants and can silently mask an authored file below. + // A scaffold that retained its A-roll