diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 6c665035b1..df191863dc 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -807,7 +807,8 @@ export default defineCommand({ // ── Pre-render lint ────────────────────────────────────────────────── { - const lintResult = await lintProject(project.dir); + const explicitEntry = args.composition && args.composition !== "." ? entryFile : undefined; + const lintResult = await lintProject(project.dir, explicitEntry); 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/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts index 0ce2eb0c67..439ba1c202 100644 --- a/packages/cli/src/utils/lintProject.test.ts +++ b/packages/cli/src/utils/lintProject.test.ts @@ -876,6 +876,43 @@ describe("texture_mask_asset_not_found", () => { }); describe("multiple_root_compositions", () => { + it("scopes lint to an explicit render composition entry", async () => { + const project = makeProject(validHtml()); + const standalone = join(project, "standalone.html"); + writeFileSync(standalone, validHtml("standalone")); + + const { totalErrors, results } = await lintProject(project, standalone); + + expect(totalErrors).toBe(0); + expect(results.map((result) => result.file)).toEqual(["standalone.html"]); + expect( + results[0]?.result.findings.find((finding) => finding.code === "multiple_root_compositions"), + ).toBeUndefined(); + }); + + it("reports findings from an explicit render composition entry", async () => { + const project = makeProject(validHtml()); + const standalone = join(project, "standalone.html"); + writeFileSync(standalone, htmlWithMissingMediaId()); + + const { totalErrors, results } = await lintProject(project, standalone); + + expect(totalErrors).toBeGreaterThan(0); + expect(results[0]?.result.findings.some((finding) => finding.code === "media_missing_id")).toBe( + true, + ); + }); + + it("rejects an explicit render composition entry outside the project", async () => { + const project = makeProject(validHtml()); + const outsideDir = tmpProject("outside-entry"); + dirs.push(outsideDir); + const outsideEntry = join(outsideDir, "standalone.html"); + writeFileSync(outsideEntry, validHtml("standalone")); + + await expect(lintProject(project, outsideEntry)).rejects.toThrow(/outside.*project/i); + }); + it("fires when two HTML files have data-composition-id", async () => { const project = makeProject(validHtml()); writeFileSync( diff --git a/packages/lint/src/project.ts b/packages/lint/src/project.ts index 52d828c1db..a23226542f 100644 --- a/packages/lint/src/project.ts +++ b/packages/lint/src/project.ts @@ -180,8 +180,16 @@ function resolveCssAssetCandidates( return resolveLocalAssetCandidates(projectDir, url); } -export async function lintProject(projectDir: string): Promise { - const indexPath = resolve(projectDir, "index.html"); +export async function lintProject( + projectDir: string, + entryFile?: string, +): Promise { + const indexPath = entryFile ? resolve(entryFile) : resolve(projectDir, "index.html"); + if (entryFile && !isWithinProjectRoot(projectDir, indexPath)) { + throw new Error(`Explicit lint entry is outside the project directory: ${entryFile}`); + } + const rootFile = relative(resolve(projectDir), indexPath).replace(/\\/g, "/") || "index.html"; + const rootCompSrcPath = rootFile === "index.html" ? undefined : rootFile; const results: Array<{ file: string; result: HyperframeLintResult }> = []; let totalErrors = 0; let totalWarnings = 0; @@ -190,16 +198,16 @@ export async function lintProject(projectDir: string): Promise { const out: string[] = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { @@ -239,7 +247,7 @@ export async function lintProject(projectDir: string): Promise