Skip to content
This repository was archived by the owner on Jul 2, 2026. It is now read-only.

Commit 1b5c4b9

Browse files
committed
fix(php-cs-fixer): walk up from each file to find nearest config
Consumer projects whose .php-cs-fixer.dist.php lives in a nested package (e.g. packages/php/.php-cs-fixer.dist.php) fell through to @PER-CS default rules under prek because the hook only searched the current working directory. @PER-CS collapses empty classes to single line, whereas the consumer's PSR-12 ruleset wants multi-line — which fights any code generator emitting the multi-line form. Walk up from each input file's directory to find the nearest config. Group files by their discovered config and invoke php-cs-fixer once per bucket with the correct --config.
1 parent f75aa86 commit 1b5c4b9

2 files changed

Lines changed: 109 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12+
- `php-cs-fixer`: discover `.php-cs-fixer.dist.php` / `.php-cs-fixer.php` by
13+
walking up from each input file's directory, not just the current working
14+
directory. Polyrepos that nest a PHP package inside a larger monorepo
15+
(e.g. consumer config at `packages/php/.php-cs-fixer.dist.php`) previously
16+
fell through to the `@PER-CS` default ruleset because the hook runs from
17+
the repo root and the cwd-only search missed the nested config. The
18+
consumer's PSR-12-derived ruleset wants multi-line class declarations
19+
while `@PER-CS` collapses empty classes to single-line (`final class T {}`)
20+
— invalidating any embedded generator hash and ping-ponging with code
21+
generators (e.g. alef) that re-emit the multi-line form on every regen.
22+
The hook now groups input files by their nearest discovered config and
23+
invokes php-cs-fixer once per bucket with the correct `--config`. Falls
24+
back to the legacy cwd-only behaviour when no input paths are passed.
25+
Surfaced on html-to-markdown's prek run (alef-generated
26+
`packages/php/src/VisitorHandle.php` collapsed every cycle).
27+
(`hooks/php-cs-fixer/run.sh`)
28+
1229
- `pydocstyle`: stop triggering "files modified by this hook" via `uv run`.
1330
The hook ran `uv run ruff check --select D`, which `uv sync`s the workspace
1431
on every invocation and touches `uv.lock` / `.venv/`. prek then reports

hooks/php-cs-fixer/run.sh

Lines changed: 92 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -28,27 +28,43 @@ url="https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases/download/v${VERSION}/
2828
phar_path="$(download_tool php-cs-fixer "${VERSION}" "${url}" "${asset_name}" "${CHECKSUMS}")"
2929

3030
# php-cs-fixer requires `--config` whenever multiple positional paths are
31-
# passed and a config file exists. Auto-discover .php-cs-fixer.dist.php /
32-
# .php-cs-fixer.php in the cwd and pass `--config` explicitly so prek's
33-
# multi-file invocations don't error with "For multiple paths config parameter
34-
# is required."
31+
# passed and a config file exists. Each file's config is auto-discovered by
32+
# walking from the file's directory up to the repo root searching for
33+
# .php-cs-fixer.dist.php / .php-cs-fixer.php. The cwd-only fallback below
34+
# applies when no input paths are passed or the caller passes an explicit
35+
# --config on the command line.
36+
#
37+
# Polyrepos that nest a PHP package inside a larger monorepo (e.g. consumer
38+
# `.php-cs-fixer.dist.php` lives at `packages/php/.php-cs-fixer.dist.php`)
39+
# break under a cwd-only search because pre-commit / prek runs the hook from
40+
# the repo root, the file gets formatted with `@PER-CS` defaults instead of
41+
# the consumer's PSR-12-derived ruleset, and the consumer's PSR-12 multi-line
42+
# class style is collapsed to PER-CS single-line — invalidating any embedded
43+
# generator hash and ping-ponging with regeneration tools that re-emit the
44+
# multi-line form.
3545
extra_args=()
36-
has_config=0
37-
if [[ ! " $* " =~ " --config" && ! " $* " =~ " --config=" ]]; then
38-
for cfg in .php-cs-fixer.dist.php .php-cs-fixer.php; do
39-
if [[ -f "${cfg}" ]]; then
40-
extra_args+=("--config=${cfg}")
41-
has_config=1
42-
break
43-
fi
44-
done
45-
else
46-
has_config=1
47-
fi
48-
if ((has_config == 0)) && [[ ! " $* " =~ " --rules" && ! " $* " =~ " --rules=" ]]; then
49-
extra_args+=("--rules=@PER-CS")
46+
has_explicit_config=0
47+
if [[ " $* " =~ " --config" || " $* " =~ " --config=" ]]; then
48+
has_explicit_config=1
5049
fi
5150

