Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1209,6 +1209,12 @@ unless you pass `--verbose`, which keeps output clean for agents and CI.
`--lint-verbose`, which `render`, `preview` and `publish` use for the same wish, works here and on `check` too.
`--json` adds `errorCount`, `warningCount`, `infoCount`, and a `findings` array.

Only the compositions the project uses are linted: `index.html` and every file it mounts through
`data-composition-src`, followed through the files they mount. A file under `compositions/` that
nothing mounts (an installed block you have not placed yet) is left out, and one line says how
many were; `--include-unmounted` lints them too. `--json` lists them in `unmountedCompositions`.
`check` lints the same way and takes the same flag.

The linter catches missing attributes, missing adapter libraries (GSAP, Lottie,
Three.js), and structural problems. [Troubleshooting](/help)
explains each rule.
Expand Down Expand Up @@ -1253,6 +1259,7 @@ cover every finding from the full check.
| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `--json` | One aggregated envelope. Every finding carries a selector, `data-*` identity, source file, bbox, and sample time. |
| `--snapshots` | Save the five audited frames under `.hyperframes/artifacts/snapshots/`, plus a `finding-NN-<code>.png` crop per finding |
| `--include-unmounted` | Also lint `compositions/` files that `index.html` never mounts (left out by default, with one line saying how many) |
| `--samples` | Midpoint samples across the duration (default 9) |
| `--at` | Explicit timestamps in seconds, comma-separated |
| `--at-transitions` | Also sample every tween start and end, to catch transient overlaps at transition seams |
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,11 @@ const CHECK_COMMAND_ARGS = {
description: "Save contrast-pass PNGs under .hyperframes/artifacts/snapshots/",
default: false,
},
"include-unmounted": {
type: "boolean",
description: "Also lint compositions/ files that index.html never mounts",
default: false,
},
"caption-zone": {
type: "string",
description:
Expand Down Expand Up @@ -456,6 +461,7 @@ function parseCheckOptions(args: Record<string, unknown>): CheckOptions {
layout: parseLayout(args.layout),
autoProxy: args.proxy as boolean | undefined,
browserGpuMode: resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined),
includeUnmounted: args["include-unmounted"] === true,
};
}

Expand Down
39 changes: 39 additions & 0 deletions packages/cli/src/commands/lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,42 @@ describe("lint --lint-verbose", () => {
expect(await codes({ "lint-verbose": true })).toEqual(["warn", "note"]);
});
});

describe("lint and compositions the project never mounts", () => {
beforeEach(() => {
consumeCommandResult();
lintProjectMock.mockReset();
lintProjectMock.mockResolvedValue({
results: [{ file: "index.html", result: { findings: [] } }],
totalErrors: 0,
totalWarnings: 0,
totalInfos: 0,
unmountedCompositions: ["compositions/a.html", "compositions/b.html"],
});
});
afterEach(() => vi.restoreAllMocks());

it("says once how many it left out, and lints them with --include-unmounted", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {});
await run({ _: [] });
const lines = log.mock.calls.map((call) => String(call[0]));
expect(lines.filter((line) => line.includes("not mounted from index.html"))).toEqual([
expect.stringContaining(
"2 files in compositions/ are not mounted from index.html, so not checked; --include-unmounted adds them.",
),
]);
expect(lintProjectMock.mock.calls[0]?.[3]).toEqual({ includeUnmounted: false });

await run({ _: [], "include-unmounted": true });
expect(lintProjectMock.mock.calls[1]?.[3]).toEqual({ includeUnmounted: true });
});

