diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index d393d3acb2..22e9af259f 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -267,6 +267,18 @@ "packages/parsers/src/gsapWriterParity.acorn.test.ts", "packages/parsers/src/htmlParser.roundtrip.test.ts", "packages/parsers/src/htmlParser.test.ts", + // @hyperframes/lint rule test files: parallel arrange/act/assert test cases + // (pre-existing structure from when lint lived in packages/core/src/lint/). + "packages/lint/src/rules/adapters.test.ts", + "packages/lint/src/rules/captions.test.ts", + "packages/lint/src/rules/composition.test.ts", + "packages/lint/src/rules/core.test.ts", + "packages/lint/src/rules/fonts.test.ts", + "packages/lint/src/rules/gsap.test.ts", + "packages/lint/src/rules/media.test.ts", + "packages/lint/src/rules/slideshow.test.ts", + "packages/lint/src/rules/textures.test.ts", + "packages/lint/src/hyperframeLinter.test.ts", // slideshowPanelHelpers.ts: setSlideNotes/addFragment/addHotspot share an // intentional parallel shape (signature + mapSlidesIn → exists-check → // map/append); the per-slide mutation differs, so a shared abstraction @@ -311,6 +323,13 @@ "packages/parsers/src/htmlParser.ts", // executeGsapMutation (CRITICAL) pre-dates this PR; studio-api still lives in core. "packages/core/src/studio-api/routes/files.ts", + // lint rule implementations and project linter: pre-existing complexity + // (moved from packages/core/src/lint/). File-level exemption avoids the + // line-shift fingerprint problem for inherited findings. + "packages/lint/src/rules/media.ts", + "packages/lint/src/rules/textures.ts", + "packages/lint/src/rules/gsap.ts", + "packages/lint/src/project.ts", // SlideshowPanel.tsx: top-level editor panel that wires several independent // sections (slides/inspector/branches/hotspot). Its cyclomatic count comes // from that fan-out; splitting it would scatter shared state without @@ -329,7 +348,7 @@ // generated table's shape test; this is dev tooling, not shipped runtime. "packages/cli/scripts/sync-agent-dirs.ts", // Files modified only for import-path updates (one-line changes to switch - // from @hyperframes/core/* subpaths to @hyperframes/parsers). Their complexity + // from @hyperframes/core/* subpaths to the new packages). Their complexity // is pre-existing; the line-shift fingerprint problem makes fallow treat // the violations as new even though no logic changed. "packages/core/src/core.types.ts", @@ -338,6 +357,10 @@ "packages/studio/src/hooks/gsapShared.ts", "packages/studio/src/hooks/gsapDragPositionCommit.ts", "packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts", + "packages/cli/src/commands/lint.ts", + "packages/cli/src/commands/preview.ts", + "packages/cli/src/commands/publish.ts", + "packages/cli/src/server/studioServer.ts", // set-version.ts: compareSemver helper has pre-existing complexity from // semver string parsing logic; line-shift fingerprint problem from new // packages added to PACKAGES array makes fallow treat it as new. diff --git a/.github/workflows/preview-regression.yml b/.github/workflows/preview-regression.yml index 5ee4f10030..02e39db660 100644 --- a/.github/workflows/preview-regression.yml +++ b/.github/workflows/preview-regression.yml @@ -37,6 +37,7 @@ jobs: preview: - "packages/core/**" - "packages/parsers/**" + - "packages/lint/**" - "packages/player/**" - "packages/studio/**" - "packages/cli/**" @@ -76,7 +77,7 @@ jobs: - name: Build workspace packages (required for vite config loading) run: | - bun run --filter '@hyperframes/parsers' build + bun run --filter '@hyperframes/{parsers,lint}' build bun run --cwd packages/core build - name: Run Studio preview routing regression diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 42123e75f1..e12a14e47c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -125,6 +125,7 @@ jobs: } publish_pkg "@hyperframes/parsers" "@hyperframes/parsers" + publish_pkg "@hyperframes/lint" "@hyperframes/lint" publish_pkg "@hyperframes/core" "@hyperframes/core" publish_pkg "@hyperframes/sdk" "@hyperframes/sdk" publish_pkg "@hyperframes/engine" "@hyperframes/engine" diff --git a/Dockerfile.test b/Dockerfile.test index 5748109914..2320ac902d 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -75,6 +75,7 @@ ENV PATH="/root/.bun/bin:$PATH" # lockfile change and fails. COPY package.json bun.lock ./ COPY packages/parsers/package.json packages/parsers/package.json +COPY packages/lint/package.json packages/lint/package.json COPY packages/core/package.json packages/core/package.json COPY packages/engine/package.json packages/engine/package.json COPY packages/player/package.json packages/player/package.json @@ -90,12 +91,13 @@ RUN bun install --frozen-lockfile # Copy source COPY packages/parsers/ packages/parsers/ +COPY packages/lint/ packages/lint/ COPY packages/core/ packages/core/ COPY packages/engine/ packages/engine/ COPY packages/producer/ packages/producer/ # Build workspace packages so "node" export conditions resolve to built dist -RUN bun run --filter '@hyperframes/parsers' build \ +RUN bun run --filter '@hyperframes/{parsers,lint}' build \ && bun run --cwd packages/core build # Build core runtime artifacts (needed by renderer) diff --git a/bun.lock b/bun.lock index 35c28338b2..1dfc2b8cab 100644 --- a/bun.lock +++ b/bun.lock @@ -82,6 +82,7 @@ "@hyperframes/core": "workspace:*", "@hyperframes/engine": "workspace:*", "@hyperframes/gcp-cloud-run": "workspace:*", + "@hyperframes/lint": "workspace:*", "@hyperframes/producer": "workspace:*", "@hyperframes/studio": "workspace:*", "@types/adm-zip": "^0.5.7", @@ -104,6 +105,7 @@ "version": "0.7.13", "dependencies": { "@chenglou/pretext": "^0.0.5", + "@hyperframes/lint": "workspace:*", "@hyperframes/parsers": "workspace:*", "bpm-detective": "^2.0.5", "linkedom": "^0.18.12", @@ -167,6 +169,22 @@ "typescript": "^5.7.2", }, }, + "packages/lint": { + "name": "@hyperframes/lint", + "version": "0.7.11", + "dependencies": { + "@hyperframes/core": "workspace:*", + "@hyperframes/parsers": "workspace:*", + "postcss": "^8.5.8", + }, + "devDependencies": { + "@types/node": "^25.0.10", + "tsup": "^8.0.0", + "tsx": "^4.21.0", + "typescript": "^5.0.0", + "vitest": "^3.2.4", + }, + }, "packages/parsers": { "name": "@hyperframes/parsers", "version": "0.7.11", @@ -220,6 +238,7 @@ "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", "@hyperframes/engine": "workspace:^", + "@hyperframes/lint": "workspace:^", "hono": "^4.6.0", "linkedom": "^0.18.12", "postcss": "^8.4.0", @@ -685,6 +704,8 @@ "@hyperframes/gcp-cloud-run": ["@hyperframes/gcp-cloud-run@workspace:packages/gcp-cloud-run"], + "@hyperframes/lint": ["@hyperframes/lint@workspace:packages/lint"], + "@hyperframes/parsers": ["@hyperframes/parsers@workspace:packages/parsers"], "@hyperframes/player": ["@hyperframes/player@workspace:packages/player"], diff --git a/package.json b/package.json index ac70315d6a..6751dd3a85 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "type": "module", "scripts": { "dev": "bun run studio", - "build": "bun run --filter @hyperframes/parsers build && bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build", + "build": "bun run --filter '@hyperframes/{parsers,lint}' build && bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build", "build:producer": "bun run --filter @hyperframes/producer build", "studio": "bun run --filter @hyperframes/studio dev", "build:hyperframes-runtime": "bun run --filter @hyperframes/core build:hyperframes-runtime", diff --git a/packages/cli/package.json b/packages/cli/package.json index 11237086fa..5c8e474508 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -50,6 +50,7 @@ "@hyperframes/core": "workspace:*", "@hyperframes/engine": "workspace:*", "@hyperframes/gcp-cloud-run": "workspace:*", + "@hyperframes/lint": "workspace:*", "@hyperframes/producer": "workspace:*", "@hyperframes/studio": "workspace:*", "@types/adm-zip": "^0.5.7", diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 73b74555e6..e64a973e9a 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -38,7 +38,7 @@ export default defineCommand({ async run({ args }) { try { const project = resolveProject(args.dir); - const lintResult = await lintProject(project); + const lintResult = await lintProject(project.dir); if (args.json) { const allFindings = lintResult.results.flatMap((r) => r.result.findings); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 80749a8a6e..8a26503cda 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -123,11 +123,10 @@ export default defineCommand({ const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./"; const project = resolveProject(rawArg); const dir = project.dir; - const indexPath = project.indexPath; const projectName = isImplicitCwd ? basename(process.env.PWD ?? dir) : project.name; // Lint before starting — surface issues for the agent to fix. - const lintResult = await lintProject({ dir, name: projectName, indexPath }); + const lintResult = await lintProject(dir); if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) { console.log(); for (const line of formatLintFindings(lintResult)) console.log(line); diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 182743fcc9..260eb082d5 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -1,4 +1,4 @@ -import { basename, resolve } from "node:path"; +import { resolve } from "node:path"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { defineCommand } from "citty"; @@ -33,12 +33,9 @@ export default defineCommand({ async run({ args }) { const rawArg = args.dir; const dir = resolve(rawArg ?? "."); - const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./"; - const projectName = isImplicitCwd ? basename(process.env["PWD"] ?? dir) : basename(dir); - const indexPath = join(dir, "index.html"); if (existsSync(indexPath)) { - const lintResult = await lintProject({ dir, name: projectName, indexPath }); + const lintResult = await lintProject(dir); if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) { console.log(); for (const line of formatLintFindings(lintResult)) console.log(line); diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 77660857dd..65f12f67d2 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -738,7 +738,7 @@ export default defineCommand({ // ── Pre-render lint ────────────────────────────────────────────────── { - const lintResult = await lintProject(project); + const lintResult = await lintProject(project.dir); if (!quiet && (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0)) { console.log(""); for (const line of formatLintFindings(lintResult, { errorsFirst: true })) console.log(line); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index f25228b562..3b284cee72 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -339,7 +339,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { }, async lint(html: string, opts?: { filePath?: string }) { - const { lintHyperframeHtml } = await import("@hyperframes/core/lint"); + const { lintHyperframeHtml } = await import("@hyperframes/lint"); return await lintHyperframeHtml(html, opts); }, diff --git a/packages/cli/src/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts index aec22d15e2..2491b947aa 100644 --- a/packages/cli/src/utils/lintProject.test.ts +++ b/packages/cli/src/utils/lintProject.test.ts @@ -3,7 +3,6 @@ import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { lintProject, shouldBlockRender } from "./lintProject.js"; -import type { ProjectDir } from "./project.js"; function tmpProject(name: string): string { return mkdtempSync(join(tmpdir(), `hf-test-${name}-`)); @@ -37,7 +36,7 @@ function htmlWithPreloadNone(): string { let dirs: string[] = []; -function makeProject(indexHtml: string, subComps?: Record): ProjectDir { +function makeProject(indexHtml: string, subComps?: Record): string { const dir = tmpProject("lint"); dirs.push(dir); writeFileSync(join(dir, "index.html"), indexHtml); @@ -48,7 +47,7 @@ function makeProject(indexHtml: string, subComps?: Record): Proj writeFileSync(join(compsDir, name), html); } } - return { dir, name: "test-project", indexPath: join(dir, "index.html") }; + return dir; } afterEach(() => { @@ -108,12 +107,7 @@ describe("lintProject", () => { `; writeFileSync(join(framesDir, "04-mechanism.html"), frameHtml); - const project: ProjectDir = { - dir, - name: "test-project", - indexPath: join(dir, "index.html"), - }; - const { results } = await lintProject(project); + const { results } = await lintProject(dir); const frameResult = results.find((r) => r.file === "compositions/frames/04-mechanism.html"); expect(frameResult).toBeDefined(); @@ -146,7 +140,7 @@ describe("lintProject", () => { `, }); writeFileSync( - join(project.dir, "compositions", "scene.css"), + join(project, "compositions", "scene.css"), '[data-composition-id="scene"] .title { opacity: 0; }', ); @@ -169,7 +163,7 @@ describe("lintProject", () => { `, }); writeFileSync( - join(project.dir, "compositions", decodeURIComponent(encodedFilename)), + join(project, "compositions", decodeURIComponent(encodedFilename)), '[data-composition-id="scene"] .title { opacity: 0; }', ); @@ -227,7 +221,7 @@ describe("lintProject", () => { "captions.html": validHtml("captions"), }); // Add a non-HTML file - writeFileSync(join(project.dir, "compositions", "readme.txt"), "not html"); + writeFileSync(join(project, "compositions", "readme.txt"), "not html"); const { results } = await lintProject(project); @@ -270,7 +264,7 @@ function validHtmlWithMaskImageUrl(url: string): string { describe("audio_file_without_element", () => { it("warns when audio file exists but no