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];
28
29/// Plugins proven to write nothing at install time under a Composer whose
30/// plugins are active (the corpus baseline, docs/corpus/): installed as
31/// libraries, reported with a note. A plugin that writes a file is either
32/// emulated (EMULATED_PLUGINS) or unknown — never listed here. Removed on
33/// the corpus's evidence (2026-09-16): pestphp/pest-plugin
34/// (vendor/pest-plugins.json), phpstan/extension-installer and
35/// rector/extension-installer (GeneratedConfig.php),
36/// dealerdirect/phpcodesniffer-composer-installer (CodeSniffer.conf).
37pub const BENIGN_PLUGINS: &[&str] = &[
38    "symfony/flex",
39    "composer/package-versions-deprecated",
40    "php-http/discovery",
41    // Only listens to POST_CREATE_PROJECT_CMD / POST_INSTALL_CMD to print a
42    // message (MessagePlugin::getSubscribedEvents): no disk effect.
43    "drupal/core-project-message",
44    // Only listens to POST_UPDATE_CMD / POST_CREATE_PROJECT_CMD, and only acts
45    // in a `require` context (Plugin::getSubscribedEvents): inert at install.
46    "drupal/core-recipe-unpack",
47];
48
49/// Plugins known to change the install layout or the package contents:
50/// always out of scope.
51pub const LAYOUT_PLUGINS: &[&str] = &[
52    "cweagans/composer-patches",
53    "oomphinc/composer-installers-extender",
54    "mnsami/composer-custom-directory-installer",
55];
56
57#[derive(Debug, PartialEq, Eq)]
58pub enum ScopeIssue {
59    /// Plugin absent from the known lists: unpredictable behaviour.
60    UnknownPlugin(String),
61    /// Plugin known to change the layout (patches, installers-extender...).
62    LayoutPlugin(String),
63    /// Non-reproducible layout (composer/installers: version not ported,
64    /// framework with custom logic, refused target...).
65    Layout(String),
66    /// Package without a usable zip dist (source-only, exotic dist).
67    NoUsableDist(String),
68    /// A `config` key vivacity does not read and that changes the layout
69    /// (`vendor-dir`, `bin-dir`, `preferred-install: source`): Composer's
70    /// output would differ, so the lock is handed over.
71    Config(String),
72    /// `wikimedia/composer-merge-plugin` could not be emulated for this
73    /// run (a `require` pattern without match, an invalid included file,
74    /// or a bare vendor whose lock misses a merged requirement — where
75    /// Composer would run the plugin's implicit update).
76    MergePlugin(String),
77}
78
79impl std::fmt::Display for ScopeIssue {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            ScopeIssue::UnknownPlugin(p) => {
83                write!(f, "plugin {p} is not on vivacity's known-plugin list")
84            }
85            ScopeIssue::LayoutPlugin(p) => {
86                write!(f, "plugin {p} changes the install layout (not emulated)")
87            }
88            ScopeIssue::Layout(why) => write!(f, "{why}"),
89            ScopeIssue::NoUsableDist(p) => {
90                write!(f, "package {p} has no usable dist (no zip, no path source)")
91            }
92            ScopeIssue::Config(why) => write!(f, "config {why} is not supported natively"),
93            ScopeIssue::MergePlugin(why) => write!(f, "wikimedia/composer-merge-plugin: {why}"),
94        }
95    }
96}
97
98#[derive(Debug, Default)]
99pub struct ScopeReport {
100    /// Blocking: at least one -> fallback (or error without Composer).
101    pub issues: Vec<ScopeIssue>,
102    /// Non-blocking: harmless plugins ignored, to report on stderr.
103    pub skipped_plugins: Vec<String>,
104    /// Resolved layout (None if a layout issue blocks).
105    pub layout: Option<Layout>,
106}
107
108impl ScopeReport {
109    pub fn is_native_ok(&self) -> bool {
110        self.issues.is_empty()
111    }
112}
113
114/// `plugins_enabled` = no `--no-plugins`: with the flag, Composer ignores
115/// every plugin, composer/installers included; everything goes into vendor/.
116pub fn analyze(
117    project_dir: &Path,
118    lock: &Lock,
119    root_manifest: &Value,
120    with_dev: bool,
121    plugins_enabled: bool,
122) -> ScopeReport {
123    let mut report = ScopeReport::default();
124
125    for p in lock.wanted_packages(with_dev) {
126        classify_package(project_dir, p, plugins_enabled, &mut report);
127    }
128    report.issues.extend(
129        config_issues(root_manifest)
130            .into_iter()
131            .map(ScopeIssue::Config),
132    );
133    match Layout::resolve(project_dir, lock, root_manifest, with_dev, plugins_enabled) {
134        Ok(layout) => report.layout = Some(layout),
135        Err(issues) => report
136            .issues
137            .extend(issues.into_iter().map(ScopeIssue::Layout)),
138    }
139    report
140}
141
142/// Blocking plugin issues alone (unknown or layout-changing plugins in the
143/// lock), for commands that do not install but would still let Composer run
144/// plugin listeners — `dump-autoload` and its PRE_AUTOLOAD_DUMP.
145pub fn plugin_issues(lock: &Lock, with_dev: bool) -> Vec<ScopeIssue> {
146    let mut report = ScopeReport::default();
147    for p in lock.wanted_packages(with_dev) {
148        classify_plugin(p, &mut report);
149    }
150    report.issues
151}
152
153/// The `config` keys of the manifest (or the global config) that vivacity
154/// does not honour: a `preferred-install` asking for `source` anywhere.
155/// (`vendor-dir` / `bin-dir` are resolved by `dirs::Dirs`; the forms it
156/// refuses come back as layout issues.)
157pub fn config_issues(root_manifest: &Value) -> Vec<String> {
158    let value = |key: &str| -> Option<Value> {
159        root_manifest
160            .get("config")
161            .and_then(|c| c.get(key))
162            .cloned()
163            .or_else(|| crate::layout::global_config_value(key))
164    };
165    let mut out = Vec::new();
166    if let Some(v) = value("preferred-install") {
167        let wants_source = match &v {
168            Value::String(s) => s == "source",
169            Value::Object(m) => m.values().any(|x| x.as_str() == Some("source")),
170            _ => false,
171        };
172        if wants_source {
173            out.push(format!("preferred-install {v}"));
174        }
175    }
176    out
177}
178
179fn classify_package(
180    project_dir: &Path,
181    p: &LockPackage,
182    plugins_enabled: bool,
183    report: &mut ScopeReport,
184) {
185    // Under --no-plugins Composer never loads a plugin (PluginManager::
186    // registerPackage returns at once): the package is a plain library in
187    // vendor/, whatever it would do when active, and nothing is printed.
188    if plugins_enabled {
189        classify_plugin(p, report);
190    }
191    if p.is_metapackage() {
192        return;
193    }
194    let usable = match p.dist_kind() {
195        DistKind::Zip => true,
196        // A `path` package is laid out natively (symlink or mirror) on
197        // Linux/macOS when its source directory is there; Windows
198        // (junctions) is left to Composer.
199        DistKind::Path => cfg!(unix) && p.dist_url().is_some_and(|u| project_dir.join(u).is_dir()),
200        DistKind::Other | DistKind::Missing => false,
201    };
202    if !usable {
203        report
204            .issues
205            .push(ScopeIssue::NoUsableDist(p.name().to_owned()));
206    }
207}
208
209fn classify_plugin(p: &LockPackage, report: &mut ScopeReport) {
210    if p.package_type() != "composer-plugin" {
211        return;
212    }
213    let name = p.name().to_owned();
214    if EMULATED_PLUGINS.contains(&name.as_str()) {
215        // Emulated natively: nothing to report.
216    } else if BENIGN_PLUGINS.contains(&name.as_str()) {
217        report.skipped_plugins.push(name);
218    } else if LAYOUT_PLUGINS.contains(&name.as_str()) {
219        report.issues.push(ScopeIssue::LayoutPlugin(name));
220    } else {
221        report.issues.push(ScopeIssue::UnknownPlugin(name));
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::lock::Lock;
229    use serde_json::json;
230
231    fn lock_with(packages: serde_json::Value) -> Lock {
232        Lock::parse(&json!({ "packages": packages, "packages-dev": [] }).to_string()).expect("lock")
233    }
234
235    fn zip_pkg(name: &str, r#type: &str) -> serde_json::Value {
236        json!({"name": name, "version": "1.0.0", "type": r#type,
237               "dist": {"type": "zip", "url": "https://x/y.zip", "reference": "r"}})
238    }
239
240    fn proj() -> std::path::PathBuf {
241        std::path::PathBuf::from("/nonexistent-vivacity-scope")
242    }
243
244    #[test]
245    fn plain_library_is_native() {
246        let lock = lock_with(json!([zip_pkg("a/b", "library")]));
247        let r = analyze(&proj(), &lock, &json!({}), true, true);
248        assert!(r.is_native_ok());
249        assert!(r.skipped_plugins.is_empty());
250        assert_eq!(r.layout.expect("layout").rel("a/b"), Some("vendor/a/b"));
251    }
252
253    #[test]
254    fn emulated_and_benign_plugins_stay_native() {
255        let lock = lock_with(json!([
256            zip_pkg("symfony/runtime", "composer-plugin"),
257            zip_pkg("symfony/flex", "composer-plugin"),
258        ]));
259        let r = analyze(&proj(), &lock, &json!({}), true, true);
260        assert!(r.is_native_ok());
261        assert_eq!(r.skipped_plugins, vec!["symfony/flex"]);
262    }
263
264    #[test]
265    fn no_plugins_makes_every_plugin_a_plain_library() {
266        let lock = lock_with(json!([
267            zip_pkg("acme/mystery-plugin", "composer-plugin"),
268            zip_pkg("cweagans/composer-patches", "composer-plugin"),
269            zip_pkg("symfony/flex", "composer-plugin"),
270        ]));
271        let r = analyze(&proj(), &lock, &json!({}), true, false);
272        assert!(r.is_native_ok(), "{:?}", r.issues);
273        assert!(r.skipped_plugins.is_empty());
274        assert_eq!(
275            r.layout.expect("layout").rel("acme/mystery-plugin"),
276            Some("vendor/acme/mystery-plugin")
277        );
278    }
279
280    #[test]
281    fn unknown_or_layout_plugin_is_out_of_scope() {
282        let lock = lock_with(json!([
283            zip_pkg("acme/mystery-plugin", "composer-plugin"),
284            zip_pkg("cweagans/composer-patches", "composer-plugin"),
285        ]));
286        let r = analyze(&proj(), &lock, &json!({}), true, true);
287        assert_eq!(
288            r.issues,
289            vec![
290                ScopeIssue::UnknownPlugin("acme/mystery-plugin".into()),
291                ScopeIssue::LayoutPlugin("cweagans/composer-patches".into()),
292            ]
293        );
294    }
295
296    #[test]
297    fn installers_without_allow_plugins_and_sourceless_dist_are_out_of_scope() {
298        let lock = lock_with(json!([
299            {"name": "a/src-only", "version": "1.0.0", "type": "library",
300             "source": {"type": "git", "url": "https://g/x.git", "reference": "r"}},
301            {"name": "a/meta", "version": "1.0.0", "type": "metapackage"},
302            zip_pkg("composer/installers", "composer-plugin"),
303        ]));
304        // installer-paths alone is inert (as in Composer); the plugin without
305        // allow-plugins, however, blocks.
306        let manifest = json!({"extra": {"installer-paths": {"web/modules/{$name}": []}}});
307        let r = analyze(&proj(), &lock, &manifest, true, true);
308        assert_eq!(r.issues.len(), 2, "{:?}", r.issues);
309        assert_eq!(r.issues[0], ScopeIssue::NoUsableDist("a/src-only".into()));
310        assert!(matches!(&r.issues[1], ScopeIssue::Layout(m) if m.contains("allow-plugins")));
311    }
312
313    #[test]
314    fn path_package_is_native_when_its_source_exists() {
315        let tmp = tempfile::tempdir().expect("tmp");
316        std::fs::create_dir_all(tmp.path().join("packages/here")).expect("mkdir");
317        let lock = lock_with(json!([
318            {"name": "a/here", "version": "dev-main", "type": "library",
319             "dist": {"type": "path", "url": "packages/here", "reference": "r"}},
320            {"name": "a/gone", "version": "dev-main", "type": "library",
321             "dist": {"type": "path", "url": "packages/gone", "reference": "r"}},
322        ]));
323        let r = analyze(tmp.path(), &lock, &json!({}), true, true);
324        let expected = if cfg!(unix) {
325            vec![ScopeIssue::NoUsableDist("a/gone".into())]
326        } else {
327            vec![
328                ScopeIssue::NoUsableDist("a/here".into()),
329                ScopeIssue::NoUsableDist("a/gone".into()),
330            ]
331        };
332        assert_eq!(r.issues, expected);
333    }
334
335    #[test]
336    fn unread_config_keys_are_scope_issues() {
337        let lock = lock_with(json!([zip_pkg("a/b", "library")]));
338        let manifest = json!({"config": {"vendor-dir": "lib/", "bin-dir": "vendor/bin",
339            "preferred-install": {"acme/*": "source", "*": "dist"}}});
340        let r = analyze(&proj(), &lock, &manifest, true, true);
341        assert_eq!(
342            r.issues,
343            vec![ScopeIssue::Config(
344                "preferred-install {\"acme/*\":\"source\",\"*\":\"dist\"}".into()
345            ),]
346        );
347        let manifest = json!({"config": {"vendor-dir": "vendor", "preferred-install": "auto"}});
348        assert!(analyze(&proj(), &lock, &manifest, true, true).is_native_ok());
349    }
350
351    #[test]
352    fn no_dev_skips_dev_packages() {
353        let lock = Lock::parse(
354            &json!({
355                "packages": [zip_pkg("a/b", "library")],
356                "packages-dev": [zip_pkg("acme/mystery-plugin", "composer-plugin")]
357            })
358            .to_string(),
359        )
360        .expect("lock");
361        assert!(analyze(&proj(), &lock, &json!({}), false, true).is_native_ok());
362        assert!(!analyze(&proj(), &lock, &json!({}), true, true).is_native_ok());
363    }
364}