tokmd-analysis 1.13.1

Analysis logic and enrichers for tokmd receipts.
Documentation
use super::facts::{
    SyntaxCallSite, SyntaxExport, SyntaxFacts, SyntaxImport, SyntaxRiskSeam, SyntaxSpan,
    SyntaxSymbol,
};
use tree_sitter::Node;

#[must_use]
pub fn extract_typescript_facts(root: Node<'_>, source: &str) -> SyntaxFacts {
    let mut facts = SyntaxFacts::default();
    visit_node(root, source, false, &mut facts);
    facts.normalize();
    facts
}

fn visit_node(node: Node<'_>, source: &str, exported: bool, facts: &mut SyntaxFacts) {
    let kind = node.kind();
    let exported = exported || kind == "export_statement";

    match kind {
        "import_statement" => push_static_import(node, source, facts),
        "function_declaration" | "generator_function_declaration" => {
            push_named_symbol(node, source, "function", exported, facts);
        }
        "class_declaration" => {
            push_named_symbol(node, source, "class", exported, facts);
        }
        "method_definition" | "public_field_definition" => {
            push_named_symbol(node, source, "member", exported, facts);
        }
        "variable_declarator" => {
            push_named_symbol(node, source, "variable", exported, facts);
        }
        "call_expression" => push_call_expression(node, source, facts),
        "new_expression" => push_new_expression(node, source, facts),
        "as_expression" | "type_assertion" => {
            push_risk("risky_cast", node_text(source, node), node, facts);
        }
        "non_null_expression" => {
            push_risk("non_null_assertion", node_text(source, node), node, facts);
        }
        _ => {}
    }

    let mut cursor = node.walk();
    for child in node.children(&mut cursor) {
        visit_node(child, source, exported, facts);
    }
}

fn push_named_symbol(
    node: Node<'_>,
    source: &str,
    kind: &str,
    exported: bool,
    facts: &mut SyntaxFacts,
) {
    let name = node
        .child_by_field_name("name")
        .and_then(|name| text_if_named(source, name))
        .or_else(|| first_identifier_text(source, node))
        .unwrap_or_else(|| {
            if exported {
                "default".to_owned()
            } else {
                "<anonymous>".to_owned()
            }
        });
    let public_surface = exported
        || looks_native_or_binding(&name)
        || looks_native_or_binding(node_text(source, node));
    let span = SyntaxSpan::from_node(node);

    facts.symbols.push(SyntaxSymbol {
        kind: kind.to_owned(),
        name: name.clone(),
        span,
        exported,
        public_surface,
    });

    if exported {
        facts.exports.push(SyntaxExport {
            kind: kind.to_owned(),
            name: name.clone(),
            span,
        });
    }

    if public_surface && looks_native_or_binding(&name) {
        facts.risk_seams.push(SyntaxRiskSeam {
            kind: "native_boundary_hint".to_owned(),
            evidence: name,
            span,
        });
    }
}

fn push_static_import(node: Node<'_>, source: &str, facts: &mut SyntaxFacts) {
    let module = node
        .child_by_field_name("source")
        .and_then(|source_node| cleaned_string(source, source_node))
        .or_else(|| first_string_text(source, node).and_then(|module| cleaned_literal(&module)));
    let imported = named_imports(source, node);

    facts.imports.push(SyntaxImport {
        kind: "static".to_owned(),
        module,
        imported,
        dynamic: false,
        span: SyntaxSpan::from_node(node),
    });
}

fn push_call_expression(node: Node<'_>, source: &str, facts: &mut SyntaxFacts) {
    let callee = node
        .child_by_field_name("function")
        .map(|callee| compact_text(node_text(source, callee)))
        .or_else(|| first_named_child_text(source, node))
        .unwrap_or_else(|| "<unknown>".to_owned());
    let span = SyntaxSpan::from_node(node);
    let dynamic = is_dynamic_callee(&callee);

    facts.call_sites.push(SyntaxCallSite {
        kind: "call".to_owned(),
        callee: callee.clone(),
        dynamic,
        span,
    });

    if callee == "import" {
        let module = first_string_text(source, node).and_then(|module| cleaned_literal(&module));
        facts.imports.push(SyntaxImport {
            kind: "dynamic".to_owned(),
            module,
            imported: Vec::new(),
            dynamic: true,
            span,
        });
        push_risk("dynamic_import", "import(...)", node, facts);
    }

    if dynamic {
        push_risk("dynamic_call", callee.as_str(), node, facts);
    }
    if is_entrypoint_callee(&callee) {
        push_risk("entrypoint", callee.as_str(), node, facts);
    }
    if looks_native_or_binding(&callee) || looks_native_or_binding(node_text(source, node)) {
        push_risk("native_boundary_hint", callee.as_str(), node, facts);
    }
}

fn push_new_expression(node: Node<'_>, source: &str, facts: &mut SyntaxFacts) {
    let callee = node
        .child_by_field_name("constructor")
        .map(|callee| compact_text(node_text(source, callee)))
        .or_else(|| first_named_child_text(source, node))
        .unwrap_or_else(|| "<unknown>".to_owned());
    let span = SyntaxSpan::from_node(node);
    let dynamic = is_dynamic_callee(&callee);

    facts.call_sites.push(SyntaxCallSite {
        kind: "new".to_owned(),
        callee: callee.clone(),
        dynamic,
        span,
    });

    if callee == "Function" {
        push_risk("dynamic_call", "new Function(...)", node, facts);
    }
}

fn push_risk(kind: &str, evidence: &str, node: Node<'_>, facts: &mut SyntaxFacts) {
    facts.risk_seams.push(SyntaxRiskSeam {
        kind: kind.to_owned(),
        evidence: compact_text(evidence),
        span: SyntaxSpan::from_node(node),
    });
}

fn node_text<'source>(source: &'source str, node: Node<'_>) -> &'source str {
    source.get(node.byte_range()).unwrap_or("")
}

fn text_if_named(source: &str, node: Node<'_>) -> Option<String> {
    node.is_named()
        .then(|| compact_text(node_text(source, node)))
}

fn first_named_child_text(source: &str, node: Node<'_>) -> Option<String> {
    let mut cursor = node.walk();
    node.children(&mut cursor)
        .find(|child| child.is_named())
        .map(|child| compact_text(node_text(source, child)))
}

fn first_identifier_text(source: &str, node: Node<'_>) -> Option<String> {
    let mut cursor = node.walk();
    for child in node.children(&mut cursor) {
        if matches!(
            child.kind(),
            "identifier" | "property_identifier" | "type_identifier"
        ) {
            return Some(compact_text(node_text(source, child)));
        }
    }
    None
}

fn first_string_text(source: &str, node: Node<'_>) -> Option<String> {
    if matches!(node.kind(), "string" | "string_fragment") {
        return Some(compact_text(node_text(source, node)));
    }

    let mut cursor = node.walk();
    for child in node.children(&mut cursor) {
        if let Some(value) = first_string_text(source, child) {
            return Some(value);
        }
    }
    None
}

fn cleaned_string(source: &str, node: Node<'_>) -> Option<String> {
    cleaned_literal(node_text(source, node))
}

fn cleaned_literal(text: &str) -> Option<String> {
    let trimmed = text.trim();
    if trimmed.len() >= 2 {
        let first = trimmed.as_bytes()[0];
        let last = trimmed.as_bytes()[trimmed.len() - 1];
        if matches!(first, b'\'' | b'"' | b'`') && first == last {
            return Some(trimmed[1..trimmed.len() - 1].to_owned());
        }
    }
    None
}

fn named_imports(source: &str, node: Node<'_>) -> Vec<String> {
    let mut names = Vec::new();
    collect_import_identifiers(source, node, &mut names);
    names.sort();
    names.dedup();
    names
}

fn collect_import_identifiers(source: &str, node: Node<'_>, names: &mut Vec<String>) {
    if matches!(node.kind(), "identifier" | "property_identifier") {
        let name = compact_text(node_text(source, node));
        if !matches!(name.as_str(), "from" | "import" | "type") {
            names.push(name);
        }
    }

    let mut cursor = node.walk();
    for child in node.children(&mut cursor) {
        if child.kind() != "string" {
            collect_import_identifiers(source, child, names);
        }
    }
}

fn compact_text(text: &str) -> String {
    let mut compact = text.split_whitespace().collect::<Vec<_>>().join(" ");
    if compact.len() > 120 {
        compact.truncate(117);
        compact.push_str("...");
    }
    compact
}

fn is_dynamic_callee(callee: &str) -> bool {
    callee == "import"
        || callee == "eval"
        || callee == "Function"
        || callee == "Reflect.apply"
        || callee.contains('[')
}

fn is_entrypoint_callee(callee: &str) -> bool {
    matches!(callee, "Bun.serve" | "addEventListener")
}

fn looks_native_or_binding(text: &str) -> bool {
    let lower = text.to_ascii_lowercase();
    lower.contains("ffi")
        || lower.contains("dlopen")
        || lower.contains("native")
        || lower.contains("binding")
}