code-buster 0.11.0

Repository-aware static analysis for AI-assisted development
Documentation
use std::collections::BTreeMap;
use std::sync::LazyLock;

use regex::Regex;

use super::mask_javascript;
use crate::findings::{Finding, Severity};
use crate::rule_catalog;

pub fn analyze(sources: &BTreeMap<String, String>) -> Vec<Finding> {
    let mut findings = Vec::new();
    for (path, source) in sources
        .iter()
        .filter(|(path, _)| path.to_ascii_lowercase().ends_with(".svelte"))
    {
        let source = SvelteSource::new(path, source);
        async_on_mount(&source, &mut findings);
        global_listeners(&source, &mut findings);
        intervals(&source, &mut findings);
        dynamic_html(&source, &mut findings);
        unkeyed_animation(&source, &mut findings);
        module_browser_globals(&source, &mut findings);
    }
    findings
}

fn async_on_mount(source: &SvelteSource<'_>, findings: &mut Vec<Finding>) {
    for matched in ASYNC_ON_MOUNT.find_iter(&source.masked) {
        findings.push(finding(
            "svelte-async-onmount-cleanup",
            source,
            matched.start(),
            "onMount uses an async callback and cannot return cleanup synchronously",
            "high",
        ));
    }
}

fn global_listeners(source: &SvelteSource<'_>, findings: &mut Vec<Finding>) {
    for captures in ADD_EVENT_LISTENER.captures_iter(&source.masked) {
        let target = captures
            .get(1)
            .expect("listener target must exist")
            .as_str();
        let removal = Regex::new(&format!(
            r"\b{}\.removeEventListener\s*\(",
            regex::escape(target)
        ))
        .expect("listener removal regex must compile");
        if removal.is_match(&source.masked) {
            continue;
        }
        let matched = captures.get(0).expect("listener match must exist");
        findings.push(finding(
            "svelte-global-listener-without-cleanup",
            source,
            matched.start(),
            &format!("{target}.addEventListener has no matching removal in this component"),
            "high",
        ));
        break;
    }
}

fn intervals(source: &SvelteSource<'_>, findings: &mut Vec<Finding>) {
    let Some(interval) = SET_INTERVAL.find(&source.masked) else {
        return;
    };
    if CLEAR_INTERVAL.is_match(&source.masked) {
        return;
    }
    findings.push(finding(
        "svelte-interval-without-cleanup",
        source,
        interval.start(),
        "setInterval has no matching clearInterval in this component",
        "high",
    ));
}

fn dynamic_html(source: &SvelteSource<'_>, findings: &mut Vec<Finding>) {
    for captures in DYNAMIC_HTML.captures_iter(&source.masked) {
        let expression = captures
            .get(1)
            .expect("dynamic HTML expression must exist")
            .as_str()
            .trim();
        if expression.is_empty() || TRUSTED_HTML.is_match(expression) {
            continue;
        }
        let matched = captures.get(0).expect("dynamic HTML match must exist");
        findings.push(finding(
            "svelte-dynamic-html",
            source,
            matched.start(),
            &format!("{{@html}} renders dynamic content from `{expression}`"),
            "medium",
        ));
    }
}

fn unkeyed_animation(source: &SvelteSource<'_>, findings: &mut Vec<Finding>) {
    for captures in EACH_START.captures_iter(&source.masked) {
        let header = captures
            .get(1)
            .expect("each header must exist")
            .as_str()
            .trim();
        if KEYED_EACH.is_match(header) {
            continue;
        }
        let matched = captures.get(0).expect("each match must exist");
        let remainder = &source.masked[matched.end()..];
        let Some(close) = remainder.find("{/each}") else {
            continue;
        };
        if !ANIMATE_DIRECTIVE.is_match(&remainder[..close]) {
            continue;
        }
        findings.push(finding(
            "svelte-animate-unkeyed-each",
            source,
            matched.start(),
            "an animated each block has no stable key expression",
            "high",
        ));
    }
}

fn module_browser_globals(source: &SvelteSource<'_>, findings: &mut Vec<Finding>) {
    for opening in MODULE_SCRIPT.find_iter(&source.comment_masked) {
        let remainder = &source.comment_masked[opening.end()..];
        let Some(close) = remainder.find("</script>") else {
            continue;
        };
        let body = &source.masked[opening.end()..opening.end() + close];
        for browser_global in BROWSER_GLOBAL.find_iter(body) {
            let absolute = opening.end() + browser_global.start();
            let line_start = source.masked[..absolute]
                .rfind('\n')
                .map_or(0, |offset| offset + 1);
            let prefix = &source.masked[line_start..absolute];
            if GUARDED_BROWSER_GLOBAL.is_match(prefix) {
                continue;
            }
            findings.push(finding(
                "svelte-module-browser-global",
                source,
                absolute,
                "module script accesses a browser-only global during initialization",
                "high",
            ));
            break;
        }
    }
}

