Skip to content

fix(lint): stop three rules matching code a composition only displays - #2811

Draft
xuanruli wants to merge 1 commit into
mainfrom
fix/lint-displayed-code
Draft

fix(lint): stop three rules matching code a composition only displays#2811
xuanruli wants to merge 1 commit into
mainfrom
fix/lint-displayed-code

Conversation

@xuanruli

Copy link
Copy Markdown
Contributor

The defect

Three error-severity rules matched code that a composition merely renders as on-screen text. Code-explainer videos are a first-class use case here (there is a /pr-to-video skill), and one lint error makes check return emptyBrowserResult() (packages/cli/src/utils/checkPipeline.ts:1061) — so each of these did not just fail the build, it silently discarded the entire runtime / layout / motion / WCAG-contrast audit.

rule why it fired
requestanimationframe_in_composition scanned comment-stripped source, but stripJsComments preserves string literals by design → const CODE = "requestAnimationFrame(step);" gated
template_literal_selector stripped nothing at all → a JS comment warning against the pattern gated
split_data_attribute_selector scanned raw style/script text → a CSS comment showing the wrong form gated

The fix

New stripJsStringLiterals blanks string, template and regex literal contents, preserving delimiters, length and newline positions (so a reported snippet still slices the real source) and keeping template ${…} expressions, which are code.

