diff --git a/packages/lint/src/hyperframeLinter.ts b/packages/lint/src/hyperframeLinter.ts index 4f95a37402..8d0abae64e 100644 --- a/packages/lint/src/hyperframeLinter.ts +++ b/packages/lint/src/hyperframeLinter.ts @@ -19,6 +19,7 @@ import { textureRules } from "./rules/textures"; import { fontRules } from "./rules/fonts"; import { slideshowRules } from "./rules/slideshow"; import { structureRules } from "./rules/structure"; +import { shaderRules } from "./rules/shaders"; // Rules are grouped by source module so a timing can be attributed to // something a human can act on. Individual rules stay anonymous: an @@ -46,6 +47,7 @@ const RULE_GROUPS: ReadonlyArray<{ { group: "fonts", rules: fontRules }, { group: "slideshow", rules: slideshowRules }, { group: "structure", rules: structureRules }, + { group: "shaders", rules: shaderRules }, ]; /** @@ -74,6 +76,7 @@ function dedupeKeyFor(finding: HyperframeLintFinding): string { finding.selector || "", finding.elementId || "", finding.message, + ...(finding.code === "shader_unbounded_cosine_pow" ? [finding.line, finding.column] : []), ].join("|"); } @@ -136,6 +139,7 @@ export async function lintHyperframeHtml( const findings = rawFindings.map((finding) => { if ( finding.line !== undefined || + finding.code === "shader_unbounded_cosine_pow" || finding.code === "invalid_inline_script_syntax" || finding.code === "non_deterministic_code" || [ diff --git a/packages/lint/src/project.ts b/packages/lint/src/project.ts index f26a049189..dc48a5429a 100644 --- a/packages/lint/src/project.ts +++ b/packages/lint/src/project.ts @@ -23,6 +23,7 @@ import { lintVideoMediaStartPastEof, } from "./hevcPreviewLint.js"; import { lintHyperframeHtml } from "./hyperframeLinter.js"; +import { lintProjectShaderScripts } from "./projectShaders.js"; import type { HyperframeLintFinding, HyperframeLintResult, @@ -256,6 +257,7 @@ export async function lintProject( } const projectFindings = [ + ...lintProjectShaderScripts(projectDir, allHtmlSources), ...lintProjectAudioFiles(projectDir, allHtmlSources), ...lintAudioSrcNotFound(projectDir, allHtmlSources), ...lintMissingLocalAsset(projectDir, allHtmlSources), diff --git a/packages/lint/src/projectShaders.test.ts b/packages/lint/src/projectShaders.test.ts new file mode 100644 index 0000000000..d71a61f30c --- /dev/null +++ b/packages/lint/src/projectShaders.test.ts @@ -0,0 +1,87 @@ +import { afterEach, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { lintProject } from "./project"; + +const dirs: string[] = []; +const unsafe = "pow(1.0-max(dot(vN,vV),0.0),1.6)"; +const code = (expression = unsafe) => `const fragmentShader=\`varying vec3 vN,vV; +void main(){ +float f=${expression}; +gl_FragColor=vec4(vec3(f),1.0); +}\`;`; + +function project(files: Record) { + const root = mkdtempSync(join(tmpdir(), "hf-shader-lint-")); + dirs.push(root); + for (const [file, text] of Object.entries(files)) { + mkdirSync(dirname(join(root, file)), { recursive: true }); + writeFileSync(join(root, file), text); + } + return root; +} + +async function warnings(root: string) { + const report = await lintProject(root); + return report.results + .flatMap(({ result }) => result.findings) + .filter((finding) => finding.code === "shader_unbounded_cosine_pow"); +} + +afterEach(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); + dirs.length = 0; +}); + +it("finds the external orb shader at its original line once across compositions", async () => { + const root = project({ + "index.html": '', + "compositions/world.html": '', + "assets/world-glsl.js": "// authored\n" + code(), + }); + expect(await warnings(root)).toMatchObject([ + { + file: join(root, "assets/world-glsl.js"), + line: 4, + column: 9, + severity: "warning", + }, + ]); + writeFileSync(join(root, "assets/world-glsl.js"), code("pow(1.0-clamp(dot(vN,vV),0.0,1.0),1.6)")); + expect(await warnings(root)).toEqual([]); +}); + +it("resolves nested, root-relative and URL-encoded local scripts", async () => { + const root = project({ + "index.html": '', + "assets/glow one.mjs": code(), + "compositions/world.html": '', + "compositions/glow.cjs": code(), + }); + expect((await warnings(root)).map((finding) => finding.file)).toEqual([ + join(root, "assets/glow one.mjs"), + join(root, "compositions/glow.cjs"), + ]); +}); + +it("skips remote, missing, vendor and commented-out scripts without fetching", async () => { + const root = project({ + "index.html": ` + + + `, + "unused.js": code(), + "vendor/lib.js": code(), + "lib.min.js": code(), + "data.js": code(), + }); + expect(await warnings(root)).toEqual([]); +}); + +it("does not read a linked shader outside the project through a symlink", async () => { + const outside = project({ "secret.js": code() }); + const root = project({ "index.html": '' }); + symlinkSync(join(outside, "secret.js"), join(root, "outside.js")); + expect(await warnings(root)).toEqual([]); +}); diff --git a/packages/lint/src/projectShaders.ts b/packages/lint/src/projectShaders.ts new file mode 100644 index 0000000000..33e5ce588f --- /dev/null +++ b/packages/lint/src/projectShaders.ts @@ -0,0 +1,73 @@ +import { realpathSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { + isWithinProjectRoot, + readProjectFile, + resolveExistingLocalAsset, +} from "@hyperframes/parsers/asset-resolution"; +import { shaderDomainOffsets } from "./shaderDomains"; +import { shaderDomainWarning } from "./rules/shaders"; +import { sourcePosition } from "./sourceCoordinates"; +import type { HyperframeLintFinding } from "./types"; +import { parseHtmlStructure, readDecodedAttr } from "./utils"; + +interface HtmlSource { + html: string; + compSrcPath?: string; +} + +function isAuthoredScript(path: string): boolean { + return ( + /\.(?:js|mjs|cjs)$/i.test(path) && + !/(?:\.min\.js$|(?:^|[/\\])(?:vendor|node_modules)[/\\])/i.test(path) + ); +} + +function localScript(projectDir: string, src: string, compSrcPath?: string): string | undefined { + if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(src)) return; + const besideFile = src.startsWith("/") ? src : join(dirname(compSrcPath ?? "index.html"), src); + const asset = resolveExistingLocalAsset(projectDir, besideFile); + if (!asset) return; + try { + const file = realpathSync(asset.resolved); + if (!isWithinProjectRoot(realpathSync(projectDir), file)) return; + if (!isAuthoredScript(relative(projectDir, file))) return; + return file; + } catch { + return; + } +} + +function linkedScripts(projectDir: string, { html, compSrcPath }: HtmlSource): string[] { + const files: string[] = []; + for (const script of parseHtmlStructure(html).scripts) { + const tag = ``, { filePath: "index.html" }); + return result.findings.filter((finding) => finding.code === "shader_unbounded_cosine_pow"); +} + +describe("shader numeric domains", () => { + it("warns at the authored Crescendo orb expression inside a static shader", async () => { + const result = await findings(`const fragmentShader = \`${shader(unsafe)}\`;`); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ severity: "warning", file: "index.html", line: 3 }); + expect(result[0]?.fixHint).toContain("clamp"); + }); + + it("accepts the one-line clamp that fixes the affected Mac", async () => { + expect( + await findings( + `const fragmentShader = \`${shader("pow(1.0-clamp(dot(vN,vV),0.0,1.0),1.6)")}\`;`, + ), + ).toEqual([]); + }); + + it.each([ + "pow(1.-dot(normalize(vN), normalize(vV)), 1.6)", + "pow((1.0 - max(0.0, dot(vN, vV))), 2.0)", + "pow(1.0 - dot(vN, vV), exponent)", + ])("warns for an unbounded cosine: %s", async (expression) => { + expect(await findings(`const fragmentShader = \`${shader(expression)}\`;`)).toHaveLength(1); + }); + + it.each([ + "pow(max(1.0-dot(vN,vV),0.0),1.6)", + "pow(clamp(1.0-dot(vN,vV),0.0,1.0),1.6)", + "pow(max(dot(vN,vV),0.0),2.0)", + "pow(1.0-clamp(dot(vN,vV),0.0,1.0),2.0)", + ])("accepts a bounded base: %s", async (expression) => { + expect(await findings(`const fragmentShader = \`${shader(expression)}\`;`)).toEqual([]); + }); + + it("ignores JavaScript comments, ordinary maths, and shader comments", async () => { + expect( + await findings(`// const fragmentShader = \`${shader(unsafe)}\`; + const f = Math.pow(1-Math.max(dot(vN,vV),0),1.6); + const fragmentShader = \`${shader("1.0 /* " + unsafe + " */")}\`;`), + ).toEqual([]); + }); + + it("reports both authored locations rather than collapsing identical warnings", async () => { + const result = await findings(`const a=\`${shader(unsafe)}\`; + const b=\`${shader(unsafe)}\`;`); + expect(result.map((finding) => finding.line)).toEqual([3, 8]); + }); + + it("checks native GLSL script blocks", async () => { + const result = await lintHyperframeHtml( + ``, + ); + expect( + result.findings.filter((finding) => finding.code === "shader_unbounded_cosine_pow"), + ).toHaveLength(1); + }); + + it("checks escaped static strings without claiming unmapped source coordinates", async () => { + const result = await findings(`const fragmentShader=${JSON.stringify(shader(unsafe))};`); + expect(result).toMatchObject([{ file: "index.html" }]); + expect(result[0]?.line).toBeUndefined(); + }); + + it("does not claim exact offsets after template CRLF normalization", async () => { + const result = await findings( + `const fragmentShader = \`${shader(unsafe).replaceAll("\n", "\r\n")}\`;`, + ); + expect(result).toHaveLength(1); + expect(result[0]?.line).toBeUndefined(); + expect(result[0]?.column).toBeUndefined(); + }); + + it("leaves dynamic shader assembly and malformed JavaScript to other checks", async () => { + expect(await findings("const shader = `void main(){${expression}}`;")).toEqual([]); + expect(await findings("const shader = `")).toEqual([]); + }); +}); diff --git a/packages/lint/src/rules/shaders.ts b/packages/lint/src/rules/shaders.ts new file mode 100644 index 0000000000..886ae996f4 --- /dev/null +++ b/packages/lint/src/rules/shaders.ts @@ -0,0 +1,29 @@ +import type { LintContext, HyperframeLintFinding } from "../context"; +import { shaderDomainOffsets } from "../shaderDomains"; +import { readAttr } from "../utils"; + +export function shaderDomainWarning(): HyperframeLintFinding { + return { + code: "shader_unbounded_cosine_pow", + severity: "warning", + message: + "Shader pow(1 - dot(...), ...) can receive a negative base when the cosine exceeds 1, including through GPU rounding. Undefined GLSL results can spread through glow or transmission textures as black artifacts.", + fixHint: + "Bound the cosine on both sides: pow(1.0 - clamp(dot(normal, view), 0.0, 1.0), exponent). A lower-only max(dot(...), 0.0) does not bound the upper end. Inspect the affected frames on the target GPU.", + }; +} + +export const shaderRules = [ + ({ scripts, locate }: LintContext): HyperframeLintFinding[] => + scripts.flatMap((script) => { + const tag = `