struct SvelteSource<'a> {
    path: &'a str,
    source: &'a str,
    comment_masked: String,
    masked: String,
}

impl<'a> SvelteSource<'a> {
    fn new(path: &'a str, source: &'a str) -> Self {
        Self {
            path,
            source,
            comment_masked: mask_javascript(source, false, true),
            masked: mask_javascript(source, true, true),
        }
    }

    fn line_at(&self, offset: usize) -> usize {
        self.source.as_bytes()[..offset]
            .iter()
            .filter(|byte| **byte == b'\n')
            .count()
            + 1
    }
}

fn finding(
    code: &str,
    source: &SvelteSource<'_>,
    offset: usize,
    message: &str,
    confidence: &str,
) -> Finding {
    let metadata = rule_catalog::metadata(code).expect("Svelte rule metadata must exist");
    Finding {
        code: code.to_owned(),
        severity: Severity::parse(metadata.default_severity)
            .expect("catalog severity must be valid"),
        path: source.path.to_owned(),
        line: source.line_at(offset),
        end_line: 0,
        message: message.to_owned(),
        confidence: confidence.to_owned(),
        why: metadata.why.to_owned(),
        suggestion: metadata.suggestion.to_owned(),
        related_files: Vec::new(),
        snippet: String::new(),
        code_flow: Vec::new(),
    }
}

static ASYNC_ON_MOUNT: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\bonMount\s*\(\s*async\b").unwrap());
static ADD_EVENT_LISTENER: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\b(window|document)\.addEventListener\s*\(").unwrap());
static SET_INTERVAL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\bsetInterval\s*\(").unwrap());
static CLEAR_INTERVAL: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\bclearInterval\s*\(").unwrap());
static DYNAMIC_HTML: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\{@html\s+([^}]+)\}").unwrap());
static TRUSTED_HTML: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"^(?:DOMPurify\.)?sanitize\s*\(|^trusted(?:Html|Markup)\b").unwrap()
});
static EACH_START: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\{#each\s+([^}\n]+)\}").unwrap());
static KEYED_EACH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\([^()]+\)\s*$").unwrap());
static ANIMATE_DIRECTIVE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\banimate:[A-Za-z_$]").unwrap());
static MODULE_SCRIPT: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r#"(?i)<script\b([^>]*(?:\bmodule\b|context\s*=\s*["']module["'])[^>]*)>"#).unwrap()
});
static BROWSER_GLOBAL: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\b(?:window|document|localStorage|sessionStorage)\b").unwrap());
static GUARDED_BROWSER_GLOBAL: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\b(?:typeof|browser\s*(?:&&|\?))\s*$").unwrap());

#[cfg(test)]
mod tests {
    use std::collections::BTreeSet;

    use super::*;

    #[test]
    fn reports_complete_svelte_rule_family() {
        let source = r#"<script module>
  const width = window.innerWidth;
</script>
<script lang="ts">
  import { onMount } from 'svelte';
  import { flip } from 'svelte/animate';
  let content = $state('');
  onMount(async () => {
    window.addEventListener('resize', resize);
    setInterval(refresh, 1000);
    await refresh();
    return () => refresh();
  });
</script>
{#each rows as row}
  <div animate:flip>{row.name}</div>
{/each}
<section>{@html content}</section>"#;
        let findings = analyze(&BTreeMap::from([(
            "src/Panel.svelte".to_owned(),
            source.to_owned(),
        )]));
        assert_eq!(
            findings
                .iter()
                .map(|finding| finding.code.as_str())
                .collect::<BTreeSet<_>>(),
            BTreeSet::from([
                "svelte-animate-unkeyed-each",
                "svelte-async-onmount-cleanup",
                "svelte-dynamic-html",
                "svelte-global-listener-without-cleanup",
                "svelte-interval-without-cleanup",
                "svelte-module-browser-global",
            ])
        );
        assert_eq!(findings.len(), 6);
    }

    #[test]
    fn accepts_cleanup_keys_trusted_html_guards_comments_and_strings() {
        let source = r#"<script module>
  export const prerender = true;
  const width = typeof window;
</script>
<script>
  import { onMount } from 'svelte';
  onMount(() => {
    window.addEventListener('resize', resize);
    const timer = setInterval(refresh, 1000);
    const example = "document.addEventListener('click', click)";
    return () => {
      window.removeEventListener('resize', resize);
      clearInterval(timer);
    };
  });
  // onMount(async () => setInterval(work, 1));
</script>
<!-- {@html content} -->
{#each rows as row (row.id)}
  <div animate:flip>{row.name}</div>
{/each}
<section>{@html sanitize(content)}</section>"#;
        assert!(
            analyze(&BTreeMap::from([(
                "src/Panel.svelte".to_owned(),
                source.to_owned()
            )]))
            .is_empty()
        );
    }
}