diff --git a/packages/core/src/studio-api/helpers/safePath.ts b/packages/core/src/studio-api/helpers/safePath.ts index 7a925c3c6..39ae869fc 100644 --- a/packages/core/src/studio-api/helpers/safePath.ts +++ b/packages/core/src/studio-api/helpers/safePath.ts @@ -9,6 +9,18 @@ export function isSafePath(base: string, resolved: string): boolean { const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]); +/** + * True when any directory segment of a relative path is a dot-directory or + * node_modules. Projects that vendor tooling assets under dot-directories + * (.hyperframes/, .cache/, …) ship example/preset HTML that must not surface + * as project compositions or studio lint targets (#1384). The file tree is + * deliberately not filtered — this only gates discovery. + */ +export function isInHiddenOrVendorDir(relPath: string): boolean { + const segments = relPath.split("/"); + return segments.slice(0, -1).some((seg) => seg.startsWith(".") || seg === "node_modules"); +} + /** Recursively walk a directory and return relative file paths. */ export function walkDir(dir: string, prefix = ""): string[] { const files: string[] = []; diff --git a/packages/core/src/studio-api/routes/lint.test.ts b/packages/core/src/studio-api/routes/lint.test.ts new file mode 100644 index 000000000..7783fc3c4 --- /dev/null +++ b/packages/core/src/studio-api/routes/lint.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { Hono } from "hono"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { registerLintRoutes } from "./lint"; +import type { StudioApiAdapter } from "../types"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// Project layout for #1384: one real composition plus vendored example HTML +// inside a dot-directory that must not inflate the lint findings. +function createProjectDir(): string { + const projectDir = mkdtempSync(join(tmpdir(), "hf-lint-test-")); + tempDirs.push(projectDir); + writeFileSync(join(projectDir, "index.html"), "
real"); + mkdirSync(join(projectDir, ".hyperframes")); + writeFileSync(join(projectDir, ".hyperframes", "preset.html"), "junk"); + return projectDir; +} + +// Every linted file reports one finding, so the response reveals exactly +// which files were linted. +function createAdapter(projectDir: string): StudioApiAdapter { + return { + listProjects: () => [], + resolveProject: async (id: string) => ({ id, dir: projectDir }), + bundle: async () => null, + lint: async () => ({ findings: [{ severity: "warning", message: "finding" }] }), + runtimeUrl: "/api/runtime.js", + rendersDir: () => "/tmp/renders", + startRender: () => ({ + id: "job-1", + status: "rendering", + progress: 0, + outputPath: "/tmp/out.mp4", + }), + }; +} + +describe("registerLintRoutes — dot-directory exclusion (#1384)", () => { + it("does not lint HTML inside dot-directories", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerLintRoutes(app, createAdapter(projectDir)); + + const response = await app.request("http://localhost/projects/demo/lint"); + const payload = (await response.json()) as { findings?: Array<{ file?: string }> }; + + expect(response.status).toBe(200); + const lintedFiles = (payload.findings ?? []).map((f) => f.file); + expect(lintedFiles).toContain("index.html"); + expect(lintedFiles).not.toContain(".hyperframes/preset.html"); + }); +}); diff --git a/packages/core/src/studio-api/routes/lint.ts b/packages/core/src/studio-api/routes/lint.ts index e399e52d3..5a1b48b97 100644 --- a/packages/core/src/studio-api/routes/lint.ts +++ b/packages/core/src/studio-api/routes/lint.ts @@ -2,14 +2,16 @@ import type { Hono } from "hono"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import type { StudioApiAdapter } from "../types.js"; -import { walkDir } from "../helpers/safePath.js"; +import { isInHiddenOrVendorDir, walkDir } from "../helpers/safePath.js"; export function registerLintRoutes(api: Hono, adapter: StudioApiAdapter): void { api.get("/projects/:id/lint", async (c) => { const project = await adapter.resolveProject(c.req.param("id")); if (!project) return c.json({ error: "not found" }, 404); try { - const htmlFiles = walkDir(project.dir).filter((f) => f.endsWith(".html")); + const htmlFiles = walkDir(project.dir).filter( + (f) => f.endsWith(".html") && !isInHiddenOrVendorDir(f), + ); const allFindings: Array<{ severity: string; message: string; diff --git a/packages/core/src/studio-api/routes/projects.test.ts b/packages/core/src/studio-api/routes/projects.test.ts new file mode 100644 index 000000000..f726a4dcf --- /dev/null +++ b/packages/core/src/studio-api/routes/projects.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { Hono } from "hono"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { registerProjectRoutes } from "./projects"; +import type { StudioApiAdapter } from "../types"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +const COMPOSITION_HTML = ''; + +// Project layout for #1384: real compositions at the root and under +// compositions/, plus vendored example HTML inside dot-directories that +// must not surface as compositions. +function createProjectDir(): string { + const projectDir = mkdtempSync(join(tmpdir(), "hf-projects-test-")); + tempDirs.push(projectDir); + writeFileSync(join(projectDir, "index.html"), COMPOSITION_HTML); + mkdirSync(join(projectDir, "compositions")); + writeFileSync(join(projectDir, "compositions", "scene.html"), COMPOSITION_HTML); + mkdirSync(join(projectDir, ".hyperframes", "examples"), { recursive: true }); + writeFileSync(join(projectDir, ".hyperframes", "examples", "preset.html"), COMPOSITION_HTML); + return projectDir; +} + +function createAdapter(projectDir: string): StudioApiAdapter { + return { + listProjects: () => [], + resolveProject: async (id: string) => ({ id, dir: projectDir }), + bundle: async () => null, + lint: async () => ({ findings: [] }), + runtimeUrl: "/api/runtime.js", + rendersDir: () => "/tmp/renders", + startRender: () => ({ + id: "job-1", + status: "rendering", + progress: 0, + outputPath: "/tmp/out.mp4", + }), + }; +} + +describe("registerProjectRoutes — composition discovery (#1384)", () => { + it("excludes HTML inside dot-directories from compositions", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerProjectRoutes(app, createAdapter(projectDir)); + + const response = await app.request("http://localhost/projects/demo"); + const payload = (await response.json()) as { compositions?: string[] }; + + expect(response.status).toBe(200); + expect(payload.compositions).toContain("index.html"); + expect(payload.compositions).toContain("compositions/scene.html"); + expect(payload.compositions).not.toContain(".hyperframes/examples/preset.html"); + }); + + it("keeps dot-directory files visible in the file tree", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerProjectRoutes(app, createAdapter(projectDir)); + + const response = await app.request("http://localhost/projects/demo"); + const payload = (await response.json()) as { files?: string[] }; + + expect(payload.files).toContain(".hyperframes/examples/preset.html"); + }); +}); diff --git a/packages/core/src/studio-api/routes/projects.ts b/packages/core/src/studio-api/routes/projects.ts index 3627a1f04..76fb0079e 100644 --- a/packages/core/src/studio-api/routes/projects.ts +++ b/packages/core/src/studio-api/routes/projects.ts @@ -2,12 +2,12 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import type { Hono } from "hono"; import type { StudioApiAdapter } from "../types.js"; -import { walkDir } from "../helpers/safePath.js"; +import { isInHiddenOrVendorDir, walkDir } from "../helpers/safePath.js"; const COMPOSITION_ID_RE = /data-composition-id\s*=/; async function filterCompositionFiles(projectDir: string, files: string[]): Promise