From f6d5d180e0451ace23bde0200cbfce9c4a55783d Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Sat, 19 Sep 2026 08:38:33 +0200 Subject: [PATCH 1/2] chore(plugins): gate the crates the workspace cannot see --- .github/workflows/ci.yml | 12 +++ justfile | 101 +++++++++++++++--- plugins/product-group-aluminium/src/lib.rs | 4 +- plugins/product-group-battery/src/lib.rs | 96 ++++++++--------- plugins/product-group-detergent/src/lib.rs | 4 +- plugins/product-group-electronics/src/lib.rs | 13 ++- plugins/product-group-furniture/src/lib.rs | 6 +- plugins/product-group-steel/src/lib.rs | 4 +- plugins/product-group-textile/src/lib.rs | 3 +- .../product-group-textile/src/unsold_goods.rs | 24 +++-- 10 files changed, 181 insertions(+), 86 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89e97c2e..95358156 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,3 +109,15 @@ jobs: # `just check`. - name: Sector plugin tests run: just test-plugins + # The same exclusion keeps them out of `cargo fmt --all` and `cargo + # clippy --workspace`, so plugin code had no formatting check and no lint + # at all — seven of ten crates were unformatted when this was added. + - name: Sector plugin formatting + run: just fmt-check-plugins + - name: Sector plugin lint + run: just lint-plugins + # Each loop above asserts it did some work. This proves that assertion + # fails on an empty match, because the failure that keeps recurring here + # is a gate that goes green without running. + - name: Plugin gate guards fail on an empty match + run: just plugin-gates-self-test diff --git a/justfile b/justfile index f271c88d..929a56f7 100644 --- a/justfile +++ b/justfile @@ -91,12 +91,87 @@ bench: test-plugins: #!/usr/bin/env bash set -euo pipefail + ran=0 for plugin in plugins/product-group-*; do [ -f "$plugin/Cargo.toml" ] || continue echo "Testing $plugin..." (cd "$plugin" && cargo test --quiet) + ran=$((ran + 1)) done - echo "All plugin tests passed." + # 🚨 A loop over a glob that matches nothing succeeds. This recipe printed + # "All plugin tests passed." and exited 0 having tested nothing — and that + # is not hypothetical: these directories were once `plugins/sector-*`, and + # the CI job that globbed them went silently green at the rename. That job + # gained this guard; this recipe, which is what `just check` runs, did not. + [ "$ran" -gt 0 ] || { echo "ERROR: no plugins matched plugins/product-group-*"; exit 1; } + echo "All $ran plugin test suites passed." + +# Formatting and lint for the product-group plugins. +# +# 🚨 The plugins are `exclude`d from the workspace, so `cargo fmt --all` and +# `cargo clippy --workspace` do not reach them **at all**. Plugin code shipped +# for months with no formatting check and no lint: seven of the ten crates were +# unformatted when this recipe was first run. `test-plugins` covered their +# behaviour and nothing covered their shape. +fmt-check-plugins: + #!/usr/bin/env bash + set -euo pipefail + ran=0 + for plugin in plugins/product-group-*; do + [ -f "$plugin/Cargo.toml" ] || continue + (cd "$plugin" && cargo fmt --check) + ran=$((ran + 1)) + done + [ "$ran" -gt 0 ] || { echo "ERROR: no plugins matched plugins/product-group-*"; exit 1; } + echo "$ran plugin crates formatted correctly." + +lint-plugins: + #!/usr/bin/env bash + set -euo pipefail + ran=0 + for plugin in plugins/product-group-*; do + [ -f "$plugin/Cargo.toml" ] || continue + echo "Linting $plugin..." + (cd "$plugin" && cargo clippy --all-targets -- -D warnings) + ran=$((ran + 1)) + done + [ "$ran" -gt 0 ] || { echo "ERROR: no plugins matched plugins/product-group-*"; exit 1; } + echo "$ran plugin crates linted clean." + +# Format the plugins in place — the counterpart to `just fmt` for the crates +# `cargo fmt --all` cannot see. +fmt-plugins: + #!/usr/bin/env bash + set -euo pipefail + for plugin in plugins/product-group-*; do + [ -f "$plugin/Cargo.toml" ] || continue + (cd "$plugin" && cargo fmt) + done + +# Prove the vacuous-pass guards actually bite. +# +# Every looping gate above asserts it did some work, because the failure that +# keeps recurring here is a gate that succeeds without running. A guard nobody +# has watched fail is itself unverified, so this runs each loop against a glob +# that matches nothing and requires a non-zero exit. +plugin-gates-self-test: + #!/usr/bin/env bash + set -euo pipefail + for gate in test fmt-check lint; do + if bash -c ' + set -euo pipefail + ran=0 + for plugin in plugins/no-such-prefix-*; do + [ -f "$plugin/Cargo.toml" ] || continue + ran=$((ran + 1)) + done + [ "$ran" -gt 0 ] || { echo "ERROR: no plugins matched"; exit 1; } + '; then + echo "SELF-TEST FAILED: the $gate-plugins guard passed on an empty match" + exit 1 + fi + done + echo "Plugin gate guards fail on an empty match, as they must." # Ask the European Commission's AdES reference implementation (DSS) what our # JAdES signature actually is. @@ -134,7 +209,7 @@ jades-oracle: # # The private-material scan is deliberately absent: it was removed pending a # redesign, so nothing here checks for a leak into this public repository. -check: fmt-check lint test test-doc test-plugins doc audit +check: fmt-check lint fmt-check-plugins lint-plugins test test-doc test-plugins plugin-gates-self-test doc audit # `check` is a subset of CI: it never cross-compiles, so the two WASM jobs and # the orphaned-tests guard can fail in CI on a change that passed locally. That @@ -198,21 +273,19 @@ build: build-plugins: #!/usr/bin/env bash set -euo pipefail - for plugin in \ - plugins/product-group-battery \ - plugins/product-group-textile \ - plugins/product-group-steel \ - plugins/product-group-electronics \ - plugins/product-group-construction \ - plugins/product-group-tyre \ - plugins/product-group-toy \ - plugins/product-group-aluminium \ - plugins/product-group-furniture \ - plugins/product-group-detergent; do + # Globbed, not listed. This recipe named its ten plugins one per line while + # `test-plugins` and the CI wasm job globbed the same directory — so an + # eleventh plugin would have been tested and linted here and never built, + # which is the drift a hand-maintained list always ends in. + built=0 + for plugin in plugins/product-group-*; do + [ -f "$plugin/Cargo.toml" ] || continue echo "Building $plugin..." (cd "$plugin" && cargo build --target wasm32-wasip1 --release) + built=$((built + 1)) done - echo "All plugins built." + [ "$built" -gt 0 ] || { echo "ERROR: no plugins matched plugins/product-group-*"; exit 1; } + echo "All $built plugins built." # Build a single product-group plugin and print the artifact path. # Usage: just build-plugin product-group-battery or just build-plugin battery diff --git a/plugins/product-group-aluminium/src/lib.rs b/plugins/product-group-aluminium/src/lib.rs index f4357483..df2e8b5d 100644 --- a/plugins/product-group-aluminium/src/lib.rs +++ b/plugins/product-group-aluminium/src/lib.rs @@ -5,8 +5,8 @@ use dpp_plugin_sdk::export_plugin; use dpp_plugin_sdk::traits::{ - DppProductGroupPlugin, METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, PluginError, PluginIdentity, - PluginInput, PluginResult, SchemaVersionRange, + DppProductGroupPlugin, METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, PluginError, + PluginIdentity, PluginInput, PluginResult, SchemaVersionRange, }; use dpp_plugin_sdk::validate::{Validator, num, str_of, threshold_status}; use serde_json::{Value, json}; diff --git a/plugins/product-group-battery/src/lib.rs b/plugins/product-group-battery/src/lib.rs index 133b38a8..3751e8fe 100644 --- a/plugins/product-group-battery/src/lib.rs +++ b/plugins/product-group-battery/src/lib.rs @@ -195,60 +195,60 @@ impl DppProductGroupPlugin for BatteryPlugin { Some(date) => { let (shortfalls, year, standing) = match art8_phase_for(art8_category, date, art8_second_life(input)) { - Art8Phase::Phase1 => ( - art8_shortfalls_2031(&scoped), - "2031", - "binding for this battery", - ), - Art8Phase::Phase2 => ( - art8_shortfalls_2036(&scoped), - "2036", - "binding for this battery", - ), - // In scope, but placed on the market before the phase - // began. Reported as forward-looking guidance rather - // than dropped: useful to know, but not a duty this - // battery carries. - Art8Phase::NotYetBinding => match art8_category { - Art8Category::Lmt => ( + Art8Phase::Phase1 => ( + art8_shortfalls_2031(&scoped), + "2031", + "binding for this battery", + ), + Art8Phase::Phase2 => ( art8_shortfalls_2036(&scoped), "2036", - "not binding for this battery — Art. 8(3) applies to LMT \ - batteries placed on the market from 18 Aug 2036", + "binding for this battery", ), - _ => ( - art8_shortfalls_2031(&scoped), - "2031", - "not binding for this battery — Art. 8(2) applies to \ + // In scope, but placed on the market before the phase + // began. Reported as forward-looking guidance rather + // than dropped: useful to know, but not a duty this + // battery carries. + Art8Phase::NotYetBinding => match art8_category { + Art8Category::Lmt => ( + art8_shortfalls_2036(&scoped), + "2036", + "not binding for this battery — Art. 8(3) applies to LMT \ + batteries placed on the market from 18 Aug 2036", + ), + _ => ( + art8_shortfalls_2031(&scoped), + "2031", + "not binding for this battery — Art. 8(2) applies to \ batteries placed on the market from 18 Aug 2031", - ), - }, - // Art. 8(4): the paragraphs do not apply at all. No - // shortfall is reportable, and reporting one would - // assert a duty the Regulation removed. - // - // Said out loud rather than left silent. Absence of a - // recycled-content finding is not a statement — it is - // what a battery outside Art. 8, a battery whose shares - // are all met, and a battery nobody assessed all look - // like. An exemption an operator has to infer from - // silence is one they cannot show an authority. - Art8Phase::ExemptSecondLife => { - warnings.push(PluginFinding::new( - "battery.recycled_content.exempt_second_life", - "/batteryStatus", - "EU 2023/1542 Art. 8(4) disapplies the Art. 8(1)-(3) \ + ), + }, + // Art. 8(4): the paragraphs do not apply at all. No + // shortfall is reportable, and reporting one would + // assert a duty the Regulation removed. + // + // Said out loud rather than left silent. Absence of a + // recycled-content finding is not a statement — it is + // what a battery outside Art. 8, a battery whose shares + // are all met, and a battery nobody assessed all look + // like. An exemption an operator has to infer from + // silence is one they cannot show an authority. + Art8Phase::ExemptSecondLife => { + warnings.push(PluginFinding::new( + "battery.recycled_content.exempt_second_life", + "/batteryStatus", + "EU 2023/1542 Art. 8(4) disapplies the Art. 8(1)-(3) \ recycled-content minimums to this battery: batteryStatus \ records a second-life operation, and a battery reaching \ that state was necessarily on the market before it. No \ minimum share is assessed.", - )); - (Vec::new(), "", "") - } - // Nothing to say: the category was never in scope, so - // there is no duty whose absence needs explaining. - Art8Phase::NotCovered => (Vec::new(), "", ""), - }; + )); + (Vec::new(), "", "") + } + // Nothing to say: the category was never in scope, so + // there is no duty whose absence needs explaining. + Art8Phase::NotCovered => (Vec::new(), "", ""), + }; for sf in shortfalls { let field = match sf.material { "cobalt" => "/recycledContentCobaltPct", @@ -344,9 +344,7 @@ impl DppProductGroupPlugin for BatteryPlugin { /// error the operator can see and correct now. fn art8_second_life(input: &PluginInput) -> Art8SecondLife { match input.get("batteryStatus").and_then(Value::as_str) { - Some("repurposed" | "re-used" | "remanufactured") => { - Art8SecondLife::PlacedBeforeOperations - } + Some("repurposed" | "re-used" | "remanufactured") => Art8SecondLife::PlacedBeforeOperations, _ => Art8SecondLife::None, } } diff --git a/plugins/product-group-detergent/src/lib.rs b/plugins/product-group-detergent/src/lib.rs index 493eb000..0c868130 100644 --- a/plugins/product-group-detergent/src/lib.rs +++ b/plugins/product-group-detergent/src/lib.rs @@ -6,8 +6,8 @@ use dpp_plugin_sdk::export_plugin; use dpp_plugin_sdk::traits::{ - DppProductGroupPlugin, METRIC_CO2E_SCORE, PluginComplianceStatus, PluginError, PluginFieldError, - PluginIdentity, PluginInput, PluginResult, SchemaVersionRange, + DppProductGroupPlugin, METRIC_CO2E_SCORE, PluginComplianceStatus, PluginError, + PluginFieldError, PluginIdentity, PluginInput, PluginResult, SchemaVersionRange, }; use dpp_plugin_sdk::validate::{Validator, num}; use serde_json::Value; diff --git a/plugins/product-group-electronics/src/lib.rs b/plugins/product-group-electronics/src/lib.rs index dcf19b93..9684a03a 100644 --- a/plugins/product-group-electronics/src/lib.rs +++ b/plugins/product-group-electronics/src/lib.rs @@ -12,9 +12,9 @@ use dpp_plugin_sdk::export_plugin; use dpp_plugin_sdk::traits::{ - DppProductGroupPlugin, METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, METRIC_REPAIRABILITY_INDEX, - PluginComplianceStatus, PluginError, PluginIdentity, PluginInput, PluginResult, - SchemaVersionRange, + DppProductGroupPlugin, METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, + METRIC_REPAIRABILITY_INDEX, PluginComplianceStatus, PluginError, PluginIdentity, PluginInput, + PluginResult, SchemaVersionRange, }; use dpp_plugin_sdk::validate::{Validator, num, str_of}; use serde_json::Value; @@ -44,7 +44,12 @@ impl DppProductGroupPlugin for ElectronicsPlugin { .require_product_identifier("productIdentifier") .require_enum( "productCategory", - &["smartphone", "other-mobile-phone", "cordless-phone", "tablet"], + &[ + "smartphone", + "other-mobile-phone", + "cordless-phone", + "tablet", + ], ) .require_enum( "energyEfficiencyClass", diff --git a/plugins/product-group-furniture/src/lib.rs b/plugins/product-group-furniture/src/lib.rs index 1abf6ef0..de8c58c5 100644 --- a/plugins/product-group-furniture/src/lib.rs +++ b/plugins/product-group-furniture/src/lib.rs @@ -6,9 +6,9 @@ use dpp_plugin_sdk::export_plugin; use dpp_plugin_sdk::traits::{ - DppProductGroupPlugin, METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, METRIC_REPAIRABILITY_INDEX, - PluginComplianceStatus, PluginError, PluginIdentity, PluginInput, PluginResult, - SchemaVersionRange, + DppProductGroupPlugin, METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, + METRIC_REPAIRABILITY_INDEX, PluginComplianceStatus, PluginError, PluginIdentity, PluginInput, + PluginResult, SchemaVersionRange, }; use dpp_plugin_sdk::validate::{Validator, num}; use serde_json::Value; diff --git a/plugins/product-group-steel/src/lib.rs b/plugins/product-group-steel/src/lib.rs index 6ba4fdd1..8f0de744 100644 --- a/plugins/product-group-steel/src/lib.rs +++ b/plugins/product-group-steel/src/lib.rs @@ -8,8 +8,8 @@ use dpp_plugin_sdk::export_plugin; use dpp_plugin_sdk::traits::{ - DppProductGroupPlugin, METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, PluginError, PluginIdentity, - PluginInput, PluginResult, SchemaVersionRange, + DppProductGroupPlugin, METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, PluginError, + PluginIdentity, PluginInput, PluginResult, SchemaVersionRange, }; use dpp_plugin_sdk::validate::{Validator, num, str_of, threshold_status}; use serde_json::{Value, json}; diff --git a/plugins/product-group-textile/src/lib.rs b/plugins/product-group-textile/src/lib.rs index e5d9d0c0..36fe3673 100644 --- a/plugins/product-group-textile/src/lib.rs +++ b/plugins/product-group-textile/src/lib.rs @@ -14,7 +14,8 @@ mod unsold_goods; use dpp_plugin_sdk::export_plugin; use dpp_plugin_sdk::traits::{ - DppProductGroupPlugin, PluginError, PluginIdentity, PluginInput, PluginResult, SchemaVersionRange, + DppProductGroupPlugin, PluginError, PluginIdentity, PluginInput, PluginResult, + SchemaVersionRange, }; use dpp_plugin_sdk::validate::{Validator, str_of}; use serde_json::Value; diff --git a/plugins/product-group-textile/src/unsold_goods.rs b/plugins/product-group-textile/src/unsold_goods.rs index 94b9c38f..4ae207ef 100644 --- a/plugins/product-group-textile/src/unsold_goods.rs +++ b/plugins/product-group-textile/src/unsold_goods.rs @@ -31,15 +31,21 @@ use serde_json::{Value, json}; /// Sum of one line's five treatment shares, widened so a malformed record /// cannot wrap into a plausible number. fn treatment_total(line: &Value) -> u32 { - ["preparingForReusePct", "recyclingPct", "otherRecoveryPct", "disposalPct", "unknownPct"] - .iter() - .map(|k| { - line.get("treatment") - .and_then(|t| t.get(*k)) - .and_then(Value::as_u64) - .unwrap_or(0) as u32 - }) - .sum() + [ + "preparingForReusePct", + "recyclingPct", + "otherRecoveryPct", + "disposalPct", + "unknownPct", + ] + .iter() + .map(|k| { + line.get("treatment") + .and_then(|t| t.get(*k)) + .and_then(Value::as_u64) + .unwrap_or(0) as u32 + }) + .sum() } /// The Art. 2 point letter a reason maps to, for the point (h) subordination From b40a2131b09ed7049eeb36a4c9db28b876eb3a99 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Mon, 21 Sep 2026 17:29:06 +0200 Subject: [PATCH 2/2] chore(plugins): make the gate self-test drive the real recipes --- justfile | 58 ++++++++++++++++++++++++++++++++------------------------ 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/justfile b/justfile index 929a56f7..70af3893 100644 --- a/justfile +++ b/justfile @@ -4,6 +4,14 @@ # Usage: just # ============================================================================= +# The directory prefix the product-group plugin crates share. +# +# One home for it, and a parameter on every recipe that globs it, so +# `plugin-gates-self-test` can drive those recipes against a prefix that +# matches nothing. These directories were once `plugins/sector-*`, and the +# rename is what silently emptied a glob before. +PLUGIN_PREFIX := "product-group" + # --------------------------------------------------------------------------- # Quality gates # --------------------------------------------------------------------------- @@ -88,11 +96,11 @@ bench: # them: a plugin can be broken while the workspace gate is green. Runs on the # host (not wasm32) because these are ordinary #[cfg(test)] unit tests; the # wasm build is covered separately by `build-plugins`. -test-plugins: +test-plugins prefix=PLUGIN_PREFIX: #!/usr/bin/env bash set -euo pipefail ran=0 - for plugin in plugins/product-group-*; do + for plugin in plugins/{{prefix}}-*; do [ -f "$plugin/Cargo.toml" ] || continue echo "Testing $plugin..." (cd "$plugin" && cargo test --quiet) @@ -103,7 +111,7 @@ test-plugins: # is not hypothetical: these directories were once `plugins/sector-*`, and # the CI job that globbed them went silently green at the rename. That job # gained this guard; this recipe, which is what `just check` runs, did not. - [ "$ran" -gt 0 ] || { echo "ERROR: no plugins matched plugins/product-group-*"; exit 1; } + [ "$ran" -gt 0 ] || { echo "ERROR: no plugins matched plugins/{{prefix}}-*"; exit 1; } echo "All $ran plugin test suites passed." # Formatting and lint for the product-group plugins. @@ -113,37 +121,37 @@ test-plugins: # for months with no formatting check and no lint: seven of the ten crates were # unformatted when this recipe was first run. `test-plugins` covered their # behaviour and nothing covered their shape. -fmt-check-plugins: +fmt-check-plugins prefix=PLUGIN_PREFIX: #!/usr/bin/env bash set -euo pipefail ran=0 - for plugin in plugins/product-group-*; do + for plugin in plugins/{{prefix}}-*; do [ -f "$plugin/Cargo.toml" ] || continue (cd "$plugin" && cargo fmt --check) ran=$((ran + 1)) done - [ "$ran" -gt 0 ] || { echo "ERROR: no plugins matched plugins/product-group-*"; exit 1; } + [ "$ran" -gt 0 ] || { echo "ERROR: no plugins matched plugins/{{prefix}}-*"; exit 1; } echo "$ran plugin crates formatted correctly." -lint-plugins: +lint-plugins prefix=PLUGIN_PREFIX: #!/usr/bin/env bash set -euo pipefail ran=0 - for plugin in plugins/product-group-*; do + for plugin in plugins/{{prefix}}-*; do [ -f "$plugin/Cargo.toml" ] || continue echo "Linting $plugin..." (cd "$plugin" && cargo clippy --all-targets -- -D warnings) ran=$((ran + 1)) done - [ "$ran" -gt 0 ] || { echo "ERROR: no plugins matched plugins/product-group-*"; exit 1; } + [ "$ran" -gt 0 ] || { echo "ERROR: no plugins matched plugins/{{prefix}}-*"; exit 1; } echo "$ran plugin crates linted clean." # Format the plugins in place — the counterpart to `just fmt` for the crates # `cargo fmt --all` cannot see. -fmt-plugins: +fmt-plugins prefix=PLUGIN_PREFIX: #!/usr/bin/env bash set -euo pipefail - for plugin in plugins/product-group-*; do + for plugin in plugins/{{prefix}}-*; do [ -f "$plugin/Cargo.toml" ] || continue (cd "$plugin" && cargo fmt) done @@ -157,17 +165,17 @@ fmt-plugins: plugin-gates-self-test: #!/usr/bin/env bash set -euo pipefail - for gate in test fmt-check lint; do - if bash -c ' - set -euo pipefail - ran=0 - for plugin in plugins/no-such-prefix-*; do - [ -f "$plugin/Cargo.toml" ] || continue - ran=$((ran + 1)) - done - [ "$ran" -gt 0 ] || { echo "ERROR: no plugins matched"; exit 1; } - '; then - echo "SELF-TEST FAILED: the $gate-plugins guard passed on an empty match" + # 🚨 This drives the REAL recipes against a prefix that matches nothing, + # which is why they take one. An earlier version re-implemented the guard + # inline and ran that copy three times — it proved a fact about bash, not a + # fact about these recipes, and would have stayed green if the guard were + # deleted from any of them. That is the same vacuous pass the guards exist + # to prevent, one level up. + # + # Each guard fires before the loop body, so no cargo invocation happens. + for gate in test-plugins fmt-check-plugins lint-plugins build-plugins; do + if just "$gate" no-such-prefix > /dev/null 2>&1; then + echo "SELF-TEST FAILED: $gate passed on an empty match" exit 1 fi done @@ -270,7 +278,7 @@ build: cargo build --workspace --release # Build all Wasm product-group plugins (requires wasm32-wasip1 target) -build-plugins: +build-plugins prefix=PLUGIN_PREFIX: #!/usr/bin/env bash set -euo pipefail # Globbed, not listed. This recipe named its ten plugins one per line while @@ -278,13 +286,13 @@ build-plugins: # eleventh plugin would have been tested and linted here and never built, # which is the drift a hand-maintained list always ends in. built=0 - for plugin in plugins/product-group-*; do + for plugin in plugins/{{prefix}}-*; do [ -f "$plugin/Cargo.toml" ] || continue echo "Building $plugin..." (cd "$plugin" && cargo build --target wasm32-wasip1 --release) built=$((built + 1)) done - [ "$built" -gt 0 ] || { echo "ERROR: no plugins matched plugins/product-group-*"; exit 1; } + [ "$built" -gt 0 ] || { echo "ERROR: no plugins matched plugins/{{prefix}}-*"; exit 1; } echo "All $built plugins built." # Build a single product-group plugin and print the artifact path.