From c32174c7219e7aaba60aa640183aaa223f926c00 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Sat, 17 Oct 2026 15:07:50 -0300 Subject: [PATCH 1/8] =?UTF-8?q?test(http):=20for=C3=A7a=20falha=20de=20con?= =?UTF-8?q?ex=C3=A3o=20sem=20depender=20de=20DNS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O teste de FailurePhase supunha que o TLD .invalid nunca resolve. Em rede com DNS curinga o host resolve e o teste falha. Agora ele reserva uma porta efêmera livre em 127.0.0.1, libera e conecta no IP literal: a conexão é recusada (CURLE_COULDNT_CONNECT, errno 7) de forma determinística e offline, e a classificação continua sendo ConnectionNotEstablished. --- tests/Http/CurlTransportFailurePhaseTest.php | 21 ++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/Http/CurlTransportFailurePhaseTest.php b/tests/Http/CurlTransportFailurePhaseTest.php index 9c8d731..96c3f7b 100644 --- a/tests/Http/CurlTransportFailurePhaseTest.php +++ b/tests/Http/CurlTransportFailurePhaseTest.php @@ -7,18 +7,27 @@ use Nfe\Http\FailurePhase; use Nfe\Http\Request; -it('classifies a DNS resolution failure as ConnectionNotEstablished', function (): void { - // RFC 6761 guarantees the .invalid TLD never resolves — a deterministic, - // offline way to force CURLE_COULDNT_RESOLVE_HOST (errno 6). +it('classifies a refused connection as ConnectionNotEstablished', function (): void { + // Forces CURLE_COULDNT_CONNECT (errno 7) without touching DNS: grab a free + // ephemeral port on the loopback, release it and connect to the IP literal. + // Nothing listens there, so the kernel answers the SYN with RST at once. + // A `.invalid` hostname is not enough: networks with wildcard DNS resolve it. + $server = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); + if ($server === false) { + $this->fail("Cannot bind a loopback socket: {$errstr}"); + } + $address = (string) stream_socket_get_name($server, false); + fclose($server); + $transport = new CurlTransport(defaultTimeout: 2, connectTimeout: 2); - $request = new Request('GET', 'https://nfe-sdk-nonexistent.invalid', '/v1/x'); + $request = new Request('GET', "http://{$address}", '/v1/x'); try { $transport->send($request); - $this->fail('Expected ApiConnectionException on DNS failure.'); + $this->fail('Expected ApiConnectionException on a refused connection.'); } catch (ApiConnectionException $e) { expect($e->failurePhase)->toBe(FailurePhase::ConnectionNotEstablished); - expect($e->curlErrno)->toBe(6); // CURLE_COULDNT_RESOLVE_HOST + expect($e->curlErrno)->toBe(7); // CURLE_COULDNT_CONNECT } }); From 5fb9e0ec1b75523a2e17dccd6675236694a3f3fc Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Sat, 17 Oct 2026 15:09:18 -0300 Subject: [PATCH 2/8] ci: adiciona gate de php -l em todo src/ (falha em erro ou deprecation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O lint do gerador usa token_get_all, que só verifica sintaxe e deixa passar erros de compilação, como case de enum duplicado (#39), e deprecations de assinatura (#36). O novo scripts/lint.sh roda `php -l` com E_ALL em cada arquivo de src/ e reprova qualquer saída diferente de "No syntax errors". Entra como job próprio no CI (PHP 8.2), como passo do verify da release e como `composer lint` / `make lint`. Nesta revisão o gate ainda falha: 1 fatal (#39) e 4 deprecations (#36), corrigidos nos commits seguintes. --- .github/workflows/ci.yml | 19 ++++++++++++ .github/workflows/release.yml | 4 +++ CONTRIBUTING.md | 1 + Makefile | 6 +++- composer.json | 1 + scripts/lint.sh | 55 +++++++++++++++++++++++++++++++++++ 6 files changed, 85 insertions(+), 1 deletion(-) create mode 100755 scripts/lint.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a44900a..f7e49dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,25 @@ jobs: - name: Run PHP-CS-Fixer run: composer cs + lint: + name: php -l em src/ (PHP 8.2) + runs-on: ubuntu-latest + # `php -l` compila cada arquivo: pega o que o lint do gerador + # (token_get_all) não vê, como case de enum duplicado (#39) e + # deprecations de assinatura (#36). Falha em qualquer diagnóstico. + steps: + - uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: curl, json, mbstring + coverage: none + + - name: php -l em src/ + run: bash scripts/lint.sh + openapi-sync: name: OpenAPI generated code in sync runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a8f0e6c..1db3282 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,6 +49,10 @@ jobs: if: matrix.php == '8.2' run: composer generate:check + - name: php -l em src/ + if: matrix.php == '8.2' + run: bash scripts/lint.sh + - name: Version.php matches tag if: matrix.php == '8.2' run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b019272..442a34a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,6 +54,7 @@ Requires PHP 8.2, 8.3, or 8.4 and Composer 2 on the host. | PHP-CS-Fixer (auto-fix) | Apply style fixes | `composer cs:fix` | | OpenAPI codegen | Regenerate `src/Generated/` from `openapi/*.yaml` | `composer generate` | | OpenAPI sync check | Fail if `src/Generated/` is out of sync with specs | `composer generate:check` | +| Lint gate | `php -l` (E_ALL) over every file in `src/`; fails on any error or deprecation | `composer lint` | All four are enforced by CI (`.github/workflows/ci.yml`) on every push and PR to `v3`. diff --git a/Makefile b/Makefile index 3cc77c0..23f534f 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ DC := docker compose RUN := $(DC) run --rm PHP ?= php -.PHONY: help build install update test test-matrix stan cs cs-fix generate generate-check shell clean +.PHONY: help build install update test test-matrix stan cs cs-fix lint generate generate-check shell clean help: @echo "NFE.io PHP SDK — dev shortcuts (all run via docker compose)" @@ -21,6 +21,7 @@ help: @echo " make stan PHPStan analyse" @echo " make cs php-cs-fixer dry-run" @echo " make cs-fix php-cs-fixer fix (writes changes)" + @echo " make lint php -l over src/ (fails on errors or deprecations)" @echo " make generate Regenerate src/Generated/ from openapi/*.yaml" @echo " make generate-check Fail if src/Generated/ is out of sync with specs" @echo " make shell Drop into a bash shell inside the PHP 8.2 container" @@ -54,6 +55,9 @@ cs: cs-fix: $(RUN) $(PHP) composer cs:fix +lint: + $(RUN) $(PHP) composer lint + generate: $(RUN) $(PHP) composer generate diff --git a/composer.json b/composer.json index 054259c..cada190 100755 --- a/composer.json +++ b/composer.json @@ -63,6 +63,7 @@ "stan": "phpstan analyse --memory-limit=512M", "cs": "php-cs-fixer fix --dry-run --diff", "cs:fix": "php-cs-fixer fix", + "lint": "bash scripts/lint.sh", "generate": "@php scripts/generate.php", "generate:check": "@php scripts/generate.php --check" }, diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 0000000..4d33472 --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# Gate de lint: roda `php -l` em todo arquivo PHP de src/ (inclusive +# src/Generated/) com error_reporting=E_ALL e falha se algum arquivo tiver +# erro ou emitir qualquer diagnóstico (Deprecated, Warning, Fatal error...). +# +# Ao contrário do lint do gerador (token_get_all), `php -l` compila o arquivo, +# então pega erros de compilação como case de enum duplicado e deprecations +# de assinatura (parâmetro opcional antes de obrigatório). +# +# Uso: +# scripts/lint.sh # lint de src/ +# scripts/lint.sh # lint de outro diretório +# LINT_JOBS=8 scripts/lint.sh +# +# Em Docker: docker compose run --rm -T php composer lint + +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +target="${1:-$root/src}" +jobs="${LINT_JOBS:-4}" + +if [ ! -d "$target" ]; then + echo "✗ Diretório não encontrado: $target" >&2 + exit 1 +fi + +total=$(find "$target" -type f -name '*.php' | wc -l | tr -d ' ') +if [ "$total" -eq 0 ]; then + echo "✗ Nenhum arquivo PHP em $target" >&2 + exit 1 +fi + +# Cada `php -l` imprime "No syntax errors detected in " quando o +# arquivo está limpo. Qualquer outra linha não vazia é um problema. O exit +# code do `php -l` é descartado de propósito: a saída é a fonte da verdade, +# porque deprecations não mudam o exit code, e um fatal (status 255) faria o +# xargs abortar antes de varrer os demais arquivos. +output=$( + find "$target" -type f -name '*.php' -print0 \ + | xargs -0 -n 1 -P "$jobs" sh -c \ + 'php -d error_reporting=E_ALL -d display_errors=1 -d log_errors=0 -l "$1" 2>&1; exit 0' lint +) + +clean=$(printf '%s\n' "$output" | grep -c '^No syntax errors detected in ' || true) +problems=$(printf '%s\n' "$output" | grep -v -e '^No syntax errors detected in ' -e '^[[:space:]]*$' || true) + +if [ -n "$problems" ] || [ "$clean" -ne "$total" ]; then + echo "✗ php -l reprovou $((total - clean)) arquivo(s) com erro e emitiu os diagnósticos abaixo ($total analisados):" >&2 + printf '%s\n' "$problems" >&2 + exit 1 +fi + +echo "✓ php -l: $total arquivo(s) sem erros nem deprecations em ${target#"$root"/}" From 3bf61cd7807042aa71a6ba474727260d15b2b15f Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Sat, 17 Oct 2026 15:14:06 -0300 Subject: [PATCH 3/8] =?UTF-8?q?fix(codegen):=20aceita=20string=20entre=20a?= =?UTF-8?q?spas=20com=20continua=C3=A7=C3=A3o=20em=20coluna=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As specs atuais de NF-e v2, v3 e RTC de produto (texto "Indisponível no momento" dos eventos de autoridade) trazem descriptions entre aspas com linhas de continuação na coluna 0. PyYAML e js-yaml aceitam; o symfony/yaml recusa com "Malformed inline YAML string", porque o YAML 1.2 exige que essas linhas fiquem indentadas além do nó pai. O SpecLoader agora tenta o parse do arquivo como está e, só se o symfony/yaml recusar, tenta uma segunda vez depois de reindentar as linhas de continuação de escalares entre aspas que estejam na coluna da chave ou à esquerda dela (YamlNormalizer). Dentro de string entre aspas, o espaço inicial da continuação não é conteúdo, então o valor lido não muda. Blocos | e >, comentários e strings que fecham na mesma linha não são tocados. Spec que já era lida não passa pelo normalizador, e o hash do cabeçalho continua sendo o dos bytes em disco. O resumo do `composer generate` informa quantas linhas foram reindentadas por spec, para o reparo nunca ficar silencioso e o caso ser reportado ao time de docs. Nas 3 specs atuais da fonte, o documento lido é idêntico ao do PyYAML. --- scripts/Generator/Generator.php | 13 +- scripts/Generator/SpecLoader.php | 38 ++++- scripts/Generator/YamlNormalizer.php | 153 ++++++++++++++++++ scripts/generate.php | 7 + tests/Generator/SpecLoaderTest.php | 120 ++++++++++++++ .../quoted-continuation-v1.yaml | 37 +++++ 6 files changed, 363 insertions(+), 5 deletions(-) create mode 100644 scripts/Generator/YamlNormalizer.php create mode 100644 tests/Generator/SpecLoaderTest.php create mode 100644 tests/fixtures/openapi-quoted-continuation/quoted-continuation-v1.yaml diff --git a/scripts/Generator/Generator.php b/scripts/Generator/Generator.php index 8a3f333..a50fe83 100644 --- a/scripts/Generator/Generator.php +++ b/scripts/Generator/Generator.php @@ -98,8 +98,12 @@ public function writeTo(string $outputRoot, ?array $files = null): array * only reads `components.schemas`). Zero emissions from an OpenAPI 3.x * spec is a possible sync regression and deserves a warning upstream. * + * `reindented` counts the quoted-scalar continuation lines the loader had + * to re-indent before symfony/yaml would parse the spec (see + * {@see YamlNormalizer}); non-zero means the upstream YAML should be fixed. + * * @param array|null $files Precomputed {@see self::generate()} output, to avoid regenerating. - * @return list + * @return list */ public function specSummary(?array $files = null): array { @@ -119,9 +123,10 @@ public function specSummary(?array $files = null): array $ns = NameMapper::namespaceFromSpec($specPath); $summary[] = [ - 'spec' => basename($specPath), - 'emitted' => $emittedByNs[$ns] ?? 0, - 'swagger2' => isset($loader->raw['swagger']), + 'spec' => basename($specPath), + 'emitted' => $emittedByNs[$ns] ?? 0, + 'swagger2' => isset($loader->raw['swagger']), + 'reindented' => $loader->reindentedLines, ]; } diff --git a/scripts/Generator/SpecLoader.php b/scripts/Generator/SpecLoader.php index dc7c5ce..1049179 100644 --- a/scripts/Generator/SpecLoader.php +++ b/scripts/Generator/SpecLoader.php @@ -5,6 +5,7 @@ namespace Nfe\Build; use RuntimeException; +use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml; /** @@ -19,11 +20,13 @@ final class SpecLoader * @param array $raw The full parsed OpenAPI document. * @param string $path The source path (used in generated header markers). * @param string $hash SHA-256 of the source file (used in generated header markers). + * @param int $reindentedLines Continuation lines {@see YamlNormalizer} had to re-indent (0 = parsed as-is). */ public function __construct( public readonly array $raw, public readonly string $path, public readonly string $hash, + public readonly int $reindentedLines = 0, ) {} public static function fromFile(string $path): self @@ -37,7 +40,7 @@ public static function fromFile(string $path): self throw new RuntimeException("Cannot read spec file: {$path}"); } - $parsed = Yaml::parse($contents); + [$parsed, $reindented] = self::parse($contents, $path); if (!is_array($parsed)) { throw new RuntimeException("Spec is not a YAML document: {$path}"); } @@ -45,10 +48,43 @@ public static function fromFile(string $path): self return new self( raw: $parsed, path: $path, + // Hash of the bytes on disk, not of the normalised text: the header + // marker must identify the vendored spec file itself. hash: hash('sha256', $contents), + reindentedLines: $reindented, ); } + /** + * Parse as-is; only if symfony/yaml rejects the document, retry once with + * under-indented quoted-scalar continuation lines re-indented. A document + * symfony/yaml already accepts is never rewritten. + * + * @return array{0: mixed, 1: int} Parsed value and number of re-indented lines. + */ + private static function parse(string $contents, string $path): array + { + try { + return [Yaml::parse($contents), 0]; + } catch (ParseException $original) { + [$normalised, $reindented] = YamlNormalizer::reindentQuotedContinuations($contents); + if ($reindented === 0) { + throw new RuntimeException("Cannot parse spec {$path}: {$original->getMessage()}", 0, $original); + } + + try { + return [Yaml::parse($normalised), $reindented]; + } catch (ParseException $retry) { + throw new RuntimeException( + "Cannot parse spec {$path}: {$original->getMessage()} " + . "(still failing after re-indenting {$reindented} quoted-scalar continuation line(s): {$retry->getMessage()})", + 0, + $original, + ); + } + } + } + /** * @return array> */ diff --git a/scripts/Generator/YamlNormalizer.php b/scripts/Generator/YamlNormalizer.php new file mode 100644 index 0000000..3b13c6a --- /dev/null +++ b/scripts/Generator/YamlNormalizer.php @@ -0,0 +1,153 @@ +`) and comments are + * skipped so their content is never rewritten. {@see SpecLoader} only calls + * this after symfony/yaml has already rejected the raw document. + */ +final class YamlNormalizer +{ + /** + * Matches the start of a block node line and captures the column where + * its value begins: + * [1] indentation plus any `- ` sequence markers + * [2] optional implicit key (plain, single- or double-quoted) and `:` + * [3] the value itself (rest of the line) + */ + private const NODE_LINE = '/^((?:[ ]*-(?:[ ]+|$))*[ ]*)' + . '((?:"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\']|\'\')*\'|[^\s#"\'\-?:,\[\]{}&*!|>%@`][^#]*?|-[^\s#][^#]*?)[ ]*:(?:[ ]+|$))?' + . '(.*)$/'; + + private const BLOCK_SCALAR = '/^[|>][1-9+\-]*[ ]*(?:#.*)?$/'; + + /** + * @return array{0: string, 1: int} The normalised document and how many lines were re-indented. + */ + public static function reindentQuotedContinuations(string $yaml): array + { + $lines = preg_split('/(?<=\n)/', $yaml) ?: []; + $out = []; + $changed = 0; + + $blockIndent = null; // inside a block scalar opened at this indentation + $quote = null; // inside a quoted scalar opened with this character + $column = 0; // column of the key/entry that owns the open scalar + + foreach ($lines as $raw) { + $line = rtrim($raw, "\r\n"); + $eol = substr($raw, strlen($line)); + + if ($blockIndent !== null) { + if (trim($line) === '' || self::indent($line) > $blockIndent) { + $out[] = $raw; + continue; + } + $blockIndent = null; + } + + if ($quote !== null) { + if (trim($line) !== '' && self::indent($line) <= $column) { + $line = str_repeat(' ', $column + 2) . ltrim($line, " \t"); + $changed++; + } + if (self::closingQuote($line, 0, $quote) !== null) { + $quote = null; + } + $out[] = $line . $eol; + continue; + } + + $out[] = $raw; + + $trimmed = ltrim($line, ' '); + if ($trimmed === '' || $trimmed[0] === '#' || preg_match(self::NODE_LINE, $line, $m) !== 1) { + continue; + } + + // A value only counts when it belongs to a key or a `- ` entry: a bare + // line starting with a quote may be the continuation of a plain scalar. + $hasKey = $m[2] !== ''; + if (!$hasKey && !str_contains($m[1], '-')) { + continue; + } + + $value = $m[3]; + if (preg_match(self::BLOCK_SCALAR, $value) === 1) { + // Content belongs to the block while indented past the key column + // (or past the entry's own indentation for `- |`). + $blockIndent = $hasKey ? strlen($m[1]) : self::indent($line); + continue; + } + + $opening = $value[0] ?? ''; + if ($opening !== '"' && $opening !== "'") { + continue; + } + + $valueOffset = strlen($m[1]) + strlen($m[2]); + if (self::closingQuote($line, $valueOffset + 1, $opening) === null) { + $quote = $opening; + $column = strlen($m[1]); + } + } + + return [implode('', $out), $changed]; + } + + private static function indent(string $line): int + { + return strlen($line) - strlen(ltrim($line, ' ')); + } + + /** + * Offset of the quote that closes a scalar opened with $quote, scanning + * from $from; null when the scalar continues on the next line. + * Double-quoted scalars escape with a backslash, single-quoted with `''`. + */ + private static function closingQuote(string $line, int $from, string $quote): ?int + { + $length = strlen($line); + for ($i = $from; $i < $length; $i++) { + $char = $line[$i]; + if ($quote === '"' && $char === '\\') { + $i++; + continue; + } + if ($char !== $quote) { + continue; + } + if ($quote === "'" && ($line[$i + 1] ?? '') === "'") { + $i++; + continue; + } + return $i; + } + + return null; + } +} diff --git a/scripts/generate.php b/scripts/generate.php index 632e016..6474672 100644 --- a/scripts/generate.php +++ b/scripts/generate.php @@ -47,6 +47,13 @@ } else { fwrite(STDOUT, sprintf(" %-40s %d schema file(s)\n", $row['spec'], $row['emitted'])); } + if ($row['reindented'] > 0) { + fwrite(STDOUT, sprintf( + " %-40s ↳ note: re-indented %d under-indented quoted-string continuation line(s) to parse; fix upstream\n", + '', + $row['reindented'], + )); + } } // Syntactic lint: every emitted file must parse. This is the hard gate that diff --git a/tests/Generator/SpecLoaderTest.php b/tests/Generator/SpecLoaderTest.php new file mode 100644 index 0000000..c1bb7d6 --- /dev/null +++ b/tests/Generator/SpecLoaderTest.php @@ -0,0 +1,120 @@ + Yaml::parse((string) file_get_contents(QUOTED_CONTINUATION_FIXTURE))) + ->toThrow(ParseException::class, 'Malformed inline YAML string'); +}); + +it('loads a spec with column-0 quoted continuations and folds them like a lenient parser', function (): void { + $loader = SpecLoader::fromFile(QUOTED_CONTINUATION_FIXTURE); + $op = $loader->raw['paths']['/v2/companies/{companyId}/productinvoices/{id}/authority-events']['post']; + + // Values match PyYAML/libyaml on the same file. + expect($op['description'])->toBe( + "### Informações adicionais\r\n⛔ **Indisponível no momento.** Recurso protegido por feature flag.\n" + . '| `type` | tpEvento | |---|---| - item: com dois-pontos e "aspas" escapadas', + ); + expect($op['tags'])->toBe(['Nota Fiscal de Produto']); + expect($op['responses'])->toBe(['200' => ['description' => 'OK']]); + + $schema = $loader->schemas()['AuthorityEvent']; + expect($schema['description'])->toBe('Evento de autoridade. Indisponível no momento.'); + expect($schema['properties']['type']['description'])->toBe("Tipo do evento; 'aspas' simples continuam aqui"); + expect($schema['properties']['note']['description'])->toBe("Bloco literal com \"aspas que não fecham\nfica intacto\n"); + expect($schema['required'])->toBe(['type']); + + expect($loader->reindentedLines)->toBe(7); +}); + +it('hashes the bytes on disk, not the normalised text', function (): void { + $loader = SpecLoader::fromFile(QUOTED_CONTINUATION_FIXTURE); + + expect($loader->hash)->toBe(hash_file('sha256', QUOTED_CONTINUATION_FIXTURE)); +}); + +it('does not touch a spec symfony/yaml already parses', function (): void { + $loader = SpecLoader::fromFile(__DIR__ . '/../fixtures/openapi/minimal.yaml'); + + expect($loader->reindentedLines)->toBe(0); +}); + +it('still fails loud, with the spec path, when re-indenting does not help', function (): void { + $path = sys_get_temp_dir() . '/nfe-sdk-broken-' . bin2hex(random_bytes(4)) . '.yaml'; + file_put_contents($path, "openapi: 3.0.1\npaths: {\n"); + + try { + expect(fn() => SpecLoader::fromFile($path))->toThrow(RuntimeException::class, $path); + } finally { + @unlink($path); + } +}); + +it('generates the schema from the fixture with the folded description', function (): void { + $generator = new Generator( + specsDir: dirname(QUOTED_CONTINUATION_FIXTURE), + outputRoot: sys_get_temp_dir() . '/nfe-sdk-unused', + ); + + $files = $generator->generate(); + + expect($files)->toHaveKey('QuotedContinuationV1/AuthorityEvent.php'); + expect($files['QuotedContinuationV1/AuthorityEvent.php']) + ->toContain(' * Evento de autoridade. Indisponível no momento.'); + expect($generator->specSummary($files)[0]['reindented'])->toBe(7); +}); + +it('re-indents only continuation lines at or left of the owning key', function (): void { + $yaml = "a:\n b:\n description: \"um\ndois\n tres\n\n quatro\"\n other: 1\n"; + + [$out, $count] = YamlNormalizer::reindentQuotedContinuations($yaml); + + expect($count)->toBe(2); + expect($out)->toBe("a:\n b:\n description: \"um\n dois\n tres\n\n quatro\"\n other: 1\n"); + expect(Yaml::parse($out)['a']['b'])->toBe(['description' => "um dois tres\nquatro", 'other' => 1]); +}); + +it('leaves block scalars, closed quotes and bare quoted lines alone', function (): void { + $yaml = <<<'YAML' + a: + literal: | + title: "nao fecha + folded: >- + - "nao fecha + closed: "fecha \" aqui" + single: 'fecha '' aqui' + plain: texto + "continuacao plain + b: 1 + + YAML; + + [$out, $count] = YamlNormalizer::reindentQuotedContinuations($yaml); + + expect($count)->toBe(0); + expect($out)->toBe($yaml); +}); + +it('handles sequence entries and keys inside sequence entries', function (): void { + $yaml = "tags:\n - \"um\ndois\"\nitems:\n - name: 'tres\n quatro'\n"; + + [$out, $count] = YamlNormalizer::reindentQuotedContinuations($yaml); + + expect($count)->toBe(2); + expect(Yaml::parse($out))->toBe([ + 'tags' => ['um dois'], + 'items' => [['name' => 'tres quatro']], + ]); +}); diff --git a/tests/fixtures/openapi-quoted-continuation/quoted-continuation-v1.yaml b/tests/fixtures/openapi-quoted-continuation/quoted-continuation-v1.yaml new file mode 100644 index 0000000..550635d --- /dev/null +++ b/tests/fixtures/openapi-quoted-continuation/quoted-continuation-v1.yaml @@ -0,0 +1,37 @@ +openapi: 3.0.1 +info: + title: Quoted continuation fixture + version: "1.0" +paths: + /v2/companies/{companyId}/productinvoices/{id}/authority-events: + post: + tags: + - "Nota Fiscal +de Produto" + summary: Registrar um evento de autoridade + description: "### Informações adicionais\r\n⛔ **Indisponível no momento.** Recurso protegido +por feature flag. + +| `type` | tpEvento | +|---|---| +- item: com dois-pontos e \"aspas\" escapadas" + responses: + '200': + description: OK +components: + schemas: + AuthorityEvent: + type: object + description: "Evento de autoridade. +Indisponível no momento." + required: [type] + properties: + type: + type: string + description: 'Tipo do evento; ''aspas'' simples +continuam aqui' + note: + type: string + description: | + Bloco literal com "aspas que não fecham + fica intacto From bb3b9ab0085f63ede60810c12739a327dc0440de Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Sat, 17 Oct 2026 15:15:22 -0300 Subject: [PATCH 4/8] =?UTF-8?q?fix(codegen):=20nomeia=20valor=20negativo?= =?UTF-8?q?=20de=20enum=20como=20Value=5FMinusN=20e=20falha=20em=20colis?= =?UTF-8?q?=C3=A3o=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O gerador descartava o sinal ao nomear o case: 1 e -1 viravam Value_1, e ConsultaCteV2\DFe_NetCore_Domain_Enums_EntityStatus dava erro fatal ("Cannot redefine class constant") no carregamento, em `php -l` e em --classmap-authoritative. Valores numéricos negativos agora recebem o prefixo Value_Minus (-1 vira Value_Minus1), seguindo o padrão Value_ já usado nos positivos. O único valor negativo nas specs vendoradas é esse -1, então nenhum outro case muda de nome. O EnumCompiler passa a recusar, com erro que cita spec, schema e valores, dois valores que gerem o mesmo nome de case ou o mesmo valor de backing, em vez de emitir um enum que não compila. Regerado a partir das specs vendoradas: só o enum do #39 muda. --- scripts/Generator/EnumCompiler.php | 41 ++++++++++++++- scripts/Generator/Generator.php | 8 ++- .../DFe_NetCore_Domain_Enums_EntityStatus.php | 2 +- tests/Generator/EnumCompilerTest.php | 52 +++++++++++++++++++ 4 files changed, 98 insertions(+), 5 deletions(-) diff --git a/scripts/Generator/EnumCompiler.php b/scripts/Generator/EnumCompiler.php index 2847b49..af17404 100644 --- a/scripts/Generator/EnumCompiler.php +++ b/scripts/Generator/EnumCompiler.php @@ -4,6 +4,8 @@ namespace Nfe\Build; +use RuntimeException; + /** * Compiles an OpenAPI schema fragment carrying `enum: [...]` into a native * PHP 8.1+ enum source string. @@ -29,6 +31,8 @@ public static function compile(string $schemaName, array $schema): ?string $className = NameMapper::className($schemaName); $backing = self::backingType($schema['type'] ?? 'string'); $cases = []; + $byName = []; + $byLiteral = []; foreach ($schema['enum'] as $value) { if (!is_scalar($value)) { @@ -38,7 +42,31 @@ public static function compile(string $schemaName, array $schema): ?string $literal = $backing === 'int' ? (string) (int) $value : "'" . str_replace("'", "\\'", (string) $value) . "'"; - $cases[] = " case {$caseName} = {$literal};"; + + // Two cases with the same name or the same backing value are a + // fatal error in PHP: refuse to emit the enum instead of shipping it. + if (isset($byName[$caseName])) { + throw new RuntimeException(sprintf( + 'Enum %s: values %s and %s both map to case name %s.', + $schemaName, + var_export($byName[$caseName], true), + var_export($value, true), + $caseName, + )); + } + if (isset($byLiteral[$literal])) { + throw new RuntimeException(sprintf( + 'Enum %s: values %s and %s both map to backing value %s.', + $schemaName, + var_export($byLiteral[$literal], true), + var_export($value, true), + $literal, + )); + } + $byName[$caseName] = $value; + $byLiteral[$literal] = $value; + + $cases[] = " case {$caseName} = {$literal};"; } $description = self::cleanDescription($schema['description'] ?? null); @@ -63,7 +91,8 @@ private static function backingType(mixed $type): string * * "WaitingDefineRpsNumber" -> "WaitingDefineRpsNumber" * "in-progress" -> "InProgress" - * "01.02" -> "Value_01_02" + * "1" -> "Value_1" + * "-1" -> "Value_Minus1" */ private static function caseName(string $value): string { @@ -72,6 +101,10 @@ private static function caseName(string $value): string return ucfirst($value); } + // A leading minus sign on a number is meaning, not punctuation: without + // it "1" and "-1" would both become Value_1 (#39). + $negative = preg_match('/^-[0-9]/', $value) === 1; + // Convert kebab/snake to PascalCase. $parts = preg_split('/[^A-Za-z0-9]+/', $value) ?: []; $studly = ''; @@ -82,6 +115,10 @@ private static function caseName(string $value): string $studly .= strtoupper($part[0]) . substr($part, 1); } + if ($negative) { + return 'Value_Minus' . $studly; + } + if ($studly === '' || ctype_digit($studly[0])) { $studly = 'Value_' . $studly; } diff --git a/scripts/Generator/Generator.php b/scripts/Generator/Generator.php index a50fe83..64c4c6a 100644 --- a/scripts/Generator/Generator.php +++ b/scripts/Generator/Generator.php @@ -42,8 +42,12 @@ public function generate(): array $relSpecPath = $this->relativeSpecPath($specPath); foreach ($loader->schemas() as $schemaName => $schema) { - $body = EnumCompiler::compile($schemaName, $schema) - ?? SchemaCompiler::compile($schemaName, $schema, $namespace); + try { + $body = EnumCompiler::compile($schemaName, $schema) + ?? SchemaCompiler::compile($schemaName, $schema, $namespace); + } catch (RuntimeException $e) { + throw new RuntimeException("{$relSpecPath}: {$e->getMessage()}", 0, $e); + } if ($body === null) { continue; diff --git a/src/Generated/ConsultaCteV2/DFe_NetCore_Domain_Enums_EntityStatus.php b/src/Generated/ConsultaCteV2/DFe_NetCore_Domain_Enums_EntityStatus.php index 4949290..426c5fd 100644 --- a/src/Generated/ConsultaCteV2/DFe_NetCore_Domain_Enums_EntityStatus.php +++ b/src/Generated/ConsultaCteV2/DFe_NetCore_Domain_Enums_EntityStatus.php @@ -12,5 +12,5 @@ enum DFe_NetCore_Domain_Enums_EntityStatus: int { case Value_0 = 0; case Value_1 = 1; - case Value_1 = -1; + case Value_Minus1 = -1; } diff --git a/tests/Generator/EnumCompilerTest.php b/tests/Generator/EnumCompilerTest.php index dba2f12..9c038f8 100644 --- a/tests/Generator/EnumCompilerTest.php +++ b/tests/Generator/EnumCompilerTest.php @@ -27,6 +27,58 @@ expect($body)->toContain('case Value_1 = 1;'); }); +it('names negative int values with a Minus prefix so they never collide with positives (#39)', function (): void { + // Shape of ConsultaCteV2 DFe.NetCore.Domain.Enums.EntityStatus. + $body = EnumCompiler::compile('DFe.NetCore.Domain.Enums.EntityStatus', [ + 'type' => 'integer', + 'enum' => [0, 1, -1], + ]); + + expect($body)->toContain('case Value_0 = 0;'); + expect($body)->toContain('case Value_1 = 1;'); + expect($body)->toContain('case Value_Minus1 = -1;'); +}); + +it('names negative string values with a Minus prefix too', function (): void { + $body = EnumCompiler::compile('Code', [ + 'type' => 'string', + 'enum' => ['10', '-10', '-1.5'], + ]); + + expect($body)->toContain("case Value_10 = '10';"); + expect($body)->toContain("case Value_Minus10 = '-10';"); + expect($body)->toContain("case Value_Minus15 = '-1.5';"); +}); + +it('emits an enum with negative values that PHP can compile', function (): void { + $body = EnumCompiler::compile('EntityStatusCompileCheck', [ + 'type' => 'integer', + 'enum' => [0, 1, -1], + ]); + + // Compiling the generator's own output (from the literal schema above) is + // the point: the #39 enum parsed fine and only failed at compile time. + eval("namespace Nfe\\Tests\\Generated\\Eval;\n" . $body); + $enum = 'Nfe\\Tests\\Generated\\Eval\\EntityStatusCompileCheck'; + + expect($enum::from(-1)->name)->toBe('Value_Minus1'); + expect($enum::from(1)->name)->toBe('Value_1'); +}); + +it('fails loud when two values map to the same case name', function (): void { + expect(fn() => EnumCompiler::compile('Status', [ + 'type' => 'string', + 'enum' => ['in-progress', 'in progress'], + ]))->toThrow(RuntimeException::class, "Enum Status: values 'in-progress' and 'in progress' both map to case name InProgress."); +}); + +it('fails loud when two values map to the same int backing value', function (): void { + expect(fn() => EnumCompiler::compile('Code', [ + 'type' => 'integer', + 'enum' => ['1', '01'], + ]))->toThrow(RuntimeException::class, 'both map to backing value 1'); +}); + it('returns null when the schema is not an enum', function (): void { expect(EnumCompiler::compile('Borrower', ['type' => 'object']))->toBeNull(); }); From 320d809060b8b273eeeca35a2a3c684514ead41d Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Sat, 17 Oct 2026 15:16:57 -0300 Subject: [PATCH 5/8] =?UTF-8?q?fix(codegen):=20n=C3=A3o=20emite=20default?= =?UTF-8?q?=20inalcan=C3=A7=C3=A1vel=20antes=20de=20par=C3=A2metro=20obrig?= =?UTF-8?q?at=C3=B3rio=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Propriedade obrigatória e nullable vira `?T $x = null` entre as obrigatórias. Quando uma obrigatória não nullable vem depois, o PHP 8.1+ emite "Optional parameter declared before required parameter is implicitly treated as a required parameter". Isso acontecia em 3 DTOs de ConsultaNfeDistribuicaoV1 (Sucessonarequisio, Sucessonarequisio2 e Sucessonarequisio6). O SchemaCompiler agora remove o `= null` de todo parâmetro declarado antes de outro sem default. A ordem dos parâmetros não muda (quem chama posicionalmente não quebra) e o tipo `?T` continua. O default já era inalcançável: omitir o argumento, inclusive por nome, já lançava ArgumentCountError, e a Reflection já o tratava como não opcional (conferido em PHP 8.2, 8.3 e 8.4). Regerado a partir das specs vendoradas: só esses 3 arquivos mudam, nos 4 parâmetros apontados pelas deprecations. --- scripts/Generator/SchemaCompiler.php | 33 +++++++++ .../Sucessonarequisio.php | 2 +- .../Sucessonarequisio2.php | 4 +- .../Sucessonarequisio6.php | 2 +- tests/Generator/SchemaCompilerTest.php | 74 +++++++++++++++++++ 5 files changed, 111 insertions(+), 4 deletions(-) diff --git a/scripts/Generator/SchemaCompiler.php b/scripts/Generator/SchemaCompiler.php index 225d182..e208d89 100644 --- a/scripts/Generator/SchemaCompiler.php +++ b/scripts/Generator/SchemaCompiler.php @@ -64,6 +64,8 @@ public static function compile(string $schemaName, array $schema, string $namesp } } + $paramLines = self::dropUnreachableDefaults($paramLines); + $body = ''; if ($description !== null) { $body .= "/**\n * {$description}\n */\n"; @@ -132,6 +134,37 @@ private static function compileProperty( return [$line, $docLine]; } + /** + * Strip `= null` from any parameter declared before a parameter without a + * default (#36). + * + * A required-but-nullable property is emitted as `?T $x = null` among the + * required ones; when a required non-nullable property follows, PHP 8.1+ + * deprecates the declaration ("Optional parameter declared before required + * parameter is implicitly treated as a required parameter"). The default is + * already unreachable there — omitting the argument, even by name, throws + * ArgumentCountError and Reflection reports it as not optional — so + * dropping it changes nothing for callers. The `?T` type stays, and the + * parameter order is never touched (positional callers rely on it). + * + * @param list $paramLines + * @return list + */ + private static function dropUnreachableDefaults(array $paramLines): array + { + $requiredFollows = false; + for ($i = count($paramLines) - 1; $i >= 0; $i--) { + $hasDefault = str_ends_with($paramLines[$i], ' = null'); + if ($hasDefault && $requiredFollows) { + $paramLines[$i] = substr($paramLines[$i], 0, -strlen(' = null')); + } elseif (!$hasDefault) { + $requiredFollows = true; + } + } + + return $paramLines; + } + /** * PHP reserved words and property names that need sanitisation. */ diff --git a/src/Generated/ConsultaNfeDistribuicaoV1/Sucessonarequisio.php b/src/Generated/ConsultaNfeDistribuicaoV1/Sucessonarequisio.php index a959660..73284b9 100644 --- a/src/Generated/ConsultaNfeDistribuicaoV1/Sucessonarequisio.php +++ b/src/Generated/ConsultaNfeDistribuicaoV1/Sucessonarequisio.php @@ -23,7 +23,7 @@ public function __construct( public string $xmlUrl, public string $federalTaxNumberSender, public string $nameSender, - public ?string $type = null, + public ?string $type, public string $nsu, public string $nsuParent, public string $nfeNumber, diff --git a/src/Generated/ConsultaNfeDistribuicaoV1/Sucessonarequisio2.php b/src/Generated/ConsultaNfeDistribuicaoV1/Sucessonarequisio2.php index 32f75ba..f2f7865 100644 --- a/src/Generated/ConsultaNfeDistribuicaoV1/Sucessonarequisio2.php +++ b/src/Generated/ConsultaNfeDistribuicaoV1/Sucessonarequisio2.php @@ -13,11 +13,11 @@ public function __construct( public string $startFromNsu, public string $startFromDate, - public ?string $environmentSEFAZ = null, + public ?string $environmentSEFAZ, public AutomaticManifesting $automaticManifesting, public string $webhookVersion, public string $companyId, - public ?string $status = null, + public ?string $status, public string $createdOn, public string $modifiedOn, ) {} diff --git a/src/Generated/ConsultaNfeDistribuicaoV1/Sucessonarequisio6.php b/src/Generated/ConsultaNfeDistribuicaoV1/Sucessonarequisio6.php index 21bd2c5..a89f357 100644 --- a/src/Generated/ConsultaNfeDistribuicaoV1/Sucessonarequisio6.php +++ b/src/Generated/ConsultaNfeDistribuicaoV1/Sucessonarequisio6.php @@ -23,7 +23,7 @@ public function __construct( public Issuer $issuer, public Buyer $buyer, public Transportation $transportation, - public ?string $type = null, + public ?string $type, public string $nsu, public string $nfeNumber, public string $issuedOn, diff --git a/tests/Generator/SchemaCompilerTest.php b/tests/Generator/SchemaCompilerTest.php index 9f8f0da..5c84034 100644 --- a/tests/Generator/SchemaCompilerTest.php +++ b/tests/Generator/SchemaCompilerTest.php @@ -96,3 +96,77 @@ expect($namePos)->toBeLessThan($optionalPos); }); + +it('drops the unreachable default of a nullable param declared before a required one (#36)', function (): void { + // Shape of ConsultaNfeDistribuicaoV1 Sucessonarequisio2: required-but-nullable + // properties interleaved with required non-nullable ones. + $body = SchemaCompiler::compile('Sucessonarequisio2', [ + 'type' => 'object', + 'required' => ['startFromNsu', 'environmentSEFAZ', 'companyId', 'status', 'modifiedOn'], + 'properties' => [ + 'startFromNsu' => ['type' => 'string'], + 'environmentSEFAZ' => ['type' => 'string', 'nullable' => true], + 'companyId' => ['type' => 'string'], + 'status' => ['type' => 'string', 'nullable' => true], + 'modifiedOn' => ['type' => 'string'], + 'tail' => ['type' => 'string', 'nullable' => true], + 'note' => ['type' => 'string'], + ], + ], 'Nfe\Generated\Test'); + + // Order is unchanged; the nullable type stays; only the dead default goes. + expect($body)->toContain(implode(",\n", [ + ' public string $startFromNsu', + ' public ?string $environmentSEFAZ', + ' public string $companyId', + ' public ?string $status', + ' public string $modifiedOn', + ' public ?string $tail = null', + ' public ?string $note = null', + ]) . ",\n"); +}); + +it('keeps the null default of a required nullable param when only optionals follow', function (): void { + $body = SchemaCompiler::compile('Foo', [ + 'type' => 'object', + 'required' => ['id', 'deletedOn'], + 'properties' => [ + 'id' => ['type' => 'string'], + 'deletedOn' => ['type' => 'string', 'nullable' => true], + 'extra' => ['type' => 'string'], + ], + ], 'Nfe\Generated\Test'); + + expect($body)->toContain('public ?string $deletedOn = null'); + expect($body)->toContain('public ?string $extra = null'); +}); + +it('emits constructors that compile without deprecations', function (): void { + $body = SchemaCompiler::compile('DeprecationCheck', [ + 'type' => 'object', + 'required' => ['a', 'b'], + 'properties' => [ + 'a' => ['type' => 'string', 'nullable' => true], + 'b' => ['type' => 'string'], + ], + ], 'Nfe\Generated\Test'); + + $deprecations = []; + set_error_handler(function (int $errno, string $message) use (&$deprecations): bool { + $deprecations[] = $message; + + return true; + }, E_DEPRECATED); + + try { + // Compiling the generator's own output (from the literal schema above) + // is the point: the #36 deprecation is raised at compile time. + eval("namespace Nfe\\Tests\\Generated\\Eval;\n" . $body); + } finally { + restore_error_handler(); + } + + expect($deprecations)->toBe([]); + $ctor = (new ReflectionClass('Nfe\\Tests\\Generated\\Eval\\DeprecationCheck'))->getConstructor(); + expect($ctor?->getNumberOfRequiredParameters())->toBe(2); +}); From 8975aa39feeb42d9f0be46b19dc46443068110e4 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Sat, 17 Oct 2026 15:17:15 -0300 Subject: [PATCH 6/8] chore: alinha o arquivo VERSION com src/Version.php (3.5.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VERSION estava em 3.4.1 enquanto src/Version.php e o Packagist dizem 3.5.0. Nada no repositório lê esse arquivo: scripts/release.sh só atualiza src/Version.php e o release.yml confere a tag contra src/Version.php. Como não está em export-ignore, ele vai no dist do Composer com valor defasado. Por ora só alinha; a remoção fica para decisão do mantenedor. --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 47b322c..1545d96 100755 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.4.1 +3.5.0 From f8f25e779458378913893055ea35794a72646eb7 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Sat, 17 Oct 2026 15:17:33 -0300 Subject: [PATCH 7/8] =?UTF-8?q?docs(changelog):=20registra=20as=20corre?= =?UTF-8?q?=C3=A7=C3=B5es=20da=20v3.5.1=20em=20[Unreleased]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f249273..3b61556 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,46 @@ e este projeto segue [Versionamento Semântico](https://semver.org/lang/pt-BR/sp ## [Unreleased] +### Corrigido + +- **Enum de CT-e com case duplicado** (#39): + `Nfe\Generated\ConsultaCteV2\DFe_NetCore_Domain_Enums_EntityStatus` declarava + `Value_1` duas vezes (para `1` e `-1`) e dava erro fatal ao carregar a classe, + em `php -l` e com `--classmap-authoritative`. O gerador descartava o sinal ao + nomear o case. Agora valores negativos recebem o prefixo `Value_Minus` + (`-1` vira `Value_Minus1`), seguindo o padrão `Value_` dos positivos. O + enum nunca carregou, então nenhum código podia depender do nome antigo. O + gerador também passa a falhar, citando spec, schema e valores, quando dois + valores geram o mesmo nome de case ou o mesmo valor de backing. +- **Deprecations em 3 DTOs de `ConsultaNfeDistribuicaoV1`** (#36): + `Sucessonarequisio`, `Sucessonarequisio2` e `Sucessonarequisio6` declaravam + parâmetro opcional (`?string $x = null`) antes de obrigatório, e o PHP 8.1+ + emitia `Deprecated` ao compilar. O gerador não emite mais esse default, que já + era inalcançável (omitir o argumento, inclusive por nome, já lançava + `ArgumentCountError`). A ordem dos parâmetros e o tipo `?string` não mudam. +- **Gerador aceita string entre aspas com continuação na coluna 0**: as specs + atuais de NF-e v2, v3 e RTC de produto (aviso "Indisponível no momento" dos + eventos de autoridade) têm descriptions entre aspas com linhas de continuação + na coluna 0. PyYAML e js-yaml aceitam esse formato, mas o `symfony/yaml` recusa + com `Malformed inline YAML string`, e o `composer generate` não rodava. Quando + o `symfony/yaml` recusa uma spec, o gerador agora reindenta essas linhas e + tenta de novo. O valor lido não muda: nas 3 specs, o documento é idêntico ao + do PyYAML. Spec que já era lida não passa por esse caminho, e o resumo do + `composer generate` informa quantas linhas foram reindentadas. +- **Teste de `FailurePhase` independente de DNS**: + `CurlTransportFailurePhaseTest` supunha que o TLD `.invalid` nunca resolve e + falhava em rede com DNS curinga. Agora ele força conexão recusada numa porta + livre de `127.0.0.1`. +- Arquivo `VERSION` alinhado com `src/Version.php` (dizia `3.4.1`). Nada no + repositório lê esse arquivo. + +### Adicionado + +- **Gate de `php -l` no CI** (`scripts/lint.sh`, `composer lint`): compila cada + arquivo de `src/` com `E_ALL` e falha em qualquer erro ou deprecation. Roda + como job próprio do CI (PHP 8.2) e no verify da release. O lint do gerador + (`token_get_all`) só verifica sintaxe e deixou passar o #39 e o #36. + ## [3.5.0] — 2026-08-01 ### Adicionado From cabf83392f37dc4ae280e1965f44b149c3431e47 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Sat, 17 Oct 2026 19:09:24 -0300 Subject: [PATCH 8/8] chore(release): v3.5.1 e remove o arquivo VERSION legado --- CHANGELOG.md | 9 +++++++-- VERSION | 1 - src/Version.php | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) delete mode 100755 VERSION diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b61556..37c5bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ e este projeto segue [Versionamento Semântico](https://semver.org/lang/pt-BR/sp ## [Unreleased] +## [3.5.1] — 2026-10-17 + ### Corrigido - **Enum de CT-e com case duplicado** (#39): @@ -37,8 +39,11 @@ e este projeto segue [Versionamento Semântico](https://semver.org/lang/pt-BR/sp `CurlTransportFailurePhaseTest` supunha que o TLD `.invalid` nunca resolve e falhava em rede com DNS curinga. Agora ele força conexão recusada numa porta livre de `127.0.0.1`. -- Arquivo `VERSION` alinhado com `src/Version.php` (dizia `3.4.1`). Nada no - repositório lê esse arquivo. + +### Removido + +- Arquivo `VERSION` da raiz. Era resto da linha v2, dizia `3.4.1` e nada no + repositório o lia; a versão do SDK vem só de `Nfe\Version::CURRENT`. ### Adicionado diff --git a/VERSION b/VERSION deleted file mode 100755 index 1545d96..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -3.5.0 diff --git a/src/Version.php b/src/Version.php index 7aad052..fbc8606 100644 --- a/src/Version.php +++ b/src/Version.php @@ -12,5 +12,5 @@ */ final class Version { - public const CURRENT = '3.5.0'; + public const CURRENT = '3.5.1'; }