cuke-dedup 0.9.0

Static analysis for duplicate and reusable Cucumber step definitions
Documentation
use cuke_dedup::analysis::{analyze, analyze_with_diagnostics};
use cuke_dedup::config::{Config, ConfigOverrides, ReporterKind};
use cuke_dedup::discovery::{SourceFile, SourceLanguage};
use cuke_dedup::model::SourceLocation;
use cuke_dedup::model::{AnalysisResult, Rule, Severity};
use cuke_dedup::modes::{BaselineFile, BASELINE_SCHEMA_VERSION};
use cuke_dedup::reporters::{
    render_html, render_json, render_jsonl, render_sarif, write_terminal, ExecutionMetrics,
    ReportContext,
};
use cuke_dedup::source_adapter::{
    Extraction, ExtractionDiagnostic, ExtractionDiagnosticLevel, SourceAdapter,
    SourceAdapterRegistration, SOURCE_ADAPTER_REGISTRY,
};
use cuke_dedup::typescript;
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;

/// The registered adapter for `language`, for tests that drive extraction directly.
fn registered_adapter(language: SourceLanguage) -> &'static dyn SourceAdapter {
    SOURCE_ADAPTER_REGISTRY
        .iter()
        .map(|registration| registration.adapter)
        .find(|adapter| adapter.language() == language)
        .expect("an adapter is registered for the language")
}

#[test]
fn extraction_session_preserves_public_constructors_auto_traits_and_adapter_defaults() {
    use cuke_dedup::source_adapter::SourceExtractionSession;
    use std::panic::{RefUnwindSafe, UnwindSafe};
    fn assert_auto_traits<T: Send + Sync + Unpin + UnwindSafe + RefUnwindSafe>() {}
    assert_auto_traits::<SourceExtractionSession>();

    struct ExternalAdapter;
    impl SourceAdapter for ExternalAdapter {
        fn name(&self) -> &'static str {
            "external"
        }
        fn language(&self) -> SourceLanguage {
            SourceLanguage::JavaScript
        }
        fn extract(&self, _: &str, _: &SourceFile) -> anyhow::Result<Extraction> {
            anyhow::bail!("external adapter reached")
        }
    }
    let project = tempfile::tempdir().unwrap();
    let file = SourceFile {
        path: project.path().join("steps.js"),
        language: SourceLanguage::JavaScript,
    };
    let adapter: &dyn SourceAdapter = &ExternalAdapter;
    for mut session in [
        SourceExtractionSession::default(),
        SourceExtractionSession::new(project.path()),
        SourceExtractionSession::with_registrations(project.path(), &["Setup".into()]),
    ] {
        assert_eq!(
            adapter
                .extract_with_session("", &file, &mut session)
                .unwrap_err()
                .to_string(),
            "external adapter reached"
        );
    }
}

fn reporter_name(reporter: ReporterKind) -> &'static str {
    match reporter {
        ReporterKind::Terminal => "terminal",
        ReporterKind::Json => "json",
        ReporterKind::Jsonl => "jsonl",
        ReporterKind::Html => "html",
        ReporterKind::Sarif => "sarif",
        _ => "future reporter",
    }
}

#[test]
fn external_callers_can_preserve_partial_results_and_operational_diagnostics() {
    let definitions = typescript::extract(
        "Given(/a{1000000}/, () => work());",
        &SourceFile {
            path: PathBuf::from("steps.ts"),
            language: SourceLanguage::TypeScript,
        },
    )
    .unwrap();
    let directory = tempfile::tempdir().unwrap();
    let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();

    let outcome = analyze_with_diagnostics(definitions.clone(), Vec::new(), &config).unwrap();
    assert_eq!(outcome.result.definitions.len(), 1);
    assert_eq!(outcome.incomplete.len(), 1);
    assert!(outcome.incomplete[0].contains("regex resource limit"));

    let error = analyze(definitions, Vec::new(), &config).unwrap_err();
    assert!(error.to_string().contains("regex resource limit"));
}

