Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<n>` 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
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand All @@ -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"
Expand Down Expand Up @@ -54,6 +55,9 @@ cs:
cs-fix:
$(RUN) $(PHP) composer cs:fix

lint:
$(RUN) $(PHP) composer lint

generate:
$(RUN) $(PHP) composer generate

Expand Down
1 change: 0 additions & 1 deletion VERSION

This file was deleted.

1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
41 changes: 39 additions & 2 deletions scripts/Generator/EnumCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)) {
Expand All @@ -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);
Expand All @@ -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
{
Expand All @@ -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 = '';
Expand All @@ -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;
}
Expand Down
21 changes: 15 additions & 6 deletions scripts/Generator/Generator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, string>|null $files Precomputed {@see self::generate()} output, to avoid regenerating.
* @return list<array{spec: string, emitted: int, swagger2: bool}>
* @return list<array{spec: string, emitted: int, swagger2: bool, reindented: int}>
*/
public function specSummary(?array $files = null): array
{
Expand All @@ -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,
];
}

Expand Down
33 changes: 33 additions & 0 deletions scripts/Generator/SchemaCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string> $paramLines
* @return list<string>
*/
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.
*/
Expand Down
38 changes: 37 additions & 1 deletion scripts/Generator/SpecLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Nfe\Build;

use RuntimeException;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;

/**
Expand All @@ -19,11 +20,13 @@ final class SpecLoader
* @param array<string, mixed> $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
Expand All @@ -37,18 +40,51 @@ 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}");
}

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<string, array<string, mixed>>
*/
Expand Down
Loading
Loading