Skip to main content

vivacity_core/
scope.rs

1//! Out-of-scope detector: decides, BEFORE touching the disk, whether vivacity
2//! can install this lock natively or must delegate to `composer install`
3//! (default fallback) / fail explicitly (when Composer is not available).
4//!
5//! Principle (plan r1/F3-F5): never a silently divergent vendor/. An unknown
6//! plugin, or one that changes the layout, is out of scope. Plugins proven
7//! harmless at boot (fixture qualification) are installed like ordinary
8//! libraries, with a warning.
9
10use crate::layout::Layout;
11use crate::lock::{DistKind, Lock, LockPackage};
12use serde_json::Value;
13use std::path::Path;
14
15/// Plugins emulated natively by vivacity (identical files, drift test; the
16/// stdout lines of phpstan/rector's extension-installers and of
17/// dealerdirect's are not printed, docs/plans/install-order.md).
18/// composer/installers (see `layout`) is, under conditions checked before
19/// any write. drupal/core-composer-scaffold is deliberately absent: its
20/// source is GPL-2.0-or-later and cannot be ported here (NOTICE.md).
21pub const EMULATED_PLUGINS: &[&str] = &[
22    "symfony/runtime",
23    "composer/installers",
24    "pestphp/pest-plugin",
25    "dealerdirect/phpcodesniffer-composer-installer",
26    "phpstan/extension-installer",
27    "rector/extension-installer",
28    "wikimedia/composer-merge-plugin",
29    "composer/package-versions-deprecated",
30    // At install: `POST_INSTALL_CMD` copies `.env` from its `.dist` and
31    // prints, `symfony-pack` packages are laid out as metapackages, and
32    // nothing else fires (`fetchRecipes` is POST_UPDATE_CMD only). The
33    // resolution commands have their own rules (`resolution_effect`); on
34    // `update` with install, the recipes Flex applies at `POST_UPDATE_CMD`
35    // are reproduced for the `bundles` (and auto-generated),
36    // `copy-from-recipe`, `env`, `dotenv`, `gitignore`, `add-lines` and
37    // `composer-scripts` configurators, and the Docker ones when
38    // `extra.symfony.docker` is false (`vivacity::flex_recipes`); any other
39    // configurator hands the command over, named.
40    "symfony/flex",
41    "yiisoft/yii2-composer",
42    "codeception/c3",
43    "bamarni/composer-bin-plugin",
44    "roots/wordpress-core-installer",
45    "mnsami/composer-custom-directory-installer",
46];
47
48/// Plugins proven to write nothing at install time under a Composer whose
49/// plugins are active (the corpus baseline, docs/corpus/): installed as
50/// libraries, reported with a note. A plugin that writes a file is either
51/// emulated (EMULATED_PLUGINS) or unknown — never listed here. Removed on
52/// the corpus's evidence (2026-09-16): pestphp/pest-plugin
53/// (vendor/pest-plugins.json), phpstan/extension-installer and
54/// rector/extension-installer (GeneratedConfig.php),
55/// dealerdirect/phpcodesniffer-composer-installer (CodeSniffer.conf).
56/// `metasyntactical/composer-plugin-license-check` checks each installed
57/// package's licence against the root's `extra` lists and throws when one
58/// is denied — Composer stops, vendor/ half written. With no list
59/// configured every package passes and the plugin writes nothing: benign
60/// then, handed over when it is configured.
61pub const LICENSE_CHECK_PLUGIN: &str = "metasyntactical/composer-plugin-license-check";
62
63/// Its configuration, as `ComposerConfig` reads it: `array_filter` over
64/// `allow-list`, `deny-list` and `allowed-packages`, so empty (or absent)
65/// lists mean no check at all.
66pub fn license_check_configured(root_manifest: &Value) -> bool {
67    let Some(cfg) = root_manifest
68        .get("extra")
69        .and_then(|e| e.get(LICENSE_CHECK_PLUGIN))
70    else {
71        return false;
72    };
73    ["allow-list", "deny-list"].iter().any(|k| {
74        cfg.get(*k).and_then(Value::as_array).is_some_and(|l| {
75            l.iter()
76                .any(|v| !matches!(v, Value::Null | Value::Bool(false)))
77        })
78    })
79}
80
81pub const BENIGN_PLUGINS: &[&str] = &[
82    "php-http/discovery",
83    // Only listens to POST_CREATE_PROJECT_CMD / POST_INSTALL_CMD to print a
84    // message (MessagePlugin::getSubscribedEvents): no disk effect.
85    "drupal/core-project-message",
86    // Only listens to POST_UPDATE_CMD / POST_CREATE_PROJECT_CMD, and only acts
87    // in a `require` context (Plugin::getSubscribedEvents): inert at install.
88    "drupal/core-recipe-unpack",
89    LICENSE_CHECK_PLUGIN,
90    // A `CommandProvider` (`ibexa setup`) whose activate/deactivate only
91    // write at DEBUG verbosity; no listener (read at v5.0.10).
92    "ibexa/post-install",
93    // `Thanks::activate` arms its reminder only when the command is
94    // `update`; `POST_PACKAGE_UPDATE` then `POST_UPDATE_CMD` print it after
95    // a GitHub GraphQL call. On `install`: two commands added, nothing
96    // else (read at v1.4.1).
97    "symfony/thanks",
98    // A `CommandProvider` (`composer normalize`) with an empty `activate`
99    // and no listener (NormalizePlugin, read at 2.45.0).
100    "ergebnis/composer-normalize",
101];
102
103/// Plugins known to change the install layout or the package contents:
104/// always out of scope.
105pub const LAYOUT_PLUGINS: &[&str] = &[
106    "cweagans/composer-patches",
107    "oomphinc/composer-installers-extender",
108];
109
110/// The resolution commands (`update`, `require`, `remove`): Composer loads
111/// the installed plugins before resolving (`PluginManager::loadInstalledPlugins`
112/// from installed.json, global ones included), and some of them change what
113/// gets resolved or written. Read in the plugins' sources (plan v0.16):
114/// these have no listener that touches the resolution or the lock —
115/// `POST_INSTALL/UPDATE_CMD` writers of files under vendor/ (handled at
116/// install), install-path mappers, message printers.
117pub const RESOLUTION_INERT: &[&str] = &[
118    "composer/installers",
119    "symfony/runtime",
120    "pestphp/pest-plugin",
121    "dealerdirect/phpcodesniffer-composer-installer",
122    "phpstan/extension-installer",
123    "rector/extension-installer",
124    "composer/package-versions-deprecated",
125    "ergebnis/composer-normalize",
126    // `POST_INSTALL_CMD` / `POST_UPDATE_CMD` copy `c3.php` to the root:
127    // the install side's business, emulated there.
128    "codeception/c3",
129    // `COMMAND` forwards `install` / `update` to vendor-bin/* when
130    // `forward-command` is true — nested installs, the root lock untouched;
131    // the install side refuses that configuration.
132    "bamarni/composer-bin-plugin",
133    // An installer for the `wordpress-core` type: install paths only.
134    "roots/wordpress-core-installer",
135    // `COMMAND` (a verbose-only line) and `POST_PACKAGE_INSTALL/UPDATE`,
136    // where it only ever throws: the lock is untouched either way.
137    "metasyntactical/composer-plugin-license-check",
138    "ibexa/post-install",
139    // `POST_INSTALL/UPDATE_CMD`: an in-process `require` of a PSR
140    // implementation only when one is missing — with a complete lock, a
141    // no-op (verified on install with the corpus).
142    "php-http/discovery",
143    "drupal/core-project-message",
144    // `PRE_UPDATE_CMD` gathers patches, `POST_PACKAGE_*` applies them: the
145    // lock is untouched; the install side already hands these to Composer.
146    "cweagans/composer-patches",
147    "mlocati/composer-patcher",
148    "oomphinc/composer-installers-extender",
149    "mnsami/composer-custom-directory-installer",
150];
151
152/// A resolution command, for the per-plugin rules below.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum ResolutionCommand {
155    Update,
156    Require,
157    Remove,
158}
159
160impl ResolutionCommand {
161    pub fn name(self) -> &'static str {
162        match self {
163            ResolutionCommand::Update => "update",
164            ResolutionCommand::Require => "require",
165            ResolutionCommand::Remove => "remove",
166        }
167    }
168}
169
170/// What an active plugin does to `command`, as far as the lock is
171/// concerned: nothing (inert, or emulated for this command), or something
172/// vivacity does not emulate (the reason, for the fallback line).
173/// `with_install`: the command installs after resolving (no `--no-install`).
174pub fn resolution_effect(
175    plugin: &str,
176    command: ResolutionCommand,
177    with_install: bool,
178) -> Option<String> {
179    if RESOLUTION_INERT.contains(&plugin) {
180        return None;
181    }
182    Some(match plugin {
183        // `PRE_POOL_CREATE` filters the pool against `extra.symfony.require`
184        // (`Restricting packages listed in "symfony/symfony" to …`) —
185        // emulated (`vivacity_resolver::flex_filter`) for `update` and
186        // `remove` without install. `require` resolves Flex aliases and
187        // `POST_UPDATE_CMD` applies recipes to the installed packages
188        // (files, symfony.lock): with install, or on `require`, Composer.
189        "symfony/flex" => {
190            if command == ResolutionCommand::Require {
191                "resolves Flex aliases and applies recipes on require (not emulated)".to_owned()
192            } else if with_install && command == ResolutionCommand::Remove {
193                "applies recipes to the packages it installs (not emulated; --no-install resolves natively)".to_owned()
194            } else {
195                // `update` with install is decided once the lock is solved
196                // (`flex_install_decision`): the recipes Flex would apply
197                // depend on the packages the install lays out, and are
198                // either reproduced or handed over with their reason.
199                return None;
200            }
201        }
202        // `POST_PACKAGE_UPDATE` arms the reminder, `POST_UPDATE_CMD` prints
203        // it after a GitHub GraphQL query (`GitHubClient`): without a token
204        // that is Composer's GitHub auth prompt (an exception under
205        // `--no-interaction`) — stderr and exit code differ. Only once a
206        // package was actually updated, which is not known before
207        // resolving: `update` with install, Composer (`activate` arms it
208        // for the `update` command only: `require` / `remove` stay inert).
209        "symfony/thanks" => {
210            if command == ResolutionCommand::Update && with_install {
211                "prints a reminder after a package update, after querying GitHub (not emulated; --no-install resolves natively)".to_owned()
212            } else {
213                return None;
214            }
215        }
216        // `POST_PACKAGE_UPDATE` records updates, `POST_UPDATE_CMD` prints
217        // yiisoft/yii2's UPGRADE.md notes when it was updated — only with
218        // install, and only known after resolving: with install, Composer.
219        "yiisoft/yii2-composer" => {
220            if with_install {
221                "prints yiisoft/yii2's upgrade notes after an update with install (not emulated; --no-install resolves natively)".to_owned()
222            } else {
223                return None;
224            }
225        }
226        // INIT / `PRE_UPDATE_CMD` merge other manifests into the root
227        // before resolving: emulated (`vivacity_resolver::merge_plugin`),
228        // the merged links, stability flags, aliases and references in
229        // the pool. A merged `repositories` section is the exception,
230        // checked by the caller on the merge itself.
231        "wikimedia/composer-merge-plugin" => return None,
232        // `POST_UPDATE_CMD`, acting only in a `require` context: unpacks
233        // recipes into composer.json.
234        "drupal/core-recipe-unpack" => {
235            if command == ResolutionCommand::Require {
236                "unpacks recipes into composer.json on require (not emulated)".to_owned()
237            } else {
238                return None;
239            }
240        }
241        _ => "is not on vivacity's known-plugin list".to_owned(),
242    })
243}
244
245/// The plugins Composer would load for a resolution command, and what
246/// each does to it: those installed (installed.json of the project and
247/// of `COMPOSER_HOME`) that `allow-plugins` allows. Empty under
248/// `--no-plugins`. One issue per plugin that is not inert for `command`.
249pub fn resolution_issues(
250    project_dir: &Path,
251    root_manifest: &Value,
252    command: ResolutionCommand,
253    plugins_enabled: bool,
254    with_install: bool,
255) -> Vec<ScopeIssue> {
256    let mut issues = Vec::new();
257    for name in active_plugins(project_dir, root_manifest, plugins_enabled) {
258        if let Some(effect) = resolution_effect(&name, command, with_install) {
259            issues.push(ScopeIssue::ResolutionPlugin(name, effect));
260        }
261    }
262    issues
263}
264
265/// The plugins Composer loads for a command: `composer-plugin` packages of
266/// installed.json (project and `COMPOSER_HOME`) that `allow-plugins`
267/// allows. Empty under `--no-plugins`.
268pub fn active_plugins(
269    project_dir: &Path,
270    root_manifest: &Value,
271    plugins_enabled: bool,
272) -> Vec<String> {
273    if !plugins_enabled {
274        return Vec::new();
275    }
276    let mut names: Vec<String> = Vec::new();
277    let mut collect = |packages: Vec<Value>| {
278        for p in packages {
279            if p.get("type").and_then(Value::as_str) == Some("composer-plugin") {
280                if let Some(n) = p.get("name").and_then(Value::as_str) {
281                    if !names.iter().any(|x| x == n) {
282                        names.push(n.to_owned());
283                    }
284                }
285            }
286        }
287    };
288    collect(crate::layout::installed_packages_of(
289        project_dir,
290        root_manifest,
291    ));
292    if let Some(home) = crate::fetch::composer_home() {
293        collect(crate::layout::installed_packages_at(
294            &home.join("vendor").join("composer"),
295        ));
296    }
297    names.retain(|name| {
298        matches!(
299            crate::layout::plugin_allowed(root_manifest, name),
300            crate::layout::PluginVerdict::Allowed
301        )
302    });
303    names
304}
305
306#[derive(Debug, PartialEq, Eq)]
307pub enum ScopeIssue {
308    /// Plugin absent from the known lists: unpredictable behaviour.
309    UnknownPlugin(String),
310    /// An installed, allowed plugin that changes what `update`/`require`/
311    /// `remove` resolve or write (name, what it does).
312    ResolutionPlugin(String, String),
313    /// Plugin known to change the layout (patches, installers-extender...).
314    LayoutPlugin(String),
315    /// Non-reproducible layout (composer/installers: version not ported,
316    /// framework with custom logic, refused target...).
317    Layout(String),
318    /// Package without a usable zip dist (source-only, exotic dist).
319    NoUsableDist(String),
320    /// A `config` key vivacity does not read and that changes the layout
321    /// (`vendor-dir`, `bin-dir`, `preferred-install: source`): Composer's
322    /// output would differ, so the lock is handed over.
323    Config(String),
324    /// `wikimedia/composer-merge-plugin` could not be emulated for this
325    /// run (a `require` pattern without match, an invalid included file,
326    /// or a bare vendor whose lock misses a merged requirement — where
327    /// Composer would run the plugin's implicit update).
328    MergePlugin(String),
329}
330
331impl std::fmt::Display for ScopeIssue {
332    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333        match self {
334            ScopeIssue::UnknownPlugin(p) => {
335                write!(f, "plugin {p} is not on vivacity's known-plugin list")
336            }
337            ScopeIssue::LayoutPlugin(p) => {
338                write!(f, "plugin {p} changes the install layout (not emulated)")
339            }
340            ScopeIssue::ResolutionPlugin(p, what) => write!(f, "plugin {p} {what}"),
341            ScopeIssue::Layout(why) => write!(f, "{why}"),
342            ScopeIssue::NoUsableDist(p) => {
343                write!(
344                    f,
345                    "package {p} has no usable dist (no zip or tar dist, no path source)"
346                )
347            }
348            ScopeIssue::Config(why) => write!(f, "config {why} is not supported natively"),
349            ScopeIssue::MergePlugin(why) => write!(f, "wikimedia/composer-merge-plugin: {why}"),
350        }
351    }
352}
353
354#[derive(Debug, Default)]
355pub struct ScopeReport {
356    /// Blocking: at least one -> fallback (or error without Composer).
357    pub issues: Vec<ScopeIssue>,
358    /// Non-blocking: harmless plugins ignored, to report on stderr.
359    pub skipped_plugins: Vec<String>,
360    /// Resolved layout (None if a layout issue blocks).
361    pub layout: Option<Layout>,
362}
363
364impl ScopeReport {
365    pub fn is_native_ok(&self) -> bool {
366        self.issues.is_empty()
367    }
368}
369
370/// `plugins_enabled` = no `--no-plugins`: with the flag, Composer ignores
371/// every plugin, composer/installers included; everything goes into vendor/.
372pub fn analyze(
373    project_dir: &Path,
374    lock: &Lock,
375    root_manifest: &Value,
376    with_dev: bool,
377    plugins_enabled: bool,
378) -> ScopeReport {
379    let mut report = ScopeReport::default();
380
381    for p in lock.wanted_packages(with_dev) {
382        classify_package(project_dir, p, plugins_enabled, &mut report);
383    }
384    // yii2-composer's installer writes three `Yii.php` shims for
385    // `yiisoft/yii2-dev` (`linkBaseYiiFiles`): not emulated.
386    if plugins_enabled
387        && matches!(
388            crate::layout::plugin_allowed(root_manifest, crate::yii2_composer::PLUGIN_NAME),
389            crate::layout::PluginVerdict::Allowed
390        )
391        && lock
392            .wanted_packages(with_dev)
393            .any(|p| p.name() == crate::yii2_composer::PLUGIN_NAME)
394        && lock
395            .wanted_packages(with_dev)
396            .any(|p| p.name() == crate::yii2_composer::YII2_DEV)
397    {
398        report.issues.push(ScopeIssue::UnknownPlugin(format!(
399            "{} with {} (the Yii.php shims are not emulated); it",
400            crate::yii2_composer::PLUGIN_NAME,
401            crate::yii2_composer::YII2_DEV
402        )));
403    }
404    // The licence check, configured: a denied licence stops Composer
405    // mid-install, which vivacity does not reproduce.
406    if plugins_enabled
407        && license_check_configured(root_manifest)
408        && matches!(
409            crate::layout::plugin_allowed(root_manifest, LICENSE_CHECK_PLUGIN),
410            crate::layout::PluginVerdict::Allowed
411        )
412        && lock
413            .wanted_packages(with_dev)
414            .any(|p| p.name() == LICENSE_CHECK_PLUGIN)
415    {
416        report.issues.push(ScopeIssue::UnknownPlugin(format!(
417            "{LICENSE_CHECK_PLUGIN} with an allow-list or deny-list in `extra` (it stops the install on a denied licence); it"
418        )));
419    }
420    // bamarni/composer-bin-plugin: `forward-command: true` runs nested
421    // installs under vendor-bin/ (not emulated); a wrong setting type is
422    // Composer's abort.
423    if plugins_enabled
424        && matches!(
425            crate::layout::plugin_allowed(root_manifest, crate::bamarni_bin::PLUGIN_NAME),
426            crate::layout::PluginVerdict::Allowed
427        )
428        && lock
429            .wanted_packages(with_dev)
430            .any(|p| p.name() == crate::bamarni_bin::PLUGIN_NAME)
431    {
432        match crate::bamarni_bin::config(root_manifest.get("extra")) {
433            Ok(cfg) if cfg.forward_command => report.issues.push(ScopeIssue::UnknownPlugin(
434                format!(
435                    "{} with extra.bamarni-bin.forward-command true (forwards the command to vendor-bin/*, not emulated); it",
436                    crate::bamarni_bin::PLUGIN_NAME
437                ),
438            )),
439            Ok(_) => {}
440            Err(msg) => report.issues.push(ScopeIssue::Config(format!(
441                "extra.bamarni-bin ({msg})"
442            ))),
443        }
444    }
445    report.issues.extend(
446        config_issues(root_manifest, lock, with_dev)
447            .into_iter()
448            .map(ScopeIssue::Config),
449    );
450    match Layout::resolve(project_dir, lock, root_manifest, with_dev, plugins_enabled) {
451        Ok(layout) => report.layout = Some(layout),
452        Err(issues) => report
453            .issues
454            .extend(issues.into_iter().map(ScopeIssue::Layout)),
455    }
456    report
457}
458
459/// Blocking plugin issues alone (unknown or layout-changing plugins in the
460/// lock), for commands that do not install but would still let Composer run
461/// plugin listeners — `dump-autoload` and its PRE_AUTOLOAD_DUMP.
462pub fn plugin_issues(lock: &Lock, with_dev: bool) -> Vec<ScopeIssue> {
463    let mut report = ScopeReport::default();
464    for p in lock.wanted_packages(with_dev) {
465        classify_plugin(p, &mut report);
466    }
467    report.issues
468}
469
470/// The `config` keys of the manifest (or the global config) that vivacity
471/// does not honour: a `preferred-install` that is not exactly `dist`.
472///
473/// `DownloadManager::resolvePackageInstallPreference` takes the FIRST pattern
474/// that matches, and with no match at all it answers
475/// `$package->isDev() ? 'source' : 'dist'`. So `auto` and any partial pattern
476/// map install a dev-version package from **source** — a git clone where
477/// vivacity extracts a dist, i.e. a different vendor/ and not a missing line.
478/// Composer's own default is the string `dist` (`Config`), which is why the
479/// common case needs no refusal. A map is accepted only when every value is
480/// `dist` AND a `*` pattern makes it total; anything else hands over.
481/// (`vendor-dir` / `bin-dir` are resolved by `dirs::Dirs`; the forms it
482/// refuses come back as layout issues.)
483/// `BaseCommand::getPreferredInstallOptions`: the `preferred-install` this
484/// command really runs with, once `--prefer-source`, `--prefer-dist` and
485/// `--prefer-install` have had their say — `None` when they say nothing and the
486/// config stands.
487///
488/// The cascade, ported: the config's string sets one of the two flags (`auto`, a
489/// map, or anything else sets neither); `--prefer-install` may not be combined
490/// with either flag (`InvalidArgumentException`) and its value stands in for the
491/// matching flag, `auto` clearing both; finally, if either flag is set, the two
492/// flags replace whatever the config said.
493///
494/// Reported as the effective `preferred-install`, which is what the refusal is
495/// about: `config_issues` then judges it per package, exactly as
496/// `resolvePackageInstallPreference` does. `Err` is Composer's own usage
497/// message, printed as text with exit 1.
498pub fn effective_preferred_install(
499    root_manifest: &Value,
500    prefer_source: bool,
501    prefer_dist: bool,
502    prefer_install: Option<&str>,
503) -> Result<Option<Value>, String> {
504    if prefer_install.is_none() && !prefer_source && !prefer_dist {
505        return Ok(None);
506    }
507    let config = root_manifest
508        .get("config")
509        .and_then(|c| c.get("preferred-install"))
510        .cloned()
511        .or_else(|| crate::layout::global_config_value("preferred-install"));
512    let mut flag_source = prefer_source;
513    let mut flag_dist = prefer_dist;
514    let mut cleared = false;
515    if let Some(value) = prefer_install {
516        if prefer_source {
517            return Err(
518                "--prefer-source can not be used together with --prefer-install".to_owned(),
519            );
520        }
521        if prefer_dist {
522            return Err("--prefer-dist can not be used together with --prefer-install".to_owned());
523        }
524        match value {
525            "dist" => flag_dist = true,
526            "source" => flag_source = true,
527            "auto" => cleared = true,
528            other => {
529                return Err(format!(
530                    "--prefer-install accepts one of \"dist\", \"source\" or \"auto\", got {other}"
531                ))
532            }
533        }
534    }
535    if flag_source {
536        return Ok(Some(Value::String("source".to_owned())));
537    }
538    if flag_dist {
539        return Ok(Some(Value::String("dist".to_owned())));
540    }
541    if cleared {
542        // Both flags off: the config's string no longer applies, but its map
543        // still reaches `setPreferences`, so only a string is replaced.
544        return Ok(Some(match config {
545            Some(Value::Object(m)) => Value::Object(m),
546            _ => Value::String("auto".to_owned()),
547        }));
548    }
549    Ok(None)
550}
551
552/// `DownloadManager::resolvePackageInstallPreference` plus `Factory`'s switch,/// `DownloadManager::resolvePackageInstallPreference` plus `Factory`'s switch,
553/// for one package: would Composer install it from source?
554///
555/// A string is handled by the switch — `dist` forces dist, `source` forces
556/// source, anything else (including `auto`) leaves both flags off and falls
557/// through to the per-package rule with no patterns. A map goes to
558/// `setPreferences`, where the FIRST matching pattern decides: `dist` is dist,
559/// `auto` is dist only for a non-dev package, and anything else is source.
560/// With no pattern matching at all: `$package->isDev() ? 'source' : 'dist'`.
561fn prefers_source(preference: &Value, name: &str, is_dev: bool) -> bool {
562    match preference {
563        Value::String(s) => match s.as_str() {
564            "dist" => false,
565            "source" => true,
566            _ => is_dev,
567        },
568        Value::Object(map) => {
569            for (pattern, pref) in map {
570                if pattern_matches(pattern, name) {
571                    return match pref.as_str() {
572                        Some("dist") => false,
573                        Some("auto") => is_dev,
574                        _ => true,
575                    };
576                }
577            }
578            is_dev
579        }
580        _ => is_dev,
581    }
582}
583
584/// `'{^'.str_replace('\*', '.*', preg_quote($pattern)).'$}i'`: a whole-name
585/// The pattern of `resolvePackageInstallPreference`, built as
586/// `preg_quote($pattern)` with `\*` turned into `.*`, anchored, `i`: the whole
587/// name, case-insensitive, `*` the only metacharacter and everything else
588/// literal. Greedy left to right, as `.*` is.
589fn pattern_matches(pattern: &str, name: &str) -> bool {
590    let name = name.to_ascii_lowercase();
591    let pieces: Vec<String> = pattern
592        .to_ascii_lowercase()
593        .split('*')
594        .map(str::to_owned)
595        .collect();
596    if pieces.len() == 1 {
597        return name == pieces[0];
598    }
599    let first = &pieces[0];
600    let last = &pieces[pieces.len() - 1];
601    if !name.starts_with(first.as_str()) || !name.ends_with(last.as_str()) {
602        return false;
603    }
604    // The first and last pieces may not overlap in the name.
605    if name.len() < first.len() + last.len() {
606        return false;
607    }
608    let mut rest = &name[first.len()..name.len() - last.len()];
609    for piece in &pieces[1..pieces.len() - 1] {
610        match rest.find(piece.as_str()) {
611            Some(pos) => rest = &rest[pos + piece.len()..],
612            None => return false,
613        }
614    }
615    true
616}
617
618/// `Package::isDev()`: the version's stability is `dev`.
619fn is_dev_version(version: &str) -> bool {
620    version.starts_with("dev-") || version.ends_with("-dev")
621}
622
623pub fn config_issues(root_manifest: &Value, lock: &Lock, with_dev: bool) -> Vec<String> {
624    let value = |key: &str| -> Option<Value> {
625        root_manifest
626            .get("config")
627            .and_then(|c| c.get(key))
628            .cloned()
629            .or_else(|| crate::layout::global_config_value(key))
630    };
631    let mut out = Vec::new();
632    if let Some(v) = value("preferred-install") {
633        if lock
634            .wanted_packages(with_dev)
635            .any(|p| prefers_source(&v, p.name(), is_dev_version(p.version())))
636        {
637            out.push(format!("preferred-install {v}"));
638        }
639    }
640    out
641}
642
643fn classify_package(
644    project_dir: &Path,
645    p: &LockPackage,
646    plugins_enabled: bool,
647    report: &mut ScopeReport,
648) {
649    // Under --no-plugins Composer never loads a plugin (PluginManager::
650    // registerPackage returns at once): the package is a plain library in
651    // vendor/, whatever it would do when active, and nothing is printed.
652    if plugins_enabled {
653        classify_plugin(p, report);
654    }
655    if p.is_metapackage() {
656        return;
657    }
658    let usable = match p.dist_kind() {
659        DistKind::Zip | DistKind::Tar => true,
660        // A `path` package is laid out natively (symlink or mirror) on
661        // Linux/macOS when its source directory is there; Windows
662        // (junctions) is left to Composer.
663        DistKind::Path => cfg!(unix) && p.dist_url().is_some_and(|u| project_dir.join(u).is_dir()),
664        DistKind::Other | DistKind::Missing => false,
665    };
666    if !usable {
667        report
668            .issues
669            .push(ScopeIssue::NoUsableDist(p.name().to_owned()));
670    }
671}
672
673fn classify_plugin(p: &LockPackage, report: &mut ScopeReport) {
674    if p.package_type() != "composer-plugin" {
675        return;
676    }
677    let name = p.name().to_owned();
678    if EMULATED_PLUGINS.contains(&name.as_str()) {
679        // Emulated natively: nothing to report.
680    } else if BENIGN_PLUGINS.contains(&name.as_str()) {
681        report.skipped_plugins.push(name);
682    } else if LAYOUT_PLUGINS.contains(&name.as_str()) {
683        report.issues.push(ScopeIssue::LayoutPlugin(name));
684    } else {
685        report.issues.push(ScopeIssue::UnknownPlugin(name));
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692    use crate::lock::Lock;
693    use serde_json::json;
694
695    fn lock_with(packages: serde_json::Value) -> Lock {
696        Lock::parse(&json!({ "packages": packages, "packages-dev": [] }).to_string()).expect("lock")
697    }
698
699    fn zip_pkg(name: &str, r#type: &str) -> serde_json::Value {
700        json!({"name": name, "version": "1.0.0", "type": r#type,
701               "dist": {"type": "zip", "url": "https://x/y.zip", "reference": "r"}})
702    }
703
704    fn proj() -> std::path::PathBuf {
705        std::path::PathBuf::from("/nonexistent-vivacity-scope")
706    }
707
708    #[test]
709    fn preferred_install_auto_hands_over_for_a_dev_package() {
710        // `resolvePackageInstallPreference` with no pattern answers
711        // `$package->isDev() ? 'source' : 'dist'`, so `auto` clones a dev
712        // version from source — a git checkout where vivacity extracts a dist.
713        // Measured against Composer 2.10.3: `  - Syncing … into cache` for the
714        // dev packages, `  - Downloading …` for the stable ones.
715        let dev = json!({"name": "a/b", "version": "dev-main", "type": "library",
716                         "dist": {"type": "zip", "url": "https://x/y.zip", "reference": "r"}});
717        for preference in [
718            json!("auto"),
719            json!("source"),
720            json!({"*": "auto"}),
721            json!({"a/*": "auto"}),
722            // No pattern matches, so the default rule applies to a dev package.
723            json!({"other/*": "dist"}),
724        ] {
725            let lock = lock_with(json!([dev.clone()]));
726            let manifest = json!({"config": {"preferred-install": preference}});
727            assert!(
728                !analyze(&proj(), &lock, &manifest, true, true).is_native_ok(),
729                "{preference} with a dev package must hand over"
730            );
731            // The same preference with a stable package is dist throughout.
732            let stable = lock_with(json!([zip_pkg("a/b", "library")]));
733            let native = analyze(&proj(), &stable, &manifest, true, true).is_native_ok();
734            assert_eq!(
735                native,
736                preference != json!("source"),
737                "{preference} with a stable package"
738            );
739        }
740    }
741
742    #[test]
743    fn install_preference_patterns_match_like_pcre() {
744        // `preg_quote` then `\*` -> `.*`, anchored, case-insensitive.
745        for (pattern, name, want) in [
746            ("a/b", "a/b", true),
747            ("a/b", "a/bc", false),
748            ("A/B", "a/b", true),
749            ("*", "anything/at-all", true),
750            ("acme/*", "acme/thing", true),
751            ("acme/*", "acmex/thing", false),
752            ("*/thing", "acme/thing", true),
753            ("a*b", "a/xb", true),
754            // `.*` matches the empty string.
755            ("a*b", "ab", true),
756            ("a**b", "a/b", true),
757            // A dot is literal, not "any character".
758            ("a.b/c", "axb/c", false),
759            ("a.b/c", "a.b/c", true),
760            ("acme/*-bundle", "acme/my-bundle", true),
761            ("acme/*-bundle", "acme/bundle", false),
762        ] {
763            assert_eq!(
764                pattern_matches(pattern, name),
765                want,
766                "pattern {pattern:?} against {name:?}"
767            );
768        }
769    }
770
771    #[test]
772    fn plain_library_is_native() {
773        let lock = lock_with(json!([zip_pkg("a/b", "library")]));
774        let r = analyze(&proj(), &lock, &json!({}), true, true);
775        assert!(r.is_native_ok());
776        assert!(r.skipped_plugins.is_empty());
777        assert_eq!(r.layout.expect("layout").rel("a/b"), Some("vendor/a/b"));
778    }
779
780    #[test]
781    fn emulated_and_benign_plugins_stay_native() {
782        let lock = lock_with(json!([
783            zip_pkg("symfony/runtime", "composer-plugin"),
784            zip_pkg("symfony/flex", "composer-plugin"),
785        ]));
786        let r = analyze(&proj(), &lock, &json!({}), true, true);
787        assert!(r.is_native_ok());
788        // Both are emulated now (symfony/flex since its POST_INSTALL_CMD
789        // listener is): nothing to report as merely skipped.
790        assert!(r.skipped_plugins.is_empty(), "{:?}", r.skipped_plugins);
791    }
792
793    #[test]
794    fn thanks_and_normalize_are_benign_at_install() {
795        let lock = lock_with(json!([
796            zip_pkg("symfony/thanks", "composer-plugin"),
797            zip_pkg("ergebnis/composer-normalize", "composer-plugin"),
798        ]));
799        let r = analyze(&proj(), &lock, &json!({}), true, true);
800        assert!(r.is_native_ok(), "{:?}", r.issues);
801        assert_eq!(
802            r.skipped_plugins,
803            vec!["symfony/thanks", "ergebnis/composer-normalize"]
804        );
805    }
806
807    #[test]
808    fn thanks_reminder_only_on_update_with_install() {
809        use ResolutionCommand::*;
810        let effect = |c, i| resolution_effect("symfony/thanks", c, i);
811        assert!(effect(Update, true).is_some());
812        assert!(effect(Update, false).is_none());
813        assert!(effect(Require, true).is_none());
814        assert!(effect(Remove, true).is_none());
815        assert!(resolution_effect("ergebnis/composer-normalize", Update, true).is_none());
816    }
817
818    #[test]
819    fn yii2_composer_is_emulated_except_with_yii2_dev() {
820        let allow = json!({"config": {"allow-plugins": {"yiisoft/yii2-composer": true}}});
821        let lock = lock_with(json!([
822            zip_pkg("yiisoft/yii2-composer", "composer-plugin"),
823            zip_pkg("yiisoft/yii2-gii", "yii2-extension"),
824        ]));
825        assert!(analyze(&proj(), &lock, &allow, true, true).is_native_ok());
826        let lock = lock_with(json!([
827            zip_pkg("yiisoft/yii2-composer", "composer-plugin"),
828            zip_pkg("yiisoft/yii2-dev", "yii2-extension"),
829        ]));
830        let r = analyze(&proj(), &lock, &allow, true, true);
831        assert!(!r.is_native_ok());
832        assert!(
833            r.issues[0].to_string().contains("yiisoft/yii2-dev"),
834            "{}",
835            r.issues[0]
836        );
837        // Under --no-plugins the shims are not written by Composer either.
838        assert!(analyze(&proj(), &lock, &allow, true, false).is_native_ok());
839    }
840
841    #[test]
842    fn license_check_native_unless_configured() {
843        let lock = lock_with(json!([zip_pkg(
844            "metasyntactical/composer-plugin-license-check",
845            "composer-plugin"
846        )]));
847        let manifest = |extra: serde_json::Value| json!({"config": {"allow-plugins": {"metasyntactical/composer-plugin-license-check": true}}, "extra": extra});
848        // No list: `ComposerConfig` filters everything away, nothing checked.
849        for extra in [
850            json!({}),
851            json!({"metasyntactical/composer-plugin-license-check": {}}),
852            json!({"metasyntactical/composer-plugin-license-check": {"allow-list": []}}),
853            json!({"metasyntactical/composer-plugin-license-check": {"allowed-packages": {"a/b": "why"}}}),
854        ] {
855            let r = analyze(&proj(), &lock, &manifest(extra.clone()), true, true);
856            assert!(
857                r.is_native_ok(),
858                "{extra} should stay native: {:?}",
859                r.issues
860            );
861        }
862        for key in ["allow-list", "deny-list"] {
863            let extra = json!({"metasyntactical/composer-plugin-license-check": {key: ["MIT"]}});
864            let r = analyze(&proj(), &lock, &manifest(extra), true, true);
865            assert!(
866                r.issues[0].to_string().contains("allow-list or deny-list"),
867                "{key}: {:?}",
868                r.issues
869            );
870        }
871    }
872
873    #[test]
874    fn bamarni_bin_native_unless_it_forwards() {
875        let lock = lock_with(json!([zip_pkg(
876            "bamarni/composer-bin-plugin",
877            "composer-plugin"
878        )]));
879        let allow = |extra: serde_json::Value| json!({"config": {"allow-plugins": {"bamarni/composer-bin-plugin": true}}, "extra": extra});
880        assert!(analyze(&proj(), &lock, &allow(json!({})), true, true).is_native_ok());
881        let r = analyze(
882            &proj(),
883            &lock,
884            &allow(json!({"bamarni-bin": {"forward-command": true}})),
885            true,
886            true,
887        );
888        assert!(
889            r.issues[0].to_string().contains("forward-command true"),
890            "{}",
891            r.issues[0]
892        );
893        let r = analyze(
894            &proj(),
895            &lock,
896            &allow(json!({"bamarni-bin": {"bin-links": "no"}})),
897            true,
898            true,
899        );
900        assert!(
901            r.issues[0].to_string().contains("boolean value"),
902            "{}",
903            r.issues[0]
904        );
905    }
906
907    #[test]
908    fn no_plugins_makes_every_plugin_a_plain_library() {
909        let lock = lock_with(json!([
910            zip_pkg("acme/mystery-plugin", "composer-plugin"),
911            zip_pkg("cweagans/composer-patches", "composer-plugin"),
912            zip_pkg("symfony/flex", "composer-plugin"),
913        ]));
914        let r = analyze(&proj(), &lock, &json!({}), true, false);
915        assert!(r.is_native_ok(), "{:?}", r.issues);
916        assert!(r.skipped_plugins.is_empty());
917        assert_eq!(
918            r.layout.expect("layout").rel("acme/mystery-plugin"),
919            Some("vendor/acme/mystery-plugin")
920        );
921    }
922
923    #[test]
924    fn unknown_or_layout_plugin_is_out_of_scope() {
925        let lock = lock_with(json!([
926            zip_pkg("acme/mystery-plugin", "composer-plugin"),
927            zip_pkg("cweagans/composer-patches", "composer-plugin"),
928        ]));
929        let r = analyze(&proj(), &lock, &json!({}), true, true);
930        assert_eq!(
931            r.issues,
932            vec![
933                ScopeIssue::UnknownPlugin("acme/mystery-plugin".into()),
934                ScopeIssue::LayoutPlugin("cweagans/composer-patches".into()),
935            ]
936        );
937    }
938
939    #[test]
940    fn installers_without_allow_plugins_and_sourceless_dist_are_out_of_scope() {
941        let lock = lock_with(json!([
942            {"name": "a/src-only", "version": "1.0.0", "type": "library",
943             "source": {"type": "git", "url": "https://g/x.git", "reference": "r"}},
944            {"name": "a/meta", "version": "1.0.0", "type": "metapackage"},
945            zip_pkg("composer/installers", "composer-plugin"),
946        ]));
947        // installer-paths alone is inert (as in Composer); the plugin without
948        // allow-plugins, however, blocks.
949        let manifest = json!({"extra": {"installer-paths": {"web/modules/{$name}": []}}});
950        let r = analyze(&proj(), &lock, &manifest, true, true);
951        assert_eq!(r.issues.len(), 2, "{:?}", r.issues);
952        assert_eq!(r.issues[0], ScopeIssue::NoUsableDist("a/src-only".into()));
953        assert!(matches!(&r.issues[1], ScopeIssue::Layout(m) if m.contains("allow-plugins")));
954    }
955
956    #[test]
957    fn path_package_is_native_when_its_source_exists() {
958        let tmp = tempfile::tempdir().expect("tmp");
959        std::fs::create_dir_all(tmp.path().join("packages/here")).expect("mkdir");
960        let lock = lock_with(json!([
961            {"name": "a/here", "version": "dev-main", "type": "library",
962             "dist": {"type": "path", "url": "packages/here", "reference": "r"}},
963            {"name": "a/gone", "version": "dev-main", "type": "library",
964             "dist": {"type": "path", "url": "packages/gone", "reference": "r"}},
965        ]));
966        let r = analyze(tmp.path(), &lock, &json!({}), true, true);
967        let expected = if cfg!(unix) {
968            vec![ScopeIssue::NoUsableDist("a/gone".into())]
969        } else {
970            vec![
971                ScopeIssue::NoUsableDist("a/here".into()),
972                ScopeIssue::NoUsableDist("a/gone".into()),
973            ]
974        };
975        assert_eq!(r.issues, expected);
976    }
977
978    #[test]
979    fn unread_config_keys_are_scope_issues() {
980        // `preferred-install` is judged per package, as
981        // `resolvePackageInstallPreference` judges it: only a package that
982        // would come from source is a reason to hand over.
983        let lock = lock_with(json!([zip_pkg("acme/thing", "library")]));
984        let manifest = json!({"config": {"vendor-dir": "lib/", "bin-dir": "vendor/bin",
985            "preferred-install": {"acme/*": "source", "*": "dist"}}});
986        let r = analyze(&proj(), &lock, &manifest, true, true);
987        assert_eq!(
988            r.issues,
989            vec![ScopeIssue::Config(
990                "preferred-install {\"acme/*\":\"source\",\"*\":\"dist\"}".into()
991            ),]
992        );
993        // The same map with a package no source pattern matches: Composer
994        // installs it from dist, so there is nothing to hand over.
995        let lock = lock_with(json!([zip_pkg("a/b", "library")]));
996        let manifest = json!({"config": {"vendor-dir": "vendor",
997            "preferred-install": {"acme/*": "source", "*": "dist"}}});
998        assert!(analyze(&proj(), &lock, &manifest, true, true).is_native_ok());
999        // `auto` with a stable package is dist.
1000        let manifest = json!({"config": {"vendor-dir": "vendor", "preferred-install": "auto"}});
1001        assert!(analyze(&proj(), &lock, &manifest, true, true).is_native_ok());
1002    }
1003
1004    #[test]
1005    fn no_dev_skips_dev_packages() {
1006        let lock = Lock::parse(
1007            &json!({
1008                "packages": [zip_pkg("a/b", "library")],
1009                "packages-dev": [zip_pkg("acme/mystery-plugin", "composer-plugin")]
1010            })
1011            .to_string(),
1012        )
1013        .expect("lock");
1014        assert!(analyze(&proj(), &lock, &json!({}), false, true).is_native_ok());
1015        assert!(!analyze(&proj(), &lock, &json!({}), true, true).is_native_ok());
1016    }
1017}