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 output, drift test).
16/// composer/installers (see `layout`) is, under conditions checked before
17/// any write. drupal/core-composer-scaffold is deliberately absent: its
18/// source is GPL-2.0-or-later and cannot be ported here (NOTICE.md).
19pub const EMULATED_PLUGINS: &[&str] = &[
20    "symfony/runtime",
21    "composer/installers",
22    "pestphp/pest-plugin",
23    "dealerdirect/phpcodesniffer-composer-installer",
24    "phpstan/extension-installer",
25    "rector/extension-installer",
26    "wikimedia/composer-merge-plugin",
27    "yiisoft/yii2-composer",
28    "codeception/c3",
29    "bamarni/composer-bin-plugin",
30    "roots/wordpress-core-installer",
31    "mnsami/composer-custom-directory-installer",
32];
33
34/// Plugins proven to write nothing at install time under a Composer whose
35/// plugins are active (the corpus baseline, docs/corpus/): installed as
36/// libraries, reported with a note. A plugin that writes a file is either
37/// emulated (EMULATED_PLUGINS) or unknown — never listed here. Removed on
38/// the corpus's evidence (2026-09-16): pestphp/pest-plugin
39/// (vendor/pest-plugins.json), phpstan/extension-installer and
40/// rector/extension-installer (GeneratedConfig.php),
41/// dealerdirect/phpcodesniffer-composer-installer (CodeSniffer.conf).
42pub const BENIGN_PLUGINS: &[&str] = &[
43    "symfony/flex",
44    "composer/package-versions-deprecated",
45    "php-http/discovery",
46    // Only listens to POST_CREATE_PROJECT_CMD / POST_INSTALL_CMD to print a
47    // message (MessagePlugin::getSubscribedEvents): no disk effect.
48    "drupal/core-project-message",
49    // Only listens to POST_UPDATE_CMD / POST_CREATE_PROJECT_CMD, and only acts
50    // in a `require` context (Plugin::getSubscribedEvents): inert at install.
51    "drupal/core-recipe-unpack",
52    // `Thanks::activate` arms its reminder only when the command is
53    // `update`; `POST_PACKAGE_UPDATE` then `POST_UPDATE_CMD` print it after
54    // a GitHub GraphQL call. On `install`: two commands added, nothing
55    // else (read at v1.4.1).
56    "symfony/thanks",
57    // A `CommandProvider` (`composer normalize`) with an empty `activate`
58    // and no listener (NormalizePlugin, read at 2.45.0).
59    "ergebnis/composer-normalize",
60];
61
62/// Plugins known to change the install layout or the package contents:
63/// always out of scope.
64pub const LAYOUT_PLUGINS: &[&str] = &[
65    "cweagans/composer-patches",
66    "oomphinc/composer-installers-extender",
67];
68
69/// The resolution commands (`update`, `require`, `remove`): Composer loads
70/// the installed plugins before resolving (`PluginManager::loadInstalledPlugins`
71/// from installed.json, global ones included), and some of them change what
72/// gets resolved or written. Read in the plugins' sources (plan v0.16):
73/// these have no listener that touches the resolution or the lock —
74/// `POST_INSTALL/UPDATE_CMD` writers of files under vendor/ (handled at
75/// install), install-path mappers, message printers.
76pub const RESOLUTION_INERT: &[&str] = &[
77    "composer/installers",
78    "symfony/runtime",
79    "pestphp/pest-plugin",
80    "dealerdirect/phpcodesniffer-composer-installer",
81    "phpstan/extension-installer",
82    "rector/extension-installer",
83    "composer/package-versions-deprecated",
84    "ergebnis/composer-normalize",
85    // `POST_INSTALL_CMD` / `POST_UPDATE_CMD` copy `c3.php` to the root:
86    // the install side's business, emulated there.
87    "codeception/c3",
88    // `COMMAND` forwards `install` / `update` to vendor-bin/* when
89    // `forward-command` is true — nested installs, the root lock untouched;
90    // the install side refuses that configuration.
91    "bamarni/composer-bin-plugin",
92    // An installer for the `wordpress-core` type: install paths only.
93    "roots/wordpress-core-installer",
94    // `POST_INSTALL/UPDATE_CMD`: an in-process `require` of a PSR
95    // implementation only when one is missing — with a complete lock, a
96    // no-op (verified on install with the corpus).
97    "php-http/discovery",
98    "drupal/core-project-message",
99    // `PRE_UPDATE_CMD` gathers patches, `POST_PACKAGE_*` applies them: the
100    // lock is untouched; the install side already hands these to Composer.
101    "cweagans/composer-patches",
102    "mlocati/composer-patcher",
103    "oomphinc/composer-installers-extender",
104    "mnsami/composer-custom-directory-installer",
105];
106
107/// A resolution command, for the per-plugin rules below.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum ResolutionCommand {
110    Update,
111    Require,
112    Remove,
113}
114
115impl ResolutionCommand {
116    pub fn name(self) -> &'static str {
117        match self {
118            ResolutionCommand::Update => "update",
119            ResolutionCommand::Require => "require",
120            ResolutionCommand::Remove => "remove",
121        }
122    }
123}
124
125/// What an active plugin does to `command`, as far as the lock is
126/// concerned: nothing (inert, or emulated for this command), or something
127/// vivacity does not emulate (the reason, for the fallback line).
128/// `with_install`: the command installs after resolving (no `--no-install`).
129pub fn resolution_effect(
130    plugin: &str,
131    command: ResolutionCommand,
132    with_install: bool,
133) -> Option<String> {
134    if RESOLUTION_INERT.contains(&plugin) {
135        return None;
136    }
137    Some(match plugin {
138        // `PRE_POOL_CREATE` filters the pool against `extra.symfony.require`
139        // (`Restricting packages listed in "symfony/symfony" to …`) —
140        // emulated (`vivacity_resolver::flex_filter`) for `update` and
141        // `remove` without install. `require` resolves Flex aliases and
142        // `POST_UPDATE_CMD` applies recipes to the installed packages
143        // (files, symfony.lock): with install, or on `require`, Composer.
144        "symfony/flex" => {
145            if command == ResolutionCommand::Require {
146                "resolves Flex aliases and applies recipes on require (not emulated)".to_owned()
147            } else if with_install {
148                "applies recipes to the packages it installs (not emulated; --no-install resolves natively)".to_owned()
149            } else {
150                return None;
151            }
152        }
153        // `POST_PACKAGE_UPDATE` arms the reminder, `POST_UPDATE_CMD` prints
154        // it after a GitHub GraphQL query (`GitHubClient`): without a token
155        // that is Composer's GitHub auth prompt (an exception under
156        // `--no-interaction`) — stderr and exit code differ. Only once a
157        // package was actually updated, which is not known before
158        // resolving: `update` with install, Composer (`activate` arms it
159        // for the `update` command only: `require` / `remove` stay inert).
160        "symfony/thanks" => {
161            if command == ResolutionCommand::Update && with_install {
162                "prints a reminder after a package update, after querying GitHub (not emulated; --no-install resolves natively)".to_owned()
163            } else {
164                return None;
165            }
166        }
167        // `POST_PACKAGE_UPDATE` records updates, `POST_UPDATE_CMD` prints
168        // yiisoft/yii2's UPGRADE.md notes when it was updated — only with
169        // install, and only known after resolving: with install, Composer.
170        "yiisoft/yii2-composer" => {
171            if with_install {
172                "prints yiisoft/yii2's upgrade notes after an update with install (not emulated; --no-install resolves natively)".to_owned()
173            } else {
174                return None;
175            }
176        }
177        // INIT / `PRE_UPDATE_CMD` merge other manifests into the root
178        // before resolving: emulated (`vivacity_resolver::merge_plugin`),
179        // the merged links, stability flags, aliases and references in
180        // the pool. A merged `repositories` section is the exception,
181        // checked by the caller on the merge itself.
182        "wikimedia/composer-merge-plugin" => return None,
183        // `POST_UPDATE_CMD`, acting only in a `require` context: unpacks
184        // recipes into composer.json.
185        "drupal/core-recipe-unpack" => {
186            if command == ResolutionCommand::Require {
187                "unpacks recipes into composer.json on require (not emulated)".to_owned()
188            } else {
189                return None;
190            }
191        }
192        _ => "is not on vivacity's known-plugin list".to_owned(),
193    })
194}
195
196/// The plugins Composer would load for a resolution command, and what
197/// each does to it: those installed (installed.json of the project and
198/// of `COMPOSER_HOME`) that `allow-plugins` allows. Empty under
199/// `--no-plugins`. One issue per plugin that is not inert for `command`.
200pub fn resolution_issues(
201    project_dir: &Path,
202    root_manifest: &Value,
203    command: ResolutionCommand,
204    plugins_enabled: bool,
205    with_install: bool,
206) -> Vec<ScopeIssue> {
207    let mut issues = Vec::new();
208    for name in active_plugins(project_dir, root_manifest, plugins_enabled) {
209        if let Some(effect) = resolution_effect(&name, command, with_install) {
210            issues.push(ScopeIssue::ResolutionPlugin(name, effect));
211        }
212    }
213    issues
214}
215
216/// The plugins Composer loads for a command: `composer-plugin` packages of
217/// installed.json (project and `COMPOSER_HOME`) that `allow-plugins`
218/// allows. Empty under `--no-plugins`.
219pub fn active_plugins(
220    project_dir: &Path,
221    root_manifest: &Value,
222    plugins_enabled: bool,
223) -> Vec<String> {
224    if !plugins_enabled {
225        return Vec::new();
226    }
227    let mut names: Vec<String> = Vec::new();
228    let mut collect = |packages: Vec<Value>| {
229        for p in packages {
230            if p.get("type").and_then(Value::as_str) == Some("composer-plugin") {
231                if let Some(n) = p.get("name").and_then(Value::as_str) {
232                    if !names.iter().any(|x| x == n) {
233                        names.push(n.to_owned());
234                    }
235                }
236            }
237        }
238    };
239    collect(crate::layout::installed_packages_of(
240        project_dir,
241        root_manifest,
242    ));
243    if let Some(home) = crate::fetch::composer_home() {
244        collect(crate::layout::installed_packages_at(
245            &home.join("vendor").join("composer"),
246        ));
247    }
248    names.retain(|name| {
249        matches!(
250            crate::layout::plugin_allowed(root_manifest, name),
251            crate::layout::PluginVerdict::Allowed
252        )
253    });
254    names
255}
256
257#[derive(Debug, PartialEq, Eq)]
258pub enum ScopeIssue {
259    /// Plugin absent from the known lists: unpredictable behaviour.
260    UnknownPlugin(String),
261    /// An installed, allowed plugin that changes what `update`/`require`/
262    /// `remove` resolve or write (name, what it does).
263    ResolutionPlugin(String, String),
264    /// Plugin known to change the layout (patches, installers-extender...).
265    LayoutPlugin(String),
266    /// Non-reproducible layout (composer/installers: version not ported,
267    /// framework with custom logic, refused target...).
268    Layout(String),
269    /// Package without a usable zip dist (source-only, exotic dist).
270    NoUsableDist(String),
271    /// A `config` key vivacity does not read and that changes the layout
272    /// (`vendor-dir`, `bin-dir`, `preferred-install: source`): Composer's
273    /// output would differ, so the lock is handed over.
274    Config(String),
275    /// `wikimedia/composer-merge-plugin` could not be emulated for this
276    /// run (a `require` pattern without match, an invalid included file,
277    /// or a bare vendor whose lock misses a merged requirement — where
278    /// Composer would run the plugin's implicit update).
279    MergePlugin(String),
280}
281
282impl std::fmt::Display for ScopeIssue {
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        match self {
285            ScopeIssue::UnknownPlugin(p) => {
286                write!(f, "plugin {p} is not on vivacity's known-plugin list")
287            }
288            ScopeIssue::LayoutPlugin(p) => {
289                write!(f, "plugin {p} changes the install layout (not emulated)")
290            }
291            ScopeIssue::ResolutionPlugin(p, what) => write!(f, "plugin {p} {what}"),
292            ScopeIssue::Layout(why) => write!(f, "{why}"),
293            ScopeIssue::NoUsableDist(p) => {
294                write!(
295                    f,
296                    "package {p} has no usable dist (no zip or tar dist, no path source)"
297                )
298            }
299            ScopeIssue::Config(why) => write!(f, "config {why} is not supported natively"),
300            ScopeIssue::MergePlugin(why) => write!(f, "wikimedia/composer-merge-plugin: {why}"),
301        }
302    }
303}
304
305#[derive(Debug, Default)]
306pub struct ScopeReport {
307    /// Blocking: at least one -> fallback (or error without Composer).
308    pub issues: Vec<ScopeIssue>,
309    /// Non-blocking: harmless plugins ignored, to report on stderr.
310    pub skipped_plugins: Vec<String>,
311    /// Resolved layout (None if a layout issue blocks).
312    pub layout: Option<Layout>,
313}
314
315impl ScopeReport {
316    pub fn is_native_ok(&self) -> bool {
317        self.issues.is_empty()
318    }
319}
320
321/// `plugins_enabled` = no `--no-plugins`: with the flag, Composer ignores
322/// every plugin, composer/installers included; everything goes into vendor/.
323pub fn analyze(
324    project_dir: &Path,
325    lock: &Lock,
326    root_manifest: &Value,
327    with_dev: bool,
328    plugins_enabled: bool,
329) -> ScopeReport {
330    let mut report = ScopeReport::default();
331
332    for p in lock.wanted_packages(with_dev) {
333        classify_package(project_dir, p, plugins_enabled, &mut report);
334    }
335    // yii2-composer's installer writes three `Yii.php` shims for
336    // `yiisoft/yii2-dev` (`linkBaseYiiFiles`): not emulated.
337    if plugins_enabled
338        && matches!(
339            crate::layout::plugin_allowed(root_manifest, crate::yii2_composer::PLUGIN_NAME),
340            crate::layout::PluginVerdict::Allowed
341        )
342        && lock
343            .wanted_packages(with_dev)
344            .any(|p| p.name() == crate::yii2_composer::PLUGIN_NAME)
345        && lock
346            .wanted_packages(with_dev)
347            .any(|p| p.name() == crate::yii2_composer::YII2_DEV)
348    {
349        report.issues.push(ScopeIssue::UnknownPlugin(format!(
350            "{} with {} (the Yii.php shims are not emulated); it",
351            crate::yii2_composer::PLUGIN_NAME,
352            crate::yii2_composer::YII2_DEV
353        )));
354    }
355    // bamarni/composer-bin-plugin: `forward-command: true` runs nested
356    // installs under vendor-bin/ (not emulated); a wrong setting type is
357    // Composer's abort.
358    if plugins_enabled
359        && matches!(
360            crate::layout::plugin_allowed(root_manifest, crate::bamarni_bin::PLUGIN_NAME),
361            crate::layout::PluginVerdict::Allowed
362        )
363        && lock
364            .wanted_packages(with_dev)
365            .any(|p| p.name() == crate::bamarni_bin::PLUGIN_NAME)
366    {
367        match crate::bamarni_bin::config(root_manifest.get("extra")) {
368            Ok(cfg) if cfg.forward_command => report.issues.push(ScopeIssue::UnknownPlugin(
369                format!(
370                    "{} with extra.bamarni-bin.forward-command true (forwards the command to vendor-bin/*, not emulated); it",
371                    crate::bamarni_bin::PLUGIN_NAME
372                ),
373            )),
374            Ok(_) => {}
375            Err(msg) => report.issues.push(ScopeIssue::Config(format!(
376                "extra.bamarni-bin ({msg})"
377            ))),
378        }
379    }
380    report.issues.extend(
381        config_issues(root_manifest)
382            .into_iter()
383            .map(ScopeIssue::Config),
384    );
385    match Layout::resolve(project_dir, lock, root_manifest, with_dev, plugins_enabled) {
386        Ok(layout) => report.layout = Some(layout),
387        Err(issues) => report
388            .issues
389            .extend(issues.into_iter().map(ScopeIssue::Layout)),
390    }
391    report
392}
393
394/// Blocking plugin issues alone (unknown or layout-changing plugins in the
395/// lock), for commands that do not install but would still let Composer run
396/// plugin listeners — `dump-autoload` and its PRE_AUTOLOAD_DUMP.
397pub fn plugin_issues(lock: &Lock, with_dev: bool) -> Vec<ScopeIssue> {
398    let mut report = ScopeReport::default();
399    for p in lock.wanted_packages(with_dev) {
400        classify_plugin(p, &mut report);
401    }
402    report.issues
403}
404
405/// The `config` keys of the manifest (or the global config) that vivacity
406/// does not honour: a `preferred-install` asking for `source` anywhere.
407/// (`vendor-dir` / `bin-dir` are resolved by `dirs::Dirs`; the forms it
408/// refuses come back as layout issues.)
409pub fn config_issues(root_manifest: &Value) -> Vec<String> {
410    let value = |key: &str| -> Option<Value> {
411        root_manifest
412            .get("config")
413            .and_then(|c| c.get(key))
414            .cloned()
415            .or_else(|| crate::layout::global_config_value(key))
416    };
417    let mut out = Vec::new();
418    if let Some(v) = value("preferred-install") {
419        let wants_source = match &v {
420            Value::String(s) => s == "source",
421            Value::Object(m) => m.values().any(|x| x.as_str() == Some("source")),
422            _ => false,
423        };
424        if wants_source {
425            out.push(format!("preferred-install {v}"));
426        }
427    }
428    out
429}
430
431fn classify_package(
432    project_dir: &Path,
433    p: &LockPackage,
434    plugins_enabled: bool,
435    report: &mut ScopeReport,
436) {
437    // Under --no-plugins Composer never loads a plugin (PluginManager::
438    // registerPackage returns at once): the package is a plain library in
439    // vendor/, whatever it would do when active, and nothing is printed.
440    if plugins_enabled {
441        classify_plugin(p, report);
442    }
443    if p.is_metapackage() {
444        return;
445    }
446    let usable = match p.dist_kind() {
447        DistKind::Zip | DistKind::Tar => true,
448        // A `path` package is laid out natively (symlink or mirror) on
449        // Linux/macOS when its source directory is there; Windows
450        // (junctions) is left to Composer.
451        DistKind::Path => cfg!(unix) && p.dist_url().is_some_and(|u| project_dir.join(u).is_dir()),
452        DistKind::Other | DistKind::Missing => false,
453    };
454    if !usable {
455        report
456            .issues
457            .push(ScopeIssue::NoUsableDist(p.name().to_owned()));
458    }
459}
460
461fn classify_plugin(p: &LockPackage, report: &mut ScopeReport) {
462    if p.package_type() != "composer-plugin" {
463        return;
464    }
465    let name = p.name().to_owned();
466    if EMULATED_PLUGINS.contains(&name.as_str()) {
467        // Emulated natively: nothing to report.
468    } else if BENIGN_PLUGINS.contains(&name.as_str()) {
469        report.skipped_plugins.push(name);
470    } else if LAYOUT_PLUGINS.contains(&name.as_str()) {
471        report.issues.push(ScopeIssue::LayoutPlugin(name));
472    } else {
473        report.issues.push(ScopeIssue::UnknownPlugin(name));
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use crate::lock::Lock;
481    use serde_json::json;
482
483    fn lock_with(packages: serde_json::Value) -> Lock {
484        Lock::parse(&json!({ "packages": packages, "packages-dev": [] }).to_string()).expect("lock")
485    }
486
487    fn zip_pkg(name: &str, r#type: &str) -> serde_json::Value {
488        json!({"name": name, "version": "1.0.0", "type": r#type,
489               "dist": {"type": "zip", "url": "https://x/y.zip", "reference": "r"}})
490    }
491
492    fn proj() -> std::path::PathBuf {
493        std::path::PathBuf::from("/nonexistent-vivacity-scope")
494    }
495
496    #[test]
497    fn plain_library_is_native() {
498        let lock = lock_with(json!([zip_pkg("a/b", "library")]));
499        let r = analyze(&proj(), &lock, &json!({}), true, true);
500        assert!(r.is_native_ok());
501        assert!(r.skipped_plugins.is_empty());
502        assert_eq!(r.layout.expect("layout").rel("a/b"), Some("vendor/a/b"));
503    }
504
505    #[test]
506    fn emulated_and_benign_plugins_stay_native() {
507        let lock = lock_with(json!([
508            zip_pkg("symfony/runtime", "composer-plugin"),
509            zip_pkg("symfony/flex", "composer-plugin"),
510        ]));
511        let r = analyze(&proj(), &lock, &json!({}), true, true);
512        assert!(r.is_native_ok());
513        assert_eq!(r.skipped_plugins, vec!["symfony/flex"]);
514    }
515
516    #[test]
517    fn thanks_and_normalize_are_benign_at_install() {
518        let lock = lock_with(json!([
519            zip_pkg("symfony/thanks", "composer-plugin"),
520            zip_pkg("ergebnis/composer-normalize", "composer-plugin"),
521        ]));
522        let r = analyze(&proj(), &lock, &json!({}), true, true);
523        assert!(r.is_native_ok(), "{:?}", r.issues);
524        assert_eq!(
525            r.skipped_plugins,
526            vec!["symfony/thanks", "ergebnis/composer-normalize"]
527        );
528    }
529
530    #[test]
531    fn thanks_reminder_only_on_update_with_install() {
532        use ResolutionCommand::*;
533        let effect = |c, i| resolution_effect("symfony/thanks", c, i);
534        assert!(effect(Update, true).is_some());
535        assert!(effect(Update, false).is_none());
536        assert!(effect(Require, true).is_none());
537        assert!(effect(Remove, true).is_none());
538        assert!(resolution_effect("ergebnis/composer-normalize", Update, true).is_none());
539    }
540
541    #[test]
542    fn yii2_composer_is_emulated_except_with_yii2_dev() {
543        let allow = json!({"config": {"allow-plugins": {"yiisoft/yii2-composer": true}}});
544        let lock = lock_with(json!([
545            zip_pkg("yiisoft/yii2-composer", "composer-plugin"),
546            zip_pkg("yiisoft/yii2-gii", "yii2-extension"),
547        ]));
548        assert!(analyze(&proj(), &lock, &allow, true, true).is_native_ok());
549        let lock = lock_with(json!([
550            zip_pkg("yiisoft/yii2-composer", "composer-plugin"),
551            zip_pkg("yiisoft/yii2-dev", "yii2-extension"),
552        ]));
553        let r = analyze(&proj(), &lock, &allow, true, true);
554        assert!(!r.is_native_ok());
555        assert!(
556            r.issues[0].to_string().contains("yiisoft/yii2-dev"),
557            "{}",
558            r.issues[0]
559        );
560        // Under --no-plugins the shims are not written by Composer either.
561        assert!(analyze(&proj(), &lock, &allow, true, false).is_native_ok());
562    }
563
564    #[test]
565    fn bamarni_bin_native_unless_it_forwards() {
566        let lock = lock_with(json!([zip_pkg(
567            "bamarni/composer-bin-plugin",
568            "composer-plugin"
569        )]));
570        let allow = |extra: serde_json::Value| json!({"config": {"allow-plugins": {"bamarni/composer-bin-plugin": true}}, "extra": extra});
571        assert!(analyze(&proj(), &lock, &allow(json!({})), true, true).is_native_ok());
572        let r = analyze(
573            &proj(),
574            &lock,
575            &allow(json!({"bamarni-bin": {"forward-command": true}})),
576            true,
577            true,
578        );
579        assert!(
580            r.issues[0].to_string().contains("forward-command true"),
581            "{}",
582            r.issues[0]
583        );
584        let r = analyze(
585            &proj(),
586            &lock,
587            &allow(json!({"bamarni-bin": {"bin-links": "no"}})),
588            true,
589            true,
590        );
591        assert!(
592            r.issues[0].to_string().contains("boolean value"),
593            "{}",
594            r.issues[0]
595        );
596    }
597
598    #[test]
599    fn no_plugins_makes_every_plugin_a_plain_library() {
600        let lock = lock_with(json!([
601            zip_pkg("acme/mystery-plugin", "composer-plugin"),
602            zip_pkg("cweagans/composer-patches", "composer-plugin"),
603            zip_pkg("symfony/flex", "composer-plugin"),
604        ]));
605        let r = analyze(&proj(), &lock, &json!({}), true, false);
606        assert!(r.is_native_ok(), "{:?}", r.issues);
607        assert!(r.skipped_plugins.is_empty());
608        assert_eq!(
609            r.layout.expect("layout").rel("acme/mystery-plugin"),
610            Some("vendor/acme/mystery-plugin")
611        );
612    }
613
614    #[test]
615    fn unknown_or_layout_plugin_is_out_of_scope() {
616        let lock = lock_with(json!([
617            zip_pkg("acme/mystery-plugin", "composer-plugin"),
618            zip_pkg("cweagans/composer-patches", "composer-plugin"),
619        ]));
620        let r = analyze(&proj(), &lock, &json!({}), true, true);
621        assert_eq!(
622            r.issues,
623            vec![
624                ScopeIssue::UnknownPlugin("acme/mystery-plugin".into()),
625                ScopeIssue::LayoutPlugin("cweagans/composer-patches".into()),
626            ]
627        );
628    }
629
630    #[test]
631    fn installers_without_allow_plugins_and_sourceless_dist_are_out_of_scope() {
632        let lock = lock_with(json!([
633            {"name": "a/src-only", "version": "1.0.0", "type": "library",
634             "source": {"type": "git", "url": "https://g/x.git", "reference": "r"}},
635            {"name": "a/meta", "version": "1.0.0", "type": "metapackage"},
636            zip_pkg("composer/installers", "composer-plugin"),
637        ]));
638        // installer-paths alone is inert (as in Composer); the plugin without
639        // allow-plugins, however, blocks.
640        let manifest = json!({"extra": {"installer-paths": {"web/modules/{$name}": []}}});
641        let r = analyze(&proj(), &lock, &manifest, true, true);
642        assert_eq!(r.issues.len(), 2, "{:?}", r.issues);
643        assert_eq!(r.issues[0], ScopeIssue::NoUsableDist("a/src-only".into()));
644        assert!(matches!(&r.issues[1], ScopeIssue::Layout(m) if m.contains("allow-plugins")));
645    }
646
647    #[test]
648    fn path_package_is_native_when_its_source_exists() {
649        let tmp = tempfile::tempdir().expect("tmp");
650        std::fs::create_dir_all(tmp.path().join("packages/here")).expect("mkdir");
651        let lock = lock_with(json!([
652            {"name": "a/here", "version": "dev-main", "type": "library",
653             "dist": {"type": "path", "url": "packages/here", "reference": "r"}},
654            {"name": "a/gone", "version": "dev-main", "type": "library",
655             "dist": {"type": "path", "url": "packages/gone", "reference": "r"}},
656        ]));
657        let r = analyze(tmp.path(), &lock, &json!({}), true, true);
658        let expected = if cfg!(unix) {
659            vec![ScopeIssue::NoUsableDist("a/gone".into())]
660        } else {
661            vec![
662                ScopeIssue::NoUsableDist("a/here".into()),
663                ScopeIssue::NoUsableDist("a/gone".into()),
664            ]
665        };
666        assert_eq!(r.issues, expected);
667    }
668
669    #[test]
670    fn unread_config_keys_are_scope_issues() {
671        let lock = lock_with(json!([zip_pkg("a/b", "library")]));
672        let manifest = json!({"config": {"vendor-dir": "lib/", "bin-dir": "vendor/bin",
673            "preferred-install": {"acme/*": "source", "*": "dist"}}});
674        let r = analyze(&proj(), &lock, &manifest, true, true);
675        assert_eq!(
676            r.issues,
677            vec![ScopeIssue::Config(
678                "preferred-install {\"acme/*\":\"source\",\"*\":\"dist\"}".into()
679            ),]
680        );
681        let manifest = json!({"config": {"vendor-dir": "vendor", "preferred-install": "auto"}});
682        assert!(analyze(&proj(), &lock, &manifest, true, true).is_native_ok());
683    }
684
685    #[test]
686    fn no_dev_skips_dev_packages() {
687        let lock = Lock::parse(
688            &json!({
689                "packages": [zip_pkg("a/b", "library")],
690                "packages-dev": [zip_pkg("acme/mystery-plugin", "composer-plugin")]
691            })
692            .to_string(),
693        )
694        .expect("lock");
695        assert!(analyze(&proj(), &lock, &json!({}), false, true).is_native_ok());
696        assert!(!analyze(&proj(), &lock, &json!({}), true, true).is_native_ok());
697    }
698}