#[test]
fn sessionless_file_extraction_uses_the_nearest_package_boundary() {
    let directory = tempfile::tempdir().unwrap();
    fs::write(directory.path().join("package.json"), "{}").unwrap();
    fs::write(
        directory.path().join("support.ts"),
        "export { Given } from '@cucumber/cucumber';\n",
    )
    .unwrap();
    let steps = directory.path().join("nested/deeper/steps.ts");
    fs::create_dir_all(steps.parent().unwrap()).unwrap();
    fs::write(
        &steps,
        "import { Given } from '../../support';\nGiven('nested public API step', () => work());\n",
    )
    .unwrap();

    let definitions = typescript::extract_file(&SourceFile {
        path: steps,
        language: SourceLanguage::TypeScript,
    })
    .unwrap();

    assert_eq!(definitions.len(), 1);
    assert_eq!(definitions[0].matcher, "nested public API step");
}

#[test]
fn extensible_public_outputs_use_stable_constructors() {
    let overrides = ConfigOverrides {
        config_file: None,
        definitions: None,
        features: None,
        exclude: None,
        exclude_defaults: None,
        include_hidden: None,
        reporters: Some(vec![ReporterKind::Json]),
        output: None,
        threshold: None,
        require_features: None,
        no_metrics: None,
        rules: BTreeMap::<Rule, Severity>::new(),
    };
    assert_eq!(overrides.reporters, Some(vec![ReporterKind::Json]));

    let metrics = ExecutionMetrics::new(1, 2, 1.0, 2.0, 3.0);
    assert_eq!(metrics.files_discovered, 3);

    let diagnostic = ExtractionDiagnostic::new(
        ExtractionDiagnosticLevel::Warning,
        SourceLocation::new("steps.ts", 1, 1, 1, 2),
        "dynamic matcher",
    );
    let extraction = Extraction::new(Vec::new(), vec![diagnostic]);
    assert_eq!(extraction.diagnostics.len(), 1);

    let baseline = BaselineFile::new(BTreeMap::from([("finding".to_owned(), 1)]));
    assert_eq!(baseline.fingerprints["finding"], 1);

    let default_baseline = BaselineFile::default();
    assert_eq!(default_baseline.schema_version, BASELINE_SCHEMA_VERSION);
    assert!(default_baseline.fingerprints.is_empty());

    let registration =
        SourceAdapterRegistration::new(".custom", SOURCE_ADAPTER_REGISTRY[0].adapter);
    assert_eq!(registration.suffix, ".custom");

    let all_rules: &[Rule] = Rule::ALL;
    let duplication_rules: &[Rule] = Rule::DUPLICATION_THRESHOLD;
    assert!(all_rules.len() > duplication_rules.len());

    assert_eq!(reporter_name(ReporterKind::Json), "json");
}

#[test]
fn report_context_is_the_single_public_rendering_entry_point() {
    let root = PathBuf::from("/repo");
    let result = AnalysisResult::new(Vec::new(), Vec::new(), Vec::new());
    let context = ReportContext::new(&result, &root, 5.0);

    assert!(render_json(&context)
        .unwrap()
        .contains("\"threshold\": 5.0"));
    assert!(render_jsonl(&context)
        .unwrap()
        .contains("\"type\":\"summary\""));
    assert!(render_html(&context).unwrap().contains("<!doctype html>"));
    assert!(render_sarif(&context)
        .unwrap()
        .contains("\"version\": \"2.1.0\""));

    let mut terminal = Vec::new();
    write_terminal(&context, &mut terminal).unwrap();
    assert!(String::from_utf8(terminal)
        .unwrap()
        .contains("Analyzed 0 definitions"));
}