it("names them in the JSON report", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {});
await run({ _: [], json: true });
expect(JSON.parse(String(log.mock.calls[0]?.[0])).unmountedCompositions).toEqual([
"compositions/a.html",
"compositions/b.html",
]);
});
});
10 changes: 10 additions & 0 deletions packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const examples: Example[] = [
["Include info-level findings", "hyperframes lint --verbose"],
];
import { formatLintFindings } from "../utils/lintFormat.js";
import { unmountedNotice } from "@hyperframes/lint";
import { lintProject } from "../utils/lintProject.js";
import { isUnderCompositions, resolveLintTarget } from "../utils/project.js";
import { trackLintRun } from "../telemetry/lintRun.js";
Expand Down Expand Up @@ -39,6 +40,11 @@ export default defineCommand({
description: "Show info-level findings (hidden by default)",
default: false,
},
"include-unmounted": {
type: "boolean",
description: "Also lint compositions/ files that index.html never mounts",
default: false,
},
"lint-verbose": {
type: "boolean",
description: "Same as --verbose (render, preview and publish spell it --lint-verbose)",
Expand All @@ -62,7 +68,9 @@ export default defineCommand({
entryFile && isUnderCompositions(project.dir, entryFile)
? { isSubComposition: true }
: undefined,
{ includeUnmounted: Boolean(args["include-unmounted"]) },
);
const unmounted = lintResult.unmountedCompositions ?? [];
trackLintRun(project.dir, lintResult, {
command: "lint",
durationMs: Date.now() - startedAt,
Expand All @@ -78,6 +86,7 @@ export default defineCommand({
infoCount: lintResult.totalInfos,
findings: verbose ? allFindings : allFindings.filter((f) => f.severity !== "info"),
filesScanned: lintResult.results.length,
unmountedCompositions: unmounted,
};
console.log(JSON.stringify(withMeta(combined), null, 2));
setCommandExitCode(combined.ok ? 0 : 1);
Expand All @@ -88,6 +97,7 @@ export default defineCommand({
const fileLabel =
fileCount === 1 ? (lintResult.results[0]?.file ?? "index.html") : `${fileCount} files`;
console.log(`${c.accent("◆")} Linting ${c.accent(`${project.name}/${fileLabel}`)}`);
if (unmounted.length > 0) console.log(c.dim(unmountedNotice(unmounted.length)));
console.log();

if (lintResult.totalErrors === 0 && lintResult.totalWarnings === 0) {
Expand Down
11 changes: 8 additions & 3 deletions packages/cli/src/utils/checkPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { trackLintRun } from "../telemetry/lintRun.js";
import { getRunId } from "../telemetry/runId.js";
import type { ProjectDir } from "./project.js";
import { lintProject, type ProjectLintResult } from "./lintProject.js";
import { unmountedNotice } from "@hyperframes/lint";
import {
buildLayoutSampleTimes,
buildTransitionSampleTimes,
Expand Down Expand Up @@ -1693,7 +1694,11 @@ export async function runCheckPipeline(
let lintResult: ProjectLintResult;
const lintStartedAt = Date.now();
try {
lintResult = await dependencies.lintProject(project.dir);
lintResult = await dependencies.lintProject(project.dir, {
includeUnmounted: options.includeUnmounted === true,
});
const unmounted = lintResult.unmountedCompositions?.length ?? 0;
if (unmounted > 0) console.error(`[hyperframes] check: ${unmountedNotice(unmounted)}`);
} catch (error) {
// The linter itself crashed (unreadable file, internal error) — distinct
// from lint findings; a runtime-failure code would send the agent hunting
Expand Down Expand Up @@ -2269,7 +2274,7 @@ function emptyBrowserResult(): CheckBrowserResult {
};
}

export const STOP_CODES = [
const STOP_CODES = [
"media_copies_unfinished",
"check_budget_reached",
"check_runtime_failure",
Expand Down Expand Up @@ -2435,7 +2440,7 @@ function audioCheckFailure(project: ProjectDir, error: unknown): CheckFinding[]
}

const DEFAULT_DEPENDENCIES: CheckDependencies = {
lintProject,
lintProject: (projectDir, options) => lintProject(projectDir, undefined, {}, options),
resolveMotionSpec,
runBrowserCheck,
prelaunchBrowser,
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/utils/checkTypes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { CheckDeadline } from "./checkDeadline.js";
import type { CheckProgress } from "./checkProgress.js";
import type { ProjectLintResult } from "./lintProject.js";
import type { ProjectLintOptions, ProjectLintResult } from "./lintProject.js";
import type { LayoutIssue, LayoutOverflow, LayoutRect } from "./layoutAudit.js";
import type { Canvas, MotionFrame } from "./motionAudit.js";
import type { MotionSpec } from "./motionSpec.js";
Expand Down Expand Up @@ -37,6 +37,7 @@ export interface CheckOptions {
/** Explicit --proxy/--no-proxy override; undefined preserves project config. */
autoProxy?: boolean;
browserGpuMode?: BrowserGpuMode;
includeUnmounted?: boolean;
}

export interface CaptionZoneOptions {
Expand Down Expand Up @@ -422,7 +423,7 @@ export interface CheckReport {
}

export interface CheckDependencies {
lintProject(projectDir: string): Promise<ProjectLintResult>;
lintProject(projectDir: string, options?: ProjectLintOptions): Promise<ProjectLintResult>;
resolveMotionSpec(projectDir: string): MotionSpecResolution;
runBrowserCheck(
project: ProjectDir,
Expand Down
46 changes: 33 additions & 13 deletions packages/cli/src/utils/lintProject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ describe("lintProject", () => {
</template>`;
writeFileSync(join(framesDir, "04-mechanism.html"), frameHtml);

const { results } = await lintProject(dir);
const { results } = await lintProject(dir, undefined, {}, { includeUnmounted: true });

const frameResult = results.find((r) => r.file === "compositions/frames/04-mechanism.html");
expect(frameResult).toBeDefined();
Expand All @@ -126,7 +126,12 @@ describe("lintProject", () => {
const project = makeProject(validHtml(), {
"captions.html": htmlWithMissingMediaId(),
});
const { totalErrors, results } = await lintProject(project);
const { totalErrors, results } = await lintProject(
project,
undefined,
{},
{ includeUnmounted: true },
);

expect(results).toHaveLength(2);
const second = results[1];
Expand All @@ -149,7 +154,7 @@ describe("lintProject", () => {
'[data-composition-id="no-such-comp"] .title { opacity: 0; }',
);

const { results } = await lintProject(project);
const { results } = await lintProject(project, undefined, {}, { includeUnmounted: true });
const subResult = results.find((result) => result.file === "compositions/scene.html");
// The linked stylesheet scopes CSS to a composition id that has no wrapper
// here, so this finding can only come from the linked file being read.
Expand All @@ -174,7 +179,7 @@ describe("lintProject", () => {
'[data-composition-id="no-such-comp"] .title { opacity: 0; }',
);

const { results } = await lintProject(project);
const { results } = await lintProject(project, undefined, {}, { includeUnmounted: true });
const subResult = results.find((result) => result.file === "compositions/scene.html");
// The linked stylesheet scopes CSS to a composition id that has no wrapper
// here, so this finding can only come from the linked file being read.
Expand All @@ -190,7 +195,12 @@ describe("lintProject", () => {
const project = makeProject(htmlWithMissingMediaId(), {
"overlay.html": htmlWithMissingMediaId(),
});
const { totalErrors, results } = await lintProject(project);
const { totalErrors, results } = await lintProject(
project,
undefined,
{},
{ includeUnmounted: true },
);

expect(results).toHaveLength(2);
const first = results[0];
Expand All @@ -207,7 +217,12 @@ describe("lintProject", () => {
const project = makeProject(validHtml(), {
"captions.html": htmlWithPreloadNone(),
});
const { totalWarnings, results } = await lintProject(project);
const { totalWarnings, results } = await lintProject(
project,
undefined,
{},
{ includeUnmounted: true },
);

expect(results).toHaveLength(2);
expect(totalWarnings).toBeGreaterThan(0);
Expand Down Expand Up @@ -247,7 +262,7 @@ describe("lintProject", () => {
// Add a non-HTML file
writeFileSync(join(project, "compositions", "readme.txt"), "not html");

const { results } = await lintProject(project);
const { results } = await lintProject(project, undefined, {}, { includeUnmounted: true });

expect(results).toHaveLength(2); // index.html + captions.html, not readme.txt
});
Expand Down Expand Up @@ -390,7 +405,7 @@ describe("audio_file_without_element", () => {
});
writeFileSync(join(project, "song.mp3"), "fake");

const { results } = await lintProject(project);
const { results } = await lintProject(project, undefined, {}, { includeUnmounted: true });

const first = results[0];
expect(first).toBeDefined();
Expand Down Expand Up @@ -444,7 +459,12 @@ describe("audio_src_not_found", () => {
});
// song.mp3 referenced in sub-comp but not on disk

const { totalErrors, results } = await lintProject(project);
const { totalErrors, results } = await lintProject(
project,
undefined,
{},
{ includeUnmounted: true },
);

expect(totalErrors).toBeGreaterThan(0);
const finding = allFindings(results).find((f) => f.code === "audio_src_not_found");
Expand Down Expand Up @@ -510,7 +530,7 @@ describe("audio_src_not_found", () => {
});
// Both reference song.mp3 which doesn't exist

const { results } = await lintProject(project);
const { results } = await lintProject(project, undefined, {}, { includeUnmounted: true });

const findings = results
.flatMap((entry) => entry.result.findings)
Expand Down Expand Up @@ -551,7 +571,7 @@ describe("audio_src_not_found", () => {
const project = makeProject(validHtml(), { "captions.html": subComp });
// No assets/ directory at all.

const { results } = await lintProject(project);
const { results } = await lintProject(project, undefined, {}, { includeUnmounted: true });

const finding = allFindings(results).find((f) => f.code === "audio_src_not_found");
expect(finding).toBeDefined();
Expand Down Expand Up @@ -725,7 +745,7 @@ describe("missing_local_asset", () => {
},
);

const { results } = await lintProject(project);
const { results } = await lintProject(project, undefined, {}, { includeUnmounted: true });

const findings = results
.flatMap((entry) => entry.result.findings)
Expand Down Expand Up @@ -867,7 +887,7 @@ describe("texture_mask_asset_not_found", () => {
'.hf-texture-lava { mask-image: url("masks/missing.png"); }',
);

const { results } = await lintProject(project);
const { results } = await lintProject(project, undefined, {}, { includeUnmounted: true });
const finding = allFindings(results).find(
(item) => item.code === "texture_mask_asset_not_found",
);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/utils/lintProject.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// 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";
export type { ProjectLintOptions, ProjectLintResult } from "@hyperframes/lint";

import type { ProjectLintResult } from "@hyperframes/lint";

Expand Down
4 changes: 2 additions & 2 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export {
LINT_RULE_COUNT,
LINT_RULE_GROUP_COUNTS,
} from "./hyperframeLinter.js";
export { linkedSources, lintProject, shouldBlockRender } from "./project.js";
export type { ProjectLintResult } from "./project.js";
export { linkedSources, lintProject, shouldBlockRender, unmountedNotice } from "./project.js";
export type { ProjectLintOptions, ProjectLintResult } from "./project.js";
export { projectIgnore } from "./projectIgnore.js";
export { audioAttributeShapeError, type AudioAttribute } from "./rules/audioAttributes.js";
Loading
Loading