fix(core): exclude dot-directories and node_modules from studio composition discovery and lint (#1385)

Projects that vendor tooling assets under dot-directories ended up with
every example/preset HTML inside them listed and preview-rendered in the
comps sidebar, and the studio Lint badge inflated with findings from
files that are not part of the video. walkDir only skipped three exact
names (.thumbnails, node_modules, .git), so any other dot-directory
(.hyperframes/, .cache/, ...) was walked.

Add an isInHiddenOrVendorDir helper that rejects paths with a
dot-directory or node_modules segment and apply it to composition
discovery and the studio lint route. The file tree is deliberately left
unfiltered - this only gates discovery.

Fixes #1384
This commit is contained in:
Leonel Rivas
2026-06-12 17:51:03 -07:00
committed by GitHub
parent aec3c3b58c
commit b952dc9ce0
5 changed files with 154 additions and 4 deletions
@@ -9,6 +9,18 @@ export function isSafePath(base: string, resolved: string): boolean {
const IGNORE_DIRS = new Set([".hyperframes", ".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[] = [];
@@ -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"), "<html><body>real</body></html>");
mkdirSync(join(projectDir, ".hyperframes"));
writeFileSync(join(projectDir, ".hyperframes", "preset.html"), "<html><body>junk</body></html>");
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");
});
});
+4 -2
View File
@@ -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;
@@ -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 = '<html><body><div data-composition-id="main"></div></body></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");
});
});
@@ -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<string[]> {
const htmlFiles = files.filter((f) => f.endsWith(".html"));
const htmlFiles = files.filter((f) => f.endsWith(".html") && !isInHiddenOrVendorDir(f));
const checks = await Promise.all(
htmlFiles.map(async (f) => {
try {