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