Skip to main content

fix_engine_js_fix/
lib.rs

1//! JS/TS/JSX/TSX language-specific fix operations.
2//!
3//! Implements [`LanguageFixProvider`] for the JavaScript/TypeScript ecosystem:
4//! - Skips `node_modules/` paths
5//! - Deduplicates ES import specifiers after renames
6//! - Removes JSX attributes (props) using syntax-aware regex
7//! - Extracts matched text from JSX/React incident variables
8//! - Manages `package.json` dependencies
9//! - Resolves ecosystem dependency versions via npm registry
10//! - Resolves transitive dependency conflicts from lockfiles
11
12mod lockfile;
13
14use fix_engine::language::LanguageFixProvider;
15use fix_engine_core::*;
16use konveyor_core::incident::Incident;
17use std::path::{Path, PathBuf};
18
19/// Language fix provider for JavaScript/TypeScript/JSX/TSX files.
20pub struct JsFixProvider;
21
22impl JsFixProvider {
23    pub fn new() -> Self {
24        Self
25    }
26}
27
28impl Default for JsFixProvider {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34impl LanguageFixProvider for JsFixProvider {
35    fn should_skip_path(&self, path: &Path) -> bool {
36        // Skip node_modules — these are updated via package.json
37        // version bumps, not by patching source directly.
38        // Note: src/vendor/ is NOT skipped — vendored source code
39        // (e.g., forked libraries) is compiled as part of the project
40        // and needs migration alongside the rest of the codebase.
41        path.components().any(|c| c.as_os_str() == "node_modules")
42    }
43
44    fn post_process_lines(&self, lines: &mut [String]) {
45        dedup_import_specifiers(lines);
46    }
47
48    fn plan_remove_attribute(
49        &self,
50        rule_id: &str,
51        incident: &Incident,
52        file_path: &Path,
53    ) -> Option<PlannedFix> {
54        plan_remove_prop(rule_id, incident, file_path)
55    }
56
57    fn plan_ensure_dependency(
58        &self,
59        rule_id: &str,
60        incident: &Incident,
61        package: &str,
62        new_version: &str,
63        file_path: &Path,
64    ) -> Vec<PlannedFix> {
65        plan_ensure_npm_dependency(rule_id, incident, package, new_version, file_path)
66    }
67
68    fn get_matched_text(&self, incident: &Incident) -> String {
69        get_matched_text_from_incident(incident)
70    }
71
72    fn get_matched_text_for_rename(
73        &self,
74        incident: &Incident,
75        mappings: &[RenameMapping],
76    ) -> String {
77        get_matched_text_for_rename_from_incident(incident, mappings)
78    }
79
80    fn is_whole_file_rename(&self, incident: &Incident) -> bool {
81        // Component/import renames (detected via importedName variable) need
82        // whole-file scanning since JSX usage of the component appears on many
83        // lines beyond the import: opening tags, closing tags, type references.
84        incident.variables.contains_key("importedName")
85    }
86
87    fn pre_apply(&self, project_root: &Path) -> Option<Box<dyn std::any::Any>> {
88        // For yarn projects, capture the baseline set of unmet peer dep names
89        // BEFORE any edits are written. This lets post_apply diff against the
90        // baseline and only install peers that are newly introduced by our
91        // version updates — not pre-existing intentionally-unmet ones (e.g.,
92        // host-provided shared modules like react-redux in console plugins).
93        if !project_root.join("yarn.lock").exists() {
94            return None;
95        }
96
97        tracing::info!("Capturing baseline peer dependency warnings before applying fixes");
98        let baseline = capture_yarn_missing_peer_names(project_root);
99        tracing::info!(
100            count = baseline.len(),
101            peers = ?baseline,
102            "Baseline unmet peer dependencies captured"
103        );
104        Some(Box::new(baseline))
105    }
106
107    fn post_apply(
108        &self,
109        project_root: &Path,
110        modified_files: &[std::path::PathBuf],
111        pre_state: Option<Box<dyn std::any::Any>>,
112    ) -> anyhow::Result<()> {
113        // Check if any package.json was modified — if so, run install to
114        // regenerate the lockfile and node_modules.
115        let any_package_json = modified_files
116            .iter()
117            .any(|p| p.file_name().and_then(|f| f.to_str()) == Some("package.json"));
118
119        if !any_package_json {
120            return Ok(());
121        }
122
123        tracing::info!("package.json was modified, running install to sync lockfile");
124
125        if project_root.join("yarn.lock").exists() {
126            // Extract the baseline peer dep names captured by pre_apply
127            let baseline = pre_state
128                .and_then(|s| s.downcast::<std::collections::HashSet<String>>().ok())
129                .map(|b| *b)
130                .unwrap_or_default();
131            run_yarn_install_and_resolve_peers(project_root, &baseline);
132        } else if project_root.join("pnpm-lock.yaml").exists() {
133            run_pnpm_install(project_root);
134        } else {
135            run_npm_install(project_root);
136        }
137
138        Ok(())
139    }
140}
141
142// ── Post-apply install helpers ──────────────────────────────────────────
143
144/// Run `yarn install` and return the set of missing peer dependency names
145/// from YN0002 warnings. Used to capture a baseline before edits are
146/// applied, so that post-apply can diff and only install newly-introduced peers.
147fn capture_yarn_missing_peer_names(project_root: &Path) -> std::collections::HashSet<String> {
148    let output = std::process::Command::new("yarn")
149        .args(["install"])
150        .env("YARN_ENABLE_SCRIPTS", "false")
151        .current_dir(project_root)
152        .output();
153
154    match output {
155        Ok(o) => {
156            let stdout = String::from_utf8_lossy(&o.stdout);
157            parse_yarn_missing_peer_deps(&stdout)
158                .into_iter()
159                .map(|p| p.peer_name)
160                .collect()
161        }
162        Err(e) => {
163            tracing::warn!(
164                "yarn install could not be executed for baseline capture: {}",
165                e
166            );
167            std::collections::HashSet::new()
168        }
169    }
170}
171
172/// Run `yarn install`, parse peer dependency warnings (YN0002), and install
173/// any missing peers that are *newly* introduced by our edits.
174///
175/// `baseline_peers` is the set of peer dep names that were already unmet
176/// before any edits were applied. Only peers NOT in this baseline set are
177/// installed, preventing accidental installation of host-provided packages
178/// (like `react-redux` in OpenShift console plugins) or other pre-existing
179/// intentionally-unmet peers.
180///
181/// Yarn berry does not auto-install peer dependencies and has no config to
182/// enable it. We capture its output, parse the YN0002 warning lines to
183/// extract the names of missing peer packages, then run `yarn add -D` for
184/// the newly-introduced ones.
185fn run_yarn_install_and_resolve_peers(
186    project_root: &Path,
187    baseline_peers: &std::collections::HashSet<String>,
188) {
189    tracing::info!("Running yarn install (scripts disabled)");
190
191    // Yarn berry (v2+) doesn't support --ignore-scripts; use the env var instead.
192    // Yarn classic (v1) supports both the flag and the env var.
193    let output = std::process::Command::new("yarn")
194        .args(["install"])
195        .env("YARN_ENABLE_SCRIPTS", "false")
196        .current_dir(project_root)
197        .output();
198
199    let output = match output {
200        Ok(o) => o,
201        Err(e) => {
202            tracing::warn!("yarn install could not be executed: {}", e);
203            return;
204        }
205    };
206
207    if !output.status.success() {
208        let stderr = String::from_utf8_lossy(&output.stderr);
209        tracing::warn!("yarn install failed: {}", stderr.trim());
210    }
211
212    // Yarn berry writes warnings to stdout. Parse YN0002 lines for missing peers.
213    let stdout = String::from_utf8_lossy(&output.stdout);
214    let all_missing_peers = parse_yarn_missing_peer_deps(&stdout);
215
216    // Filter to only peers that are NEW (not in the baseline). Pre-existing
217    // unmet peers are intentionally absent (e.g., host-provided shared modules
218    // like react-redux, redux, redux-thunk in OpenShift console plugins).
219    let missing_peers: Vec<_> = all_missing_peers
220        .into_iter()
221        .filter(|p| !baseline_peers.contains(&p.peer_name))
222        .collect();
223
224    if missing_peers.is_empty() {
225        tracing::info!("yarn install completed, no newly-introduced missing peer dependencies");
226        return;
227    }
228
229    tracing::info!(
230        new_count = missing_peers.len(),
231        baseline_count = baseline_peers.len(),
232        "Filtered peer deps: {} new (out of {} total warnings, {} were pre-existing)",
233        missing_peers.len(),
234        missing_peers.len() + baseline_peers.len(),
235        baseline_peers.len(),
236    );
237
238    // Build version-qualified install specs by looking up each peer's
239    // required version range from the requesting package's peerDependencies
240    // in node_modules. This prevents installing incompatible latest versions.
241    let mut install_specs: Vec<String> = Vec::new();
242    let mut seen = std::collections::HashSet::new();
243
244    for peer in &missing_peers {
245        if !seen.insert(peer.peer_name.clone()) {
246            continue;
247        }
248
249        match lookup_peer_dep_version(project_root, &peer.requested_by, &peer.peer_name) {
250            Some(version_range) => {
251                tracing::info!(
252                    peer = %peer.peer_name,
253                    version = %version_range,
254                    requested_by = %peer.requested_by,
255                    "Resolved peer dep version range from requesting package"
256                );
257                install_specs.push(format!("{}@{}", peer.peer_name, version_range));
258            }
259            None => {
260                tracing::warn!(
261                    peer = %peer.peer_name,
262                    requested_by = %peer.requested_by,
263                    "Could not resolve peer dep version; skipping to avoid installing incompatible version"
264                );
265            }
266        }
267    }
268
269    if install_specs.is_empty() {
270        tracing::info!("No peer dependencies with resolved versions to install");
271        return;
272    }
273
274    tracing::info!(
275        count = install_specs.len(),
276        specs = ?install_specs,
277        "Installing missing peer dependencies with resolved versions"
278    );
279
280    // Use -D (--dev) so peer deps land in devDependencies, not dependencies.
281    // These are transitive peer requirements from dev tooling packages
282    // (e.g., @patternfly/react-component-groups needs react-drag-drop),
283    // not production dependencies the consumer ships.
284    let add_result = std::process::Command::new("yarn")
285        .args(["add", "-D"])
286        .args(&install_specs)
287        .env("YARN_ENABLE_SCRIPTS", "false")
288        .current_dir(project_root)
289        .output();
290
291    match add_result {
292        Ok(o) if o.status.success() => {
293            tracing::info!("Successfully installed missing peer dependencies");
294        }
295        Ok(o) => {
296            let stderr = String::from_utf8_lossy(&o.stderr);
297            tracing::warn!("yarn add for peer dependencies failed: {}", stderr.trim());
298        }
299        Err(e) => {
300            tracing::warn!("yarn add could not be executed: {}", e);
301        }
302    }
303}
304
305/// A missing peer dependency detected from yarn's YN0002 warnings.
306#[derive(Debug, Clone)]
307struct MissingPeerDep {
308    /// The missing peer package name (e.g., "victory")
309    peer_name: String,
310    /// The package that requires it (e.g., "@patternfly/react-charts")
311    requested_by: String,
312}
313
314/// Parse yarn berry output for YN0002 (missing peer dependency) warnings.
315///
316/// Yarn berry emits lines like:
317/// ```text
318/// ➤ YN0002: @patternfly/react-charts@npm:8.4.1 doesn't provide victory (p1a2b3), requested by ...
319/// ```
320///
321/// The output contains ANSI escape codes which are stripped before matching.
322/// Returns a list of `MissingPeerDep` with both the peer name and the
323/// requesting package, so the caller can look up the required version range.
324fn parse_yarn_missing_peer_deps(output: &str) -> Vec<MissingPeerDep> {
325    // Strip ANSI escape codes: ESC[ followed by parameters and a letter
326    let ansi_re = regex::Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").expect("valid regex");
327    let stripped = ansi_re.replace_all(output, "");
328
329    // Match YN0002 lines. Two formats exist:
330    //   Package-level: YN0002: <pkg>@npm:<ver> doesn't provide <peer> (<hash>), requested by <requester>.
331    //   Workspace-level: YN0002: │ <workspace>@workspace:. doesn't provide <peer> (<hash>), requested by <requester>.
332    // Both requester and peer name can be scoped (e.g., @scope/pkg).
333    // Yarn 4.6.0 uses a "│ " (box-drawing vertical bar) separator after the
334    // warning code in some output formats (e.g., workspace peer dep warnings).
335    //
336    // For workspace-level warnings, the first name is the workspace (e.g.,
337    // "pipelines-console-plugin") which won't exist in node_modules. We also
338    // capture the "requested by" package at the end of the line (group 3) so
339    // `lookup_peer_dep_version` can find the actual package that declares the
340    // peer dependency.
341    let peer_re = regex::Regex::new(
342        r"YN0002: (?:│ )?(@?[^@\s]+)@\S+ doesn't provide (@?[^\s(]+) \([^)]+\),? ?(?:requested by (@?[^\s.]+))?",
343    )
344    .expect("valid regex");
345
346    let mut seen = std::collections::HashSet::new();
347    peer_re
348        .captures_iter(&stripped)
349        .filter_map(|cap| {
350            let provider = cap[1].to_string();
351            let peer_name = cap[2].to_string();
352            // Prefer the "requested by" package (group 3) when available,
353            // since the provider field for workspace-level warnings is the
354            // workspace name (not in node_modules). For package-level warnings,
355            // the provider IS the requesting package, so fall back to it.
356            let requested_by = cap
357                .get(3)
358                .map(|m| m.as_str().to_string())
359                .unwrap_or(provider);
360            // Deduplicate by peer_name — use the first requester encountered
361            seen.insert(peer_name.clone()).then_some(MissingPeerDep {
362                peer_name,
363                requested_by,
364            })
365        })
366        .collect()
367}
368
369/// Look up the required version range for a peer dependency from the
370/// requesting package's `peerDependencies` in `node_modules`.
371///
372/// Returns the version range string (e.g., `"^37.3.6"`) if found.
373fn lookup_peer_dep_version(
374    project_root: &Path,
375    requested_by: &str,
376    peer_name: &str,
377) -> Option<String> {
378    let pkg_json_path = project_root
379        .join("node_modules")
380        .join(requested_by)
381        .join("package.json");
382
383    let content = std::fs::read_to_string(&pkg_json_path).ok()?;
384    let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
385
386    parsed
387        .get("peerDependencies")?
388        .get(peer_name)?
389        .as_str()
390        .map(|s| s.to_string())
391}
392
393/// Run `pnpm install` with auto-install-peers enabled via env var.
394///
395/// pnpm supports `auto-install-peers` (default true since v8) but we set
396/// the env var explicitly to ensure it works on older pnpm versions too.
397fn run_pnpm_install(project_root: &Path) {
398    tracing::info!("Running pnpm install --ignore-scripts (with auto-install-peers)");
399
400    let output = std::process::Command::new("pnpm")
401        .args(["install", "--ignore-scripts"])
402        .env("npm_config_auto_install_peers", "true")
403        .current_dir(project_root)
404        .output();
405
406    match output {
407        Ok(o) if o.status.success() => {
408            tracing::info!("pnpm install completed successfully");
409        }
410        Ok(o) => {
411            let stderr = String::from_utf8_lossy(&o.stderr);
412            tracing::warn!("pnpm install failed: {}", stderr.trim());
413        }
414        Err(e) => {
415            tracing::warn!("pnpm install could not be executed: {}", e);
416        }
417    }
418}
419
420/// Run `npm install`. npm v7+ auto-installs peer dependencies by default.
421fn run_npm_install(project_root: &Path) {
422    tracing::info!("Running npm install --ignore-scripts --no-audit --no-fund");
423
424    let output = std::process::Command::new("npm")
425        .args(["install", "--ignore-scripts", "--no-audit", "--no-fund"])
426        .current_dir(project_root)
427        .output();
428
429    match output {
430        Ok(o) if o.status.success() => {
431            tracing::info!("npm install completed successfully");
432        }
433        Ok(o) => {
434            let stderr = String::from_utf8_lossy(&o.stderr);
435            tracing::warn!("npm install failed: {}", stderr.trim());
436        }
437        Err(e) => {
438            tracing::warn!("npm install could not be executed: {}", e);
439        }
440    }
441}
442
443// -- JSX prop removal --
444
445fn plan_remove_prop(rule_id: &str, incident: &Incident, file_path: &Path) -> Option<PlannedFix> {
446    let line = incident.line_number?;
447    let prop_name = incident
448        .variables
449        .get("propName")
450        .and_then(|v| v.as_str())?;
451
452    // Read the actual file line to construct a precise removal edit.
453    let source = std::fs::read_to_string(file_path).ok()?;
454    let all_lines: Vec<&str> = source.lines().collect();
455    let line_idx = (line as usize).saturating_sub(1);
456    let file_line = all_lines.get(line_idx)?;
457    let trimmed = file_line.trim();
458
459    // If the entire line is just the prop (common in formatted JSX), remove it.
460    if trimmed.starts_with(prop_name) {
461        let depth = bracket_depth(file_line);
462        if depth == 0 {
463            // Single-line prop -- safe to remove just this line
464            Some(PlannedFix {
465                edits: vec![TextEdit {
466                    line,
467                    old_text: file_line.to_string(),
468                    new_text: String::new(),
469                    rule_id: rule_id.to_string(),
470                    description: format!("Remove prop '{}' (entire line)", prop_name),
471                    replace_all: false,
472                }],
473                confidence: FixConfidence::High,
474                source: FixSource::Pattern,
475                rule_id: rule_id.to_string(),
476                file_uri: incident.file_uri.clone(),
477                line,
478                description: format!("Remove prop '{}'", prop_name),
479            })
480        } else {
481            // Multi-line prop value -- scan forward to find where brackets balance.
482            let mut cumulative_depth = depth;
483            let mut end_idx = line_idx;
484            for (i, subsequent_line) in all_lines.iter().enumerate().skip(line_idx + 1) {
485                cumulative_depth += bracket_depth(subsequent_line);
486                end_idx = i;
487                if cumulative_depth <= 0 {
488                    break;
489                }
490            }
491
492            if cumulative_depth > 0 {
493                return Some(PlannedFix {
494                    edits: vec![],
495                    confidence: FixConfidence::Low,
496                    source: FixSource::Pattern,
497                    rule_id: rule_id.to_string(),
498                    file_uri: incident.file_uri.clone(),
499                    line,
500                    description: format!(
501                        "Remove prop '{}' (unbalanced brackets, manual)",
502                        prop_name
503                    ),
504                });
505            }
506
507            // Remove all lines from prop start through closing bracket
508            let mut edits = Vec::new();
509            for i in line_idx..=end_idx {
510                if let Some(l) = all_lines.get(i) {
511                    edits.push(TextEdit {
512                        line: (i + 1) as u32,
513                        old_text: l.to_string(),
514                        new_text: String::new(),
515                        rule_id: rule_id.to_string(),
516                        description: format!(
517                            "Remove prop '{}' (line {} of multi-line)",
518                            prop_name,
519                            i - line_idx + 1
520                        ),
521                        replace_all: false,
522                    });
523                }
524            }
525
526            Some(PlannedFix {
527                edits,
528                confidence: FixConfidence::High,
529                source: FixSource::Pattern,
530                rule_id: rule_id.to_string(),
531                file_uri: incident.file_uri.clone(),
532                line,
533                description: format!(
534                    "Remove prop '{}' ({} lines)",
535                    prop_name,
536                    end_idx - line_idx + 1
537                ),
538            })
539        }
540    } else {
541        // Prop is inline with other content -- try to remove just the prop fragment.
542        let prop_re = regex::Regex::new(&format!(
543            r#"\s+{prop_name}(?:=\{{[^}}]*\}}|="[^"]*"|='[^']*'|=\{{.*?\}})?"#
544        ))
545        .ok()?;
546
547        if let Some(m) = prop_re.find(file_line) {
548            if bracket_depth(m.as_str()) != 0 {
549                return Some(PlannedFix {
550                    edits: vec![],
551                    confidence: FixConfidence::Low,
552                    source: FixSource::Pattern,
553                    rule_id: rule_id.to_string(),
554                    file_uri: incident.file_uri.clone(),
555                    line,
556                    description: format!("Remove prop '{}' (multi-line inline, manual)", prop_name),
557                });
558            }
559
560            Some(PlannedFix {
561                edits: vec![TextEdit {
562                    line,
563                    old_text: m.as_str().to_string(),
564                    new_text: String::new(),
565                    rule_id: rule_id.to_string(),
566                    description: format!("Remove prop '{}'", prop_name),
567                    replace_all: false,
568                }],
569                confidence: FixConfidence::High,
570                source: FixSource::Pattern,
571                rule_id: rule_id.to_string(),
572                file_uri: incident.file_uri.clone(),
573                line,
574                description: format!("Remove prop '{}'", prop_name),
575            })
576        } else {
577            Some(PlannedFix {
578                edits: vec![],
579                confidence: FixConfidence::Low,
580                source: FixSource::Pattern,
581                rule_id: rule_id.to_string(),
582                file_uri: incident.file_uri.clone(),
583                line,
584                description: format!("Remove prop '{}' (manual)", prop_name),
585            })
586        }
587    }
588}
589
590// -- Import deduplication --
591
592/// Deduplicate import specifiers on lines that look like ES import statements.
593fn dedup_import_specifiers(lines: &mut [String]) {
594    let import_re = regex::Regex::new(r"^(\s*import\s+\{)([^}]+)(\}\s*from\s+.*)$").unwrap();
595
596    for line in lines.iter_mut() {
597        if let Some(caps) = import_re.captures(line) {
598            let prefix = caps.get(1).unwrap().as_str();
599            let specifiers_str = caps.get(2).unwrap().as_str();
600            let suffix = caps.get(3).unwrap().as_str();
601
602            let specifiers: Vec<&str> = specifiers_str
603                .split(',')
604                .map(|s| s.trim())
605                .filter(|s| !s.is_empty())
606                .collect();
607
608            let mut seen = std::collections::HashSet::new();
609            let deduped: Vec<&str> = specifiers
610                .into_iter()
611                .filter(|s| seen.insert(s.to_string()))
612                .collect();
613
614            let new_specifiers = format!(" {} ", deduped.join(", "));
615            let new_line = format!("{}{}{}", prefix, new_specifiers, suffix);
616
617            if new_line != *line {
618                *line = new_line;
619            }
620        }
621    }
622}
623
624// -- Bracket depth --
625
626/// Count net bracket/brace depth change for a line.
627fn bracket_depth(line: &str) -> i32 {
628    let mut depth: i32 = 0;
629    let mut in_single_quote = false;
630    let mut in_double_quote = false;
631    let mut in_backtick = false;
632    let mut prev = '\0';
633    for ch in line.chars() {
634        match ch {
635            '\'' if !in_double_quote && !in_backtick && prev != '\\' => {
636                in_single_quote = !in_single_quote
637            }
638            '"' if !in_single_quote && !in_backtick && prev != '\\' => {
639                in_double_quote = !in_double_quote
640            }
641            '`' if !in_single_quote && !in_double_quote && prev != '\\' => {
642                in_backtick = !in_backtick
643            }
644            '(' | '{' | '[' if !in_single_quote && !in_double_quote && !in_backtick => depth += 1,
645            ')' | '}' | ']' if !in_single_quote && !in_double_quote && !in_backtick => depth -= 1,
646            _ => {}
647        }
648        prev = ch;
649    }
650    depth
651}
652
653// -- Incident variable extraction --
654
655/// Extract the matched text from incident variables.
656fn get_matched_text_from_incident(incident: &Incident) -> String {
657    for key in &[
658        "propName",
659        "componentName",
660        "importedName",
661        "className",
662        "variableName",
663    ] {
664        if let Some(serde_json::Value::String(s)) = incident.variables.get(*key) {
665            return s.clone();
666        }
667    }
668    String::new()
669}
670
671/// Get the matched text, considering both prop names and prop values.
672fn get_matched_text_for_rename_from_incident(
673    incident: &Incident,
674    mappings: &[RenameMapping],
675) -> String {
676    let prop_name = get_matched_text_from_incident(incident);
677
678    if mappings.iter().any(|m| m.old == prop_name) {
679        return prop_name;
680    }
681
682    if let Some(serde_json::Value::String(val)) = incident.variables.get("propValue") {
683        if mappings.iter().any(|m| m.old == val.as_str()) {
684            return val.clone();
685        }
686    }
687
688    if let Some(serde_json::Value::Array(vals)) = incident.variables.get("propObjectValues") {
689        for v in vals {
690            if let serde_json::Value::String(s) = v {
691                if mappings.iter().any(|m| m.old == s.as_str()) {
692                    return s.clone();
693                }
694            }
695        }
696    }
697
698    prop_name
699}
700
701// -- npm dependency management (package.json) --
702
703/// Walk up the directory tree from `path` to find the nearest `package.json`.
704fn find_nearest_package_json(path: &Path) -> Option<PathBuf> {
705    let mut dir = if path.is_file() { path.parent()? } else { path };
706    loop {
707        let candidate = dir.join("package.json");
708        if candidate.exists() {
709            return Some(candidate);
710        }
711        dir = dir.parent()?;
712    }
713}
714
715/// Ensure a dependency exists at the correct version in `package.json`.
716///
717/// Three paths:
718///
719/// 1. **Lockfile incident** (URI points to a lockfile): The incident fired on a
720///    transitive copy of the package. Parse the lockfile to find which direct
721///    deps in `package.json` pull it in, resolve their latest compatible
722///    versions from npm, and plan updates for those parent packages.
723///
724/// 2. **Dependent incident** (has `isDependentOf` variable): Legacy path for
725///    transitive conflicts detected by the lockfile scanner in the provider.
726///    Resolves the actual package from `dependencyName` via npm.
727///
728/// 3. **Direct incident** (URI points to `package.json` or source file): Update
729///    or insert the package in `package.json` with the given version.
730fn plan_ensure_npm_dependency(
731    rule_id: &str,
732    incident: &Incident,
733    package: &str,
734    new_version: &str,
735    file_path: &Path,
736) -> Vec<PlannedFix> {
737    // ── Path 1: Lockfile incident ────────────────────────────────────
738    //
739    // When the incident URI points to a lockfile (yarn.lock, package-lock.json,
740    // pnpm-lock.yaml), the rule fired on a transitive copy of `package` (e.g.,
741    // a nested @patternfly/react-core@5.x pulled in by react-topology).
742    //
743    // Instead of redundantly updating the target package (the direct incident
744    // handles that), we find which direct deps bring in the transitive copy
745    // and update those parent packages to versions compatible with the new
746    // major version of the target.
747    if lockfile::is_lockfile(file_path) {
748        tracing::info!(
749            package = %package,
750            lockfile = %file_path.display(),
751            "Lockfile incident: resolving parent packages for transitive dependency"
752        );
753
754        // Find the sibling package.json
755        let pkg_json = match find_nearest_package_json(file_path) {
756            Some(p) => p,
757            None => {
758                tracing::warn!(
759                    lockfile = %file_path.display(),
760                    "No package.json found near lockfile; skipping"
761                );
762                return Vec::new();
763            }
764        };
765
766        // Before resolving parents, check if the consumer's existing version
767        // of the target package already satisfies the required range. If so,
768        // there's nothing to do — the lockfile will sort itself out once the
769        // direct deps are updated by their own (non-lockfile) incidents.
770        //
771        // This prevents spurious updates like bumping react-dom from ^17 to ^19
772        // when the consumer has react@^17.0.1 and PF's peer dep is
773        // "^17 || ^18 || ^19" (which ^17.0.1 already satisfies).
774        if let Some(current_version) = read_dep_version_from_package_json(&pkg_json, package) {
775            if is_range_already_compatible(&current_version, new_version) {
776                tracing::info!(
777                    package = %package,
778                    current = %current_version,
779                    required = %new_version,
780                    "Lockfile path: consumer's version already satisfies required range; skipping parent resolution"
781                );
782                return Vec::new();
783            }
784        }
785
786        // Get the set of direct dep names from package.json
787        let direct_deps = lockfile::parse_direct_dep_names(&pkg_json);
788
789        // Find which lockfile entries transitively depend on the target package.
790        // This walks up the dependency chain: if A → B → C and C is the target,
791        // both A and B are returned as ancestors.
792        let all_parents = lockfile::find_transitive_ancestor_packages(file_path, package);
793
794        // Filter to only direct deps (we can only update what's in package.json)
795        let actionable_parents: Vec<&String> = all_parents
796            .iter()
797            .filter(|name| direct_deps.contains(name.as_str()))
798            .collect();
799
800        if actionable_parents.is_empty() {
801            tracing::debug!(
802                package = %package,
803                "No direct-dep parents found for transitive lockfile dep; skipping"
804            );
805            return Vec::new();
806        }
807
808        let target_major = extract_major(new_version);
809        let mut fixes = Vec::new();
810
811        for parent in &actionable_parents {
812            tracing::info!(
813                parent = %parent,
814                compatible_with = %package,
815                target_major = target_major,
816                "Resolving npm-compatible version for lockfile parent"
817            );
818
819            let resolved = resolve_npm_compatible_version(parent, package, target_major);
820
821            match resolved {
822                Some(ref ver) => {
823                    tracing::info!(
824                        parent = %parent,
825                        resolved_version = %ver,
826                        "Resolved npm-compatible version for lockfile parent"
827                    );
828                    if let Some(fix) =
829                        plan_ensure_npm_dependency_inner(rule_id, &pkg_json, parent, ver)
830                    {
831                        fixes.push(fix);
832                    }
833                }
834                None => {
835                    tracing::warn!(
836                        parent = %parent,
837                        compatible_with = %package,
838                        "Could not resolve compatible version from npm; skipping parent"
839                    );
840                }
841            }
842        }
843
844        tracing::info!(
845            package = %package,
846            parents = actionable_parents.len(),
847            fixes = fixes.len(),
848            "Lockfile incident resolved"
849        );
850
851        return fixes;
852    }
853
854    // ── Path 2: Dependent incident (isDependentOf variable) ──────────
855    //
856    // Legacy path for transitive conflicts with explicit variables.
857    if let Some(serde_json::Value::String(depends_on)) = incident.variables.get("isDependentOf") {
858        let actual_package = match incident
859            .variables
860            .get("dependencyName")
861            .and_then(|v| v.as_str())
862        {
863            Some(p) => p,
864            None => return Vec::new(),
865        };
866
867        // If the consumer already has a compatible version of the dependency
868        // that this package depends on, skip the transitive update.
869        if let Some(current) = read_dep_version_from_package_json(file_path, depends_on) {
870            if is_range_already_compatible(&current, new_version) {
871                tracing::info!(
872                    package = %depends_on,
873                    current = %current,
874                    required = %new_version,
875                    "Dependent path: consumer's version already satisfies required range; skipping"
876                );
877                return Vec::new();
878            }
879        }
880
881        let target_major = extract_major(new_version);
882
883        tracing::info!(
884            dependent = %actual_package,
885            depends_on = %depends_on,
886            target_major = target_major,
887            "Resolving compatible version from npm for dependent package"
888        );
889
890        let resolved = resolve_npm_compatible_version(actual_package, depends_on, target_major);
891
892        return match resolved {
893            Some(ref ver) => {
894                tracing::info!(
895                    package = %actual_package,
896                    resolved_version = %ver,
897                    "Resolved npm-compatible version for dependent"
898                );
899                plan_ensure_npm_dependency_inner(rule_id, file_path, actual_package, ver)
900                    .into_iter()
901                    .collect()
902            }
903            None => {
904                tracing::warn!(
905                    package = %actual_package,
906                    depends_on = %depends_on,
907                    "Could not resolve compatible version from npm; skipping"
908                );
909                Vec::new()
910            }
911        };
912    }
913
914    // ── Path 3: Direct incident ──────────────────────────────────────
915    plan_ensure_npm_dependency_inner(rule_id, file_path, package, new_version)
916        .into_iter()
917        .collect()
918}
919
920/// Inner implementation: update or insert a dependency in package.json.
921fn plan_ensure_npm_dependency_inner(
922    rule_id: &str,
923    file_path: &Path,
924    package: &str,
925    new_version: &str,
926) -> Option<PlannedFix> {
927    // Resolve the target package.json
928    let pkg_json = if file_path.file_name().is_some_and(|f| f == "package.json") {
929        file_path.to_path_buf()
930    } else {
931        find_nearest_package_json(file_path)?
932    };
933
934    let source = std::fs::read_to_string(&pkg_json).ok()?;
935    let pkg_json_uri = format!("file://{}", pkg_json.display());
936
937    // --- Identify top-level dependency blocks ---
938    // We need to distinguish top-level "dependencies" / "devDependencies" from
939    // nested ones (e.g., "consolePlugin.dependencies"). Top-level blocks start
940    // at JSON brace depth 1 (inside the root object).
941    let lines: Vec<&str> = source.lines().collect();
942    let top_level_dep_ranges = find_top_level_dep_blocks(&lines);
943
944    // --- Try update: find the package in a top-level dep block and replace its version ---
945    let package_quoted = format!("\"{}\"", package);
946    let version_re = regex::Regex::new(r#"("[\^~><=]*[0-9][^"]*")"#).ok()?;
947
948    for (idx, file_line) in source.lines().enumerate() {
949        if !file_line.contains(&package_quoted) {
950            continue;
951        }
952        // Only match if this line is inside a top-level dep block
953        if !top_level_dep_ranges
954            .iter()
955            .any(|r| idx >= r.start && idx < r.end)
956        {
957            continue;
958        }
959        if let Some(m) = version_re.find(file_line) {
960            let line = (idx + 1) as u32;
961            let old_version = m.as_str();
962
963            // Strip quotes to get raw version strings for comparison
964            let old_ver_raw = old_version.trim_matches('"');
965
966            // If the consumer's existing version range is already compatible
967            // with the new required range, skip the update. This prevents
968            // unnecessary version churn — e.g., when PF expands its react
969            // peer dep from "^17 || ^18" to "^17 || ^18 || ^19", a consumer
970            // with "^17.0.1" doesn't need to change anything.
971            if is_range_already_compatible(old_ver_raw, new_version) {
972                tracing::info!(
973                    package = %package,
974                    current = %old_ver_raw,
975                    required = %new_version,
976                    "Skipping update: consumer's version range already satisfies the required range"
977                );
978                return None;
979            }
980
981            let new_ver_quoted = format!("\"{}\"", new_version);
982
983            return Some(PlannedFix {
984                edits: vec![TextEdit {
985                    line,
986                    old_text: old_version.to_string(),
987                    new_text: new_ver_quoted.clone(),
988                    rule_id: rule_id.to_string(),
989                    description: format!(
990                        "Update {} from {} to {}",
991                        package, old_version, new_ver_quoted
992                    ),
993                    replace_all: false,
994                }],
995                confidence: FixConfidence::Exact,
996                source: FixSource::Pattern,
997                rule_id: rule_id.to_string(),
998                file_uri: pkg_json_uri,
999                line,
1000                description: format!("Update {} to {}", package, new_version),
1001            });
1002        }
1003    }
1004
1005    // --- Insert: package not found, add it to a top-level dep block ---
1006    // Prefer "devDependencies" if it exists (most PF consumer deps live there),
1007    // fall back to "dependencies".
1008    let target_block = top_level_dep_ranges
1009        .iter()
1010        .find(|r| r.name == "devDependencies")
1011        .or_else(|| {
1012            top_level_dep_ranges
1013                .iter()
1014                .find(|r| r.name == "dependencies")
1015        });
1016
1017    let target_block = target_block?;
1018    let mut last_entry_line: Option<usize> = None;
1019    let mut closing_brace_line: Option<usize> = None;
1020
1021    for (idx, line) in lines
1022        .iter()
1023        .enumerate()
1024        .take(target_block.end)
1025        .skip(target_block.start)
1026    {
1027        let trimmed = line.trim();
1028        if trimmed == "}" || trimmed == "}," {
1029            closing_brace_line = Some(idx);
1030            break;
1031        }
1032        if !trimmed.is_empty()
1033            && !trimmed.starts_with("\"dependencies\"")
1034            && !trimmed.starts_with("\"devDependencies\"")
1035            && trimmed != "{"
1036        {
1037            last_entry_line = Some(idx);
1038        }
1039    }
1040
1041    let closing_idx = closing_brace_line?;
1042    let closing_line_num = (closing_idx + 1) as u32;
1043
1044    let entry_indent = if let Some(last_idx) = last_entry_line {
1045        let last = lines[last_idx];
1046        let indent_len = last.len() - last.trim_start().len();
1047        &last[..indent_len]
1048    } else {
1049        "    "
1050    };
1051
1052    let mut edits = Vec::new();
1053
1054    if let Some(last_idx) = last_entry_line {
1055        let last = lines[last_idx];
1056        if !last.trim_end().ends_with(',') {
1057            let last_line_num = (last_idx + 1) as u32;
1058            let trimmed_last = last.trim_end().to_string();
1059            edits.push(TextEdit {
1060                line: last_line_num,
1061                old_text: trimmed_last.clone(),
1062                new_text: format!("{},", trimmed_last),
1063                rule_id: rule_id.to_string(),
1064                description: format!("Add trailing comma before new dependency {}", package),
1065                replace_all: false,
1066            });
1067        }
1068    }
1069
1070    let closing_line_text = lines[closing_idx].to_string();
1071    let new_entry = format!(
1072        "{}\"{}\": \"{}\"\n{}",
1073        entry_indent, package, new_version, closing_line_text
1074    );
1075    edits.push(TextEdit {
1076        line: closing_line_num,
1077        old_text: closing_line_text,
1078        new_text: new_entry,
1079        rule_id: rule_id.to_string(),
1080        description: format!("Add {} {} to dependencies", package, new_version),
1081        replace_all: false,
1082    });
1083
1084    Some(PlannedFix {
1085        edits,
1086        confidence: FixConfidence::Exact,
1087        source: FixSource::Pattern,
1088        rule_id: rule_id.to_string(),
1089        file_uri: pkg_json_uri,
1090        line: closing_line_num,
1091        description: format!("Add {} {} to dependencies", package, new_version),
1092    })
1093}
1094
1095// -- Top-level dependency block detection --
1096
1097/// A range of lines in package.json belonging to a top-level dependency block.
1098struct DepBlockRange {
1099    /// "dependencies" or "devDependencies"
1100    name: &'static str,
1101    /// Start line index (inclusive, the key line)
1102    start: usize,
1103    /// End line index (exclusive, after the closing brace)
1104    end: usize,
1105}
1106
1107/// Find top-level "dependencies" and "devDependencies" blocks in package.json.
1108///
1109/// Top-level means at JSON depth 1 (direct children of the root object).
1110/// Nested blocks like "consolePlugin.dependencies" are at depth >= 2 and
1111/// are excluded.
1112fn find_top_level_dep_blocks(lines: &[&str]) -> Vec<DepBlockRange> {
1113    let mut results = Vec::new();
1114    let mut root_depth: i32 = 0;
1115
1116    let mut i = 0;
1117    while i < lines.len() {
1118        let trimmed = lines[i].trim();
1119
1120        // Track root-level brace depth (outside any dep block scan)
1121        for ch in trimmed.chars() {
1122            match ch {
1123                '{' => root_depth += 1,
1124                '}' => root_depth -= 1,
1125                _ => {}
1126            }
1127        }
1128
1129        // Only match dep block keys at depth 1 (just entered the root object)
1130        // After processing braces above, a line like `"dependencies": {` will
1131        // have bumped root_depth to 2. So we check for depth == 2 for a
1132        // combined key+brace line, or depth == 1 for key-only lines.
1133        let is_dep_key = (root_depth == 2 || root_depth == 1)
1134            && (trimmed.starts_with("\"dependencies\"")
1135                || trimmed.starts_with("\"devDependencies\""));
1136
1137        if !is_dep_key {
1138            i += 1;
1139            continue;
1140        }
1141
1142        let name = if trimmed.starts_with("\"devDependencies\"") {
1143            "devDependencies"
1144        } else {
1145            "dependencies"
1146        };
1147
1148        let start = i;
1149        // Find the matching closing brace for this block
1150        let mut block_depth: i32 = 0;
1151        for ch in trimmed.chars() {
1152            match ch {
1153                '{' => block_depth += 1,
1154                '}' => block_depth -= 1,
1155                _ => {}
1156            }
1157        }
1158
1159        i += 1;
1160        while i < lines.len() && block_depth > 0 {
1161            let t = lines[i].trim();
1162            for ch in t.chars() {
1163                match ch {
1164                    '{' => {
1165                        block_depth += 1;
1166                        root_depth += 1;
1167                    }
1168                    '}' => {
1169                        block_depth -= 1;
1170                        root_depth -= 1;
1171                    }
1172                    _ => {}
1173                }
1174            }
1175            i += 1;
1176        }
1177
1178        results.push(DepBlockRange {
1179            name,
1180            start,
1181            end: i,
1182        });
1183    }
1184
1185    results
1186}
1187
1188// -- package.json version lookup --
1189
1190/// Read the version of a specific dependency from package.json.
1191///
1192/// Searches both `dependencies` and `devDependencies` (top-level only).
1193/// Returns the raw version string (e.g., `"^17.0.1"`) if found.
1194fn read_dep_version_from_package_json(pkg_json: &Path, package: &str) -> Option<String> {
1195    let pkg_json = if pkg_json.file_name().is_some_and(|f| f == "package.json") {
1196        pkg_json.to_path_buf()
1197    } else {
1198        find_nearest_package_json(pkg_json)?
1199    };
1200
1201    let source = std::fs::read_to_string(&pkg_json).ok()?;
1202    let parsed: serde_json::Value = serde_json::from_str(&source).ok()?;
1203
1204    // Check both dependency sections
1205    for section in ["dependencies", "devDependencies"] {
1206        if let Some(version) = parsed
1207            .get(section)
1208            .and_then(|deps| deps.get(package))
1209            .and_then(|v| v.as_str())
1210        {
1211            return Some(version.to_string());
1212        }
1213    }
1214
1215    None
1216}
1217
1218// -- npm semver range compatibility --
1219
1220/// Check if the consumer's existing version range is already compatible with
1221/// the required range from a peer dependency or dependency update rule.
1222///
1223/// Returns `true` if every version matched by `consumer_range` is also
1224/// accepted by `required_range` — meaning the consumer doesn't need to
1225/// change their version.
1226///
1227/// Examples:
1228///   - `is_range_already_compatible("^17.0.1", "^17 || ^18 || ^19")` → true
1229///     (^17.0.1 is a subset of ^17)
1230///   - `is_range_already_compatible("^11.7.3", "^17.0.3")` → false
1231///     (^11 and ^17 don't overlap)
1232///   - `is_range_already_compatible("^6.4.1", "^6.4.1")` → true
1233///     (identical ranges)
1234///
1235/// If either range fails to parse (e.g., dist tags like "latest", git URLs),
1236/// returns `false` to allow the update to proceed.
1237fn is_range_already_compatible(consumer_range: &str, required_range: &str) -> bool {
1238    let consumer = match consumer_range.parse::<node_semver::Range>() {
1239        Ok(r) => r,
1240        Err(_) => return false,
1241    };
1242    let required = match required_range.parse::<node_semver::Range>() {
1243        Ok(r) => r,
1244        Err(_) => return false,
1245    };
1246
1247    // "Does the required range accept every version that the consumer's range accepts?"
1248    // If yes, the consumer's current version is already within the acceptable set.
1249    required.allows_all(&consumer)
1250}
1251
1252// -- npm registry resolution --
1253
1254/// Extract the major version number from a version string.
1255/// `"^6.4.1"` → 6, `"6.4.1"` → 6, `"~5.0.0"` → 5
1256fn extract_major(version: &str) -> u64 {
1257    let stripped = version
1258        .trim()
1259        .trim_start_matches('^')
1260        .trim_start_matches('~')
1261        .trim_start_matches(">=")
1262        .trim_start_matches("<=")
1263        .trim_start_matches('>')
1264        .trim_start_matches('<')
1265        .trim_start_matches('=');
1266    stripped
1267        .split('.')
1268        .next()
1269        .and_then(|s| s.parse().ok())
1270        .unwrap_or(0)
1271}
1272
1273/// Query the npm registry to find the latest stable version of `package`
1274/// whose dependency on `compatible_with` uses major version `target_major`.
1275///
1276/// For example, for `resolve_npm_compatible_version("@patternfly/react-topology",
1277/// "@patternfly/react-core", 6)`, this finds the latest version of
1278/// `react-topology` that depends on `@patternfly/react-core@^6.x`.
1279///
1280/// Returns the version as `"^X.Y.Z"` or `None` if no compatible version
1281/// is found or the registry query fails.
1282fn resolve_npm_compatible_version(
1283    package: &str,
1284    compatible_with: &str,
1285    target_major: u64,
1286) -> Option<String> {
1287    let url = format!("https://registry.npmjs.org/{}", package);
1288
1289    let mut response = match ureq::get(&url).call() {
1290        Ok(resp) => resp,
1291        Err(e) => {
1292            tracing::warn!(
1293                package = %package,
1294                error = %e,
1295                "npm registry query failed"
1296            );
1297            return None;
1298        }
1299    };
1300
1301    let body: serde_json::Value = match response.body_mut().read_json() {
1302        Ok(v) => v,
1303        Err(e) => {
1304            tracing::warn!(
1305                package = %package,
1306                error = %e,
1307                "Failed to parse npm registry response"
1308            );
1309            return None;
1310        }
1311    };
1312
1313    let versions = body.get("versions")?.as_object()?;
1314
1315    // Find all stable versions whose dependency on `compatible_with`
1316    // has a major version >= target_major
1317    let mut candidates: Vec<(u64, u64, u64, &str)> = Vec::new();
1318
1319    for (ver_str, ver_data) in versions {
1320        // Skip prereleases
1321        if ver_str.contains("alpha")
1322            || ver_str.contains("prerelease")
1323            || ver_str.contains("rc")
1324            || ver_str.contains("beta")
1325        {
1326            continue;
1327        }
1328
1329        // Check if this version is compatible with the target major of
1330        // compatible_with. A version is compatible if:
1331        // 1. It declares compatible_with at target_major+ (explicit compat), OR
1332        // 2. It doesn't declare compatible_with at all (the dep was dropped,
1333        //    meaning no version constraint — implicitly compatible with any version)
1334        //
1335        // A version is INCOMPATIBLE only if it explicitly constrains
1336        // compatible_with to a major version below target_major.
1337        let dep_constraint = ["dependencies", "peerDependencies"]
1338            .iter()
1339            .find_map(|section| {
1340                ver_data
1341                    .get(*section)
1342                    .and_then(|deps| deps.get(compatible_with))
1343                    .and_then(|c| c.as_str())
1344            });
1345
1346        let is_compatible = match dep_constraint {
1347            Some(c) => extract_major(c) >= target_major,
1348            None => true, // dep dropped — no constraint, implicitly compatible
1349        };
1350
1351        if !is_compatible {
1352            continue;
1353        }
1354
1355        // Parse version for sorting
1356        if let Some(parsed) = parse_semver_tuple(ver_str) {
1357            candidates.push((parsed.0, parsed.1, parsed.2, ver_str.as_str()));
1358        }
1359    }
1360
1361    // Sort and take the latest
1362    candidates.sort();
1363    let latest = candidates.last()?;
1364
1365    Some(format!("^{}", latest.3))
1366}
1367
1368/// Parse a semver string into (major, minor, patch) tuple.
1369fn parse_semver_tuple(s: &str) -> Option<(u64, u64, u64)> {
1370    let s = s.trim();
1371    let version_part = s.split('-').next().unwrap_or(s);
1372    let parts: Vec<&str> = version_part.split('.').collect();
1373    let major = parts.first()?.parse().ok()?;
1374    let minor = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0);
1375    let patch = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0);
1376    Some((major, minor, patch))
1377}
1378
1379#[cfg(test)]
1380mod tests {
1381    use super::*;
1382    use std::collections::BTreeMap;
1383
1384    /// Create a test Incident with just the fields the fix provider cares about.
1385    fn make_test_incident(
1386        uri: &str,
1387        line: u32,
1388        variables: BTreeMap<String, serde_json::Value>,
1389    ) -> Incident {
1390        Incident {
1391            file_uri: uri.to_string(),
1392            line_number: Some(line),
1393            code_location: None,
1394            message: String::new(),
1395            code_snip: None,
1396            variables,
1397            effort: None,
1398            links: Vec::new(),
1399            is_dependency_incident: false,
1400        }
1401    }
1402
1403    // -- should_skip_path tests --
1404
1405    #[test]
1406    fn test_skip_node_modules() {
1407        let provider = JsFixProvider::new();
1408        assert!(provider.should_skip_path(Path::new(
1409            "/project/node_modules/@patternfly/react-core/index.js"
1410        )));
1411    }
1412
1413    #[test]
1414    fn test_skip_nested_node_modules() {
1415        let provider = JsFixProvider::new();
1416        assert!(
1417            provider.should_skip_path(Path::new("/project/packages/app/node_modules/foo/bar.ts"))
1418        );
1419    }
1420
1421    #[test]
1422    fn test_does_not_skip_src() {
1423        let provider = JsFixProvider::new();
1424        assert!(!provider.should_skip_path(Path::new("/project/src/App.tsx")));
1425    }
1426
1427    #[test]
1428    fn test_does_not_skip_vendor() {
1429        let provider = JsFixProvider::new();
1430        assert!(!provider.should_skip_path(Path::new("/project/src/vendor/lib.ts")));
1431    }
1432
1433    // -- is_whole_file_rename tests --
1434
1435    #[test]
1436    fn test_whole_file_rename_with_imported_name() {
1437        let provider = JsFixProvider::new();
1438        let mut vars = BTreeMap::new();
1439        vars.insert(
1440            "importedName".to_string(),
1441            serde_json::Value::String("Chip".to_string()),
1442        );
1443        let incident = make_test_incident("file:///test.tsx", 1, vars);
1444        assert!(provider.is_whole_file_rename(&incident));
1445    }
1446
1447    #[test]
1448    fn test_not_whole_file_rename_without_imported_name() {
1449        let provider = JsFixProvider::new();
1450        let mut vars = BTreeMap::new();
1451        vars.insert(
1452            "propName".to_string(),
1453            serde_json::Value::String("isActive".to_string()),
1454        );
1455        let incident = make_test_incident("file:///test.tsx", 1, vars);
1456        assert!(!provider.is_whole_file_rename(&incident));
1457    }
1458
1459    // -- bracket_depth tests --
1460
1461    #[test]
1462    fn test_bracket_depth_balanced() {
1463        assert_eq!(bracket_depth("{ foo: bar }"), 0);
1464        assert_eq!(bracket_depth("foo()"), 0);
1465        assert_eq!(bracket_depth("[1, 2, 3]"), 0);
1466        assert_eq!(bracket_depth("{ foo: [1, 2] }"), 0);
1467    }
1468
1469    #[test]
1470    fn test_bracket_depth_open() {
1471        assert_eq!(bracket_depth("actions={["), 2);
1472        assert_eq!(bracket_depth("  <Button"), 0);
1473        assert_eq!(bracket_depth("foo(bar, {"), 2);
1474    }
1475
1476    #[test]
1477    fn test_bracket_depth_close() {
1478        assert_eq!(bracket_depth("]}"), -2);
1479        assert_eq!(bracket_depth(")"), -1);
1480    }
1481
1482    #[test]
1483    fn test_bracket_depth_ignores_string_literals() {
1484        assert_eq!(bracket_depth(r#"  foo="{not a bracket}""#), 0);
1485        assert_eq!(bracket_depth("  foo='[still not]'"), 0);
1486    }
1487
1488    // -- dedup_import_specifiers tests --
1489
1490    #[test]
1491    fn test_dedup_import_removes_duplicates() {
1492        let mut lines =
1493            vec!["import { Content, Content, Content } from '@patternfly/react-core';".to_string()];
1494        dedup_import_specifiers(&mut lines);
1495        let count = lines[0].matches("Content").count();
1496        assert_eq!(count, 1);
1497    }
1498
1499    #[test]
1500    fn test_dedup_import_preserves_different_specifiers() {
1501        let mut lines = vec!["import { Foo, Bar, Foo, Baz, Bar } from '@pkg';".to_string()];
1502        dedup_import_specifiers(&mut lines);
1503        assert_eq!(lines[0].matches("Foo").count(), 1);
1504        assert_eq!(lines[0].matches("Bar").count(), 1);
1505        assert_eq!(lines[0].matches("Baz").count(), 1);
1506    }
1507
1508    // -- get_matched_text tests --
1509
1510    #[test]
1511    fn test_get_matched_text_prop_name_first() {
1512        let mut vars = BTreeMap::new();
1513        vars.insert(
1514            "propName".to_string(),
1515            serde_json::Value::String("isActive".to_string()),
1516        );
1517        vars.insert(
1518            "componentName".to_string(),
1519            serde_json::Value::String("Button".to_string()),
1520        );
1521        let incident = make_test_incident("file:///test.tsx", 1, vars);
1522        assert_eq!(get_matched_text_from_incident(&incident), "isActive");
1523    }
1524
1525    #[test]
1526    fn test_get_matched_text_empty_when_no_known_vars() {
1527        let incident = make_test_incident("", 1, BTreeMap::new());
1528        assert_eq!(get_matched_text_from_incident(&incident), "");
1529    }
1530
1531    // -- get_matched_text_for_rename tests --
1532
1533    #[test]
1534    fn test_get_matched_text_for_rename_prefers_prop_name() {
1535        let mut vars = BTreeMap::new();
1536        vars.insert(
1537            "propName".into(),
1538            serde_json::Value::String("spaceItems".into()),
1539        );
1540        let incident = make_test_incident("file:///test.tsx", 1, vars);
1541        let mappings = vec![RenameMapping {
1542            old: "spaceItems".into(),
1543            new: "gap".into(),
1544        }];
1545        assert_eq!(
1546            get_matched_text_for_rename_from_incident(&incident, &mappings),
1547            "spaceItems"
1548        );
1549    }
1550
1551    #[test]
1552    fn test_get_matched_text_for_rename_falls_back_to_prop_value() {
1553        let mut vars = BTreeMap::new();
1554        vars.insert(
1555            "propName".into(),
1556            serde_json::Value::String("variant".into()),
1557        );
1558        vars.insert(
1559            "propValue".into(),
1560            serde_json::Value::String("light".into()),
1561        );
1562        let incident = make_test_incident("file:///test.tsx", 1, vars);
1563        let mappings = vec![RenameMapping {
1564            old: "light".into(),
1565            new: "secondary".into(),
1566        }];
1567        assert_eq!(
1568            get_matched_text_for_rename_from_incident(&incident, &mappings),
1569            "light"
1570        );
1571    }
1572
1573    // -- post_process_lines integration test --
1574
1575    #[test]
1576    fn test_post_process_deduplicates_imports() {
1577        let provider = JsFixProvider::new();
1578        let mut lines = vec![
1579            "import { Content, Content } from '@patternfly/react-core';".to_string(),
1580            "const x = 1;".to_string(),
1581        ];
1582        provider.post_process_lines(&mut lines);
1583        assert_eq!(lines[0].matches("Content").count(), 1);
1584        assert_eq!(lines[1], "const x = 1;");
1585    }
1586
1587    // -- npm resolution helper tests --
1588
1589    #[test]
1590    fn test_extract_major() {
1591        assert_eq!(extract_major("^6.4.1"), 6);
1592        assert_eq!(extract_major("~5.0.0"), 5);
1593        assert_eq!(extract_major("6.4.1"), 6);
1594        assert_eq!(extract_major(">=7.0.0"), 7);
1595        assert_eq!(extract_major("^6.0.0-alpha.1"), 6);
1596    }
1597
1598    #[test]
1599    fn test_parse_semver_tuple() {
1600        assert_eq!(parse_semver_tuple("6.4.1"), Some((6, 4, 1)));
1601        assert_eq!(parse_semver_tuple("5.0.0"), Some((5, 0, 0)));
1602        assert_eq!(parse_semver_tuple("6.0.0-alpha.1"), Some((6, 0, 0)));
1603    }
1604
1605    // -- dependent incident detection test --
1606
1607    #[test]
1608    fn test_dependent_incident_updates_correct_package() {
1609        let dir = tempfile::tempdir().unwrap();
1610        let pkg_json = dir.path().join("package.json");
1611        std::fs::write(
1612            &pkg_json,
1613            r#"{
1614  "devDependencies": {
1615    "@patternfly/react-core": "^6.4.1",
1616    "@patternfly/react-topology": "5.2.1"
1617  }
1618}"#,
1619        )
1620        .unwrap();
1621
1622        // Create a dependent incident (as the frontend-analyzer-provider would)
1623        let mut vars = BTreeMap::new();
1624        vars.insert(
1625            "dependencyName".into(),
1626            serde_json::Value::String("@patternfly/react-topology".into()),
1627        );
1628        vars.insert(
1629            "dependencyVersion".into(),
1630            serde_json::Value::String("5.2.1".into()),
1631        );
1632        vars.insert(
1633            "dependencyType".into(),
1634            serde_json::Value::String("devDependencies".into()),
1635        );
1636        vars.insert(
1637            "isDependentOf".into(),
1638            serde_json::Value::String("@patternfly/react-core".into()),
1639        );
1640        vars.insert(
1641            "dependentConstraint".into(),
1642            serde_json::Value::String("^5.1.1".into()),
1643        );
1644
1645        let incident = make_test_incident(&format!("file://{}", pkg_json.display()), 4, vars);
1646
1647        // Call the function — it will try to query npm for react-topology.
1648        // In CI without network, the npm query may fail, but the function
1649        // should gracefully return None rather than panic.
1650        let result = plan_ensure_npm_dependency(
1651            "semver-dep-update-patternfly-react-core",
1652            &incident,
1653            "@patternfly/react-core",
1654            "^6.4.1",
1655            &pkg_json,
1656        );
1657
1658        // If npm is reachable, we get a fix targeting react-topology (not react-core).
1659        // If npm is unreachable, we get an empty vec (graceful degradation).
1660        if let Some(fix) = result.first() {
1661            assert!(
1662                fix.description.contains("react-topology"),
1663                "Fix should target react-topology, got: {}",
1664                fix.description
1665            );
1666            assert!(
1667                !fix.description.contains("react-core"),
1668                "Fix should NOT mention react-core as the package to update"
1669            );
1670        }
1671        // Either way: no panic, no crash.
1672    }
1673
1674    // -- non-dependent incident preserves existing behavior --
1675
1676    #[test]
1677    fn test_non_dependent_incident_uses_provided_version() {
1678        let dir = tempfile::tempdir().unwrap();
1679        let pkg_json = dir.path().join("package.json");
1680        std::fs::write(
1681            &pkg_json,
1682            r#"{
1683  "dependencies": {
1684    "@patternfly/react-core": "5.3.4"
1685  }
1686}"#,
1687        )
1688        .unwrap();
1689
1690        let vars = BTreeMap::new();
1691        let incident = make_test_incident(&format!("file://{}", pkg_json.display()), 3, vars);
1692
1693        let result = plan_ensure_npm_dependency(
1694            "semver-dep-update-patternfly-react-core",
1695            &incident,
1696            "@patternfly/react-core",
1697            "^6.4.1",
1698            &pkg_json,
1699        );
1700
1701        assert_eq!(
1702            result.len(),
1703            1,
1704            "Should produce exactly one fix for primary dep update"
1705        );
1706        let fix = &result[0];
1707        assert_eq!(fix.edits.len(), 1);
1708        assert!(fix.edits[0].new_text.contains("6.4.1"));
1709        assert!(fix.description.contains("react-core"));
1710    }
1711
1712    #[test]
1713    fn parse_yarn_missing_peers_basic() {
1714        let output = "\
1715➤ YN0000: · Yarn 4.6.0
1716➤ YN0002: @patternfly/react-charts@npm:8.4.1 doesn't provide victory (p1a2b3), requested by @patternfly/react-charts.
1717➤ YN0002: @patternfly/react-charts@npm:8.4.1 doesn't provide victory-core (p4d5e6), requested by some-dep.
1718➤ YN0000: · Done in 1.5s
1719";
1720        let peers = super::parse_yarn_missing_peer_deps(output);
1721        assert_eq!(peers.len(), 2);
1722        assert_eq!(peers[0].peer_name, "victory");
1723        // "requested by" trailer matches the provider here
1724        assert_eq!(peers[0].requested_by, "@patternfly/react-charts");
1725        assert_eq!(peers[1].peer_name, "victory-core");
1726        // "requested by" trailer names a different package than the provider
1727        assert_eq!(peers[1].requested_by, "some-dep");
1728    }
1729
1730    #[test]
1731    fn parse_yarn_missing_peers_with_ansi() {
1732        // Simulate ANSI color codes wrapping the warning code
1733        let output = "\x1b[33m➤\x1b[0m \x1b[33mYN0002\x1b[0m: \x1b[38;5;173mfoo@npm:1.0.0\x1b[0m doesn't provide \x1b[38;5;111mbar\x1b[0m (p7g8h9), requested by baz.\n";
1734        let peers = super::parse_yarn_missing_peer_deps(output);
1735        assert_eq!(peers.len(), 1);
1736        assert_eq!(peers[0].peer_name, "bar");
1737        // "requested by" trailer takes precedence over provider
1738        assert_eq!(peers[0].requested_by, "baz");
1739    }
1740
1741    #[test]
1742    fn parse_yarn_missing_peers_scoped_package() {
1743        let output = "➤ YN0002: @scope/some-pkg@npm:2.0.0 doesn't provide @scope/peer-pkg (pabcde), requested by other-dep.\n";
1744        let peers = super::parse_yarn_missing_peer_deps(output);
1745        assert_eq!(peers.len(), 1);
1746        assert_eq!(peers[0].peer_name, "@scope/peer-pkg");
1747        // "requested by" trailer takes precedence
1748        assert_eq!(peers[0].requested_by, "other-dep");
1749    }
1750
1751    #[test]
1752    fn parse_yarn_missing_peers_deduplicates() {
1753        let output = "\
1754➤ YN0002: pkg-a@npm:1.0.0 doesn't provide victory (p11111), requested by dep-a.
1755➤ YN0002: pkg-b@npm:2.0.0 doesn't provide victory (p22222), requested by dep-b.
1756➤ YN0002: pkg-c@npm:3.0.0 doesn't provide victory (p33333), requested by dep-c.
1757";
1758        let peers = super::parse_yarn_missing_peer_deps(output);
1759        assert_eq!(peers.len(), 1);
1760        assert_eq!(peers[0].peer_name, "victory");
1761        // First requester wins for deduplication; uses "requested by" trailer
1762        assert_eq!(peers[0].requested_by, "dep-a");
1763    }
1764
1765    #[test]
1766    fn parse_yarn_missing_peers_workspace_box_separator() {
1767        // Yarn 4.6.0 uses "│ " (box-drawing vertical bar) separator for
1768        // workspace-level peer dep warnings. This is the actual format
1769        // observed when a workspace doesn't provide a peer dep required
1770        // by one of its dependencies. The "requested by" package at the
1771        // end of each line should be used as requested_by (not the workspace name).
1772        let output = "\
1773➤ YN0000: · Yarn 4.6.0
1774➤ YN0000: ┌ Resolution step
1775➤ YN0000: └ Completed
1776➤ YN0000: ┌ Post-resolution validation
1777➤ YN0002: │ pipelines-console-plugin@workspace:. doesn't provide @patternfly/react-drag-drop (pccfa6), requested by @patternfly/react-component-groups.
1778➤ YN0002: │ pipelines-console-plugin@workspace:. doesn't provide axe-core (p27d9e), requested by cypress-axe.
1779➤ YN0002: │ pipelines-console-plugin@workspace:. doesn't provide i18next (p374d5), requested by react-i18next.
1780➤ YN0086: │ Some peer dependencies are incorrectly met by your project; run yarn explain peer-requirements <hash> for details, where <hash> is the six-letter p-prefixed code.
1781➤ YN0000: └ Completed
1782➤ YN0000: · Done with warnings in 1s 4ms
1783";
1784        let peers = super::parse_yarn_missing_peer_deps(output);
1785        assert_eq!(peers.len(), 3);
1786        assert_eq!(peers[0].peer_name, "@patternfly/react-drag-drop");
1787        assert_eq!(peers[0].requested_by, "@patternfly/react-component-groups");
1788        assert_eq!(peers[1].peer_name, "axe-core");
1789        assert_eq!(peers[1].requested_by, "cypress-axe");
1790        assert_eq!(peers[2].peer_name, "i18next");
1791        assert_eq!(peers[2].requested_by, "react-i18next");
1792    }
1793
1794    #[test]
1795    fn parse_yarn_missing_peers_mixed_formats() {
1796        // Mix of workspace-level (with │) and package-level (without │) warnings.
1797        // Workspace-level uses "requested by" for requested_by; package-level
1798        // also uses "requested by" when available, falling back to the provider.
1799        let output = "\
1800➤ YN0002: │ my-app@workspace:. doesn't provide @patternfly/react-drag-drop (pccfa6), requested by @patternfly/react-component-groups.
1801➤ YN0002: @patternfly/react-charts@npm:8.4.1 doesn't provide victory (p1a2b3), requested by @patternfly/react-charts.
1802";
1803        let peers = super::parse_yarn_missing_peer_deps(output);
1804        assert_eq!(peers.len(), 2);
1805        assert_eq!(peers[0].peer_name, "@patternfly/react-drag-drop");
1806        assert_eq!(peers[0].requested_by, "@patternfly/react-component-groups");
1807        assert_eq!(peers[1].peer_name, "victory");
1808        assert_eq!(peers[1].requested_by, "@patternfly/react-charts");
1809    }
1810
1811    #[test]
1812    fn parse_yarn_missing_peers_no_warnings() {
1813        let output = "➤ YN0000: · Yarn 4.6.0\n➤ YN0000: · Done in 0.5s\n";
1814        let peers = super::parse_yarn_missing_peer_deps(output);
1815        assert!(peers.is_empty());
1816    }
1817
1818    #[test]
1819    fn lookup_peer_dep_version_finds_version() {
1820        let dir = tempfile::tempdir().unwrap();
1821        let pkg_dir = dir.path().join("node_modules/@patternfly/react-charts");
1822        std::fs::create_dir_all(&pkg_dir).unwrap();
1823        std::fs::write(
1824            pkg_dir.join("package.json"),
1825            r#"{
1826  "name": "@patternfly/react-charts",
1827  "peerDependencies": {
1828    "victory-core": "^37.3.6",
1829    "echarts": "^5.6.0 || ^6.0.0"
1830  }
1831}"#,
1832        )
1833        .unwrap();
1834
1835        assert_eq!(
1836            super::lookup_peer_dep_version(dir.path(), "@patternfly/react-charts", "victory-core"),
1837            Some("^37.3.6".to_string())
1838        );
1839        assert_eq!(
1840            super::lookup_peer_dep_version(dir.path(), "@patternfly/react-charts", "echarts"),
1841            Some("^5.6.0 || ^6.0.0".to_string())
1842        );
1843        assert_eq!(
1844            super::lookup_peer_dep_version(dir.path(), "@patternfly/react-charts", "nonexistent"),
1845            None
1846        );
1847    }
1848
1849    #[test]
1850    fn lookup_peer_dep_version_missing_package() {
1851        let dir = tempfile::tempdir().unwrap();
1852        assert_eq!(
1853            super::lookup_peer_dep_version(dir.path(), "nonexistent-pkg", "some-peer"),
1854            None
1855        );
1856    }
1857
1858    // ── semver range compatibility tests ──
1859
1860    #[test]
1861    fn range_compatible_caret_subset_of_or_range() {
1862        // ^17.0.1 is a subset of ^17 || ^18 || ^19
1863        assert!(super::is_range_already_compatible(
1864            "^17.0.1",
1865            "^17 || ^18 || ^19"
1866        ));
1867    }
1868
1869    #[test]
1870    fn range_compatible_identical_ranges() {
1871        assert!(super::is_range_already_compatible("^6.4.1", "^6.4.1"));
1872    }
1873
1874    #[test]
1875    fn range_incompatible_different_majors() {
1876        // ^11.7.3 does NOT satisfy ^17.0.3
1877        assert!(!super::is_range_already_compatible("^11.7.3", "^17.0.3"));
1878    }
1879
1880    #[test]
1881    fn range_compatible_exact_within_caret() {
1882        // 1.2.3 is within ^1.0.0
1883        assert!(super::is_range_already_compatible("1.2.3", "^1.0.0"));
1884    }
1885
1886    #[test]
1887    fn range_compatible_tilde_within_caret() {
1888        // ~1.2.3 (>=1.2.3 <1.3.0) is within ^1.0.0 (>=1.0.0 <2.0.0)
1889        assert!(super::is_range_already_compatible("~1.2.3", "^1.0.0"));
1890    }
1891
1892    #[test]
1893    fn range_incompatible_caret_not_in_tilde() {
1894        // ^1.0.0 (>=1.0.0 <2.0.0) is NOT within ~1.2.3 (>=1.2.3 <1.3.0)
1895        assert!(!super::is_range_already_compatible("^1.0.0", "~1.2.3"));
1896    }
1897
1898    #[test]
1899    fn range_compatible_or_range_within_star() {
1900        // ^17 || ^18 is within * (any version)
1901        assert!(super::is_range_already_compatible("^17 || ^18", ">=0.0.0"));
1902    }
1903
1904    #[test]
1905    fn range_unparseable_returns_false() {
1906        // Dist tags, git URLs, etc. should return false (allow update)
1907        assert!(!super::is_range_already_compatible("latest", "^1.0.0"));
1908        assert!(!super::is_range_already_compatible(
1909            "^1.0.0",
1910            "git+ssh://foo"
1911        ));
1912    }
1913
1914    #[test]
1915    fn range_compatible_prerelease() {
1916        // ^6.30.3-pre-v6.0 should be within ^6.0.0
1917        assert!(super::is_range_already_compatible(
1918            "^6.30.3-pre-v6.0",
1919            "^6.0.0"
1920        ));
1921    }
1922
1923    #[test]
1924    fn range_compatible_higher_patch_not_downgraded() {
1925        // Consumer has ^6.4.2, rule wants ^6.4.1. The consumer's range is
1926        // already within the required range, so no update should occur.
1927        // This prevents version specifier downgrades.
1928        assert!(super::is_range_already_compatible("^6.4.2", "^6.4.1"));
1929    }
1930
1931    #[test]
1932    fn range_compatible_higher_minor_not_downgraded() {
1933        // Consumer has ^6.5.0, rule wants ^6.4.1. Same principle.
1934        assert!(super::is_range_already_compatible("^6.5.0", "^6.4.1"));
1935    }
1936
1937    #[test]
1938    fn baseline_diff_filters_preexisting_peers() {
1939        // Simulate the before/after diff logic used in run_yarn_install_and_resolve_peers.
1940        // Baseline (before fixes): these peers were already unmet
1941        let baseline: std::collections::HashSet<String> = [
1942            "@patternfly/react-styles",
1943            "axe-core",
1944            "i18next",
1945            "mocha",
1946            "react-redux",
1947            "react-router",
1948            "react-router-dom",
1949            "redux",
1950            "redux-thunk",
1951        ]
1952        .iter()
1953        .map(|s| s.to_string())
1954        .collect();
1955
1956        // After fixes: some are still unmet, plus one new one
1957        let after_output = "\
1958➤ YN0002: │ my-app@workspace:. doesn't provide @patternfly/react-drag-drop (pccfa6), requested by @patternfly/react-component-groups.
1959➤ YN0002: │ my-app@workspace:. doesn't provide axe-core (p27d9e), requested by cypress-axe.
1960➤ YN0002: │ my-app@workspace:. doesn't provide i18next (p374d5), requested by react-i18next.
1961➤ YN0002: │ my-app@workspace:. doesn't provide mocha (p7ec1d), requested by cypress-multi-reporters and other dependencies.
1962➤ YN0002: │ my-app@workspace:. doesn't provide react-redux (pa391f), requested by @openshift/dynamic-plugin-sdk-extensions and other dependencies.
1963➤ YN0002: │ my-app@workspace:. doesn't provide react-router-dom (p7d47e), requested by react-router-dom-v5-compat.
1964➤ YN0002: │ my-app@workspace:. doesn't provide redux (pd1d52), requested by @openshift/dynamic-plugin-sdk-extensions and other dependencies.
1965➤ YN0002: │ my-app@workspace:. doesn't provide redux-thunk (pd9e06), requested by @openshift/dynamic-plugin-sdk-utils.
1966";
1967        let all_missing = super::parse_yarn_missing_peer_deps(after_output);
1968
1969        // Filter: only peers NOT in the baseline should remain
1970        let new_peers: Vec<_> = all_missing
1971            .into_iter()
1972            .filter(|p| !baseline.contains(&p.peer_name))
1973            .collect();
1974
1975        // Only @patternfly/react-drag-drop is new
1976        assert_eq!(new_peers.len(), 1);
1977        assert_eq!(new_peers[0].peer_name, "@patternfly/react-drag-drop");
1978        assert_eq!(
1979            new_peers[0].requested_by,
1980            "@patternfly/react-component-groups"
1981        );
1982    }
1983
1984    #[test]
1985    fn baseline_diff_installs_nothing_when_no_new_peers() {
1986        // If no new peers are introduced, nothing should be installed
1987        let baseline: std::collections::HashSet<String> = ["react-redux", "redux"]
1988            .iter()
1989            .map(|s| s.to_string())
1990            .collect();
1991
1992        let after_output = "\
1993➤ YN0002: │ my-app@workspace:. doesn't provide react-redux (pa391f), requested by some-pkg.
1994➤ YN0002: │ my-app@workspace:. doesn't provide redux (pd1d52), requested by some-pkg.
1995";
1996        let all_missing = super::parse_yarn_missing_peer_deps(after_output);
1997        let new_peers: Vec<_> = all_missing
1998            .into_iter()
1999            .filter(|p| !baseline.contains(&p.peer_name))
2000            .collect();
2001
2002        assert!(new_peers.is_empty());
2003    }
2004}