#[test]
fn configured_registrations_enable_lowercase_global_step_names() {
    use cuke_dedup::source_adapter::SourceExtractionSession;

    let project = tempfile::tempdir().unwrap();
    let file = SourceFile {
        path: project.path().join("steps.ts"),
        language: SourceLanguage::TypeScript,
    };
    let adapter = registered_adapter(SourceLanguage::TypeScript);
    let source = "given('a lowercase global step', () => work());";

    // Lowercase `given`/`when`/`then` are not ambient globals: a bare call with no import and no
    // configuration registers nothing, so the analyzer never invents a step from an unrelated
    // lowercase helper.
    let mut plain = SourceExtractionSession::new(project.path());
    assert!(adapter
        .extract_with_session(source, &file, &mut plain)
        .unwrap()
        .definitions
        .is_empty());

    // The `registrations` configuration is the documented opt-in: naming `given` there enables it
    // as a bare global registration name.
    let mut configured =
        SourceExtractionSession::with_registrations(project.path(), &["given".into()]);
    assert_eq!(
        adapter
            .extract_with_session(source, &file, &mut configured)
            .unwrap()
            .definitions
            .len(),
        1
    );
}

#[test]
fn local_commonjs_and_esm_barrel_imports_resolve_registrations_in_process() {
    use cuke_dedup::source_adapter::SourceExtractionSession;

    let project = tempfile::tempdir().unwrap();
    fs::write(project.path().join("package.json"), "{}").unwrap();
    fs::write(
        project.path().join("barrel.js"),
        "const { Given } = require('@cucumber/cucumber');\nmodule.exports = { Given };\n",
    )
    .unwrap();
    let adapter = registered_adapter(SourceLanguage::JavaScript);
    let file = SourceFile {
        path: project.path().join("steps.js"),
        language: SourceLanguage::JavaScript,
    };
    let extract = |source: &str| {
        let mut session = SourceExtractionSession::new(project.path());
        adapter
            .extract_with_session(source, &file, &mut session)
            .unwrap()
            .definitions
            .len()
    };

    // A project-local barrel resolves whether imported through CommonJS `require` or an ESM
    // specifier — the two importer paths share one resolution helper.
    assert_eq!(
        extract(
            "const { Given: reg } = require('./barrel');\nreg('cjs a', () => {});\nreg('cjs b', () => {});"
        ),
        2
    );
    assert_eq!(
        extract(
            "import { Given as reg } from './barrel';\nreg('esm a', () => {});\nreg('esm b', () => {});"
        ),
        2
    );
    // A `require` with no specifier, and one of an unresolvable local module, register nothing.
    assert_eq!(extract("const missing = require();\n"), 0);
    assert_eq!(
        extract("const { Given: reg } = require('./nope');\nreg('x', () => {});"),
        0
    );
    // Error-recovered declarations with no name or value register nothing and never panic.
    assert_eq!(extract("const = 5;\nvar ;\nlet x;"), 0);
}

#[test]
fn local_barrel_re_exporting_the_bdd_factory_resolves_through_require() {
    use cuke_dedup::source_adapter::SourceExtractionSession;

    let project = tempfile::tempdir().unwrap();
    fs::write(project.path().join("package.json"), "{}").unwrap();
    // A local barrel re-exporting Playwright-BDD's `createBdd` factory. Destructuring it from a
    // local `require` must enter the factory path, exactly as an ESM import would, or the steps it
    // creates are missed.
    fs::write(
        project.path().join("barrel.js"),
        "const { createBdd } = require('playwright-bdd');\nmodule.exports = { createBdd };\n",
    )
    .unwrap();
    let adapter = registered_adapter(SourceLanguage::JavaScript);
    let file = SourceFile {
        path: project.path().join("steps.js"),
        language: SourceLanguage::JavaScript,
    };
    let mut session = SourceExtractionSession::new(project.path());
    let definitions = adapter
        .extract_with_session(
            "const { createBdd } = require('./barrel');\nconst { Given } = createBdd();\nGiven('a step', () => {});\nGiven('a step', () => {});",
            &file,
            &mut session,
        )
        .unwrap()
        .definitions;
    assert_eq!(definitions.len(), 2);
}