From 379d152d6fca68e9c14f3b2e9e4c8da39b47f25f Mon Sep 17 00:00:00 2001 From: Bob McDonald Date: Mon, 2 Mar 2026 08:50:28 -0800 Subject: [PATCH 1/6] Require project PHPStan config and remove hidden fallback behavior --- README.md | 10 ++-- commands/web/phpstan | 129 +++---------------------------------------- tests/test.bats | 27 +++++++++ 3 files changed, 40 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index b94bad9..d2ae9a3 100644 --- a/README.md +++ b/README.md @@ -189,14 +189,14 @@ The template points PHP tooling at `.ddev/drupal-code-quality/tooling/bin` and J - You can still pass explicit paths to narrow runs. - PHPStan baseline: - Generate a baseline with `ddev phpstan --generate-baseline`. - - This writes `phpstan-baseline.neon` at the project root; the wrapper will - include it automatically when present. + - This writes `phpstan-baseline.neon` at the project root and updates + `phpstan.neon` to include it. - Use a baseline to suppress known issues in legacy code or core defaults (for example, the shipped `settings.php` files), then work it down over time. Avoid using it to hide new regressions. -- PHPStan config fallback: - - If no project `phpstan.neon*` exists, the wrapper uses the GitLab template - config shipped with the add-on. +- PHPStan config requirement: + - `ddev phpstan` requires project config (`phpstan.neon*`) unless you pass + `--configuration `. - PHPStan level: - GitLab CI template defaults use level 0. The installer can set a local default level (0-10). diff --git a/commands/web/phpstan b/commands/web/phpstan index 9e64765..fd93bfe 100755 --- a/commands/web/phpstan +++ b/commands/web/phpstan @@ -3,17 +3,15 @@ set -u PHPSTAN_BIN="vendor/bin/phpstan" -CI_CONFIG="/mnt/ddev_config/drupal-code-quality/assets/phpstan.neon" TMP_FILES=() source /mnt/ddev_config/commands/helpers/path-map.sh -DOCROOT="${DCQ_DOCROOT:-web}" print_help() { cat <<'USAGE' Usage: ddev phpstan [args] Runs PHPStan inside the DDEV web container. This wrapper forwards all arguments -and applies Drupal CI config discovery when no explicit configuration is given. +and requires a project PHPStan config unless --configuration is provided. USAGE } @@ -31,18 +29,6 @@ cleanup() { done } -config_has_paths() { - local config_file="$1" - if [ -z "$config_file" ] || [ ! -f "$config_file" ]; then - return 1 - fi - # Check if config defines parameters.paths - if grep -q '^[[:space:]]*paths:' "$config_file"; then - return 0 - fi - return 1 -} - ensure_baseline_include() { local config_path="$1" local tmp @@ -81,9 +67,7 @@ ensure_baseline_include() { has_help=false has_version=false has_config=false -has_level=false has_json_format=false -explicit_paths=false has_generate_baseline=false config_value="" @@ -94,7 +78,6 @@ seen_double_dash=false while [ "$index" -lt "$arg_count" ]; do arg="${args[$index]}" if [ "$seen_double_dash" = true ]; then - explicit_paths=true break fi case "$arg" in @@ -144,19 +127,11 @@ while [ "$index" -lt "$arg_count" ]; do ;; --error-format=*) ;; - -l|--level) - has_level=true - index=$((index + 1)) - ;; - --level=*) - has_level=true - ;; analyze|analyse) ;; -*) ;; *) - explicit_paths=true ;; esac if [ "$has_help" = true ] || [ "$has_version" = true ]; then @@ -177,14 +152,7 @@ if [ "$has_version" = true ]; then fi CONFIG_FILE="" -DEFAULT_PATHS=() -EXCLUDE_PATHS=( - "${DOCROOT}/modules/contrib" - "${DOCROOT}/themes/contrib" - "${DOCROOT}/sites/*/files/*" - "sites/*/files/*" -) -# Prefer explicit project configs before falling back to CI template config. +# Prefer explicit project configs. for config_file in phpstan.neon phpstan.neon.dist phpstan.dist.neon; do if [ -f "$config_file" ]; then CONFIG_FILE="$config_file" @@ -192,32 +160,17 @@ for config_file in phpstan.neon phpstan.neon.dist phpstan.dist.neon; do fi done -if [ "$explicit_paths" = false ]; then - # Default to custom code only unless the user passes explicit paths. - for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom"; do - if [ -d "$candidate" ]; then - DEFAULT_PATHS+=("$candidate") - fi - done - if [ -d "${DOCROOT}/sites" ]; then - while IFS= read -r site_file; do - DEFAULT_PATHS+=("$site_file") - done < <(find "${DOCROOT}/sites" -type f \( -name '*.php' -o -name '*.inc' -o -name '*.module' -o -name '*.install' -o -name '*.theme' -o -name '*.profile' \) ! -path '*/files/*') - fi -fi - BASE_CONFIG="" CONFIG_TO_USE="" BASE_CONFIG_ABS="" -BASE_CONFIG_FOR_USE="" -BASELINE_PATH="/var/www/html/phpstan-baseline.neon" if [ "$has_config" = true ]; then BASE_CONFIG="$config_value" elif [ -n "$CONFIG_FILE" ]; then BASE_CONFIG="$CONFIG_FILE" -elif [ -f "$CI_CONFIG" ]; then - BASE_CONFIG="$CI_CONFIG" +else + echo "PHPStan config file is missing. Create phpstan.neon in the project root (for example by reinstalling the add-on) or pass --configuration ." >&2 + exit 2 fi if [ -n "$BASE_CONFIG" ]; then @@ -229,24 +182,6 @@ if [ -n "$BASE_CONFIG" ]; then fi fi -if [ -f "$BASELINE_PATH" ]; then - BASELINE_INCLUDE="$BASELINE_PATH" -elif [ -f phpstan-baseline.neon ]; then - BASELINE_INCLUDE="/var/www/html/phpstan-baseline.neon" -else - BASELINE_INCLUDE="" -fi - -BASE_CONFIG_FOR_USE="$BASE_CONFIG_ABS" -if [ -n "$BASE_CONFIG_ABS" ] && [ -n "$BASELINE_INCLUDE" ]; then - if ! grep -q 'phpstan-baseline\.neon' "$BASE_CONFIG_ABS"; then - BASE_CONFIG_FOR_USE="$(mktemp /tmp/phpstan-config.XXXXXX.neon)" - TMP_FILES+=("$BASE_CONFIG_FOR_USE") - printf "includes:\n - %s\n - %s\n" "$BASE_CONFIG_ABS" "$BASELINE_INCLUDE" > "$BASE_CONFIG_FOR_USE" - trap cleanup EXIT - fi -fi - FINAL_ARGS=() index=0 seen_double_dash=false @@ -265,19 +200,11 @@ while [ "$index" -lt "$arg_count" ]; do analyze|analyse) ;; -c|--configuration) - if [ "$has_config" = true ] && [ -n "$BASE_CONFIG_FOR_USE" ]; then - FINAL_ARGS+=("$arg" "$BASE_CONFIG_FOR_USE") - else - FINAL_ARGS+=("$arg" "${args[$((index + 1))]:-}") - fi + FINAL_ARGS+=("$arg" "${args[$((index + 1))]:-}") index=$((index + 1)) ;; --configuration=*) - if [ "$has_config" = true ] && [ -n "$BASE_CONFIG_FOR_USE" ]; then - FINAL_ARGS+=("--configuration=$BASE_CONFIG_FOR_USE") - else - FINAL_ARGS+=("$arg") - fi + FINAL_ARGS+=("$arg") ;; *) FINAL_ARGS+=("$(map_path "$arg")") @@ -287,35 +214,7 @@ while [ "$index" -lt "$arg_count" ]; do done if [ "$has_config" = false ]; then - if [ "$explicit_paths" = false ]; then - # Only inject scope/excludes if the config doesn't already define paths - if config_has_paths "$BASE_CONFIG_FOR_USE"; then - # Config already has paths defined; use it as-is - CONFIG_TO_USE="$BASE_CONFIG_FOR_USE" - else - # Build a minimal config that scopes analysis and excludes contrib/files. - CONFIG_TO_USE="$(mktemp /tmp/phpstan-scope.XXXXXX.neon)" - TMP_FILES+=("$CONFIG_TO_USE") - if [ -n "$BASE_CONFIG_FOR_USE" ]; then - printf "includes:\n - %s\n" "$BASE_CONFIG_FOR_USE" > "$CONFIG_TO_USE" - fi - project_root="$(pwd)" - cat <<'EOF' >> "$CONFIG_TO_USE" -parameters: - excludePaths: - analyseAndScan: -EOF - for exclude_path in "${EXCLUDE_PATHS[@]}"; do - if [ "${exclude_path#/}" = "$exclude_path" ]; then - exclude_path="${project_root}/${exclude_path}" - fi - printf " - %s (?)\n" "$exclude_path" >> "$CONFIG_TO_USE" - done - trap cleanup EXIT - fi - else - CONFIG_TO_USE="$BASE_CONFIG_FOR_USE" - fi + CONFIG_TO_USE="$BASE_CONFIG_ABS" fi CMD=("$PHPSTAN_BIN" analyze) @@ -323,18 +222,6 @@ if [ -n "$CONFIG_TO_USE" ]; then CMD+=(--configuration "$CONFIG_TO_USE") fi -if [ "$has_level" = false ] && [ -z "$CONFIG_FILE" ] && [ "$has_config" = false ] && [ ! -f "$CI_CONFIG" ]; then - # Match PHPStan's default when no config is present at all. - CMD+=(--level=0) -fi - -# Only add DEFAULT_PATHS if config doesn't define paths -if [ "$explicit_paths" = false ] && [ "${#DEFAULT_PATHS[@]}" -gt 0 ]; then - if ! config_has_paths "$CONFIG_TO_USE" && ! config_has_paths "$BASE_CONFIG_FOR_USE"; then - FINAL_ARGS+=("${DEFAULT_PATHS[@]}") - fi -fi - if [ "$has_json_format" = true ]; then if [ -n "${DDEV_HOST_PROJECT_ROOT:-}" ]; then HOST_ROOT="$DDEV_HOST_PROJECT_ROOT" diff --git a/tests/test.bats b/tests/test.bats index 7c03422..2c9e335 100644 --- a/tests/test.bats +++ b/tests/test.bats @@ -1040,6 +1040,33 @@ PHP esac } +@test "phpstan fails with helpful message when project config is missing" { + set -u -o pipefail + export DCQ_INSTALL_DEPS=skip + export DCQ_INSTALL_NODE_DEPS=skip + run ddev add-on get "${DIR}" + assert_success + + run rm -f phpstan.neon phpstan.neon.dist phpstan.dist.neon + assert_success + + mkdir -p vendor/bin + cat > vendor/bin/phpstan <<'SH' +#!/bin/sh +echo "stub phpstan" +exit 0 +SH + chmod +x vendor/bin/phpstan + + run wait_for_container_path "/var/www/html/vendor/bin/phpstan" + assert_success + + run ddev phpstan + assert_failure + assert_output --partial "PHPStan config file is missing." + assert_output --partial "Create phpstan.neon in the project root" +} + @test "cspell config is expanded during installation" { set -u -o pipefail From 0e2c4cf880ba80d69ffcd19c002169133771089b Mon Sep 17 00:00:00 2001 From: Bob McDonald Date: Mon, 2 Mar 2026 11:42:20 -0800 Subject: [PATCH 2/6] Fix stylelint-fix path rewrite for non-web docroots --- commands/web/stylelint-fix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commands/web/stylelint-fix b/commands/web/stylelint-fix index 6aa795e..212ea6e 100755 --- a/commands/web/stylelint-fix +++ b/commands/web/stylelint-fix @@ -192,7 +192,7 @@ normalize_docroot_arg() { arg="${arg#/var/www/html/}" fi if [ "$DOCROOT" != "web" ] && [[ "$arg" == web/* ]]; then - arg="${DOCROOT}/${path#web/}" + arg="${DOCROOT}/${arg#web/}" fi if [[ "$arg" == "${DOCROOT}/"* ]]; then arg="${arg#${DOCROOT}/}" From 89e87387f0b62ebabf2bdb5e612fddea64ac684a Mon Sep 17 00:00:00 2001 From: Bob McDonald Date: Mon, 2 Mar 2026 11:45:11 -0800 Subject: [PATCH 3/6] Add regression test for stylelint-fix non-web docroot path rewrite --- tests/test.bats | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test.bats b/tests/test.bats index 2c9e335..092ba4f 100644 --- a/tests/test.bats +++ b/tests/test.bats @@ -975,6 +975,40 @@ PY assert_success } +@test "stylelint-fix rewrites explicit web paths with non-web docroot" { + set -u -o pipefail + mkdir -p docroot + run ddev config --docroot=docroot + assert_success + retry_ddev_command ddev restart -y + assert_success + + run ddev add-on get "${DIR}" + assert_success + + mkdir -p node_modules/stylelint/bin + cat > node_modules/stylelint/bin/stylelint.mjs <<'JS' +#!/usr/bin/env node +process.exit(0); +JS + chmod +x node_modules/stylelint/bin/stylelint.mjs + + mkdir -p docroot/themes/custom/dcq_theme/css + cat > docroot/themes/custom/dcq_theme/css/fixable.css <<'CSS' +.dcq-test { + color: red; +} +CSS + + run wait_for_container_path "/var/www/html/node_modules/stylelint/bin/stylelint.mjs" + assert_success + run wait_for_container_path "/var/www/html/docroot/themes/custom/dcq_theme/css/fixable.css" + assert_success + + run ddev stylelint-fix web/themes/custom/dcq_theme/css/fixable.css + assert_success +} + @test "install from directory with phpstan level override" { set -u -o pipefail export DCQ_PHPSTAN_LEVEL=3 From 4cbd8afc5fb2db54f3bc82f23408dfb9c6a49f12 Mon Sep 17 00:00:00 2001 From: Bob McDonald Date: Mon, 2 Mar 2026 12:17:38 -0800 Subject: [PATCH 4/6] Align wrappers with project config and scope decisions --- README.md | 7 +- commands/web/checks | 21 +----- commands/web/checks-full | 21 +----- commands/web/cspell | 37 ++++++--- commands/web/cspell-suggest | 40 ++++++---- commands/web/eslint | 31 ++++---- commands/web/eslint-fix | 34 +++++---- commands/web/php-parallel-lint | 6 +- commands/web/phpcbf | 7 +- commands/web/phpcs | 7 +- commands/web/prettier | 19 ++--- commands/web/prettier-fix | 13 ++-- commands/web/stylelint | 21 ++++-- commands/web/stylelint-fix | 69 +++++++++++++---- tests/test.bats | 133 +++++++++++++++++++++++++++++++++ 15 files changed, 315 insertions(+), 151 deletions(-) diff --git a/README.md b/README.md index d2ae9a3..3607a78 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,8 @@ The template points PHP tooling at `.ddev/drupal-code-quality/tooling/bin` and J - `ESLINT_TOOLCHAIN=root` forces project root toolchain. - ESLint config mode: - `ESLINT_CONFIG_MODE=nearest` (default) groups by nearest config file. - - `ESLINT_CONFIG_MODE=fixed` forces `.eslintrc.passing.json`. + - `ESLINT_CONFIG_MODE=fixed` prefers `.eslintrc.passing.json`, then + `.eslintrc.json` in the project root. - ESLint warning visibility (GitLab CI parity): - `DCQ_ESLINT_QUIET=1` (default) adds `--quiet` to `ddev eslint` and `ddev eslint-fix`, so warnings are suppressed. @@ -177,8 +178,8 @@ The template points PHP tooling at `.ddev/drupal-code-quality/tooling/bin` and J - CSpell parity: - Run `ddev exec php /mnt/ddev_config/drupal-code-quality/tooling/scripts/prepare-cspell.php -s .prepared` once and replace `.cspell.json` after reviewing the diff. - - `ddev cspell` runs from the repo root (`.`) by default; scope is controlled - by `.cspell.json` `ignorePaths`. Narrow the scan by passing explicit paths. + - `ddev cspell` defaults to custom code plus `sites` under the configured + docroot, excluding `sites/*/files/**`, when no paths are passed. - `.cspell-project-words.txt` is created by the installer (empty) and updated by `ddev cspell-suggest` when you accept suggested words. - PHPCS / PHPCBF default scope: diff --git a/commands/web/checks b/commands/web/checks index 3567e37..a4a73de 100755 --- a/commands/web/checks +++ b/commands/web/checks @@ -12,14 +12,6 @@ if [ -z "$DOCROOT" ]; then DOCROOT="web" fi -# Collect custom code paths once so tools can reuse them. -CUSTOM_PATHS=() -for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom"; do - if [ -d "$candidate" ]; then - CUSTOM_PATHS+=("$candidate") - fi -done - # Ordered list of Drupal.org GitLab CI template default tools to run. TOOLS=( "composer-validate" @@ -66,18 +58,7 @@ for tool in "${TOOLS[@]}"; do continue fi - if [ "$tool" = "phpcs" ] && [ "${#CUSTOM_PATHS[@]}" -eq 0 ]; then - # Avoid failing when there is no custom code to lint. - echo "SKIP: no custom code directories found for phpcs." | tee -a "$log_file" - STATUS["$tool"]=SKIP - continue - fi - - if [ "$tool" = "phpcs" ]; then - "$tool_path" "${CUSTOM_PATHS[@]}" >"$log_file" 2>&1 - else - "$tool_path" >"$log_file" 2>&1 - fi + "$tool_path" >"$log_file" 2>&1 exit_code=$? if [ "$exit_code" -eq 0 ]; then STATUS["$tool"]=PASS diff --git a/commands/web/checks-full b/commands/web/checks-full index b44f297..f79cf80 100755 --- a/commands/web/checks-full +++ b/commands/web/checks-full @@ -12,14 +12,6 @@ if [ -z "$DOCROOT" ]; then DOCROOT="web" fi -# Collect custom code paths once so tools can reuse them. -CUSTOM_PATHS=() -for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom"; do - if [ -d "$candidate" ]; then - CUSTOM_PATHS+=("$candidate") - fi -done - # Ordered list of baseline Drupal.org GitLab CI template default tools. TOOLS=( "composer-validate" @@ -82,18 +74,7 @@ for tool in "${ALL_TOOLS[@]}"; do continue fi - if [ "$tool" = "phpcs" ] && [ "${#CUSTOM_PATHS[@]}" -eq 0 ]; then - # Avoid failing when there is no custom code to lint. - echo "SKIP: no custom code directories found for phpcs." | tee -a "$log_file" - STATUS["$tool"]=SKIP - continue - fi - - if [ "$tool" = "phpcs" ]; then - "$tool_path" "${CUSTOM_PATHS[@]}" >"$log_file" 2>&1 - else - "$tool_path" >"$log_file" 2>&1 - fi + "$tool_path" >"$log_file" 2>&1 exit_code=$? if [ "$exit_code" -eq 0 ]; then STATUS["$tool"]=PASS diff --git a/commands/web/cspell b/commands/web/cspell index 82edf04..1bde9f1 100755 --- a/commands/web/cspell +++ b/commands/web/cspell @@ -63,6 +63,12 @@ CORE_CSPELL="${DOCROOT_PATH}/core/node_modules/.bin/cspell" ROOT_CSPELL="${PROJECT_ROOT}/node_modules/.bin/cspell" CSPELL_BIN="" +if ! command -v node >/dev/null 2>&1; then + echo "Node.js is not available in the DDEV web container." >&2 + echo "Install the Drupal core JS toolchain (${DOCROOT}/core) to run CSpell." >&2 + exit 127 +fi + if [ -x "$ROOT_CSPELL" ]; then CSPELL_BIN="$ROOT_CSPELL" elif [ -x "$CORE_CSPELL" ]; then @@ -85,12 +91,14 @@ fi CMD=("$CSPELL_BIN") if [ "$has_config" = false ]; then - # Prefer project config; warn when falling back to core. - if [ -f "${PROJECT_ROOT}/.cspell.json" ]; then - CMD+=(-c "${PROJECT_ROOT}/.cspell.json") - else - CMD+=(-c "${DOCROOT_PATH}/core/.cspell.json") - echo "Warning: using core CSpell config (${DOCROOT}/core/.cspell.json); project config not found." >&2 + if [ ! -f "${PROJECT_ROOT}/.cspell.json" ]; then + echo "CSpell config file is missing. Create .cspell.json in the project root (for example by reinstalling the add-on)." >&2 + exit 2 + fi + CMD+=(-c "${PROJECT_ROOT}/.cspell.json") + project_words_file="${PROJECT_ROOT}/.cspell-project-words.txt" + if [ ! -f "$project_words_file" ]; then + : > "$project_words_file" fi fi @@ -152,14 +160,19 @@ while [ "$index" -lt "$arg_count" ]; do done if [ "$explicit_paths" = false ]; then - "${CMD[@]}" "${FLAG_ARGS[@]}" "." + DEFAULT_PATHS=() + for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom" "${DOCROOT}/sites"; do + if [ -d "${PROJECT_ROOT}/${candidate}" ]; then + DEFAULT_PATHS+=("$candidate") + fi + done + if [ "${#DEFAULT_PATHS[@]}" -eq 0 ]; then + echo "No custom code or sites directories found under ${DOCROOT}. Nothing to check." >&2 + exit 0 + fi + "${CMD[@]}" "${FLAG_ARGS[@]}" --exclude "${DOCROOT}/sites/*/files/**" "${DEFAULT_PATHS[@]}" exit $? fi "${CMD[@]}" "${FLAG_ARGS[@]}" "${POSITIONAL_ARGS[@]}" exit $? -if ! command -v node >/dev/null 2>&1; then - echo "Node.js is not available in the DDEV web container." >&2 - echo "Install the Drupal core JS toolchain (${DOCROOT}/core) to run CSpell." >&2 - exit 127 -fi diff --git a/commands/web/cspell-suggest b/commands/web/cspell-suggest index 9c09785..593b68e 100755 --- a/commands/web/cspell-suggest +++ b/commands/web/cspell-suggest @@ -73,6 +73,13 @@ DOCROOT_PATH="${PROJECT_ROOT}/${DOCROOT}" CORE_CSPELL="${DOCROOT_PATH}/core/node_modules/.bin/cspell" ROOT_CSPELL="${PROJECT_ROOT}/node_modules/.bin/cspell" CSPELL_BIN="" + +if ! command -v node >/dev/null 2>&1; then + echo "Node.js is not available in the DDEV web container." >&2 + echo "Install the Drupal core JS toolchain (${DOCROOT}/core) to run CSpell." >&2 + exit 127 +fi + REPORT_DIR="${PROJECT_ROOT}/dcq-reports" if ! mkdir -p "$REPORT_DIR"; then echo "Unable to create report directory: $REPORT_DIR" >&2 @@ -104,14 +111,16 @@ if [ "$has_version" = true ]; then exit $? fi +PROJECT_DICTIONARY="${PROJECT_ROOT}/.cspell-project-words.txt" CMD=("$CSPELL_BIN") if [ "$has_config" = false ]; then - # Prefer project config; warn when falling back to core. - if [ -f "${PROJECT_ROOT}/.cspell.json" ]; then - CMD+=(-c "${PROJECT_ROOT}/.cspell.json") - else - CMD+=(-c "${DOCROOT_PATH}/core/.cspell.json") - echo "Warning: using core CSpell config (${DOCROOT}/core/.cspell.json); project config not found." >&2 + if [ ! -f "${PROJECT_ROOT}/.cspell.json" ]; then + echo "CSpell config file is missing. Create .cspell.json in the project root (for example by reinstalling the add-on)." >&2 + exit 2 + fi + CMD+=(-c "${PROJECT_ROOT}/.cspell.json") + if [ ! -f "$PROJECT_DICTIONARY" ]; then + : > "$PROJECT_DICTIONARY" fi fi @@ -173,7 +182,18 @@ while [ "$index" -lt "$arg_count" ]; do done if [ "$explicit_paths" = false ]; then - POSITIONAL_ARGS=(".") + DEFAULT_PATHS=() + for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom" "${DOCROOT}/sites"; do + if [ -d "${PROJECT_ROOT}/${candidate}" ]; then + DEFAULT_PATHS+=("$candidate") + fi + done + if [ "${#DEFAULT_PATHS[@]}" -eq 0 ]; then + echo "No custom code or sites directories found under ${DOCROOT}. Nothing to check." >&2 + exit 0 + fi + POSITIONAL_ARGS=("${DEFAULT_PATHS[@]}") + FLAG_ARGS+=(--exclude "${DOCROOT}/sites/*/files/**") fi if [ "$explicit_paths" = false ]; then @@ -189,7 +209,6 @@ else : > "$UNRECOGNIZED_FILE" fi -PROJECT_DICTIONARY="${PROJECT_ROOT}/.cspell-project-words.txt" if [ -f "$PROJECT_DICTIONARY" ]; then # Merge existing dictionary with new suggestions. cat "$PROJECT_DICTIONARY" "$UNRECOGNIZED_FILE" | sort -u > "$UPDATED_WORDS_FILE" @@ -219,8 +238,3 @@ if [ -s "$UNRECOGNIZED_FILE" ]; then fi exit 0 -if ! command -v node >/dev/null 2>&1; then - echo "Node.js is not available in the DDEV web container." >&2 - echo "Install the Drupal core JS toolchain (${DOCROOT}/core) to run CSpell." >&2 - exit 127 -fi diff --git a/commands/web/eslint b/commands/web/eslint index 98ba236..51237b1 100755 --- a/commands/web/eslint +++ b/commands/web/eslint @@ -127,14 +127,19 @@ if [ -n "$RESOLVE_PLUGINS_DIR" ]; then # Ensure ESLint resolves plugins from the selected toolchain. CMD+=(--resolve-plugins-relative-to "$RESOLVE_PLUGINS_DIR") fi +FIXED_CONFIG="" +if [ -f "${PROJECT_ROOT}/.eslintrc.passing.json" ]; then + FIXED_CONFIG="${PROJECT_ROOT}/.eslintrc.passing.json" +elif [ -f "${PROJECT_ROOT}/.eslintrc.json" ]; then + FIXED_CONFIG="${PROJECT_ROOT}/.eslintrc.json" +fi if [ "$has_config" = false ] && [ "$ESLINT_CONFIG_MODE" != "nearest" ]; then - # Fixed mode: force the passing config for Drupal.org GitLab CI template defaults when requested. - if [ -f "${PROJECT_ROOT}/.eslintrc.passing.json" ]; then - CMD+=(--config="${PROJECT_ROOT}/.eslintrc.passing.json") - else - CMD+=(--config="${DOCROOT_PATH}/core/.eslintrc.passing.json") - echo "Warning: using core ESLint config (${DOCROOT}/core/.eslintrc.passing.json); project config not found." >&2 + # Fixed mode: prefer project passing config, then project base config. + if [ -z "$FIXED_CONFIG" ]; then + echo "ESLint config file is missing. Create .eslintrc.passing.json (or .eslintrc.json) in the project root, or pass --config." >&2 + exit 2 fi + CMD+=(--config="$FIXED_CONFIG") fi CMD+=(--ext .js,.yml --ignore-pattern "**/node_modules/**") CMD+=("${DEFAULT_ARGS[@]}") @@ -146,7 +151,7 @@ find_nearest_config() { while true; do # Walk up the directory tree to find the closest ESLint config. for candidate in .eslintrc.passing.json .eslintrc .eslintrc.json .eslintrc.yaml .eslintrc.yml .eslintrc.js .eslintrc.cjs; do - if [ "$dir" = "web" ] || [ "$dir" = "./web" ]; then + if [ "$dir" = "$DOCROOT" ] || [ "$dir" = "./$DOCROOT" ]; then # Skip docroot configs so nearest-mode prefers theme/module configs or root passing config. continue fi @@ -273,15 +278,15 @@ fi FILES=() if [ "$explicit_paths" = false ]; then DEFAULT_FILES=() - for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom"; do + for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom" "${DOCROOT}/sites"; do if [ -d "$candidate" ]; then while IFS= read -r file_path; do DEFAULT_FILES+=("$file_path") - done < <(find "$candidate" -path '*/node_modules/*' -prune -o -type f \( -name '*.js' -o -name '*.yml' \) -print) + done < <(find "$candidate" \( -path '*/node_modules/*' -o -path '*/sites/*/files/*' \) -prune -o -type f \( -name '*.js' -o -name '*.yml' \) -print) fi done if [ "${#DEFAULT_FILES[@]}" -eq 0 ]; then - echo "No custom JS/YML files found under modules/custom, themes/custom, or profiles/custom. Nothing to lint." >&2 + echo "No default JS/YML files found under modules/custom, themes/custom, profiles/custom, or sites (excluding sites/*/files). Nothing to lint." >&2 exit 2 fi FILES=("${DEFAULT_FILES[@]}") @@ -290,11 +295,7 @@ else fi DEFAULT_CONFIG="" -if [ -f "${PROJECT_ROOT}/.eslintrc.passing.json" ]; then - DEFAULT_CONFIG="${PROJECT_ROOT}/.eslintrc.passing.json" -elif [ -f "${DOCROOT_PATH}/core/.eslintrc.passing.json" ]; then - DEFAULT_CONFIG="${DOCROOT_PATH}/core/.eslintrc.passing.json" -fi +DEFAULT_CONFIG="$FIXED_CONFIG" if [ "$ESLINT_CONFIG_MODE" = "nearest" ]; then # Group files by nearest config to avoid plugin conflicts across modules/themes. diff --git a/commands/web/eslint-fix b/commands/web/eslint-fix index bd76440..e5f0244 100755 --- a/commands/web/eslint-fix +++ b/commands/web/eslint-fix @@ -192,7 +192,7 @@ if [ "${#RAW_PATHS[@]}" -gt 0 ]; then done else # Default to custom code when no explicit paths are passed. - for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom"; do + for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom" "${DOCROOT}/sites"; do if [ -d "$candidate" ]; then TARGET_PREFIXES+=("$candidate") fi @@ -207,13 +207,18 @@ fi if [ -n "$RESOLVE_PLUGINS_DIR" ]; then CMD_BASE+=(--resolve-plugins-relative-to "$RESOLVE_PLUGINS_DIR") fi +FIXED_CONFIG="" +if [ -f "${PROJECT_ROOT}/.eslintrc.passing.json" ]; then + FIXED_CONFIG="${PROJECT_ROOT}/.eslintrc.passing.json" +elif [ -f "${PROJECT_ROOT}/.eslintrc.json" ]; then + FIXED_CONFIG="${PROJECT_ROOT}/.eslintrc.json" +fi if [ "$has_config" = false ] && [ "$ESLINT_CONFIG_MODE" != "nearest" ]; then - if [ -f "${PROJECT_ROOT}/.eslintrc.passing.json" ]; then - CMD_BASE+=(--config="${PROJECT_ROOT}/.eslintrc.passing.json") - else - CMD_BASE+=(--config="${DOCROOT_PATH}/core/.eslintrc.passing.json") - echo "Warning: using core ESLint config (${DOCROOT}/core/.eslintrc.passing.json); project config not found." >&2 + if [ -z "$FIXED_CONFIG" ]; then + echo "ESLint config file is missing. Create .eslintrc.passing.json (or .eslintrc.json) in the project root, or pass --config." >&2 + exit 2 fi + CMD_BASE+=(--config="$FIXED_CONFIG") fi CMD_BASE+=(--ext .js,.yml --ignore-pattern "**/node_modules/**") CMD_BASE+=("${DEFAULT_ARGS[@]}") @@ -223,7 +228,10 @@ find_nearest_config() { local dir dir="$(dirname "$file_path")" while true; do - for candidate in .eslintrc .eslintrc.json .eslintrc.yaml .eslintrc.yml .eslintrc.js .eslintrc.cjs; do + for candidate in .eslintrc.passing.json .eslintrc .eslintrc.json .eslintrc.yaml .eslintrc.yml .eslintrc.js .eslintrc.cjs; do + if [ "$dir" = "$DOCROOT" ] || [ "$dir" = "./$DOCROOT" ]; then + continue + fi if [ -f "$dir/$candidate" ]; then echo "$dir/$candidate" return 0 @@ -335,15 +343,15 @@ fi DEFAULT_FILES=() if [ "$explicit_paths" = false ]; then - for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom"; do + for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom" "${DOCROOT}/sites"; do if [ -d "$candidate" ]; then while IFS= read -r file_path; do DEFAULT_FILES+=("$file_path") - done < <(find "$candidate" -path '*/node_modules/*' -prune -o -type f \( -name '*.js' -o -name '*.yml' \) -print) + done < <(find "$candidate" \( -path '*/node_modules/*' -o -path '*/sites/*/files/*' \) -prune -o -type f \( -name '*.js' -o -name '*.yml' \) -print) fi done if [ "${#DEFAULT_FILES[@]}" -eq 0 ]; then - echo "No custom JS/YML files found under modules/custom, themes/custom, or profiles/custom. Nothing to fix." >&2 + echo "No default JS/YML files found under modules/custom, themes/custom, profiles/custom, or sites (excluding sites/*/files). Nothing to fix." >&2 exit 2 fi fi @@ -356,11 +364,7 @@ else fi DEFAULT_CONFIG="" -if [ -f "${PROJECT_ROOT}/.eslintrc.passing.json" ]; then - DEFAULT_CONFIG="${PROJECT_ROOT}/.eslintrc.passing.json" -elif [ -f "${DOCROOT_PATH}/core/.eslintrc.passing.json" ]; then - DEFAULT_CONFIG="${DOCROOT_PATH}/core/.eslintrc.passing.json" -fi +DEFAULT_CONFIG="$FIXED_CONFIG" # ============================================================================ # PREVIEW MODE: Generate patch, show preview, prompt to apply diff --git a/commands/web/php-parallel-lint b/commands/web/php-parallel-lint index 5b79bf2..1879cf8 100755 --- a/commands/web/php-parallel-lint +++ b/commands/web/php-parallel-lint @@ -46,17 +46,17 @@ if [ -z "$DOCROOT" ]; then fi DEFAULT_FILES=() -for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom"; do +for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom" "${DOCROOT}/sites"; do if [ -d "$candidate" ]; then while IFS= read -r file_path; do DEFAULT_FILES+=("$file_path") - done < <(find "$candidate" -type f \( -name '*.php' -o -name '*.inc' -o -name '*.module' -o -name '*.install' -o -name '*.theme' -o -name '*.profile' \)) + done < <(find "$candidate" \( -path '*/sites/*/files/*' -o -path '*/node_modules/*' \) -prune -o -type f \( -name '*.php' -o -name '*.inc' -o -name '*.module' -o -name '*.install' -o -name '*.theme' -o -name '*.profile' \) -print) fi done if [ "${#DEFAULT_FILES[@]}" -eq 0 ] && [ "$#" -eq 0 ]; then # Skip quietly if no custom PHP files are present. - echo "No custom PHP files found under ${DOCROOT}/modules/custom, ${DOCROOT}/themes/custom, or ${DOCROOT}/profiles/custom. Nothing to check." >&2 + echo "No default PHP files found under ${DOCROOT}/modules/custom, ${DOCROOT}/themes/custom, ${DOCROOT}/profiles/custom, or ${DOCROOT}/sites (excluding sites/*/files). Nothing to check." >&2 exit 0 fi diff --git a/commands/web/phpcbf b/commands/web/phpcbf index c9e826e..5c72d3d 100755 --- a/commands/web/phpcbf +++ b/commands/web/phpcbf @@ -60,8 +60,5 @@ if [ "$has_config" = true ]; then exit $? fi -"$PHPCBF_BIN" \ - --standard=Drupal \ - --extensions=php,module,inc,install,profile,theme,engine,yml \ - "${rewrite_args[@]}" -exit $? +echo "PHPCS config file is missing. Create .phpcs.xml in the project root (for example by reinstalling the add-on)." >&2 +exit 2 diff --git a/commands/web/phpcs b/commands/web/phpcs index 19a7150..4aaf8bc 100755 --- a/commands/web/phpcs +++ b/commands/web/phpcs @@ -59,8 +59,5 @@ if [ "$has_config" = true ]; then exit $? fi -"$PHPCS_BIN" \ - --standard=Drupal \ - --extensions=php,module,inc,install,profile,theme,engine,yml \ - "${rewrite_args[@]}" -exit $? +echo "PHPCS config file is missing. Create .phpcs.xml in the project root (for example by reinstalling the add-on)." >&2 +exit 2 diff --git a/commands/web/prettier b/commands/web/prettier index c41c396..b0ba50a 100755 --- a/commands/web/prettier +++ b/commands/web/prettier @@ -97,18 +97,13 @@ cd "$DOCROOT" CMD=(env "NODE_PATH=$NODE_PATH" "$PRETTIER_BIN" --check) if [ "$has_config" = false ]; then - # Prefer project configs; warn when falling back to core. - if [ -f "${PROJECT_ROOT}/.prettierrc.json" ]; then - CMD+=(--config="${PROJECT_ROOT}/.prettierrc.json") - else - CMD+=(--config="${DOCROOT_PATH}/core/.prettierrc.json") - echo "Warning: using core Prettier config (${DOCROOT}/core/.prettierrc.json); project config not found." >&2 + if [ ! -f "${PROJECT_ROOT}/.prettierrc.json" ]; then + echo "Prettier config file is missing. Create .prettierrc.json in the project root (for example by reinstalling the add-on)." >&2 + exit 2 fi + CMD+=(--config="${PROJECT_ROOT}/.prettierrc.json") if [ -f "${PROJECT_ROOT}/.prettierignore" ]; then CMD+=(--ignore-path="${PROJECT_ROOT}/.prettierignore") - elif [ -f "${DOCROOT_PATH}/core/.prettierignore" ]; then - CMD+=(--ignore-path="${DOCROOT_PATH}/core/.prettierignore") - echo "Warning: using core Prettier ignore (${DOCROOT}/core/.prettierignore); project ignore not found." >&2 fi fi @@ -178,15 +173,15 @@ fi if [ "$explicit_paths" = false ]; then # Default to custom code only when no explicit paths are passed. DEFAULT_FILES=() - for candidate in modules/custom themes/custom profiles/custom; do + for candidate in modules/custom themes/custom profiles/custom sites; do if [ -d "$candidate" ]; then while IFS= read -r file_path; do DEFAULT_FILES+=("$file_path") - done < <(find "$candidate" -type f \( -name '*.js' -o -name '*.yml' -o -name '*.yaml' -o -name '*.css' -o -name '*.scss' -o -name '*.sass' \)) + done < <(find "$candidate" \( -path '*/node_modules/*' -o -path 'sites/*/files/*' \) -prune -o -type f \( -name '*.js' -o -name '*.yml' -o -name '*.yaml' -o -name '*.css' -o -name '*.scss' -o -name '*.sass' \) -print) fi done if [ "${#DEFAULT_FILES[@]}" -eq 0 ]; then - echo "No custom files found under modules/custom, themes/custom, or profiles/custom. Nothing to check." >&2 + echo "No default files found under modules/custom, themes/custom, profiles/custom, or sites (excluding sites/*/files). Nothing to check." >&2 exit 0 fi "${CMD[@]}" "$@" "${DEFAULT_FILES[@]}" diff --git a/commands/web/prettier-fix b/commands/web/prettier-fix index 5b1a6de..143184b 100755 --- a/commands/web/prettier-fix +++ b/commands/web/prettier-fix @@ -165,7 +165,7 @@ if [ "${#RAW_PATHS[@]}" -gt 0 ]; then TARGET_PREFIXES+=("$(normalize_path "$raw")") done else - for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom"; do + for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom" "${DOCROOT}/sites"; do if [ -d "$candidate" ]; then TARGET_PREFIXES+=("$candidate") fi @@ -242,15 +242,15 @@ fi DEFAULT_FILES=() if [ "$explicit_paths" = false ]; then - for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom"; do + for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom" "${DOCROOT}/sites"; do if [ -d "$candidate" ]; then while IFS= read -r file_path; do DEFAULT_FILES+=("$file_path") - done < <(find "$candidate" -path '*/node_modules/*' -prune -o -type f \( -name '*.js' -o -name '*.yml' -o -name '*.yaml' -o -name '*.css' -o -name '*.scss' -o -name '*.sass' \) -print) + done < <(find "$candidate" \( -path '*/node_modules/*' -o -path '*/sites/*/files/*' \) -prune -o -type f \( -name '*.js' -o -name '*.yml' -o -name '*.yaml' -o -name '*.css' -o -name '*.scss' -o -name '*.sass' \) -print) fi done if [ "${#DEFAULT_FILES[@]}" -eq 0 ]; then - echo "No custom files found under modules/custom, themes/custom, or profiles/custom. Nothing to format." >&2 + echo "No default files found under modules/custom, themes/custom, profiles/custom, or sites (excluding sites/*/files). Nothing to format." >&2 exit 2 fi fi @@ -265,8 +265,9 @@ fi DEFAULT_CONFIG="" if [ -f "${PROJECT_ROOT}/.prettierrc.json" ]; then DEFAULT_CONFIG="${PROJECT_ROOT}/.prettierrc.json" -elif [ -f "${DOCROOT_PATH}/core/.prettierrc.json" ]; then - DEFAULT_CONFIG="${DOCROOT_PATH}/core/.prettierrc.json" +elif [ "$has_config" = false ]; then + echo "Prettier config file is missing. Create .prettierrc.json in the project root (for example by reinstalling the add-on)." >&2 + exit 2 fi # ============================================================================ diff --git a/commands/web/stylelint b/commands/web/stylelint index d9fcb4c..e4141d1 100755 --- a/commands/web/stylelint +++ b/commands/web/stylelint @@ -176,12 +176,10 @@ DEFAULT_CONFIG_PATH="" if [ "$has_config" = false ]; then if [ -f "${PROJECT_ROOT}/.stylelintrc.json" ]; then CONFIG_PATH="${PROJECT_ROOT}/.stylelintrc.json" - else - CONFIG_PATH="${DOCROOT_PATH}/core/.stylelintrc.json" - echo "Warning: using core Stylelint config (${DOCROOT}/core/.stylelintrc.json); project config not found." >&2 + config_dir="$(dirname "$CONFIG_PATH")" + CMD=(env "NODE_PATH=$NODE_PATH" node "$TOOLCHAIN_BIN" --config="$CONFIG_PATH" --config-basedir="$config_dir") fi DEFAULT_CONFIG_PATH="$CONFIG_PATH" - CMD+=(--config="$CONFIG_PATH") fi uses_scss=false @@ -262,15 +260,15 @@ fi if [ "$explicit_paths" = false ]; then DEFAULT_FILES=() - for candidate in modules/custom themes/custom profiles/custom; do + for candidate in modules/custom themes/custom profiles/custom sites; do if [ -d "$candidate" ]; then while IFS= read -r file_path; do DEFAULT_FILES+=("$file_path") - done < <(find "$candidate" -type f \( -name '*.css' -o -name '*.scss' -o -name '*.sass' \)) + done < <(find "$candidate" \( -path '*/node_modules/*' -o -path '*/sites/*/files/*' \) -prune -o -type f \( -name '*.css' -o -name '*.scss' -o -name '*.sass' \) -print) fi done if [ "${#DEFAULT_FILES[@]}" -eq 0 ]; then - echo "No custom style files found under modules/custom, themes/custom, or profiles/custom. Nothing to lint." >&2 + echo "No default style files found under modules/custom, themes/custom, profiles/custom, or sites (excluding sites/*/files). Nothing to lint." >&2 exit 2 fi if [ "$has_config" = false ] && [ "$CONFIG_PATH" = "$DEFAULT_CONFIG_PATH" ]; then @@ -289,6 +287,10 @@ if [ "$explicit_paths" = false ]; then fi done fi + if [ "$has_config" = false ] && [ -z "$CONFIG_PATH" ]; then + echo "Stylelint config file is missing. Create .stylelintrc.json in the project root (or a nearest config in the target paths), or pass --config." >&2 + exit 2 + fi for file_path in "${DEFAULT_FILES[@]}"; do if [[ "$file_path" == *.scss || "$file_path" == *.sass ]]; then uses_scss=true @@ -336,6 +338,11 @@ if [ "$explicit_paths" = false ]; then exit $? fi +if [ "$has_config" = false ] && [ -z "$CONFIG_PATH" ]; then + echo "Stylelint config file is missing. Create .stylelintrc.json in the project root (or a nearest config in the target paths), or pass --config." >&2 + exit 2 +fi + if [ "$uses_scss" = true ] && config_supports_scss "$CONFIG_PATH"; then scss_allowed=true fi diff --git a/commands/web/stylelint-fix b/commands/web/stylelint-fix index 212ea6e..7d39592 100755 --- a/commands/web/stylelint-fix +++ b/commands/web/stylelint-fix @@ -129,6 +129,27 @@ normalize_path() { echo "$path" } +find_stylelint_config() { + local path="$1" + local dir="$path" + if [[ "$dir" == /var/www/html/* ]]; then + dir="${dir#/var/www/html/}" + fi + if [ -f "${PROJECT_ROOT}/${dir}" ]; then + dir="$(dirname "$dir")" + fi + while [ "$dir" != "." ] && [ "$dir" != "/" ]; do + for candidate in .stylelintrc .stylelintrc.json .stylelintrc.yaml .stylelintrc.yml .stylelintrc.js; do + if [ -f "${PROJECT_ROOT}/${dir}/${candidate}" ]; then + echo "${PROJECT_ROOT}/${dir}/${candidate}" + return 0 + fi + done + dir="$(dirname "$dir")" + done + return 1 +} + TARGET_PREFIXES=() RAW_PATHS=() seen_double_dash=false @@ -165,7 +186,7 @@ if [ "${#RAW_PATHS[@]}" -gt 0 ]; then TARGET_PREFIXES+=("$(normalize_path "$raw")") done else - for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom"; do + for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom" "${DOCROOT}/sites"; do if [ -d "$candidate" ]; then TARGET_PREFIXES+=("$candidate") fi @@ -242,15 +263,15 @@ fi DEFAULT_FILES=() if [ "$explicit_paths" = false ]; then - for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom"; do + for candidate in "${DOCROOT}/modules/custom" "${DOCROOT}/themes/custom" "${DOCROOT}/profiles/custom" "${DOCROOT}/sites"; do if [ -d "$candidate" ]; then while IFS= read -r file_path; do DEFAULT_FILES+=("$file_path") - done < <(find "$candidate" -path '*/node_modules/*' -prune -o -type f \( -name '*.css' -o -name '*.scss' \) -print) + done < <(find "$candidate" \( -path '*/node_modules/*' -o -path '*/sites/*/files/*' \) -prune -o -type f \( -name '*.css' -o -name '*.scss' -o -name '*.sass' \) -print) fi done if [ "${#DEFAULT_FILES[@]}" -eq 0 ]; then - echo "No custom CSS/SCSS files found under modules/custom, themes/custom, or profiles/custom. Nothing to fix." >&2 + echo "No default CSS/SCSS/Sass files found under modules/custom, themes/custom, profiles/custom, or sites (excluding sites/*/files). Nothing to fix." >&2 exit 2 fi fi @@ -262,11 +283,29 @@ else FILES=("${FINAL_ARGS[@]}") fi -DEFAULT_CONFIG="" -if [ -f "${PROJECT_ROOT}/.stylelintrc.json" ]; then - DEFAULT_CONFIG="${PROJECT_ROOT}/.stylelintrc.json" -elif [ -f "${DOCROOT_PATH}/core/.stylelintrc.json" ]; then - DEFAULT_CONFIG="${DOCROOT_PATH}/core/.stylelintrc.json" +CONFIG_PATH="" +if [ "$has_config" = false ]; then + if [ -f "${PROJECT_ROOT}/.stylelintrc.json" ]; then + CONFIG_PATH="${PROJECT_ROOT}/.stylelintrc.json" + else + for file_path in "${FILES[@]}"; do + if [[ "$file_path" == -* ]] || [ "$file_path" = "--" ]; then + continue + fi + if config_candidate="$(find_stylelint_config "$file_path")"; then + CONFIG_PATH="$config_candidate" + break + fi + done + fi + if [ -z "$CONFIG_PATH" ]; then + echo "Stylelint config file is missing. Create .stylelintrc.json in the project root (or a nearest config in the target paths), or pass --config." >&2 + exit 2 + fi + config_dir="$(dirname "$CONFIG_PATH")" + if [ -d "${config_dir}/node_modules" ]; then + NODE_PATH="${NODE_PATH}:${config_dir}/node_modules" + fi fi # ============================================================================ @@ -293,8 +332,8 @@ if [ "$preview_mode" = true ]; then . | (cd "$tmp_root" && tar -xf -) preview_cmd=(env "NODE_PATH=$NODE_PATH" node "$TOOLCHAIN_BIN") - if [ -n "$DEFAULT_CONFIG" ] && [ "$has_config" = false ]; then - preview_cmd+=(--config "$DEFAULT_CONFIG") + if [ "$has_config" = false ]; then + preview_cmd+=(--config "$CONFIG_PATH" --config-basedir "$(dirname "$CONFIG_PATH")") fi preview_cmd+=(--fix) @@ -349,8 +388,8 @@ if [ "$preview_mode" = true ]; then # Apply fixes cd "$PROJECT_ROOT" fix_cmd=(env "NODE_PATH=$NODE_PATH" node "$TOOLCHAIN_BIN") - if [ -n "$DEFAULT_CONFIG" ] && [ "$has_config" = false ]; then - fix_cmd+=(--config "$DEFAULT_CONFIG") + if [ "$has_config" = false ]; then + fix_cmd+=(--config "$CONFIG_PATH" --config-basedir "$(dirname "$CONFIG_PATH")") fi fix_cmd+=(--fix) @@ -375,8 +414,8 @@ fi cd "$PROJECT_ROOT" fix_cmd=(env "NODE_PATH=$NODE_PATH" node "$TOOLCHAIN_BIN") -if [ -n "$DEFAULT_CONFIG" ] && [ "$has_config" = false ]; then - fix_cmd+=(--config "$DEFAULT_CONFIG") +if [ "$has_config" = false ]; then + fix_cmd+=(--config "$CONFIG_PATH" --config-basedir "$(dirname "$CONFIG_PATH")") fi fix_cmd+=(--fix) diff --git a/tests/test.bats b/tests/test.bats index 092ba4f..e74b0d7 100644 --- a/tests/test.bats +++ b/tests/test.bats @@ -1009,6 +1009,139 @@ CSS assert_success } +@test "eslint fixed mode falls back to .eslintrc.json when passing config is missing" { + set -u -o pipefail + export DCQ_INSTALL_DEPS=skip + export DCQ_INSTALL_NODE_DEPS=skip + run ddev add-on get "${DIR}" + assert_success + + run rm -f .eslintrc.passing.json + assert_success + + mkdir -p web/modules/custom/dcq_test/js + cat > web/modules/custom/dcq_test/js/fixed-mode.js <<'JS' +const x = 1; +JS + + mkdir -p node_modules/eslint/bin + cat > node_modules/eslint/bin/eslint.js <<'JS' +#!/usr/bin/env node +process.stdout.write(process.argv.slice(2).join("\n")); +JS + chmod +x node_modules/eslint/bin/eslint.js + + run wait_for_container_path "/var/www/html/node_modules/eslint/bin/eslint.js" + assert_success + + run ddev exec bash -lc 'cd /var/www/html && ESLINT_CONFIG_MODE=fixed ./.ddev/commands/web/eslint web/modules/custom/dcq_test/js/fixed-mode.js' + assert_success + assert_output --partial "--config=/var/www/html/.eslintrc.json" +} + +@test "stylelint-fix fails with helpful message when project config is missing" { + set -u -o pipefail + export DCQ_INSTALL_DEPS=skip + export DCQ_INSTALL_NODE_DEPS=skip + run ddev add-on get "${DIR}" + assert_success + + run rm -f .stylelintrc.json .stylelintrc .stylelintrc.yaml .stylelintrc.yml .stylelintrc.js + assert_success + + mkdir -p node_modules/stylelint/bin + cat > node_modules/stylelint/bin/stylelint.mjs <<'JS' +#!/usr/bin/env node +process.exit(0); +JS + chmod +x node_modules/stylelint/bin/stylelint.mjs + + run wait_for_container_path "/var/www/html/node_modules/stylelint/bin/stylelint.mjs" + assert_success + + run ddev stylelint-fix web/themes/custom/dcq_theme/css/fixable.css + assert_failure + assert_output --partial "Stylelint config file is missing." +} + +@test "stylelint-fix uses nearest config when root config is missing" { + set -u -o pipefail + export DCQ_INSTALL_DEPS=skip + export DCQ_INSTALL_NODE_DEPS=skip + run ddev add-on get "${DIR}" + assert_success + + run rm -f .stylelintrc.json .stylelintrc .stylelintrc.yaml .stylelintrc.yml .stylelintrc.js + assert_success + + mkdir -p web/themes/custom/dcq_theme/css + cat > web/themes/custom/dcq_theme/.stylelintrc.json <<'JSON' +{ + "rules": {} +} +JSON + cat > web/themes/custom/dcq_theme/css/fixable.css <<'CSS' +a { + color: RED; +} +CSS + + mkdir -p node_modules/stylelint/bin + cat > node_modules/stylelint/bin/stylelint.mjs <<'JS' +#!/usr/bin/env node +process.stdout.write(process.argv.slice(2).join("\n")); +JS + chmod +x node_modules/stylelint/bin/stylelint.mjs + + run wait_for_container_path "/var/www/html/node_modules/stylelint/bin/stylelint.mjs" + assert_success + + run ddev stylelint-fix web/themes/custom/dcq_theme/css/fixable.css + assert_success + assert_output --partial "--config" + assert_output --partial "/var/www/html/web/themes/custom/dcq_theme/.stylelintrc.json" +} + +@test "prettier-fix fails with helpful message when project config is missing" { + set -u -o pipefail + export DCQ_INSTALL_DEPS=skip + export DCQ_INSTALL_NODE_DEPS=skip + run ddev add-on get "${DIR}" + assert_success + + run rm -f .prettierrc.json + assert_success + + mkdir -p node_modules/prettier/bin + cat > node_modules/prettier/bin/prettier.cjs <<'JS' +#!/usr/bin/env node +process.exit(0); +JS + chmod +x node_modules/prettier/bin/prettier.cjs + + run wait_for_container_path "/var/www/html/node_modules/prettier/bin/prettier.cjs" + assert_success + + run ddev prettier-fix web/themes/custom/dcq_theme/js/prettier.js + assert_failure + assert_output --partial "Prettier config file is missing." +} + +@test "checks runs phpcs without forcing explicit paths" { + set -u -o pipefail + export DCQ_INSTALL_DEPS=skip + export DCQ_INSTALL_NODE_DEPS=skip + run ddev add-on get "${DIR}" + assert_success + + run ddev exec bash -lc $'for cmd in composer-validate php-parallel-lint phpstan eslint stylelint prettier cspell; do\ncat > "/var/www/html/.ddev/commands/web/\\${cmd}" <<\'SH\'\n#!/usr/bin/env bash\nexit 0\nSH\nchmod +x "/var/www/html/.ddev/commands/web/\\${cmd}"\ndone\ncat > /var/www/html/.ddev/commands/web/phpcs <<\'SH\'\n#!/usr/bin/env bash\nif [ "$#" -ne 0 ]; then\n echo "unexpected phpcs args: $*" >&2\n exit 23\nfi\nexit 0\nSH\nchmod +x /var/www/html/.ddev/commands/web/phpcs' + assert_success + + run ddev checks + assert_success + assert_output --partial "- phpcs: PASS" +} + @test "install from directory with phpstan level override" { set -u -o pipefail export DCQ_PHPSTAN_LEVEL=3 From 72fdaaf1ddc23185a4128939c89e927b073b731e Mon Sep 17 00:00:00 2001 From: Bob McDonald Date: Mon, 2 Mar 2026 15:18:12 -0800 Subject: [PATCH 5/6] Align fresh-install cspell expectation with default scope --- tests/test.bats | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test.bats b/tests/test.bats index e74b0d7..382e255 100644 --- a/tests/test.bats +++ b/tests/test.bats @@ -1463,7 +1463,12 @@ SH run ./.ddev/drupal-code-quality/tooling/bin/cspell assert_failure assert_output --partial "modlue" - assert_output --partial "roottypo" + case "$output" in + *"roottypo"*) + echo "Expected default cspell scope to exclude project-root files like cspell-test.md." + return 1 + ;; + esac before_phpcbf="$(read_container_file /var/www/html/web/modules/custom/dcq_test/dcq_fixable.php)" run ./.ddev/drupal-code-quality/tooling/bin/phpcbf web/modules/custom/dcq_test/dcq_fixable.php From fef0112e0d8ac6c720404faa1b8e5ecca3e9191a Mon Sep 17 00:00:00 2001 From: Bob McDonald Date: Tue, 3 Mar 2026 23:20:15 -0800 Subject: [PATCH 6/6] Add host-path alias symlink entrypoint and coverage --- README.md | 11 ++++ install.yaml | 3 + tests/test.bats | 70 ++++++++++++++++++++++ web-entrypoint.d/90-dcq-host-path-alias.sh | 70 ++++++++++++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 web-entrypoint.d/90-dcq-host-path-alias.sh diff --git a/README.md b/README.md index 3607a78..d927bea 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,14 @@ The template points PHP tooling at `.ddev/drupal-code-quality/tooling/bin` and J - `dcq-reports/` is created at the project root when running `checks` or the `*-fix` commands (logs + patch previews). - Add `dcq-reports/` to `.gitignore` if you do not want to track it. +- Host-path parity alias: + - The add-on installs `.ddev/web-entrypoint.d/90-dcq-host-path-alias.sh`. + - On container start, it creates a host-style project-path symlink to + `/var/www/html` so absolute host paths can resolve inside the container. + - On macOS paths under `/private/...`, it also creates a `/...` companion + alias (for example `/tmp/...`) to cover common host-path forms. + - To disable, add `DCQ_HOST_PATH_ALIAS=0` under `web_environment` in + `.ddev/config.yaml`, then run `ddev restart`. - ESLint toolchain selection: - `ESLINT_TOOLCHAIN=auto` (default) prefers root toolchain when root configs exist. - `ESLINT_TOOLCHAIN=core` forces Drupal core JS toolchain. @@ -224,6 +232,9 @@ The template points PHP tooling at `.ddev/drupal-code-quality/tooling/bin` and J - `DCQ_INSTALL_IDE_SETTINGS`: `merge` to add missing VS Code settings and extension recommendations, `overwrite` to back up and replace, `skip` to handle manually, or unset to prompt. +- `DCQ_HOST_PATH_ALIAS`: `1`/unset (default) keeps host-path alias symlinks + enabled at web-container startup; set `0`/`false`/`off` to disable and remove + add-on-managed aliases on restart. ## Uninstall diff --git a/install.yaml b/install.yaml index 2324ff5..e3da0ba 100644 --- a/install.yaml +++ b/install.yaml @@ -4,6 +4,7 @@ name: drupal-code-quality # Files copied into the project's .ddev directory. project_files: + - web-entrypoint.d/90-dcq-host-path-alias.sh - commands/helpers/path-map.sh - commands/web/checks - commands/web/composer-validate @@ -50,3 +51,5 @@ removal_actions: fi rm -f .dcq-docroot rm -rf drupal-code-quality + rm -f web-entrypoint.d/90-dcq-host-path-alias.sh + rmdir web-entrypoint.d 2>/dev/null || true diff --git a/tests/test.bats b/tests/test.bats index 382e255..16b2414 100644 --- a/tests/test.bats +++ b/tests/test.bats @@ -938,6 +938,76 @@ PY assert_output "/var/www/html/web/index.php" } +@test "host-path alias symlink is created on restart" { + set -u -o pipefail + local approot="$TESTDIR" + run ddev add-on get "${DIR}" + assert_success + + retry_ddev_command ddev restart -y + assert_success + + run ddev exec bash -lc "set -eu; test -L '${approot}'; [ \"\$(readlink '${approot}')\" = \"/var/www/html\" ]" + assert_success + + case "$approot" in + /private/*) + local alt="${approot#/private}" + run ddev exec bash -lc "set -eu; test -L '${alt}'; [ \"\$(readlink '${alt}')\" = \"/var/www/html\" ]" + assert_success + ;; + esac +} + +@test "host-path alias can be disabled via DCQ_HOST_PATH_ALIAS" { + set -u -o pipefail + local approot="$TESTDIR" + run ddev add-on get "${DIR}" + assert_success + + python3 - <<'PY' +from pathlib import Path + +path = Path(".ddev/config.yaml") +lines = path.read_text(encoding="utf-8").splitlines() + +for idx, line in enumerate(lines): + if line.strip() == "web_environment: []": + lines[idx] = "web_environment:" + lines.insert(idx + 1, " - DCQ_HOST_PATH_ALIAS=0") + break +else: + inserted = False + for idx, line in enumerate(lines): + if line.strip() == "web_environment:": + if idx + 1 < len(lines) and lines[idx + 1].strip().startswith("- "): + lines.insert(idx + 1, " - DCQ_HOST_PATH_ALIAS=0") + else: + lines.insert(idx + 1, " - DCQ_HOST_PATH_ALIAS=0") + inserted = True + break + if not inserted: + lines.append("web_environment:") + lines.append(" - DCQ_HOST_PATH_ALIAS=0") + +path.write_text("\n".join(lines) + "\n", encoding="utf-8") +PY + + retry_ddev_command ddev restart -y + assert_success + + run ddev exec bash -lc "set -eu; if [ -L '${approot}' ]; then exit 1; fi" + assert_success + + case "$approot" in + /private/*) + local alt="${approot#/private}" + run ddev exec bash -lc "set -eu; if [ -L '${alt}' ]; then exit 1; fi" + assert_success + ;; + esac +} + @test "install from directory with non-web docroot" { set -u -o pipefail mkdir -p docroot diff --git a/web-entrypoint.d/90-dcq-host-path-alias.sh b/web-entrypoint.d/90-dcq-host-path-alias.sh new file mode 100644 index 0000000..fb7cc46 --- /dev/null +++ b/web-entrypoint.d/90-dcq-host-path-alias.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +#ddev-generated +set -eu + +APPROOT_FILE="/mnt/ddev_config/.ddev-docker-compose-full.yaml" +TARGET_PATH="/var/www/html" + +if [ ! -f "$APPROOT_FILE" ]; then + return 0 2>/dev/null || exit 0 +fi + +APPROOT="$(awk -F': ' '/com\.ddev\.approot:/ {print $2; exit}' "$APPROOT_FILE" | tr -d '"')" +if [ -z "$APPROOT" ]; then + return 0 2>/dev/null || exit 0 +fi + +ALT_APPROOT="" +case "$APPROOT" in + /private/*) + ALT_APPROOT="${APPROOT#/private}" + ;; +esac + +alias_disabled() { + case "${DCQ_HOST_PATH_ALIAS:-1}" in + 0|false|FALSE|False|no|NO|No|off|OFF|Off) + return 0 + ;; + esac + return 1 +} + +cleanup_alias() { + local alias_path="$1" + local current_target="" + if [ -z "$alias_path" ]; then + return + fi + if ! sudo test -L "$alias_path"; then + return + fi + current_target="$(sudo readlink "$alias_path" || true)" + if [ "$current_target" = "$TARGET_PATH" ]; then + sudo rm -f "$alias_path" + fi +} + +ensure_alias() { + local alias_path="$1" + if [ -z "$alias_path" ]; then + return + fi + + if sudo test -e "$alias_path" && ! sudo test -L "$alias_path"; then + # Never replace real directories/files outside our managed symlink case. + return + fi + + sudo mkdir -p "$(dirname "$alias_path")" + sudo ln -sfn "$TARGET_PATH" "$alias_path" +} + +if alias_disabled; then + cleanup_alias "$APPROOT" + cleanup_alias "$ALT_APPROOT" + return 0 2>/dev/null || exit 0 +fi + +ensure_alias "$APPROOT" +ensure_alias "$ALT_APPROOT"