fix(lint): detect GSAP animations targeting clip elements (tab crash) (#114)

* fix(lint): detect GSAP animations targeting clip elements (tab crash)

The runtime manages clip visibility via inline styles. When GSAP also
writes inline styles on the same element, both systems trigger style
recalculations every frame, creating a runaway loop that crashes the
browser tab.

New rule gsap_animates_clip_element (error severity):
- Builds map of all elements with class="clip" (by id and class)
- Checks if any GSAP selector resolves to a clip element
- Nested selectors like "#overlay .title" are correctly ignored
- Merged into existing GSAP script loop (no redundant parsing)

* fix: remove non-null assertions and add missing test coverage

- Replace `!` assertions with optional chaining in lint.ts and tests
- Add shouldBlockRender tests for --strict-all without --strict
- Add clip element test for class-only detection (no id)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use optional chaining for array access in lintProject tests

TypeScript's strict mode flags array indexing as possibly undefined.
Use optional chaining and fallbacks instead of non-null assertions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-03-30 15:32:41 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent cf0ec5d27d
commit a97dc75702
5 changed files with 179 additions and 17 deletions
+27 -7
View File
@@ -67,7 +67,9 @@ describe("lintProject", () => {
expect(totalErrors).toBe(0);
expect(totalWarnings).toBe(0);
expect(results).toHaveLength(1);
expect(results[0]!.file).toBe("index.html");
const first = results[0];
expect(first).toBeDefined();
expect(first?.file).toBe("index.html");
});
it("detects errors in index.html", () => {
@@ -75,7 +77,9 @@ describe("lintProject", () => {
const { totalErrors, results } = lintProject(project);
expect(totalErrors).toBeGreaterThan(0);
const mediaFinding = results[0]!.result.findings.find((f) => f.code === "media_missing_id");
const first = results[0];
expect(first).toBeDefined();
const mediaFinding = first?.result.findings.find((f) => f.code === "media_missing_id");
expect(mediaFinding).toBeDefined();
});
@@ -86,9 +90,11 @@ describe("lintProject", () => {
const { totalErrors, results } = lintProject(project);
expect(results).toHaveLength(2);
expect(results[1]!.file).toBe("compositions/captions.html");
const second = results[1];
expect(second).toBeDefined();
expect(second?.file).toBe("compositions/captions.html");
expect(totalErrors).toBeGreaterThan(0);
const subFindings = results[1]!.result.findings;
const subFindings = second?.result.findings ?? [];
expect(subFindings.some((f) => f.code === "media_missing_id")).toBe(true);
});
@@ -99,9 +105,13 @@ describe("lintProject", () => {
const { totalErrors, results } = lintProject(project);
expect(results).toHaveLength(2);
const first = results[0];
const second = results[1];
expect(first).toBeDefined();
expect(second).toBeDefined();
// Both files have media_missing_id errors
const rootErrors = results[0]!.result.errorCount;
const subErrors = results[1]!.result.errorCount;
const rootErrors = first?.result.errorCount ?? 0;
const subErrors = second?.result.errorCount ?? 0;
expect(totalErrors).toBe(rootErrors + subErrors);
});
@@ -113,7 +123,9 @@ describe("lintProject", () => {
expect(results).toHaveLength(2);
expect(totalWarnings).toBeGreaterThan(0);
const preloadWarning = results[1]!.result.findings.find((f) => f.code === "media_preload_none");
const second = results[1];
expect(second).toBeDefined();
const preloadWarning = second?.result.findings.find((f) => f.code === "media_preload_none");
expect(preloadWarning).toBeDefined();
});
@@ -166,4 +178,12 @@ describe("shouldBlockRender", () => {
it("--strict-all: does not block when clean", () => {
expect(shouldBlockRender(true, true, 0, 0)).toBe(false);
});
it("--strict-all alone: blocks on errors", () => {
expect(shouldBlockRender(false, true, 1, 0)).toBe(true);
});
it("--strict-all alone: blocks on warnings", () => {
expect(shouldBlockRender(false, true, 0, 1)).toBe(true);
});
});