code-buster 0.11.0

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

use regex::Regex;

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

pub fn analyze(context: &SourceAnalysisContext<'_>) -> Vec<Finding> {
    let pixi_v8 = is_pixi_v8(context.auxiliary_files());
    let mut findings = Vec::new();
    for (path, source) in context
        .sources()
        .iter()
        .filter(|(path, _)| is_javascript_path(path))
    {
        let source = PixiSource::new(path, source);
        if pixi_v8 {
            legacy_interactive(&source, &mut findings);
            legacy_cache(&source, &mut findings);
            unawaited_init(&source, &mut findings);
            numeric_ticker_delta(&source, &mut findings);
        }
        ticker_removal(&source, &mut findings);
        frame_allocations(&source, &mut findings);
    }
    findings
}

fn is_javascript_path(path: &str) -> bool {
    let path = path.to_ascii_lowercase();
    [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"]
        .iter()
        .any(|extension| path.ends_with(extension))
}

fn is_pixi_v8(auxiliary_files: &BTreeMap<String, String>) -> bool {
    auxiliary_files.iter().any(|(path, source)| {
        path.to_ascii_lowercase().ends_with("package.json") && PIXI_V8_DEPENDENCY.is_match(source)
    })
}

fn legacy_interactive(source: &PixiSource<'_>, findings: &mut Vec<Finding>) {
    for matched in LEGACY_INTERACTIVE.find_iter(&source.masked) {
        findings.push(finding(
            "pixijs-v8-legacy-interactive",
            source,
            matched.start(),
            "legacy interactive=true is used with PixiJS v8",
            "high",
        ));
    }
}

fn legacy_cache(source: &PixiSource<'_>, findings: &mut Vec<Finding>) {
    for matched in LEGACY_CACHE.find_iter(&source.masked) {
        findings.push(finding(
            "pixijs-v8-cache-as-bitmap",
            source,
            matched.start(),
            "cacheAsBitmap is a legacy API in PixiJS v8",
            "high",
        ));
    }
}

fn unawaited_init(source: &PixiSource<'_>, findings: &mut Vec<Finding>) {
    let applications = APPLICATION_DECLARATION
        .captures_iter(&source.masked)
        .filter_map(|captures| captures.get(1).map(|name| name.as_str()))
        .collect::<Vec<_>>();
    for application in applications {
        for captures in INIT_CALL.captures_iter(&source.masked) {
            let name = captures.get(1).expect("init receiver must exist").as_str();
            if name != application {
                continue;
            }
            let matched = captures.get(0).expect("init call must exist");
            let line_start = source.masked[..matched.start()]
                .rfind('\n')
                .map_or(0, |offset| offset + 1);
            if AWAIT_PREFIX.is_match(&source.masked[line_start..matched.start()]) {
                continue;
            }
            findings.push(finding(
                "pixijs-v8-unawaited-application-init",
                source,
                matched.start(),
                &format!("{name}.init is not awaited"),
                "high",
            ));
        }
    }
}

fn numeric_ticker_delta(source: &PixiSource<'_>, findings: &mut Vec<Finding>) {
    for captures in INLINE_TICKER_CALLBACK.captures_iter(&source.masked) {
        let parameter = captures
            .get(1)
            .expect("ticker callback parameter must exist")
            .as_str();
        let matched = captures.get(0).expect("ticker callback must exist");
        let tail_end = floor_char_boundary(
            &source.masked,
            (matched.end() + 600).min(source.masked.len()),
        );
        let tail = &source.masked[matched.end()..tail_end];
        if TICKER_TIMING.captures_iter(tail).any(|property| {
            property
                .get(1)
                .is_some_and(|receiver| receiver.as_str() == parameter)
        }) {
            continue;
        }
        let used_as_number = NUMERIC_ASSIGNMENT.captures_iter(tail).any(|operation| {
            operation
                .get(1)
                .or_else(|| operation.get(2))
                .is_some_and(|operand| operand.as_str() == parameter)
        });
        if !used_as_number {
            continue;
        }
        findings.push(finding(
            "pixijs-v8-numeric-ticker-delta",
            source,
            matched.start(),
            &format!("ticker callback parameter `{parameter}` is used as a numeric delta"),
            "high",
        ));
    }
}

fn ticker_removal(source: &PixiSource<'_>, findings: &mut Vec<Finding>) {
    let removals = TICKER_REMOVE
        .captures_iter(&source.masked)
        .filter_map(|captures| {
            let receiver = captures.get(1)?.as_str();
            let handler = captures.get(2)?.as_str();
            Some((receiver, handler))
        })
        .collect::<BTreeSet<_>>();
    for captures in TICKER_ADD.captures_iter(&source.masked) {
        let receiver = captures
            .get(1)
            .expect("ticker receiver must exist")
            .as_str();
        let handler = captures.get(2).expect("ticker handler must exist").as_str();
        if removals.contains(&(receiver, handler)) {
            continue;
        }
        let matched = captures.get(0).expect("ticker add must exist");
        findings.push(finding(
            "pixijs-ticker-listener-without-removal",
            source,
            matched.start(),
            &format!("{receiver}.ticker.add({handler}) has no matching removal"),
            "medium",
        ));
    }
}

fn frame_allocations(source: &PixiSource<'_>, findings: &mut Vec<Finding>) {
    for callback in FRAME_CALLBACK.find_iter(&source.masked) {
        let Some(opening) = source.masked[callback.start()..]
            .find('{')
            .map(|offset| callback.start() + offset)
        else {
            continue;
        };
        let Some(close) = matching_brace(&source.masked, opening) else {
            continue;
        };
        let Some(created) = DISPLAY_OBJECT_ALLOCATION.captures(&source.masked[opening + 1..close])
        else {
            continue;
        };
        let object_type = created
            .get(1)
            .expect("display object type must exist")
            .as_str();
        let matched = created
            .get(0)
            .expect("display object allocation must exist");
        findings.push(finding(
            "pixijs-frame-loop-allocation",
            source,
            opening + 1 + matched.start(),
            &format!("ticker callback allocates a new {object_type} every frame"),
            "high",
        ));
    }
}

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

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

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

fn matching_brace(source: &str, opening: usize) -> Option<usize> {
    let mut depth = 0usize;
    for (offset, byte) in source.as_bytes()[opening..].iter().enumerate() {
        if *byte == b'{' {
            depth += 1;
        } else if *byte == b'}' {
            depth -= 1;
            if depth == 0 {
                return Some(opening + offset);
            }
        }
    }
    None
}

fn floor_char_boundary(value: &str, mut index: usize) -> usize {
    while !value.is_char_boundary(index) {
        index -= 1;
    }
    index
}

fn finding(
    code: &str,
    source: &PixiSource<'_>,
    offset: usize,
    message: &str,
    confidence: &str,
) -> Finding {
    let metadata = rule_catalog::metadata(code).expect("PixiJS 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 PIXI_V8_DEPENDENCY: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r#"["'](?:pixi\.js|@pixi/[^"']+)["']\s*:\s*["'][^"']*\b8(?:\.|["'])"#).unwrap()
});
static LEGACY_INTERACTIVE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\.[ \t]*interactive\s*=\s*true\b").unwrap());
static LEGACY_CACHE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\.cacheAsBitmap\b").unwrap());
static APPLICATION_DECLARATION: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*new\s+Application\b").unwrap()
});
static INIT_CALL: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\b([A-Za-z_$][\w$]*)\.init\s*\(").unwrap());
static AWAIT_PREFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\bawait\s*$").unwrap());
static INLINE_TICKER_CALLBACK: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"\.ticker\.add\s*\(\s*\(?\s*([A-Za-z_$][\w$]*)\s*\)?\s*=>").unwrap()
});
static TICKER_TIMING: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"\b([A-Za-z_$][\w$]*)\.(?:deltaTime|deltaMS|elapsedMS)\b").unwrap()
});
static NUMERIC_ASSIGNMENT: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"(?:[+*\-/]=\s*([A-Za-z_$][\w$]*)\b|\b([A-Za-z_$][\w$]*)\s*[*\-/])").unwrap()
});
static TICKER_ADD: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"\b([A-Za-z_$][\w$]*)\.ticker\.add\s*\(\s*([A-Za-z_$][\w$]*)\s*[,)]").unwrap()
});
static TICKER_REMOVE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"\b([A-Za-z_$][\w$]*)\.ticker\.remove\s*\(\s*([A-Za-z_$][\w$]*)\s*[,)]").unwrap()
});
static FRAME_CALLBACK: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\.ticker\.add\s*\([^=]*=>\s*\{").unwrap());
static DISPLAY_OBJECT_ALLOCATION: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\bnew\s+(Graphics|Text|Sprite|Container)\s*\(").unwrap());

#[cfg(test)]
mod tests {
    use super::*;

    fn run(source: &str, version: &str) -> Vec<Finding> {
        let sources = BTreeMap::from([("src/game.ts".to_owned(), source.to_owned())]);
        let auxiliary = BTreeMap::from([(
            "package.json".to_owned(),
            format!(r#"{{"dependencies":{{"pixi.js":"{version}"}}}}"#),
        )]);
        let empty_sources = BTreeMap::new();
        let empty_paths = BTreeSet::new();
        analyze(&SourceAnalysisContext::with_auxiliary(
            &sources,
            &empty_sources,
            &empty_paths,
            false,
            &auxiliary,
        ))
    }

    #[test]
    fn reports_complete_pixijs_rule_family() {
        let source = r#"import { Application, Graphics, Text } from 'pixi.js';
const app = new Application();
app.init({ background: '#000' });
button.interactive = true;
world.cacheAsBitmap = true;
function update() {}
app.ticker.add(update);
app.ticker.add((delta) => {
  player.x += delta;
  const graphics = new Graphics();
  const label = new Text({ text: 'score' });
  stage.addChild(graphics, label);
});"#;
        let findings = run(source, "^8.0.0");
        assert_eq!(
            findings
                .iter()
                .map(|finding| finding.code.as_str())
                .collect::<BTreeSet<_>>(),
            BTreeSet::from([
                "pixijs-frame-loop-allocation",
                "pixijs-ticker-listener-without-removal",
                "pixijs-v8-cache-as-bitmap",
                "pixijs-v8-legacy-interactive",
                "pixijs-v8-numeric-ticker-delta",
                "pixijs-v8-unawaited-application-init",
            ])
        );
        assert_eq!(findings.len(), 6);
    }

    #[test]
    fn accepts_v8_apis_cleanup_and_retained_objects() {
        let source = r#"import { Application, Graphics } from 'pixi.js';
const app = new Application();
await app.init({ background: '#000' });
button.eventMode = 'static';
world.cacheAsTexture();
const graphics = new Graphics();
function update(ticker) {
  player.x += ticker.deltaTime;
  graphics.x = player.x;
}
app.ticker.add(update);
app.ticker.remove(update);
app.ticker.add((ticker) => {
  player.y += ticker.deltaTime;
});
// sprite.interactive = true;
const note = 'world.cacheAsBitmap = true';"#;
        assert!(run(source, "^8.0.0").is_empty());
    }

    #[test]
    fn ignores_incomplete_ticker_calls() {
        let source = r#"app.ticker.remove(
app.ticker.remove(update
app.ticker.add(
app.ticker.add(update"#;
        assert!(run(source, "^8.0.0").is_empty());
    }

    #[test]
    fn gates_migration_rules_on_explicit_v8_dependency() {
        let source = r#"const app = new Application();
app.init({});
sprite.interactive = true;
world.cacheAsBitmap = true;
app.ticker.add((delta) => { sprite.x += delta; });"#;
        assert!(run(source, "^7.4.0").is_empty());
    }
}