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/CHANGELOG.md b/CHANGELOG.md index f249273..37c5bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,51 @@ 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): + `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`. + +### 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 + +- **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 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/VERSION b/VERSION deleted file mode 100755 index 47b322c..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -3.4.1 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/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 8a3f333..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; @@ -98,8 +102,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 +127,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/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/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/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"/}" 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/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/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'; } 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(); }); 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); +}); 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/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 } }); 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