Skip to content

[BUGFIX] Report bootstrap fatal errors in console output format - #441

Open
CybotTM wants to merge 2 commits into
FriendsOfTYPO3:mainfrom
CybotTM:fix/434-console-fatal-errors
Open

CybotTM wants to merge 2 commits into
FriendsOfTYPO3:mainfrom
CybotTM:fix/434-console-fatal-errors

Conversation

@CybotTM

@CybotTM CybotTM commented Sep 26, 2026 •

Copy link
Copy Markdown

When the config or the container fails to load, fractor process now prints the error on stderr in the default console format, instead of exiting 1 with no output. Both formats report the message of every previous exception too.

Fixes #434.

Changes

bin/fractor.php follows the catch block of Rector's bin/rector.php: every message of the exception chain, as fatal_errors via Json::encode() in JSON format and as an error block from SymfonyStyleFactory in console format. It differs from Rector in the places where the message would otherwise still be lost:

  • The console message goes to stderr. Symfony's Application::run() renders uncaught exceptions to getErrorOutput() as well. On stdout, fractor process … > out.txt hid the message again. JSON stays on stdout.
  • A verbosity below normal is raised to normal after SymfonyStyleFactory configured the style. The factory applies -q and SHELL_VERBOSITY=-1, and error() writes at normal verbosity, so the only output of the failed run was suppressed. Symfony's own renderThrowable() writes at VERBOSITY_QUIET for the same reason. Fractor removed --quiet in [!!!][TASK] Drop --quiet cli option #412, but Symfony still applies -q at this point. --silent (symfony/console 7.2+) is raised too, on purpose. -v and higher, --ansi and --no-ansi keep working through the factory.
  • With -v and higher, each block also names the exception class, the file (basename, as in Symfony's renderThrowable()) and the line, for example ParseError in fractor.php line 3, with n/a for a missing file or line, as Symfony prints it. The issue notes that -vvv changed nothing, and without the location a parse error in the config gives no hint where it is.
  • Invalid UTF-8 is replaced with U+FFFD when the messages are collected (a json_encode() round-trip with JSON_INVALID_UTF8_SUBSTITUTE, which needs no extension). On main, Json::encode() already throws on a malformed message (exit 255, PHP fatal), and the chain adds more messages that can be malformed. In console format, the OutputWrapper of symfony/console wraps with the u modifier, and preg_replace() returns null on malformed UTF-8, so the block is empty. The guard for that is in 6.4.46, 7.4.19 and 8.1.7, not in 6.4.45, 7.4.18 or 8.0.15.
  • Line breaks (console format): a lone \r becomes \n too, not only \r\n as in Rector. symfony/console normalizes \r\n itself only in recent patch releases (not in 6.4.32, 7.4.4 or 8.0.4, present in 6.4.34), and never a lone \r, which overwrites the block on a terminal.

e2e/run-test.sh (first commit, separate so it can be judged on its own): the script has no set -e, so only its last diff decided the exit code, and a new case could not fail CI. Every diff now counts. The script also compares Fractor's exit code (expected-exit-code.txt, default 0, must be a number) and, where the case has an expected-error-output.txt, stderr, exactly. An optional env.txt sets environment variables for one case. The script itself sets COLUMNS=120 and unsets FORCE_COLOR for every case, because the error block is padded to the terminal width and colors depend on the environment. .editorconfig keeps the padding in expected-error-output.txt. The six existing cases pass unchanged.

Four new e2e cases, each with its own config:

  • fatal-config: a config that imports a missing set class, run with SHELL_VERBOSITY=-1
  • fatal-config-chained: a config throwing a chain of three exceptions with \r\n, a lone \r and invalid UTF-8, run with -q
  • fatal-config-json: a chain in JSON format, with a / and invalid UTF-8 in the message, and an empty expected stderr
  • fatal-config-verbose: a chain of two exceptions created on different lines, one of them namespaced, run with -v

Testing

Local runs, PHP 8.5.10, highest dependency versions: symfony/console v6.4.46 in e2e/ (symplify/rule-doc-generator pins ^6.4 there). CI has not run yet: both workflow runs wait for maintainer approval.

  • e2e/run-test.sh: exit 0, 10 cases. At the first commit alone (with main's bin): exit 0, 6 cases.
  • composer style:php:check, analyze:php, rector: exit 0. test:php: OK, 320 tests. style:composer:normalize: exit 0, 13 files already normalized. Locally it fails now and then with Command "normalize" is not defined, on unmodified main as well (1 of 3 runs there).
  • Without the mbstring and iconv extensions (php -n): both formats print U+FFFD, exit 1.
  • 22 mutations of bin/fractor.php against the e2e suite, with an unmutated control run that passes. All 22 are caught: exit 0 instead of 1, only the outer message, only the innermost message, at most two messages, JSON with only the first message, main's bin, stdout instead of stderr, warning() instead of error(), no line-break normalization, \r\n only, no UTF-8 substitution, no JSON_INVALID_UTF8_SUBSTITUTE, no verbosity reset, a reset only for -q, an unconditional reset, no location, the first location for every block, the full path instead of the basename, the short class name, a bare SymfonyStyle instead of SymfonyStyleFactory, the console block also printed in JSON mode, plain json_encode() instead of Json::encode(). --ansi has no case of its own: a colored block would tie the fixture to the space padding of symfony/console 6.4, and 8.1 pads it with cursor sequences instead.
  • Not covered by the e2e suite: symfony/console versions other than 6.4.46, including the empty block on older versions, which e2e/ cannot install.

Assisted by claude-code:claude-opus-5-5 — Session

@CybotTM

CybotTM commented Sep 26, 2026 •

Copy link
Copy Markdown
Author

Local note, not part of this change: the root and the e2e/ installs share packages/extension-installer/generated/InstalledPackages.php, and each composer install rewrites it with its own package set. After e2e/run-test.sh, AllowedFileExtensionsResolverTest fails in the root suite. After a root composer install, every e2e case fails at bootstrap with Expected to find class "a9f\FractorRuleGenerator\Console\Command\GenerateRuleCommand". On main that second failure is silent; with this PR it is printed. Running the other composer install again restores the file. CI installs each side in a separate job, so it does not hit this.

@CybotTM
CybotTM force-pushed the fix/434-console-fatal-errors branch 7 times, most recently from 45aa0a0 to 0b3fe22 Compare September 26, 2026 21:30
@CybotTM
CybotTM marked this pull request as ready for review September 26, 2026 21:52
@CybotTM
CybotTM marked this pull request as draft September 27, 2026 06:55
run-test.sh has no set -e, so its exit code was the exit code of its
last diff only. A difference in any other diff was printed but did not
fail the job.

- Record every failed diff and exit non-zero at the end
- Compare the exit code of Fractor with expected-exit-code.txt, 0 when
  the file is absent; a value that is not a number fails the case
- Capture stderr in error-output.txt and compare it exactly with
  expected-error-output.txt when that file exists; stderr is still
  printed to the log
- Pass the variables in env.txt to Fractor when the file exists
- Set COLUMNS=120 and unset FORCE_COLOR, so that the output does not
  depend on the terminal width or forced colors
- Keep editors from trimming the padding in expected-error-output.txt

Assisted-by: claude-code:claude-opus-5-5
Agent-Session: https://claude.ai/code/session_016aBEJdgVbFutUUNEwFEMQK
Agent-Host: 0493f0
Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
When loading the config or building the container throws,
bin/fractor.php printed the message only for --output-format=json.
The default console format exited 1 with no output at all, so a config
with an unresolvable class import looked like a run that did nothing.

The catch block follows Rector's bin/rector.php:

- print each message as an error block in console format
- report the message of every previous exception, in both formats

It differs from Rector where the message would otherwise still be lost:

- the console message goes to stderr, where Symfony's Application
  renders uncaught exceptions too, so redirecting stdout does not hide it
- a verbosity below normal is raised to normal after the style is
  configured, so -q or SHELL_VERBOSITY=-1 cannot suppress the only
  output of a failed run
- with -v and higher, each block also names the exception class, file
  and line, which the issue missed from -vvv
- invalid UTF-8 is replaced with U+FFFD when the messages are
  collected: Nette's Json::encode threw on it, and the OutputWrapper of
  symfony/console before 6.4.46, 7.4.19 and 8.1.7 wraps with a pattern
  that empties such a message
- a lone \r is normalized as well as \r\n

Add four e2e cases, each with its own config: a missing set class, run
with SHELL_VERBOSITY=-1; an exception chain run with -q; an exception
chain in JSON format; an exception chain run with -v.

Fixes FriendsOfTYPO3#434

Assisted-by: claude-code:claude-opus-5-5
Agent-Session: https://claude.ai/code/session_016aBEJdgVbFutUUNEwFEMQK
Agent-Host: 0493f0
Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
@CybotTM
CybotTM force-pushed the fix/434-console-fatal-errors branch from 0b3fe22 to 03e1824 Compare September 27, 2026 06:55
@CybotTM
CybotTM marked this pull request as ready for review September 27, 2026 08:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] a fatal during config loading exits 1 with zero output unless --output-format=json

1 participant