51+
# Walk up from a starting directory looking for the nearest config file.
52+
# Emits the absolute config path on stdout (empty string when none found).
53+
find_php_cs_fixer_config() {
54+
local start_dir="$1"
55+
local current="${start_dir}"
56+
while [[ "${current}" != "/" && "${current}" != "." && -n "${current}" ]]; do
57+
for cfg in .php-cs-fixer.dist.php .php-cs-fixer.php; do
58+
if [[ -f "${current}/${cfg}" ]]; then
59+
printf '%s\n' "${current}/${cfg}"
60+
return 0
61+
fi
62+
done
63+
current="$(dirname "${current}")"
64+
done
65+
return 1
66+
}
67+
5268
if [[ "${1:-}" == "fix" ]]; then
5369
shift
5470
fi
@@ -77,12 +93,64 @@ for arg in "$@"; do
7793
esac
7894
done
7995

80-
if ((has_config == 0 && ${#paths[@]} > 1)); then
81-
status=0
82-
for path in "${paths[@]}"; do
83-
php "${phar_path}" fix "${extra_args[@]}" "${options[@]}" "${path}" || status=$?
96+
# When no paths are passed and no explicit --config, fall back to the legacy
97+
# cwd-search behaviour so callers that rely on cwd discovery aren't broken.
98+
if ((has_explicit_config == 0 && ${#paths[@]} == 0)); then
99+
for cfg in .php-cs-fixer.dist.php .php-cs-fixer.php; do
100+
if [[ -f "${cfg}" ]]; then
101+
extra_args+=("--config=${cfg}")
102+
has_explicit_config=1
103+
break
104+
fi
84105
done
85-
exit "${status}"
106+
if ((has_explicit_config == 0)); then
107+
extra_args+=("--rules=@PER-CS")
108+
fi
109+
exec php "${phar_path}" fix "${extra_args[@]}" "${options[@]}"
110+
fi
111+
112+
# Group paths by their nearest discovered config (or no-config bucket) so each
113+
# php-cs-fixer invocation runs against the right ruleset.
114+
declare -A paths_by_config=()
115+
no_config_paths=()
116+
for path in "${paths[@]}"; do
117+
if ((has_explicit_config == 1)); then
118+
# Caller passed --config explicitly — honour it for all paths.
119+
no_config_paths+=("${path}")
120+
continue
121+
fi
122+
start_dir="${path}"
123+
if [[ -f "${path}" ]]; then
124+
start_dir="$(dirname "${path}")"
125+
fi
126+
start_dir="$(cd "${start_dir}" 2>/dev/null && pwd || printf '%s\n' "${start_dir}")"
127+
if cfg_path="$(find_php_cs_fixer_config "${start_dir}")"; then
128+
paths_by_config["${cfg_path}"]+="${path}"$'\n'
129+
else
130+
no_config_paths+=("${path}")
131+
fi
132+
done
133+
134+
status=0
135+
for cfg_path in "${!paths_by_config[@]}"; do
136+
# shellcheck disable=SC2206
137+
bucket_paths=(${paths_by_config["${cfg_path}"]})
138+
php "${phar_path}" fix --config="${cfg_path}" "${options[@]}" "${bucket_paths[@]}" || status=$?
139+
done
140+
141+
if ((${#no_config_paths[@]} > 0)); then
142+
if ((has_explicit_config == 0)); then
143+
extra_args+=("--rules=@PER-CS")
144+
fi
145+
if ((${#no_config_paths[@]} > 1 && has_explicit_config == 0)); then
146+
# No discovered config + multiple paths: invoke per-file to avoid
147+
# php-cs-fixer's "For multiple paths config parameter is required" error.
148+
for path in "${no_config_paths[@]}"; do
149+
php "${phar_path}" fix "${extra_args[@]}" "${options[@]}" "${path}" || status=$?
150+
done
151+
else
152+
php "${phar_path}" fix "${extra_args[@]}" "${options[@]}" "${no_config_paths[@]}" || status=$?
153+
fi
86154
fi
87155

88-
exec php "${phar_path}" fix "${extra_args[@]}" "${options[@]}" "${paths[@]}"
156+
exit "${status}"

0 commit comments

Comments
 (0)