Two decisions worth reviewing explicitly:

  • Not a blanket "strip strings". split_data_attribute_selector's genuine defect lives inside a query string (gsap.to('[data-composition-id="x" data-start="0"]', …)), so blanking strings would delete its true positive. It gets comment stripping only — JS comments for scripts, and a new string-aware stripCssComments for <style>, because a slide printing comment markers as content otherwise pairs two of them and deletes the real rules in between.
  • Regex literals are tracked. Their own quotes would otherwise open a phantom string and blank the rest of the script. /[^a-z']/g ships in registry/components/caption-neon-accent/caption-neon-accent.html:284 today; without this, 8179 tail characters collapsed to 6 non-blank. Regex-vs-division is decided from an incrementally carried previous token — re-scanning the accumulated output per slash was quadratic (a 966 KB composition did not finish lint in 600 s). If the scan ends mid-literal the source is returned untouched, so a parse this scanner cannot model degrades to the prior behaviour rather than blanking real code on a gating rule.

Verification

  • 211 first-party compositions (registry/blocks + registry/components + registry/examples, each staged outside registry/ so isRegistrySourceFile cannot exempt it, at --verbose so info-level counts): 1277 findings, byte-identical base vs fix.
  • acorn differential oracle (built by a reviewer): tokenize every real inline script, then assert the strippers only alter characters inside true string/template/regex/comment ranges. 247 scripts, 0 violations.
  • Performance: 341 KB composition with an inlined vendor bundle 0.12 s → 0.15 s; 447 KB compiled fixture 0.19 s → 0.21 s; 2.0 MB fixture 0.77 s → 0.82 s. Linearity is pinned by a test with both a ratio bound and an absolute ceiling.
  • Fuzz, 300 000 inputs: length and newline-count invariants both clean, no throws.
  • packages/lint 535 tests pass. Each fix is pinned separately and verified non-vacuous: reverting the word-boundary flag fails exactly 2 tests, the property-name flag exactly 5, the ++/-- guard exactly 1.
  • Two // fallow-ignore-next-line complexity suppressions, matching how stripJsComments in the same file is already handled. A decomposition attempt reduced one function CRITICAL→HIGH but added a fourth flagged function, so it did not pass the gate either; keeping the simpler shape was preferred. (I suspected the decomposition's per-character state object would cost measurable time on the hot path — measured it, and it did not.)

What this does NOT fix

A composition that displays code is still hard-blocked by other routes. On a realistic "determinism PR" explainer, 5 errors remain after this patch — 4 of them non_deterministic_code, which is the single highest-probability blocker for displayed code:

code severity file:line stripping today
non_deterministic_code error packages/lint/src/rules/core.ts:616 comments only
gsap_timeline_not_registered error packages/lint/src/rules/gsap.ts:1697 none (raw script.content)
imperative_media_control error packages/lint/src/rules/media.ts:146,180 none
scene_layer_missing_visibility_kill error packages/lint/src/rules/gsap.ts:1650 comments only
gsap_timeline_registered_before_async_build error packages/lint/src/rules/gsap.ts:1694 comments only
gsap_infinite_repeat and the scanScriptsForRegexMatches family warning packages/lint/src/rules/gsap.ts:588 stripComments option only — wants a stripStrings one

split_data_attribute_selector also still fires on a split selector inside a displayed JS string. Its true positive and that false positive are the same text in the same position, so this is not fixable by changing what the rule scans. The correct end state is to gate the script-side match on selector-argument position — deliberately left out, because it changes what the rule matches (a different risk class), and because a naive version would newly suppress a real defect: const SEL = '[data-composition-id="x" data-start="0"]'; el.querySelectorAll(SEL); is caught today.

Two inputs are worse than baseline, both narrow:

  1. const u = /https:\/\//; followed on the same line by a real split selector — stripJsComments has no regex awareness, so the escaped \/\/ reads as a line comment and eats the rest of the line. Line-bounded, and newly inherited only by split_data_attribute_selector (this rule previously scanned raw text).
  2. An unquoted url(x/*y.png) followed by a real split selector — blanks to the end of the block. Per the CSS spec a url-token runs to ), so this is a genuine divergence; no realistic instance could be constructed (base64 has no *).

Follow-up ticket, with a shipped reproducer: the regex tracking belongs in stripJsComments too, or the two passes should merge. registry/examples/play-mode/compositions/stats.html:277 contains a punctuation-class regex holding a backtick, which puts regex-blind stripJsComments into phantom-template mode — 9 of 9 subsequent // comments in that script go unstripped. No behaviour change today, but it is a live reproducer, not a hypothetical.

Known limitations, recorded

  • Two brace-less if (…) /re/ statements bracketing the target balance the phantom quote, so the mid-literal fallback cannot save it — unbounded span, judged unreachable in practice; ) staying division-position is the right default.
  • window["requestAnimation" + "Frame"] is unmatched on both trees (pre-existing).
  • Blanking an unterminated CSS /* to EOF is more correct than baseline: browsers consume to EOF, so a selector after it is dead and baseline's finding was the false one.

Found by empirical false-negative / false-positive testing of the composition.ts rule family; full report and the ~90 fixtures are separate. Reviewed adversarially twice; this patch was blocked 8 times before approval, and every blocking finding was a case where it silently suppressed a real defect rather than over-reporting.

🤖 Generated with Claude Code

`requestanimationframe_in_composition` scanned comment-stripped source, but
`stripJsComments` preserves string literals by design, so
`const CODE = "requestAnimationFrame(step);"` produced a gating error.
`template_literal_selector` stripped nothing at all — a JS comment warning
against the pattern tripped it. `split_data_attribute_selector` scanned raw
style and script text, so a CSS comment showing the wrong form tripped it.
Code-explainer compositions are a first-class use case here, and one lint
error makes `check` return an empty browser result, so each of these also
silently discarded the whole runtime/layout/motion/contrast audit.

Add `stripJsStringLiterals`: blanks string, template and regex literal
contents, keeping delimiters, length and newline positions so a reported
snippet still slices the real source, and keeping template `${…}`
expressions because those are code. Regex literals are tracked because
their own quotes would otherwise open a phantom string and blank the rest
of the script — `/[^a-z']/g` ships in registry/components today. Regex vs
division is decided from an incrementally carried previous token, not by
re-scanning the output, which was quadratic. If the scan ends mid-literal
the source is returned untouched, so a parse this scanner cannot model
degrades to the caller's prior behaviour instead of blanking real code.

`split_data_attribute_selector` gets comment stripping only: its genuine
defect lives inside a query string, so blanking strings would remove the
true positive. Its CSS side uses a new string-aware `stripCssComments`,
because a slide printing comment markers as content otherwise pairs two of
them and deletes the real rules in between.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant