fix(lint): warn on unbounded cosine powers in shaders (#5640)

This commit is contained in:
Miguel Ángel
2026-10-15 05:40:38 +00:00
committed by GitHub
parent 8e340d220e
commit d4c8d76fff
10 changed files with 416 additions and 2 deletions
+4
View File
@@ -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" ||
[
+2
View File
@@ -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),
+87
View File
@@ -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<string, string>) {
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": '<script src="assets/world-glsl.js?build=1"></script>',
"compositions/world.html": '<script src="../assets/world-glsl.js"></script>',
"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": '<script src="/assets/glow%20one.mjs"></script>',
"assets/glow one.mjs": code(),
"compositions/world.html": '<script src="glow.cjs"></script>',
"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": `<!-- <script src="unused.js"></script> -->
<script src="https://example.invalid/shader.js"></script>
<script src="missing.js"></script><script src="vendor/lib.js"></script>
<script src="lib.min.js"></script><script type="application/json" src="data.js"></script>`,
"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": '<script src="outside.js"></script>' });
symlinkSync(join(outside, "secret.js"), join(root, "outside.js"));
expect(await warnings(root)).toEqual([]);
});
+73
View File
@@ -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 = `<script ${script.attrs}>`;
const type = readDecodedAttr(tag, "type") ?? "";
if (type && !/^(?:module|(?:text|application)\/javascript)$/i.test(type)) continue;
const src = readDecodedAttr(tag, "src");
if (!src) continue;
const file = localScript(projectDir, src, compSrcPath);
if (file) files.push(file);
}
return files;
}
/** Follow directly linked authored scripts once; never fetch URLs or traverse dependencies. */
export function lintProjectShaderScripts(
projectDir: string,
sources: ReadonlyArray<HtmlSource>,
): HyperframeLintFinding[] {
const files = new Set(sources.flatMap((source) => linkedScripts(projectDir, source)));
const findings: HyperframeLintFinding[] = [];
for (const file of files) {
const read = readProjectFile(file);
if (read.kind !== "file") continue;
for (const offset of shaderDomainOffsets(read.text)) {
findings.push({
...shaderDomainWarning(),
...(offset === undefined ? { file } : sourcePosition(read.text, offset, file)),
});
}
}
return findings;
}
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
const unsafe = "pow(1.0-max(dot(vN,vV),0.0),1.6)";
const shader = (expression: string) => `varying vec3 vN, vV;
void main(){
float f=${expression};
gl_FragColor=vec4(vec3(f),1.0);
}`;
async function findings(script: string) {
const result = await lintHyperframeHtml(`<script>${script}</script>`, { 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(
`<script type="x-shader/x-fragment">${shader(unsafe)}</script>`,
);
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([]);
});
});
+29
View File
@@ -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 = `<script ${script.attrs}>`;
if (readAttr(tag, "src") !== null) return [];
const type = readAttr(tag, "type") ?? "";
const glsl = /^(?:x-shader\/x-(?:fragment|vertex)|text\/glsl)$/i.test(type);
if (type && !glsl && !/^(?:module|(?:text|application)\/javascript)$/i.test(type)) return [];
return shaderDomainOffsets(script.content, glsl).map((offset) => ({
...shaderDomainWarning(),
...locate(script, offset),
}));
}),
];
+114
View File
@@ -0,0 +1,114 @@
import { parse, parseExpressionAt, type CallExpression, type Expression } from "acorn";
import { simple } from "acorn-walk";
interface ShaderSource {
text: string;
start: number;
exactOffsets: boolean;
}
function withoutComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\/|\/\/[^\r\n]*/g, (text) => text.replace(/[^\r\n]/g, " "));
}
function staticShaders(source: string): ShaderSource[] {
const shaders: ShaderSource[] = [];
try {
const ast = parse(source, {
ecmaVersion: "latest",
sourceType: "module",
allowReturnOutsideFunction: true,
});
simple(ast, {
Literal(node) {
if (typeof node.value !== "string") return;
shaders.push({
text: node.value,
start: node.start + 1,
exactOffsets: source.slice(node.start + 1, node.end - 1) === node.value,
});
},
TemplateLiteral(node) {
if (node.expressions.length !== 0) return;
const value = node.quasis[0]?.value;
if (value?.cooked == null) return;
shaders.push({
text: value.cooked,
start: node.start + 1,
exactOffsets: source.slice(node.start + 1, node.end - 1) === value.cooked,
});
},
});
} catch {
// Syntax diagnostics own invalid JavaScript. Do not guess from comments or prose.
}
return shaders.filter(({ text }) => /\bvoid\s+main\s*\(/.test(withoutComments(text)));
}
function isNumber(node: Expression | undefined, value: number): boolean {
return node?.type === "Literal" && node.value === value;
}
function isCall(node: Expression | undefined, name: string): boolean {
return (
node?.type === "CallExpression" &&
node.callee.type === "Identifier" &&
node.callee.name === name
);
}
function unboundedCosine(node: Expression): boolean {
if (node.type !== "CallExpression") return false;
if (isCall(node, "dot")) return node.arguments.length === 2;
if (!isCall(node, "max") || node.arguments.length !== 2) return false;
const a = argumentAt(node, 0);
const b = argumentAt(node, 1);
return zeroFlooredDot(a, b) || zeroFlooredDot(b, a);
}
function argumentAt(call: CallExpression, index: number): Expression | undefined {
const node = call.arguments[index];
return node?.type === "SpreadElement" ? undefined : node;
}
function zeroFlooredDot(a: Expression | undefined, b: Expression | undefined): boolean {
return isCall(a, "dot") && isNumber(b, 0);
}
function unsafeCosinePower(call: CallExpression): boolean {
if (call.arguments.length !== 2) return false;
const [base] = call.arguments;
return (
base?.type === "BinaryExpression" &&
base.operator === "-" &&
base.left.type !== "PrivateIdentifier" &&
isNumber(base.left, 1) &&
unboundedCosine(base.right)
);
}
function unsafePowOffsets(source: string): number[] {
const offsets: number[] = [];
const masked = withoutComments(source);
for (const match of masked.matchAll(/\bpow\s*\(/g)) {
try {
const expression = parseExpressionAt(masked, match.index, { ecmaVersion: "latest" });
simple(expression, {
CallExpression(node) {
if (node.start === match.index && unsafeCosinePower(node)) offsets.push(node.start);
},
});
} catch {
// This is a narrow expression check, not a GLSL parser or compiler.
}
}
return offsets;
}
/** Static GLSL blocks and complete static JS shader strings; no data-flow inference. */
export function shaderDomainOffsets(source: string, glsl = false): Array<number | undefined> {
const shaders = glsl ? [{ text: source, start: 0, exactOffsets: true }] : staticShaders(source);
return shaders.flatMap(({ text, start, exactOffsets }) =>
unsafePowOffsets(text).map((offset) => (exactOffsets ? start + offset : undefined)),
);
}
+2 -2
View File
@@ -22,7 +22,7 @@
"files": 31
},
"hyperframes-animation": {
"hash": "a5d09375a50f71c8",
"hash": "1c91404cdf888540",
"files": 122
},
"hyperframes-audio": {
@@ -30,7 +30,7 @@
"files": 8
},
"hyperframes-cli": {
"hash": "aeb7794b96d9efa9",
"hash": "a2f100fa1e0c2db6",
"files": 11
},
"hyperframes-core": {
@@ -145,6 +145,18 @@ Verify each cut on the last frame before it and the first frame after it, not on
A `MeshStandardMaterial` lit by one `PointLight` or `DirectionalLight`, with no ambient light, environment map or emissive color, gets no light on its unlit side. When the camera faces that light, the mesh shows only that side and renders as a flat black cutout. Add a fill that stands in for the environment's bounce light, such as a `HemisphereLight` in the colors of the surrounding glow. If the key light's brightness changes over time, drive both lights from the same source at a fixed ratio, tuned by eye. Their intensity units differ, so do not copy the key light's number onto the fill.
## Shader Numeric Domains
Clamp a cosine before subtracting it from one and passing it to GLSL `pow`:
```glsl
float rim = pow(1.0 - clamp(dot(normal, view), 0.0, 1.0), 1.6);
```
`max(dot(normal, view), 0.0)` only protects the lower end. Interpolation and GPU rounding can put the dot product above one even when vectors were normalized at vertices. A negative `pow` base is undefined in GLSL, including with integer exponents. Invalid color values from an opaque glow shader can enter glass transmission textures and spread through mipmaps or bloom; inspect the source shaders before disabling glass or replacing bad pixels with black.
`hyperframes lint` warns with `shader_unbounded_cosine_pow` for this direct cosine-power pattern in static shader strings and GLSL script blocks. Project lint also reads directly linked local authored `.js`, `.mjs`, and `.cjs` files, excluding minified/vendor scripts. Dynamic shader assembly and imported modules are outside this narrow check. A clean lint or software-renderer frame does not certify GPU arithmetic: inspect the reported times and before/after frames on the affected hardware in both preview and export.
## Good Uses
- Deterministic 3D objects, product spins, particles with seeded data, and shader plates.
@@ -174,3 +186,4 @@ npx hyperframes check
- Why `data-duration` is required here specifically (no auto-inference for this adapter): `packages/core/src/runtime/init.ts` (`resolveAdapterDurationFloorSeconds`) and the CSS/WAAPI/Lottie adapters' `getInferredDurationSeconds`, which the `three` adapter deliberately does not implement.
- Three.js `WebGLRenderer` docs: https://threejs.org/docs/pages/WebGLRenderer.html
- Three.js `AnimationMixer.setTime()` docs: https://threejs.org/docs/pages/AnimationMixer.html
- GLSL `pow` domain: https://docs.vulkan.org/glsl/latest/chapters/builtinfunctions.html
+1
View File
@@ -106,6 +106,7 @@ Treat tiny unstyled content, canvas-sized icons, missing hero elements, or timel
- Use one `HYPERFRAMES_RUN_ID` for all commands in the same verification loop.
- When disk is tight, run `npx hyperframes clean` (`--dry-run` to list first); it removes what dead renders left and idle caches that rebuild themselves, never outputs, sources or anything a running render uses. Write disposable QC frames to a temp dir, then remove only that owned directory. Managed snapshot runs retain the newest 10 plus current/latest/rendered evidence; active captures and runs younger than 24 hours are protected. Copy intentional before/after evidence outside managed storage before it ages out. Never delete `.hyperframes` wholesale: it also holds project history.
- Use `--strict`, `--strict-all`, and `--strict-variables` when the corresponding warnings, variables, or CI conditions must gate the render.
- For `shader_unbounded_cosine_pow`, clamp the cosine to `[0, 1]` before `pow(1 - cosine, exponent)`; a lower-only `max` is insufficient. Follow `/hyperframes-animation` → `adapters/three.md` for the source fix and affected-GPU preview/export verification. Lint only recognizes static shader expressions, and software rendering can miss hardware-specific invalid pixels.
- JSON paths redact the home directory as `$HOME`; do not try to reverse the redaction.
- When a hosted cloud project approaches or exceeds the 200 MB upload limit, use `cloud render --dry-run --json` and follow the `.hyperframesignore` investigation in `references/cloud.md`. Never ignore an asset merely because it is large.
- Never render merely because checks pass. Pause at the final preview and wait for approval.