diff --git a/.github/actions/setup-project-php/action.yml b/.github/actions/setup-project-php/action.yml new file mode 100644 index 0000000..1710d43 --- /dev/null +++ b/.github/actions/setup-project-php/action.yml @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Set up project PHP +description: Use the PHP compatibility contract in the root composer.json +inputs: + php-version: + description: Explicit CI matrix version, or empty for the Composer minimum + required: false + default: "" + extensions: + description: Extensions needed by the invoking job + required: false + default: "openssl, dom, soap" + coverage: + description: Coverage driver + required: false + default: "none" + ini-file: + description: PHP INI preset + required: false + default: "" + ini-values: + description: Additional INI values + required: false + default: "" +runs: + using: composite + steps: + - name: Validate root Composer PHP policy + id: php-policy + shell: bash + run: | + php tools/bin/ci-php.php validate + echo "minimum=$(php tools/bin/ci-php.php minimum)" >> "$GITHUB_OUTPUT" + - name: Install PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + with: + php-version: ${{ inputs.php-version || steps.php-policy.outputs.minimum }} + extensions: ${{ inputs.extensions }} + coverage: ${{ inputs.coverage }} + ini-file: ${{ inputs.ini-file }} + ini-values: ${{ inputs.ini-values }} diff --git a/.github/workflows/domain-tables.yml b/.github/workflows/domain-tables.yml index 16cc581..989e2ae 100644 --- a/.github/workflows/domain-tables.yml +++ b/.github/workflows/domain-tables.yml @@ -6,10 +6,13 @@ name: Domain Table Generator on: pull_request: paths: - - "tools/generate_domain_tables.py" - - "tools/tests/test_generate_domain_tables.py" + - "tools/**" + - "vendor-bin/domains/**" - "resources/domains/**" + - "composer.json" - ".github/workflows/domain-tables.yml" + - ".github/actions/setup-project-php/**" + workflow_dispatch: permissions: contents: read @@ -21,5 +24,17 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Test deterministic XLSX normalization - run: python3 -m unittest tools/tests/test_generate_domain_tables.py + - name: Set up PHP from Composer + uses: ./.github/actions/setup-project-php + with: + extensions: dom, mbstring, zip, gd, xml, xmlreader, xmlwriter, simplexml, fileinfo + - name: Install isolated domain tooling + run: composer --working-dir=vendor-bin/domains install --prefer-dist --no-progress --no-interaction + - name: Validate root and isolated Composer manifests + run: | + composer validate --strict + composer --working-dir=vendor-bin/domains validate --strict + - name: Run offline deterministic PHPUnit cases + run: composer --working-dir=vendor-bin/domains run test + - name: Lint PHP domain tools + run: find tools -type f -name '*.php' -print0 | xargs -0 -n 1 php -l diff --git a/.github/workflows/lint-php-cs.yml b/.github/workflows/lint-php-cs.yml index 5be4f18..80cda98 100644 --- a/.github/workflows/lint-php-cs.yml +++ b/.github/workflows/lint-php-cs.yml @@ -24,12 +24,10 @@ jobs: with: persist-credentials: false - - name: Set up PHP 8.2 - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + - name: Set up PHP from Composer + uses: ./.github/actions/setup-project-php with: - php-version: "8.2" extensions: openssl, dom, soap - coverage: none ini-file: development env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/minimum-dependencies.yml b/.github/workflows/minimum-dependencies.yml index 46e5fe3..dbf6eaf 100644 --- a/.github/workflows/minimum-dependencies.yml +++ b/.github/workflows/minimum-dependencies.yml @@ -22,12 +22,10 @@ jobs: with: persist-credentials: false - - name: Set up PHP 8.2 - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + - name: Set up PHP from Composer + uses: ./.github/actions/setup-project-php with: - php-version: "8.2" extensions: openssl, dom, soap - coverage: none ini-file: development ini-values: disable_functions= env: diff --git a/.github/workflows/nt009-evidence.yml b/.github/workflows/nt009-evidence.yml new file mode 100644 index 0000000..8b06052 --- /dev/null +++ b/.github/workflows/nt009-evidence.yml @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: NT009 Source Evidence + +on: + pull_request: + paths: + - "tools/**" + - "composer.json" + - "vendor-bin/domains/**" + - "resources/domains/**" + - ".github/workflows/nt009-evidence.yml" + - ".github/actions/setup-project-php/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + optional-official-evidence: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up PHP from Composer + uses: ./.github/actions/setup-project-php + with: + extensions: dom, mbstring, zip, gd, xml, xmlreader, xmlwriter, simplexml, fileinfo + - name: Install isolated domain tooling + run: composer --working-dir=vendor-bin/domains install --prefer-dist --no-progress --no-interaction + - name: Verify original XLSX hashes and compare official NT009 data + run: | + php tools/bin/domains.php audit \ + --download-dir="${{ runner.temp }}/nt009" \ + --report="${{ runner.temp }}/nt009-comparison.json" + - name: Verify versioned Annex VII against all published XLSX rows + run: php tools/bin/domains.php verify-indicators --annex-vii="${{ runner.temp }}/nt009/annex-vii-nt009.xlsx" + - name: Retain full evidence for manual review + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: nt009-official-source-evidence + path: | + ${{ runner.temp }}/nt009-comparison.json + ${{ runner.temp }}/nt009/*.xlsx + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/official-snapshot-reproduction.yml b/.github/workflows/official-snapshot-reproduction.yml new file mode 100644 index 0000000..078b6f7 --- /dev/null +++ b/.github/workflows/official-snapshot-reproduction.yml @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Official Snapshot Reproduction + +on: + pull_request: + paths: + - "tools/**" + - "composer.json" + - "vendor-bin/domains/**" + - "resources/domains/**" + - ".github/workflows/official-snapshot-reproduction.yml" + - ".github/actions/setup-project-php/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + advisory-production-snapshot-reproduction: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up PHP from Composer + uses: ./.github/actions/setup-project-php + with: + extensions: dom, mbstring, zip, gd, xml, xmlreader, xmlwriter, simplexml, fileinfo + - name: Install isolated domain tooling + run: composer --working-dir=vendor-bin/domains install --prefer-dist --no-progress --no-interaction + - name: Download checksum-verified official production annexes + run: php tools/bin/domains.php download --ids=annex-a,annex-b,annex-c --output="${{ runner.temp }}/official" + - name: Compare regenerated output byte-for-byte with versioned snapshots + run: | + php tools/bin/domains.php check \ + --annex-a="${{ runner.temp }}/official/annex-a.xlsx" \ + --annex-b="${{ runner.temp }}/official/annex-b.xlsx" \ + --annex-c="${{ runner.temp }}/official/annex-c.xlsx" + - name: Preserve source workbooks for manual review + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: official-production-domain-sources + path: ${{ runner.temp }}/official/*.xlsx + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index a029d42..5c252b9 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -13,13 +13,32 @@ concurrency: cancel-in-progress: true jobs: + php-policy: + name: PHP versions from root Composer + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.policy.outputs.matrix }} + primary: ${{ steps.policy.outputs.primary }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Read CI PHP matrix and primary from composer.json + id: policy + run: | + php tools/bin/ci-php.php validate + echo "matrix=$(php tools/bin/ci-php.php matrix)" >> "$GITHUB_OUTPUT" + echo "primary=$(php tools/bin/ci-php.php primary)" >> "$GITHUB_OUTPUT" + compatibility: name: PHP ${{ matrix.php-version }} runs-on: ubuntu-latest + needs: php-policy strategy: matrix: - php-version: ["8.2", "8.3", "8.4"] + php-version: ${{ fromJSON(needs.php-policy.outputs.matrix) }} steps: - name: Checkout @@ -27,12 +46,11 @@ jobs: with: persist-credentials: false - - name: Set up PHP ${{ matrix.php-version }} - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + - name: Set up PHP from Composer matrix + uses: ./.github/actions/setup-project-php with: php-version: ${{ matrix.php-version }} extensions: openssl, dom, soap - coverage: none ini-file: development ini-values: disable_functions= env: @@ -48,7 +66,7 @@ jobs: run: composer run test:unit - name: Run integration tests when certificate secrets are present - if: ${{ matrix.php-version == '8.3' }} + if: ${{ matrix.php-version == needs.php-policy.outputs.primary }} env: NFSE_CLIENT_CERT_B64: ${{ secrets.NFSE_CLIENT_CERT_B64 }} NFSE_CLIENT_KEY_B64: ${{ secrets.NFSE_CLIENT_KEY_B64 }} @@ -64,6 +82,7 @@ jobs: coverage: name: Coverage runs-on: ubuntu-latest + needs: php-policy steps: - name: Checkout @@ -71,10 +90,10 @@ jobs: with: persist-credentials: false - - name: Set up PHP 8.3 - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + - name: Set up PHP from Composer primary + uses: ./.github/actions/setup-project-php with: - php-version: "8.3" + php-version: ${{ needs.php-policy.outputs.primary }} extensions: openssl, dom, soap coverage: xdebug ini-file: development diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml index 6f93db1..d6e0260 100644 --- a/.github/workflows/psalm.yml +++ b/.github/workflows/psalm.yml @@ -24,12 +24,10 @@ jobs: with: persist-credentials: false - - name: Set up PHP 8.2 - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + - name: Set up PHP from Composer + uses: ./.github/actions/setup-project-php with: - php-version: "8.2" extensions: openssl, dom, soap - coverage: none ini-file: development env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/source-watch.yml b/.github/workflows/source-watch.yml new file mode 100644 index 0000000..177b0e0 --- /dev/null +++ b/.github/workflows/source-watch.yml @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Official NFS-e Source Watch + +on: + schedule: + - cron: "17 9 * * 1-5" + workflow_dispatch: + +permissions: + contents: read + +jobs: + check-published-files: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up PHP from Composer + uses: ./.github/actions/setup-project-php + with: + extensions: dom, mbstring, zip, gd, xml, xmlreader, xmlwriter, simplexml, fileinfo + - name: Install isolated domain tooling + run: composer --working-dir=vendor-bin/domains install --prefer-dist --no-progress --no-interaction + - name: Compare official source checksums + run: php tools/bin/domains.php watch --report="${{ runner.temp }}/nfse-sources-report.json" + - name: Retain diff evidence without modifying the repository + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: official-nfse-source-check + path: ${{ runner.temp }}/nfse-sources-report.json + if-no-files-found: warn + retention-days: 30 diff --git a/.gitignore b/.gitignore index c81ed4b..2c256e9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ /.env /.env.local /.secrets/ +/vendor-bin/domains/vendor/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 18ebea2..b9e3aa0 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -12,6 +12,7 @@ ->in([ __DIR__ . '/src', __DIR__ . '/tests', + __DIR__ . '/tools', ]) ->name('*.php') ->notPath('fixtures'); diff --git a/REUSE.toml b/REUSE.toml index 218045b..ff00ea9 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -13,7 +13,10 @@ default-copyright = "2026 LibreCode coop and contributors" [[annotations]] path = [ ".gitignore", - "composer.json" + "composer.json", + "vendor-bin/domains/composer.json", + "vendor-bin/domains/composer.lock", + "resources/domains/sources.json" ] precedence = "aggregate" SPDX-FileCopyrightText = "2026 LibreCode coop and contributors" diff --git a/composer.json b/composer.json index 9e3089c..96440ef 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,15 @@ "description": "Framework-agnostic PHP library for NFS-e issuance, query and cancellation via SEFIN Nacional (ABRASF 2.04 / SEFIN 1.0)", "type": "library", "license": "AGPL-3.0-or-later", - "keywords": ["nfse", "nota-fiscal", "brazil", "sefin", "nfse-php", "fiscal", "xml-signing"], + "keywords": [ + "nfse", + "nota-fiscal", + "brazil", + "sefin", + "nfse-php", + "fiscal", + "xml-signing" + ], "authors": [ { "name": "LibreCode Coop", @@ -53,13 +61,30 @@ "test:coverage": "XDEBUG_MODE=coverage phpunit --coverage-clover=build/logs/clover.xml", "cs:check": "php-cs-fixer fix --dry-run --diff", "cs:fix": "php-cs-fixer fix", - "psalm": "psalm" + "psalm": "psalm", + "domains:install": "@composer --working-dir=vendor-bin/domains install --prefer-dist --no-progress", + "domains:generate": "@php tools/bin/domains.php generate", + "domains:check": "@php tools/bin/domains.php check", + "domains:audit": "@php tools/bin/domains.php audit", + "domains:watch": "@php tools/bin/domains.php watch", + "domains:test": "@composer --working-dir=vendor-bin/domains run test", + "ci:php": "@php tools/bin/ci-php.php validate", + "domains:download": "@php tools/bin/domains.php download", + "domains:verify-indicators": "@php tools/bin/domains.php verify-indicators" }, "minimum-stability": "stable", "prefer-stable": true, "extra": { "branch-alias": { "dev-main": "1.0.x-dev" + }, + "ci": { + "php-matrix": [ + "8.2", + "8.3", + "8.4" + ], + "php-default": "8.3" } }, "config": { diff --git a/docs/NT009-SOURCE-AUDIT.md b/docs/NT009-SOURCE-AUDIT.md new file mode 100644 index 0000000..b6c78f6 --- /dev/null +++ b/docs/NT009-SOURCE-AUDIT.md @@ -0,0 +1,60 @@ + + +# NT009 v1.01: inspect published contracts without activating them + +The official [RTC portal](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/rtc) +publishes **Anexo VI v1.04.01** (DPS/NFS-e layout and fiscal business rules) +and **Anexo VII v1.03.00** (IBS/CBS operation indicators). The publication +does not by itself establish a deployed production schema or effective date. + +A strictly read-only source review uses PHP/PhpSpreadsheet from the isolated +`vendor-bin/domains` Composer project: + +```sh +composer domains:install +composer domains:audit -- --download-dir=/tmp/nfse-nt009 --report=/tmp/nt009-report.json +# Or use previously downloaded, independently verified official files: +composer domains:audit -- --annex-vi=/path/to/VI.xlsx --annex-vii=/path/to/VII.xlsx --report=/tmp/nt009-report.json +``` + +The audit first validates both workbook bytes against SHA-256 values independently +observed on **2026-10-05**, not government-issued signatures. It then reads +the layout worksheet rows with literal field names (ignoring, not evaluating, +spreadsheet helper formulas) and compares the complete +indicator-code/description/location triplets with the existing Anexo C v1.01 +snapshot. Both known official indicator worksheet names (`INDOP` and `cIndOp Public`) are +supported; unknown sheet names fail explicitly. The NT009 sheet identifies A as the +six-digit indicator, C as the supply characteristic and D as the location in the DFe. +Its column J is a separate NFS-e IBS-incidence classification and must not be +substituted for the official DFe location text. The JSON report lists additions, removals and text changes, as well +as original cell coordinates for relevant NT009 layout entries. + +**Interpretation boundary:** exported raw layout rows do *not* automatically +encode complex field conditions, business-rule activation or authorization by +the SEFIN API. The output is audit evidence, not a replacement XSD or a +compliance declaration. Neither Anexo VIII consultative correlations nor new +NT009 fields are activated in production from this tool. The existing +`DpsData`, `XmlBuilder`, `OfficialDomainCatalog` and XSD validators remain +unchanged until a separately reviewed implementation validates the full +normative conditions and deployment dates. + +If official sources cannot be downloaded, the auxiliary evidence GitHub +workflow may be inconclusive; the offline PHPUnit suite remains deterministic. + +## Frozen Annex VII versioned snapshot + +`resources/domains/indicadores-operacao-ibscbs-v1.03.00.tsv` records **all 40** +rows read directly from the official `cIndOp Public` sheet (A/C/D) on +2026-10-08, sourced from the immutable XLSX with SHA-256 +`95f30a44ee94adeea57fb92f3a0337b1e62fdc106f393e73894087889be9e5aa`. +`composer domains:verify-indicators -- --annex-vii=/path/to/ANEXO_VII.xlsx` +reproduces the auxiliary CI audit against all 40 complete indicator triplets, +without embedding parsing code into a GitHub workflow. The auxiliary CI audit +compares all 40 triplets byte-for-byte against the live official workbook. It is a **separate** snapshot: the existing production +`OfficialDomainCatalog::operationIndicator()` deliberately continues to read +Anexo C v1.01 until the new fiscal contract's effective date and schema are +confirmed. The Annex VII table contains codes applicable to multiple fiscal +document types; no unverified `indNFSe` restrictions have been added. diff --git a/resources/domains/README.md b/resources/domains/README.md index c52070f..085c3b0 100644 --- a/resources/domains/README.md +++ b/resources/domains/README.md @@ -40,24 +40,83 @@ When an official annex changes: 6. review downstream mapping changes explicitly instead of silently coercing old values. -## Reproducible generation +## PHP tooling (isolated) -The snapshots are generated directly from the three official XLSX annexes with only the Python -standard library: +The PHP SDK loads committed TSV files only; it never needs to read XLSX or access government +websites at runtime. The annex parser, PHPUnit suite and PhpSpreadsheet live in +`vendor-bin/domains/`, with a separate Composer manifest and lockfile. The nested manifest deliberately +does not repeat the PHP version: its Composer install/update hooks validate the +runtime against `require.php` in the **root** `composer.json`. The root manifest +contains *commands*, not a PhpSpreadsheet dependency. -```bash -python3 tools/generate_domain_tables.py \ - --annex-a /path/to/ANEXO_A.xlsx \ - --annex-b /path/to/ANEXO_B.xlsx \ - --annex-c /path/to/ANEXO_C.xlsx \ - --output resources/domains +Install the isolated tooling (the root Composer PHP requirement, plus PhpSpreadsheet's extensions including zip and gd): + +```sh +composer domains:install ``` -Use `--check` to compare regenerated output with the committed snapshots. The command validates -the expected official cardinalities (5,571 municipalities/general localities, 250 countries, -338 national service codes, 918 nine-digit NBS codes and 26 operation indicators) before writing -or accepting output. +Generate from exact, reviewed official workbooks: + +```sh +composer domains:generate -- --annex-a=/path/to/ANEXO_A.xlsx --annex-b=/path/to/ANEXO_B.xlsx --annex-c=/path/to/ANEXO_C.xlsx +composer domains:check -- --annex-a=/path/to/ANEXO_A.xlsx --annex-b=/path/to/ANEXO_B.xlsx --annex-c=/path/to/ANEXO_C.xlsx +composer domains:test +``` + +For Annex VII v1.03.00, also pass `--annex-vii=/path/to/ANEXO_VII.xlsx` and +`--expected-vii=N`, where N is an **independently verified** cardinality from the +official workbook. The new file is written under its own versioned filename; no +existing production catalog or XSD is silently replaced. + +The generator preserves the existing headers and row order of the current TSVs. +For the official Anexo C INDOP sheet the mapping is explicit: D = supply +characteristic, G = cIndOp and H = location. If an annex changes its physical columns, the generator +must fail pending manual examination; never guess which description belongs to a code. + +## Government source monitoring + +`resources/domains/sources.json` records official portal URLs, version, reported environment +and the available **observed** SHA-256 values. They are not official digital signatures. +All currently tracked XLSX baselines were directly downloaded and SHA-256 checked +on 2026-10-08. SHA-256 detects byte changes; it is not a government signature. +The scheduled source-watch GitHub workflow downloads these exact public URLs, rejects +HTML masquerading as an XLSX and reports byte-level differences. It also inspects +the production and RTC index pages for newer versioned annex links, because simply +hashing known URLs would never detect a newly named workbook. A changed hash +is **not** proof of changed fiscal semantics or production activation. + +```sh +composer domains:watch +composer domains:download -- --ids=annex-a,annex-b,annex-c --output=/tmp/nfse-official +composer domains:check -- --annex-a=/tmp/nfse-official/annex-a.xlsx --annex-b=/tmp/nfse-official/annex-b.xlsx --annex-c=/tmp/nfse-official/annex-c.xlsx +composer domains:audit -- --annex-vi=/path/to/ANEXO_VI.xlsx --annex-vii=/path/to/ANEXO_VII.xlsx +composer domains:verify-indicators -- --annex-vii=/path/to/ANEXO_VII.xlsx +``` -The generator reads XLSX as ZIP/XML, honors cell references and vertically merged cells, derives -UF only from the stable IBGE prefix map, and rejects an unexpected cardinality. A network-free CI -fixture exercises the same parser and proves deterministic output. +The audit command checks source bytes against the recorded Annex VI/VII October 2026 +observations. A changed source must be manually reviewed and the manifest updated. +The monitor never commits, opens issues, or modifies production data. The advisory +Annex VIII correlation remains non-enforcing. + +All required PHPUnit and formatting tests are offline. An optional source-reproduction +workflow separately downloads the public production annexes and compares generated +TSVs byte-for-byte with the committed snapshots. Government downtime must not +break the deterministic test suite. The `tools/` directory is written in PHP only. + +## CI PHP version policy + +The **root** `composer.json` owns the minimum PHP version in `require.php` and the +additional test matrix/primary version in `extra.ci`. The standalone +`tools/bin/ci-php.php` validates consistency and outputs the minimum, matrix +(JSON) or primary version without loading vendor dependencies. The local +`.github/actions/setup-project-php` composite action reads that policy before +installing PHP. The compatibility matrix is intentionally broader than the +minimum library requirement; neither a Composer constraint nor a CLI default +fixes a single runtime patch version. + +The repository workflows invoke testable PHP commands rather than embedding +PHP implementations in YAML. `OfficialAnnexDownloader` checks URLs, content +types and manifest SHA-256 before saving anything; `Nt009Inspector` verifies +the frozen indicator table against the official workbook. Both have isolated +network-free PHPUnit coverage. Advisory download jobs still need network access +when run, but the regular unit tests do not. diff --git a/resources/domains/indicadores-operacao-ibscbs-v1.03.00.tsv b/resources/domains/indicadores-operacao-ibscbs-v1.03.00.tsv new file mode 100644 index 0000000..5c3c2ae --- /dev/null +++ b/resources/domains/indicadores-operacao-ibscbs-v1.03.00.tsv @@ -0,0 +1,42 @@ +# Indicadores de operação IBS/CBS — ANEXO_VII v1.03.00 (NT009 v1.01) +# Formato: cIndOpcaracterística do fornecimentolocal a identificar no DF-e +010101 Presencial, com retirada no estabelecimento do fornecedor Estabelecimento do fornecedor como o local da entrega ou disponibilização do bem ao destinatário +010102 Presencial, com retirada fora do estabelecimento do fornecedor Local da realização da operação e sua retirada +010103 Não presencial, com entrega ou disponibilização em endereço fornecido Endereço do destinatário fornecido para entrega ou disponibilização (assim considerado o destino final indicado pelo adquirente) +010104 Licitação promovida pelo poder público de bem apreendido ou abandonado ou leilão judicial Local onde se encontra o bem móvel material +010105 Constatação de irregularidade pela falta de documentação fiscal ou pelo acobertamento por documentação inidôneo Local onde se encontra o bem móvel material (Local da retirada) +010106 Locação de bem móvel material em aquisições realizadas de forma centralizada por contribuinte sujeito ao regime regular do IBS e da CBS que possui mais de um estabelecimento e que não estejam sujeitas a vedação à apropriação de créditos Local do domicílio principal do adquirente (estabelecimento matriz) +010201 Entrega ou disponibilização do bem Domicílio principal do destinatário +010202 Entrega ou disponibilização do bem Domicílio principal do destinatário +020101 Realização de operações com bem imóvel, bem imaterial, inclusive direito, relacionado a bem imóvel Localidade do imóvel +020102 Realização de operações com bem imóvel, bem imaterial, inclusive direito, relacionado a bem imóvel de Características Especiais (BICE) Localidade do imóvel (BICE) +020201 Execução de serviços diversos prestados fisicamente sobre bem imóvel Localidade do imóvel +020202 Execução de serviços sobre Bens Imóveis de Características Especiais (BICE) Localidade do imóvel (BICE) +020301 Execução dos serviços de administração e intermediação de bens imóveis Localidade do imóvel +020401 Execução de serviços sobre Bens Imóveis de Características Especiais (BICE) Localidade do imóvel (BICE) +030101 Execução de serviços diversos exclusivamente prestados fisicamente sobre a pessoa ou integralmente fruídos presencialmente por pessoa física Estabelecimento do fornecedor como local da prestação +030102 Execução de serviços diversos exclusivamente prestados fisicamente sobre a pessoa ou integralmente fruídos presencialmente por pessoa física Local da prestação do serviço diverso do estabelecimento do fornecedor +040101 Execução de serviços de planejamento, organização e administração de feiras, exposições, congressos, espetáculos, exibições e congêneres Local do evento a que se refere o serviço +050101 Execução de serviços diversos prestados fisicamente sobre bem móvel material Estabelecimento do fornecedor como local da prestação +050102 Execução de serviços diversos prestados fisicamente sobre bem móvel material Local da prestação do serviço diverso do estabelecimento do fornecedor +050201 Execução de serviços portuários Local do porto como local da prestação +060101 Execução de serviços de transporte de passageiros Local de início do transporte +070101 Execução de serviço de transporte de carga Endereço fornecido para entrega do bem +070102 Execução de serviço de transporte de carga Local da retirada do bem +080101 Execução de serviços de exploração de via Local da prestação correspondente à extensão da via explorada, proporcionalmente ao território dos entes tributantes +090101 Execução de serviços de telefonia fixa e demais serviços de comunicação prestados por meio de cabos, fios, fibras e meios similares Local de instalação do terminal +090102 Execução de serviços de telefonia fixa e demais serviços de comunicação prestados por meio de cabos, fios, fibras e meios similares Local do domicílio principal do adquirente (assim considerada a matriz), nas aquisições realizadas de forma centralizada por contribuinte sujeito ao regime regular do IBS e da CBS que possui mais de um estabelecimento e que não estejam sujeitas a vedação à apropriação de créditos +100101 Realização de operações de cessão de espaço para prestação de serviços publicitários Local do domicílio principal do adquirente residente ou domiciliado no País. Nas aquisições indicadas no art. 11, §4º, II, considera-se domicílio principal do adquirente o estabelecimento matriz +100102 Realização de operações de cessão de espaço para prestação de serviços publicitários Local do domicílio principal do destinatário residente ou domiciliado no País, caso o adquirente não seja residente ou domiciliado no País +100201 Realização de operações de cessão de espaço para prestação de serviços publicitários local do domicílio principal do destinatário residente ou domiciliado no País +100301 Execução dos demais serviços em operações não especificadas em outros indicadores ou, nos serviços de que trata o inc. III do art. 11, esses sejam, ainda que parcialmente, prestados à distância Local do domicílio principal do adquirente residente ou domiciliado no País. Nas aquisições indicadas no art. 11, §4º, II, considera-se domicílio principal do adquirente o estabelecimento matriz +100302 Execução dos demais serviços em operações não especificadas em outros indicadores ou, nos serviços de que trata o inc. III do art. 11, esses sejam, ainda que parcialmente, prestados à distância Local do domicílio principal do destinatário residente ou domiciliado no País, caso o adquirente não seja residente ou domiciliado no País +100401 Execução dos demais serviços em operações não especificadas em outros indicadores ou, nos serviços de que tratam o inc. III, estes sejam, ainda que parcialmente, prestados à distância local do domicílio principal do destinatário residente ou domiciliado no País +100501 Realização de demais operações com bens não especificadas em outros indicadores Local do domicílio principal do adquirente residente ou domiciliado no País. Nas aquisições indicadas no art. 11, §4º, II, considera-se domicílio principal do adquirente o estabelecimento matriz +100502 Realização de demais operações com bens não especificadas em outros indicadores Local do domicílio principal do destinatário residente ou domiciliado no País, caso o adquirente não seja residente ou domiciliado no País +100601 Realização de demais operações com bens não especificadas em outros indicadores local do domicílio principal do destinatário residente ou domiciliado no País +110101 Operações destinadas a consumo Local da entrega ou disponibilização +110201 Operações que não envolvam efetivo consumo: a) no fornecimento de serviços de transmissão de energia elétrica; e b) nas demais operações, inclusive nas hipóteses de geração, distribuição ou comercialização de energia elétrica. Local do estabelecimento principal do adquirente (assim considerada a matriz), conforme §4º do art. 11 +120101 Disponibilização de energia elétrica Local do estabelecimento do agente ou de seus representados que figurem na posição devedora da liquidação financeira apurada pela Câmara de Comercialização de Energia Elétrica +130101 Execução do transporte dutoviário de gás natural, na contratação de capacidade de entrada de gás natural do duto, nos termos da legislação aplicável Local do estabelecimento principal do fornecedor +130201 Execução do transporte dutoviário de gás natural, na contratação de capacidade de saída do gás natural do duto Local do estabelecimento principal do adquirente diff --git a/resources/domains/sources.json b/resources/domains/sources.json new file mode 100644 index 0000000..a09dd2e --- /dev/null +++ b/resources/domains/sources.json @@ -0,0 +1,57 @@ +{ + "format_version": 1, + "policy": "Official Sistema Nacional NFS-e annexes are authoritative for the library. A new publication does not establish production effective date.", + "sources": [ + { + "id": "annex-a", + "version": "ANEXO_A v1.00 (20251210)", + "environment": "production", + "type": "xlsx", + "url": "https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual/anexo_a-municipio_ibge-paises_iso2-v1-00-snnfse-20251210.xlsx", + "observed_sha256": "238b715ab2dcc2c9e0857c44d69048e0af806b45ec8499568342de4a37f4419d", + "index_url": "https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual", + "file_pattern": "anexo[_-]a[-_]" + }, + { + "id": "annex-b", + "version": "ANEXO_B v1.01 (20260122)", + "environment": "production", + "type": "xlsx", + "url": "https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual/anexo_b-nbs2-lista_servico_nacional-snnfse-v1-01-20260122.xlsx", + "observed_sha256": "e74b0be8ad204e458932efb1716225a3fb5703a84e5475ce5c4c6789d1c61b1a", + "index_url": "https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual", + "file_pattern": "anexo[_-]b[-_]" + }, + { + "id": "annex-c", + "version": "ANEXO_C v1.01", + "environment": "production", + "type": "xlsx", + "url": "https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual/anexo-c-indop-ibscbs-snnfse-v1-01.xlsx", + "observed_sha256": "f59f066c5c3584ac26cc07d9b23f9fb6713e4bc9212b5e37fededf9244f29d15", + "index_url": "https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual", + "file_pattern": "anexo[-_]c[-_].*indop" + }, + { + "id": "annex-vi-nt009", + "version": "ANEXO_VI v1.04.01", + "environment": "RTC documentation (activation not established)", + "type": "xlsx", + "url": "https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/rtc/anexovi-leiautesrn_rtc_ibscbs-v1-04-01-nt009.xlsx", + "observed_sha256": "103a150dd6f56ec8edcaa3a453b59a1b0e096d7cd5025d7bd06a387f141ddf39", + "index_url": "https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/rtc", + "file_pattern": "anexovi[-_]leiautesrn" + }, + { + "id": "annex-vii-nt009", + "version": "ANEXO_VII v1.03.00", + "environment": "RTC documentation (activation not established)", + "type": "xlsx", + "url": "https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/rtc/anexovii-indop_ibscbs_v1-03-00-nt009.xlsx", + "observed_sha256": "95f30a44ee94adeea57fb92f3a0337b1e62fdc106f393e73894087889be9e5aa", + "index_url": "https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/rtc", + "file_pattern": "anexovii[-_]indop" + } + ], + "checksum_provenance": "Anexo A, B and C SHA-256 verified by direct downloads from the official NFS-e portal in GitHub Actions on 2026-10-08; Anexo VI and VII observed on 2026-10-05 independently and verified against direct official downloads on 2026-10-08. These are not government-issued digital signatures." +} diff --git a/tools/bin/ci-php.php b/tools/bin/ci-php.php new file mode 100644 index 0000000..11d799f --- /dev/null +++ b/tools/bin/ci-php.php @@ -0,0 +1,33 @@ +#!/usr/bin/env php + $policy->minimum(), + 'matrix' => json_encode($policy->matrix(), JSON_THROW_ON_ERROR), + 'primary' => $policy->primary(), + 'validate' => 'PHP CI policy is consistent with root composer.json', + 'assert-runtime' => (function () use ($policy): string { + $policy->assertRuntime(PHP_VERSION); + + return 'PHP runtime satisfies the root Composer requirement'; + })(), + default => throw new \InvalidArgumentException( + 'Usage: php tools/bin/ci-php.php {minimum|matrix|primary|validate|assert-runtime}', + ), + }; + fwrite(STDOUT, $result . "\n"); +} catch (\Throwable $error) { + fwrite(STDERR, $error->getMessage() . "\n"); + exit(1); +} diff --git a/tools/bin/domains.php b/tools/bin/domains.php new file mode 100644 index 0000000..0271bee --- /dev/null +++ b/tools/bin/domains.php @@ -0,0 +1,214 @@ +#!/usr/bin/env php + ['min_range' => 1]], + ) : null; + if ($expectedVII === false) { + throw new InvalidArgumentException('Invalid --expected-vii count'); + } + $generator = new DomainTableGenerator(); + $outputs = $generator->generate( + $options['annex-a'], + $options['annex-b'], + $options['annex-c'], + $options['annex-vii'] ?? null + ); + $generator->validateCounts($outputs, $expectedVII); + $target = $options['output'] ?? $root . '/resources/domains'; + if ($command === 'generate' && !is_dir($target) && !mkdir($target, 0o775, true)) { + throw new RuntimeException('Cannot create output directory'); + } + foreach ($outputs as $name => $output) { + $destination = $target . '/' . $name; + if ($command === 'check') { + $current = is_file($destination) ? file_get_contents($destination) : false; + if ($current !== $output) { + throw new UnexpectedValueException("Snapshot differs: {$name} (review official source)"); + } + } elseif (file_put_contents($destination, $output) === false) { + throw new RuntimeException("Unable to write {$name}"); + } + } + echo "Verified " . count($outputs) . " deterministic snapshots\n"; + } elseif ($command === 'download') { + if (!isset($options['ids'], $options['output'])) { + throw new InvalidArgumentException('Download requires --ids=ID,... and --output=DIR'); + } + $files = (new OfficialAnnexDownloader())->download( + $options['manifest'] ?? $root . '/resources/domains/sources.json', + explode(',', $options['ids']), + $options['output'], + SourceVerifier::fetchOfficial(...) + ); + foreach ($files as $id => $entry) { + echo "{$id}: {$entry['sha256']} -> {$entry['path']}\n"; + } + } elseif ($command === 'audit') { + $manifest = $options['manifest'] ?? $root . '/resources/domains/sources.json'; + $downloader = new OfficialAnnexDownloader(); + $ids = ['annex-vi-nt009', 'annex-vii-nt009']; + $sources = $downloader->sources($manifest, $ids); + if (isset($options['download-dir'])) { + $files = $downloader->download( + $manifest, + $ids, + $options['download-dir'], + SourceVerifier::fetchOfficial(...) + ); + $options['annex-vi'] = $files['annex-vi-nt009']['path']; + $options['annex-vii'] = $files['annex-vii-nt009']['path']; + } + foreach (['annex-vi', 'annex-vii'] as $name) { + if (!isset($options[$name])) { + throw new InvalidArgumentException("Missing --{$name}=PATH"); + } + } + $verifier = new SourceVerifier(); + $hashes = [ + 'annex-vi' => $sources['annex-vi-nt009']['observed_sha256'], + 'annex-vii' => $sources['annex-vii-nt009']['observed_sha256'], + ]; + foreach ($hashes as $name => $expected) { + $hash = $verifier->assertFile($options[$name], $expected); + $reader = new AnnexReader($options[$name]); + echo "{$name}: {$hash}; sheets: " . implode(', ', $reader->sheetNames()) . "\n"; + $reader->close(); + } + $comparison = (new Nt009Inspector())->inspect( + $options['annex-vi'], + $options['annex-vii'], + $root . '/resources/domains/indicadores-operacao-ibscbs-v1.01.tsv' + ); + $json = json_encode($comparison, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE + | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"; + if (isset($options['report'])) { + if (file_put_contents($options['report'], $json) === false) { + throw new RuntimeException('Unable to save NT009 comparison'); + } + } + echo 'NT009 Anexo VII: ' . $comparison['annex_vii_count'] + . ' indicators; added=' . count($comparison['added_codes']) + . ', changed=' . count($comparison['changed_codes']) + . ', removed=' . count($comparison['removed_codes']) . "\n"; + if (getenv('GITHUB_STEP_SUMMARY') !== false) { + file_put_contents( + (string) getenv('GITHUB_STEP_SUMMARY'), + "## NT009 source comparison\n\n" . 'Annex VII: ' + . $comparison['annex_vii_count'] . ' indicators, added: ' + . count($comparison['added_codes']) . ', changed: ' + . count($comparison['changed_codes']) . ', removed: ' + . count($comparison['removed_codes']) + . "\n\nA published annex does not establish production applicability.\n", + FILE_APPEND + ); + } + } elseif ($command === 'verify-indicators') { + if (!isset($options['annex-vii'])) { + throw new InvalidArgumentException('Missing --annex-vii=PATH'); + } + $snapshot = $options['snapshot'] + ?? $root . '/resources/domains/indicadores-operacao-ibscbs-v1.03.00.tsv'; + $count = (new Nt009Inspector())->verifyIndicatorSnapshot( + $options['annex-vii'], + $snapshot, + Nt009Inspector::VERIFIED_NT009_INDICATOR_COUNT + ); + echo "Official Annex VII verified: {$count} exact code/characteristic/location triplets\n"; + } elseif ($command === 'watch') { + $manifest = $options['manifest'] ?? $root . '/resources/domains/sources.json'; + $verifier = new SourceVerifier(); + $results = $verifier->inspect($manifest, SourceVerifier::fetchOfficial(...)); + $newVersions = (new PortalIndexDiscovery())->findNewer( + $manifest, + SourceVerifier::fetchOfficial(...) + ); + $report = [ + 'checked_at_utc' => gmdate('c'), + 'notice' => 'Published bytes are not evidence of production activation.', + 'sources' => $results, + 'new_versions' => $newVersions, + ]; + $json = json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"; + if (isset($options['report'])) { + if (file_put_contents($options['report'], $json) === false) { + throw new RuntimeException('Unable to write report'); + } + } + echo $json; + $summary = getenv('GITHUB_STEP_SUMMARY'); + if ($summary !== false) { + $body = "## Official NFS-e sources\n\n| Source | Version | Environment | Status | Observed SHA-256 |\n|---|---|---|---|---|\n"; + foreach ($results as $entry) { + $body .= '| ' . $entry['id'] . ' | ' . $entry['version'] . ' | ' + . $entry['environment'] . ' | ' . $entry['status'] . ' | `' + . $entry['sha256'] . "` |\n"; + } + $body .= "\nNew official document versions detected: " . count($newVersions) . "\n"; + foreach ($newVersions as $candidate) { + $body .= '- ' . $candidate['id'] . ': ' . $candidate['version'] + . ' (' . $candidate['url'] . ")\n"; + } + $body .= "\nNo published data or XSD was activated automatically.\n"; + file_put_contents($summary, $body, FILE_APPEND); + } + if ($newVersions !== [] || count(array_filter( + $results, + static fn (array $entry): bool => $entry['status'] === 'changed' + )) > 0) { + fwrite(STDERR, "Official files changed: review before updating any fiscal contract\n"); + exit(3); + } + } else { + throw new InvalidArgumentException( + "Usage: php tools/bin/domains.php {generate|check|download|audit|verify-indicators|watch} --name=PATH\n" + . "generate/check: --annex-a= --annex-b= --annex-c= --output= [--annex-vii= --expected-vii=N]\n" + . "download: --ids=annex-a,annex-b,... --output=DIR [--manifest=PATH]\n" + . "audit: --annex-vi= --annex-vii= [--report=] OR --download-dir= [--report=]\n" + . "verify-indicators: --annex-vii=PATH [--snapshot=PATH]\n" + . "watch: [--manifest=PATH --report=PATH]\n", + ); + } +} catch (Throwable $error) { + fwrite(STDERR, $error->getMessage() . "\n"); + exit(1); +} diff --git a/tools/generate_domain_tables.py b/tools/generate_domain_tables.py deleted file mode 100644 index b0a9813..0000000 --- a/tools/generate_domain_tables.py +++ /dev/null @@ -1,348 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -"""Generate normalized NFS-e domain TSV snapshots directly from official XLSX annexes.""" - -from __future__ import annotations - -import argparse -import re -import sys -import tempfile -import unicodedata -import xml.etree.ElementTree as ET -import zipfile -from pathlib import Path - -MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" -REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" -PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" - -UF_BY_IBGE_PREFIX = { - "11": "RO", "12": "AC", "13": "AM", "14": "RR", "15": "PA", "16": "AP", "17": "TO", - "21": "MA", "22": "PI", "23": "CE", "24": "RN", "25": "PB", "26": "PE", "27": "AL", - "28": "SE", "29": "BA", "31": "MG", "32": "ES", "33": "RJ", "35": "SP", "41": "PR", - "42": "SC", "43": "RS", "50": "MS", "51": "MT", "52": "GO", "53": "DF", -} - -EXPECTED_COUNTS = { - "municipios-ibge-v1.00.tsv": 5571, - "paises-iso2-v1.00.tsv": 250, - "servicos-nacionais-v1.01.tsv": 338, - "nbs-v2.0.tsv": 918, - "indicadores-operacao-ibscbs-v1.01.tsv": 26, -} - -HEADERS = { - "municipios-ibge-v1.00.tsv": [ - "# Municípios IBGE — ANEXO_A-MUNICIPIO_IBGE-PAISES_ISO2-v1.00-SNNFSe-20251210", - "# Formato: código IBGEsigla UFnome do município. UF vazia = localidade geral.", - "# A sigla é derivada dos 2 primeiros dígitos do código (padrão IBGE): o anexo só a", - "# preenche em 450 das 5570 linhas. A derivação foi conferida contra essas 450, contra", - "# o enum TSUF do XSD e contra o conjunto de prefixos presentes no anexo.", - ], - "paises-iso2-v1.00.tsv": [ - "# Países ISO 3166-1 alpha-2 — ANEXO_A-MUNICIPIO_IBGE-PAISES_ISO2-v1.00-SNNFSe-20251210", - "# Formato: código ISO2nome", - ], - "servicos-nacionais-v1.01.tsv": [ - "# Lista de Serviços Nacional — ANEXO_B-NBS2-LISTA_SERVICO_NACIONAL-SNNFSe-v1.01-20260122", - "# Gerado de doc/anexos/, aba LISTA.SERV.NAC. Formato: cTribNacdescrição", - ], - "nbs-v2.0.tsv": [ - "# Nomenclatura Brasileira de Serviços 2.0 — ANEXO_B-NBS2-LISTA_SERVICO_NACIONAL-SNNFSe-v1.01-20260122", - "# Aba LISTA.NBS_v2.0. Formato: cNBS (9 dígitos, sem pontos)descrição.", - "# Das 1210 linhas do anexo, 292 são níveis de hierarquia (5, 6, 7 e 8 dígitos) e não", - "# são valores válidos de cNBS, que o XSD tipa como [0-9]{9}.", - "# 999999999 vem sem descrição no anexo; é o código genérico, e é o que a NFS-e real", - "# consultada em produção usa. Mantido com descrição vazia — não se inventa texto.", - ], - "indicadores-operacao-ibscbs-v1.01.tsv": [ - "# Indicadores de operação IBS/CBS — ANEXO_C-INDOP_IBSCBS-SNNFSe-v1.01-20260122", - "# Formato: cIndOpcaracterística do fornecimentolocal a identificar no DF-e", - ], -} - - -def normalized_sheet_name(value: str) -> str: - decomposed = unicodedata.normalize("NFKD", value) - return "".join(ch for ch in decomposed if not unicodedata.combining(ch)).upper().strip() - - -def column(ref: str) -> str: - match = re.match(r"([A-Z]+)", ref) - return match.group(1) if match else "" - - -def clean(value: str) -> str: - return re.sub(r"\s+", " ", value.replace("\t", " ")).strip() - - -def digits(value: str) -> str: - return re.sub(r"\D+", "", value) - - -class Workbook: - def __init__(self, path: Path): - self.path = path - self.zip = zipfile.ZipFile(path) - self.shared_strings = self._read_shared_strings() - self.sheet_targets = self._read_sheet_targets() - - def close(self) -> None: - self.zip.close() - - def __enter__(self) -> "Workbook": - return self - - def __exit__(self, *_args: object) -> None: - self.close() - - def _read_shared_strings(self) -> list[str]: - try: - raw = self.zip.read("xl/sharedStrings.xml") - except KeyError: - return [] - root = ET.fromstring(raw) - values = [] - for item in root.findall(f"{{{MAIN_NS}}}si"): - values.append("".join(node.text or "" for node in item.iter(f"{{{MAIN_NS}}}t"))) - return values - - def _read_sheet_targets(self) -> dict[str, str]: - workbook = ET.fromstring(self.zip.read("xl/workbook.xml")) - rels = ET.fromstring(self.zip.read("xl/_rels/workbook.xml.rels")) - targets = { - rel.attrib["Id"]: rel.attrib["Target"] - for rel in rels.findall(f"{{{PKG_REL_NS}}}Relationship") - } - sheets: dict[str, str] = {} - for sheet in workbook.findall(f".//{{{MAIN_NS}}}sheet"): - rel_id = sheet.attrib[f"{{{REL_NS}}}id"] - target = targets[rel_id] - if not target.startswith("/"): - target = "xl/" + target.lstrip("/") - else: - target = target.lstrip("/") - sheets[normalized_sheet_name(sheet.attrib["name"])] = target - return sheets - - def rows(self, sheet_name: str) -> list[dict[str, str]]: - key = normalized_sheet_name(sheet_name) - if key not in self.sheet_targets: - raise ValueError(f"worksheet not found: {sheet_name}") - root = ET.fromstring(self.zip.read(self.sheet_targets[key])) - rows: list[dict[str, str]] = [] - by_number: dict[int, dict[str, str]] = {} - for row in root.findall(f".//{{{MAIN_NS}}}row"): - number = int(row.attrib.get("r", len(rows) + 1)) - values: dict[str, str] = {} - for cell in row.findall(f"{{{MAIN_NS}}}c"): - ref = cell.attrib.get("r", "") - col = column(ref) - cell_type = cell.attrib.get("t", "") - value = "" - if cell_type == "inlineStr": - value = "".join(node.text or "" for node in cell.iter(f"{{{MAIN_NS}}}t")) - else: - node = cell.find(f"{{{MAIN_NS}}}v") - raw = node.text if node is not None and node.text is not None else "" - if cell_type == "s" and raw != "": - value = self.shared_strings[int(raw)] - else: - value = raw - values[col] = clean(value) - rows.append(values) - by_number[number] = values - - # Preserve merged-cell semantics used by the official annexes. - for merged in root.findall(f".//{{{MAIN_NS}}}mergeCell"): - ref = merged.attrib.get("ref", "") - match = re.fullmatch(r"([A-Z]+)(\d+):([A-Z]+)(\d+)", ref) - if not match: - continue - start_col, start_row, end_col, end_row = match.groups() - if start_col != end_col: - continue - start = int(start_row) - end = int(end_row) - source = by_number.get(start, {}).get(start_col, "") - if source == "": - continue - for number in range(start, end + 1): - by_number.setdefault(number, {}).setdefault(start_col, source) - - return rows - - -def longest_text(values: list[str], excluded: set[str]) -> str: - candidates = [ - clean(value) for value in values - if clean(value) not in excluded - and clean(value) != "" - and not re.fullmatch(r"[\d.\-/]+", clean(value)) - ] - return max(candidates, key=len, default="") - - -def extract_municipalities(book: Workbook) -> list[list[str]]: - rows: list[list[str]] = [] - seen: set[str] = set() - for row in book.rows("TAB.MUN_IBGE"): - code = digits(row.get("D", "")) - name = clean(row.get("C", "")) - if len(code) != 7 or name == "" or code in seen: - continue - prefix = code[:2] - if prefix not in UF_BY_IBGE_PREFIX: - raise ValueError(f"unknown IBGE state prefix: {prefix}") - rows.append([code, UF_BY_IBGE_PREFIX[prefix], name]) - seen.add(code) - - # The annex publishes a separate general-locality sheet (e.g. maritime waters). - for row in book.rows("TAB.LOC.GERAL"): - values = list(row.values()) - for value in values: - code = digits(value) - if len(code) != 7 or code in seen: - continue - name = longest_text(values, {value}) - if name: - rows.append([code, "", name]) - seen.add(code) - return rows - - -def extract_countries(book: Workbook) -> list[list[str]]: - rows: list[list[str]] = [] - seen: set[str] = set() - for row in book.rows("TAB.PAÍS_ISO2"): - values = [clean(value) for value in row.values()] - code = next((value for value in values if re.fullmatch(r"[A-Z]{2}", value)), "") - if code == "" or code in seen: - continue - name = longest_text(values, {code}) - if name: - rows.append([code, name]) - seen.add(code) - return rows - - -def extract_code_description(book: Workbook, sheet: str, length: int) -> list[list[str]]: - rows: list[list[str]] = [] - seen: set[str] = set() - for row in book.rows(sheet): - values = [clean(value) for value in row.values()] - code_value = next((value for value in values if len(digits(value)) == length), "") - if code_value == "": - continue - code = digits(code_value) - if code in seen: - continue - description = longest_text(values, {code_value, code}) - if description == "" and code != "999999999": - continue - rows.append([code, description]) - seen.add(code) - return rows - - -def extract_indicators(book: Workbook) -> list[list[str]]: - rows: list[list[str]] = [] - seen: set[str] = set() - for sheet in book.sheet_targets: - for row in book.rows(sheet): - values = [clean(value) for value in row.values()] - code_value = next( - (value for value in values if re.fullmatch(r"\d{6}", digits(value))), - "", - ) - if code_value == "": - continue - code = digits(code_value) - if code in seen: - continue - texts = [ - value for value in values - if value not in {code_value, code} - and value != "" - and not re.fullmatch(r"[\d.\-/]+", value) - ] - if len(texts) < 2: - continue - # Characteristic is normally the longer prose field; location is the other. - ranked = sorted(texts, key=len, reverse=True) - characteristic = ranked[0] - location = ranked[1] - rows.append([code, characteristic, location]) - seen.add(code) - return rows - - -def render(name: str, rows: list[list[str]]) -> str: - body = ["\t".join(row) for row in rows] - return "\n".join(HEADERS[name] + body) + "\n" - - -def generate(annex_a: Path, annex_b: Path, annex_c: Path) -> dict[str, str]: - with Workbook(annex_a) as book_a, Workbook(annex_b) as book_b, Workbook(annex_c) as book_c: - outputs = { - "municipios-ibge-v1.00.tsv": render("municipios-ibge-v1.00.tsv", extract_municipalities(book_a)), - "paises-iso2-v1.00.tsv": render("paises-iso2-v1.00.tsv", extract_countries(book_a)), - "servicos-nacionais-v1.01.tsv": render( - "servicos-nacionais-v1.01.tsv", - extract_code_description(book_b, "LISTA.SERV.NAC.", 6), - ), - "nbs-v2.0.tsv": render( - "nbs-v2.0.tsv", - extract_code_description(book_b, "LISTA.NBS_v2.0", 9), - ), - "indicadores-operacao-ibscbs-v1.01.tsv": render( - "indicadores-operacao-ibscbs-v1.01.tsv", - extract_indicators(book_c), - ), - } - return outputs - - -def validate_counts(outputs: dict[str, str]) -> None: - for name, expected in EXPECTED_COUNTS.items(): - actual = sum(1 for line in outputs[name].splitlines() if line and not line.startswith("#")) - if actual != expected: - raise ValueError(f"{name}: expected {expected} data rows, generated {actual}") - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--annex-a", required=True, type=Path) - parser.add_argument("--annex-b", required=True, type=Path) - parser.add_argument("--annex-c", required=True, type=Path) - parser.add_argument("--output", required=True, type=Path) - parser.add_argument("--check", action="store_true", help="fail if generated output differs from existing files") - parser.add_argument("--skip-cardinality-check", action="store_true", help=argparse.SUPPRESS) - args = parser.parse_args() - - outputs = generate(args.annex_a, args.annex_b, args.annex_c) - if not args.skip_cardinality_check: - validate_counts(outputs) - - args.output.mkdir(parents=True, exist_ok=True) - failures = [] - for name, content in outputs.items(): - path = args.output / name - if args.check: - current = path.read_text(encoding="utf-8") if path.exists() else "" - if current != content: - failures.append(name) - else: - path.write_text(content, encoding="utf-8") - - if failures: - print("Domain snapshots differ: " + ", ".join(failures), file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/src/AnnexReader.php b/tools/src/AnnexReader.php new file mode 100644 index 0000000..3cc5046 --- /dev/null +++ b/tools/src/AnnexReader.php @@ -0,0 +1,129 @@ +setReadDataOnly(false); + $this->workbook = $reader->load($filename); + } + + /** + * @return list + */ + public function sheetNames(): array + { + return $this->workbook->getSheetNames(); + } + + /** + * @return list> + */ + public function rows(string $sheetName, bool $skipFormulaCells = false): array + { + $sheet = null; + foreach ($this->workbook->getWorksheetIterator() as $candidate) { + if ($this->normalizedName($candidate->getTitle()) === $this->normalizedName($sheetName)) { + $sheet = $candidate; + break; + } + } + + if ($sheet === null) { + throw new \InvalidArgumentException( + "Missing worksheet {$sheetName}; found: " . implode(', ', $this->sheetNames()), + ); + } + + $rows = []; + $lastColumn = Coordinate::columnIndexFromString($sheet->getHighestDataColumn()); + for ($r = 1; $r <= $sheet->getHighestDataRow(); ++$r) { + $values = []; + for ($i = 1; $i <= $lastColumn; ++$i) { + $name = Coordinate::stringFromColumnIndex($i); + $cell = $sheet->getCell($name . $r); + if ($cell->getDataType() === DataType::TYPE_FORMULA) { + if ($skipFormulaCells) { + // Spreadsheet helper formula: do not execute or trust its cached result. + continue; + } + throw new \UnexpectedValueException("Formula not allowed in {$sheetName}!{$name}{$r}"); + } + $raw = $cell->getFormattedValue(); + if (!is_scalar($raw) && $raw !== null) { + throw new \UnexpectedValueException("Unsupported cell type in {$sheetName}!{$name}{$r}"); + } + $clean = self::clean((string) $raw); + if ($clean !== '') { + $values[$name] = $clean; + } + } + $rows[] = $values; + } + + // The official annexes use vertically merged cells for repeated captions. + foreach ($sheet->getMergeCells() as $range) { + if (!preg_match('/^([A-Z]+)([0-9]+):([A-Z]+)([0-9]+)$/', $range, $parts)) { + continue; + } + [, $firstColumn, $firstRow, $lastColumnName, $lastRow] = $parts; + if ($firstColumn !== $lastColumnName) { + continue; + } + $value = $rows[(int) $firstRow - 1][$firstColumn] ?? ''; + if ($value === '') { + continue; + } + for ($r = (int) $firstRow; $r <= (int) $lastRow; ++$r) { + $rows[$r - 1][$firstColumn] ??= $value; + } + } + + return $rows; + } + + public function close(): void + { + $this->workbook->disconnectWorksheets(); + } + + public static function clean(string $text): string + { + return trim((string) preg_replace('/\\s+/u', ' ', str_replace("\t", ' ', $text))); + } + + private function normalizedName(string $name): string + { + return mb_strtoupper(strtr($name, [ + 'Á' => 'A', 'À' => 'A', 'Â' => 'A', 'Ã' => 'A', + 'É' => 'E', 'Ê' => 'E', 'Í' => 'I', 'Ó' => 'O', + 'Ô' => 'O', 'Õ' => 'O', 'Ú' => 'U', 'Ç' => 'C', + 'á' => 'a', 'à' => 'a', 'â' => 'a', 'ã' => 'a', + 'é' => 'e', 'ê' => 'e', 'í' => 'i', 'ó' => 'o', + 'ô' => 'o', 'õ' => 'o', 'ú' => 'u', 'ç' => 'c', + ]), 'UTF-8'); + } +} diff --git a/tools/src/DomainTableGenerator.php b/tools/src/DomainTableGenerator.php new file mode 100644 index 0000000..af0a921 --- /dev/null +++ b/tools/src/DomainTableGenerator.php @@ -0,0 +1,369 @@ + 'RO', '12' => 'AC', '13' => 'AM', '14' => 'RR', '15' => 'PA', + '16' => 'AP', '17' => 'TO', '21' => 'MA', '22' => 'PI', '23' => 'CE', + '24' => 'RN', '25' => 'PB', '26' => 'PE', '27' => 'AL', '28' => 'SE', + '29' => 'BA', '31' => 'MG', '32' => 'ES', '33' => 'RJ', '35' => 'SP', + '41' => 'PR', '42' => 'SC', '43' => 'RS', '50' => 'MS', '51' => 'MT', + '52' => 'GO', '53' => 'DF', + ]; + + /** @var array */ + public const EXPECTED_COUNTS = [ + 'municipios-ibge-v1.00.tsv' => 5571, + 'paises-iso2-v1.00.tsv' => 250, + 'servicos-nacionais-v1.01.tsv' => 338, + 'nbs-v2.0.tsv' => 918, + 'indicadores-operacao-ibscbs-v1.01.tsv' => 26, + ]; + + /** @var array> */ + private const HEADERS = [ + 'municipios-ibge-v1.00.tsv' => [ + '# Municípios IBGE — ANEXO_A-MUNICIPIO_IBGE-PAISES_ISO2-v1.00-SNNFSe-20251210', + '# Formato: código IBGEsigla UFnome do município. UF vazia = localidade geral.', + '# A sigla é derivada dos 2 primeiros dígitos do código (padrão IBGE): o anexo só a', + '# preenche em 450 das 5570 linhas. A derivação foi conferida contra essas 450, contra', + '# o enum TSUF do XSD e contra o conjunto de prefixos presentes no anexo.', + ], + 'paises-iso2-v1.00.tsv' => [ + '# Países ISO 3166-1 alpha-2 — ANEXO_A-MUNICIPIO_IBGE-PAISES_ISO2-v1.00-SNNFSe-20251210', + '# Formato: código ISO2nome', + ], + 'servicos-nacionais-v1.01.tsv' => [ + '# Lista de Serviços Nacional — ANEXO_B-NBS2-LISTA_SERVICO_NACIONAL-SNNFSe-v1.01-20260122', + '# Gerado de doc/anexos/, aba LISTA.SERV.NAC. Formato: cTribNacdescrição', + ], + 'nbs-v2.0.tsv' => [ + '# Nomenclatura Brasileira de Serviços 2.0 — ANEXO_B-NBS2-LISTA_SERVICO_NACIONAL-SNNFSe-v1.01-20260122', + '# Aba LISTA.NBS_v2.0. Formato: cNBS (9 dígitos, sem pontos)descrição.', + '# Das 1210 linhas do anexo, 292 são níveis de hierarquia (5, 6, 7 e 8 dígitos) e não', + '# são valores válidos de cNBS, que o XSD tipa como [0-9]{9}.', + '# 999999999 vem sem descrição no anexo; é o código genérico, e é o que a NFS-e real', + '# consultada em produção usa. Mantido com descrição vazia — não se inventa texto.', + ], + 'indicadores-operacao-ibscbs-v1.01.tsv' => [ + '# Indicadores de operação IBS/CBS — ANEXO_C-INDOP_IBSCBS-SNNFSe-v1.01-20260122', + '# Formato: cIndOpcaracterística do fornecimentolocal a identificar no DF-e', + ], + 'indicadores-operacao-ibscbs-v1.03.00.tsv' => [ + '# Indicadores de operação IBS/CBS — ANEXO_VII v1.03.00 (NT009 v1.01)', + '# Formato: cIndOpcaracterística do fornecimentolocal a identificar no DF-e', + ], + ]; + + /** + * @return array + */ + public function generate(string $annexA, string $annexB, string $annexC, ?string $annexVII = null): array + { + $a = new AnnexReader($annexA); + $b = new AnnexReader($annexB); + $c = new AnnexReader($annexC); + try { + $outputs = [ + 'municipios-ibge-v1.00.tsv' => $this->render( + 'municipios-ibge-v1.00.tsv', + $this->municipalities($a), + ), + 'paises-iso2-v1.00.tsv' => $this->render( + 'paises-iso2-v1.00.tsv', + $this->countries($a), + ), + 'servicos-nacionais-v1.01.tsv' => $this->render( + 'servicos-nacionais-v1.01.tsv', + $this->codeDescriptions($b, 'LISTA.SERV.NAC.', 6), + ), + 'nbs-v2.0.tsv' => $this->render( + 'nbs-v2.0.tsv', + $this->codeDescriptions($b, 'LISTA.NBS_v2.0', 9), + ), + 'indicadores-operacao-ibscbs-v1.01.tsv' => $this->render( + 'indicadores-operacao-ibscbs-v1.01.tsv', + $this->indicators($c), + ), + ]; + } finally { + $a->close(); + $b->close(); + $c->close(); + } + + if ($annexVII !== null) { + $vii = new AnnexReader($annexVII); + try { + $outputs['indicadores-operacao-ibscbs-v1.03.00.tsv'] = $this->render( + 'indicadores-operacao-ibscbs-v1.03.00.tsv', + $this->indicators($vii), + ); + } finally { + $vii->close(); + } + } + + return $outputs; + } + + /** + * @return list> + */ + public function indicatorRows(string $workbookPath): array + { + $book = new AnnexReader($workbookPath); + try { + return $this->indicators($book); + } finally { + $book->close(); + } + } + + /** + * @param array $outputs + */ + public function validateCounts(array $outputs, ?int $expectedVII = null): void + { + if (array_key_exists('indicadores-operacao-ibscbs-v1.03.00.tsv', $outputs) + && ($expectedVII === null || $expectedVII < 1)) { + throw new \InvalidArgumentException( + 'Annex VII requires an independently verified --expected-vii count', + ); + } + + foreach (self::EXPECTED_COUNTS as $file => $expected) { + $this->assertCount($file, $outputs[$file] ?? '', $expected); + } + + if (array_key_exists('indicadores-operacao-ibscbs-v1.03.00.tsv', $outputs)) { + $this->assertCount( + 'indicadores-operacao-ibscbs-v1.03.00.tsv', + $outputs['indicadores-operacao-ibscbs-v1.03.00.tsv'], + (int) $expectedVII + ); + } + } + + private function assertCount(string $file, string $data, int $expected): void + { + $actual = count(array_filter( + explode("\n", $data), + static fn (string $line): bool => $line !== '' && !str_starts_with($line, '#') + )); + if ($actual !== $expected) { + throw new \UnexpectedValueException("{$file}: expected {$expected} rows; generated {$actual}"); + } + } + + /** + * @return list> + */ + private function municipalities(AnnexReader $book): array + { + $rows = []; + $seen = []; + foreach ($book->rows('TAB.MUN_IBGE') as $row) { + $code = self::digits($row['D'] ?? ''); + $name = AnnexReader::clean($row['C'] ?? ''); + if (strlen($code) !== 7 || $name === '') { + continue; + } + $uf = self::UF[substr($code, 0, 2)] ?? null; + if ($uf === null) { + throw new \UnexpectedValueException("Unexpected IBGE prefix: {$code}"); + } + $this->appendCode($rows, $seen, [$code, $uf, $name]); + } + foreach ($book->rows('TAB.LOC.GERAL') as $row) { + $values = array_values($row); + foreach ($values as $value) { + $code = self::digits($value); + if (strlen($code) !== 7) { + continue; + } + $name = self::longestText($values, [$value]); + if ($name !== '') { + $this->appendCode($rows, $seen, [$code, '', $name]); + } + } + } + + return $rows; + } + + /** + * @return list> + */ + private function countries(AnnexReader $book): array + { + $rows = []; + $seen = []; + foreach ($book->rows('TAB.PAÍS_ISO2') as $row) { + $values = array_values($row); + $code = ''; + foreach ($values as $value) { + if (preg_match('/^[A-Z]{2}$/D', $value)) { + $code = $value; + break; + } + } + if ($code === '') { + continue; + } + $name = self::longestText($values, [$code]); + if ($name !== '') { + $this->appendCode($rows, $seen, [$code, $name]); + } + } + + return $rows; + } + + /** + * @return list> + */ + private function codeDescriptions(AnnexReader $book, string $sheet, int $length): array + { + $rows = []; + $seen = []; + foreach ($book->rows($sheet) as $row) { + $values = array_values($row); + $codeValue = null; + foreach ($values as $value) { + if (strlen(self::digits($value)) === $length) { + $codeValue = $value; + break; + } + } + if ($codeValue === null) { + continue; + } + $code = self::digits($codeValue); + $description = self::longestText($values, [$codeValue, $code]); + if ($description !== '' || $code === '999999999') { + $this->appendCode($rows, $seen, [$code, $description]); + } + } + + return $rows; + } + + /** + * For the official Anexo C "INDOP" sheet, D is the supply + * characteristic, G is cIndOp and H is the location. NT009's public + * Annex VII sheet instead uses A/C/D; never infer columns by text length. + * + * @return list> + */ + private function indicators(AnnexReader $book): array + { + $rows = []; + $seen = []; + $sheet = null; + $columns = null; + foreach ($book->sheetNames() as $name) { + $normalized = mb_strtoupper(trim($name), 'UTF-8'); + if ($normalized === 'INDOP') { + $sheet = $name; + $columns = ['code' => 'G', 'characteristic' => 'D', 'location' => 'H']; + break; + } + if ($normalized === 'CINDOP PUBLIC') { + $sheet = $name; + // Official NT009 v1.03.00: A=code, C=characteristic, + // D=DFe location; J is a separate NFS-e incidence hint. + $columns = ['code' => 'A', 'characteristic' => 'C', 'location' => 'D']; + break; + } + } + if ($sheet === null || $columns === null) { + throw new \UnexpectedValueException( + 'Missing official indicator sheet; found: ' . implode(', ', $book->sheetNames()), + ); + } + foreach ($book->rows($sheet) as $row) { + $rawCode = $row[$columns['code']] ?? ''; + $code = self::digits($rawCode); + if (strlen($code) !== 6 || !preg_match('/^[0-9.]+$/D', $rawCode)) { + continue; + } + $characteristic = $row[$columns['characteristic']] ?? ''; + $location = $row[$columns['location']] ?? ''; + if ($characteristic === '' || $location === '') { + throw new \UnexpectedValueException("Incomplete operation indicator {$code} in {$sheet}"); + } + $this->appendCode($rows, $seen, [$code, $characteristic, $location]); + } + if ($rows === []) { + throw new \UnexpectedValueException('No indicators found in official INDOP columns F/G/H'); + } + + return $rows; + } + + /** + * @param list> $rows + * @param array> $seen + * @param list $entry + */ + private function appendCode(array &$rows, array &$seen, array $entry): void + { + $code = $entry[0]; + if (isset($seen[$code])) { + if ($seen[$code] !== $entry) { + throw new \UnexpectedValueException("Conflicting duplicate official code {$code}"); + } + + return; + } + $seen[$code] = $entry; + $rows[] = $entry; + } + + /** + * @param list $values + * @param list $excluded + */ + private static function longestText(array $values, array $excluded): string + { + $candidates = array_filter( + $values, + static fn (string $text): bool => $text !== '' + && !in_array($text, $excluded, true) + && preg_match('/^[0-9.\\/\\-]+$/D', $text) !== 1 + ); + usort($candidates, static fn (string $a, string $b): int => strlen($b) <=> strlen($a)); + + return $candidates[0] ?? ''; + } + + private static function digits(string $value): string + { + return (string) preg_replace('/[^0-9]/', '', $value); + } + + /** + * @param list> $rows + */ + private function render(string $name, array $rows): string + { + $result = self::HEADERS[$name]; + foreach ($rows as $row) { + $result[] = implode("\t", $row); + } + + return implode("\n", $result) . "\n"; + } +} diff --git a/tools/src/Nt009Inspector.php b/tools/src/Nt009Inspector.php new file mode 100644 index 0000000..2140c9e --- /dev/null +++ b/tools/src/Nt009Inspector.php @@ -0,0 +1,154 @@ + + */ + public function inspect(string $annexVI, string $annexVII, string $legacyTable): array + { + $reader = new AnnexReader($annexVI); + $layout = null; + foreach ($reader->sheetNames() as $name) { + if (str_contains(mb_strtoupper($name, 'UTF-8'), 'LEIAUTE DPS')) { + $layout = $name; + break; + } + } + if ($layout === null) { + throw new \UnexpectedValueException('NT009 Annex VI DPS layout worksheet is missing'); + } + try { + $matches = []; + foreach ($reader->rows($layout, true) as $index => $columns) { + foreach ($columns as $value) { + if (in_array($value, [ + 'IBSCBS', 'cIndOp', 'CST', 'cClassTrib', 'gIBSCBS', + 'finNFSe', 'indDest', 'dest', 'regApIBSCBSSN', + 'tpNFSeDebito', 'tpNFSeCredito', 'gPgtoVinc', + 'gIBSCBSAjuste', 'indDoacao', 'indZFMALC', + ], true)) { + $matches[] = [ + 'worksheet' => $layout, + 'row' => $index + 1, + 'tag' => $value, + 'raw_columns' => $columns, + ]; + } + } + } + if ($matches === []) { + throw new \UnexpectedValueException('No NT009 DPS field rows found'); + } + } finally { + $reader->close(); + } + + $vii = (new DomainTableGenerator())->indicatorRows($annexVII); + $old = $this->readTsv($legacyTable); + $latest = []; + foreach ($vii as $row) { + if (isset($latest[$row[0]])) { + throw new \UnexpectedValueException("Repeated Annex VII indicator {$row[0]}"); + } + $latest[$row[0]] = [$row[1], $row[2]]; + } + $added = []; + $changed = []; + $removed = []; + foreach ($latest as $code => $fields) { + if (!isset($old[$code])) { + $added[$code] = $fields; + } elseif ($old[$code] !== $fields) { + $changed[$code] = ['old' => $old[$code], 'new' => $fields]; + } + } + foreach ($old as $code => $fields) { + if (!isset($latest[$code])) { + $removed[$code] = $fields; + } + } + ksort($added); + ksort($changed); + ksort($removed); + + return [ + 'note' => 'Source-level audit only. Validate conditionality, XSD, environment and effective date before emission.', + 'layout_rows' => $matches, + 'annex_vii_count' => count($latest), + 'previous_count' => count($old), + 'added_codes' => $added, + 'changed_codes' => $changed, + 'removed_codes' => $removed, + 'annex_vii_rows' => $latest, + ]; + } + + /** + * Verify every code, characteristic and location in the frozen Annex VII + * snapshot against an original XLSX file, preserving the official row order. + */ + public function verifyIndicatorSnapshot(string $workbook, string $snapshot, int $expectedCount): int + { + if ($expectedCount < 1) { + throw new \InvalidArgumentException('Expected indicator cardinality must be positive'); + } + $rows = (new DomainTableGenerator())->indicatorRows($workbook); + $published = []; + foreach ($rows as $row) { + if (isset($published[$row[0]])) { + throw new \UnexpectedValueException('Duplicate indicator in official Annex VII: ' . $row[0]); + } + $published[$row[0]] = [$row[1], $row[2]]; + } + $committed = $this->readTsv($snapshot); + if (count($published) !== $expectedCount || count($committed) !== $expectedCount + || $published !== $committed) { + throw new \UnexpectedValueException('Versioned Annex VII does not match original XLSX triplets'); + } + + return count($published); + } + + /** + * @return array> + */ + private function readTsv(string $filename): array + { + $lines = @file($filename, FILE_IGNORE_NEW_LINES); + if ($lines === false) { + throw new \RuntimeException('Missing previous official domain snapshot'); + } + $result = []; + foreach ($lines as $line) { + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + $fields = explode("\t", $line); + if (count($fields) !== 3 || !preg_match('/^[0-9]{6}$/D', $fields[0])) { + throw new \UnexpectedValueException('Malformed versioned legacy indicator snapshot'); + } + if (isset($result[$fields[0]])) { + throw new \UnexpectedValueException('Duplicate legacy indicator code'); + } + $result[$fields[0]] = [$fields[1], $fields[2]]; + } + + return $result; + } +} diff --git a/tools/src/OfficialAnnexDownloader.php b/tools/src/OfficialAnnexDownloader.php new file mode 100644 index 0000000..532e4a8 --- /dev/null +++ b/tools/src/OfficialAnnexDownloader.php @@ -0,0 +1,131 @@ + $ids + * @return array + */ + public function sources(string $manifestPath, array $ids): array + { + if ($ids === [] || count($ids) !== count(array_unique($ids))) { + throw new \InvalidArgumentException('Provide distinct official annex IDs'); + } + foreach ($ids as $id) { + if (!preg_match('/^[a-z0-9-]+$/D', $id)) { + throw new \InvalidArgumentException('Invalid official source ID: ' . $id); + } + } + + $contents = @file_get_contents($manifestPath); + if ($contents === false) { + throw new \RuntimeException('Missing official source manifest: ' . $manifestPath); + } + $manifest = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($manifest) || !isset($manifest['sources']) + || !is_array($manifest['sources'])) { + throw new \UnexpectedValueException('Invalid official source manifest'); + } + + $found = []; + foreach ($manifest['sources'] as $source) { + if (!is_array($source) || !in_array($source['id'] ?? null, $ids, true)) { + continue; + } + $id = $source['id']; + if (isset($found[$id])) { + throw new \UnexpectedValueException('Duplicate official source ID: ' . $id); + } + $url = $source['url'] ?? null; + $hash = $source['observed_sha256'] ?? null; + if (!is_string($url) || !is_string($hash) + || ($source['type'] ?? null) !== 'xlsx' + || !preg_match('/^[a-f0-9]{64}$/D', $hash)) { + throw new \UnexpectedValueException('Missing valid XLSX URL or SHA-256 for ' . $id); + } + $parts = parse_url($url); + if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' + || ($parts['host'] ?? '') !== 'www.gov.br' + || !str_starts_with($parts['path'] ?? '', '/nfse/') + || isset($parts['user']) || isset($parts['pass']) || isset($parts['port'])) { + throw new \UnexpectedValueException('Non-official source URL for ' . $id); + } + $found[$id] = ['url' => $url, 'observed_sha256' => $hash]; + } + foreach ($ids as $id) { + if (!isset($found[$id])) { + throw new \UnexpectedValueException('Official annex missing from manifest: ' . $id); + } + } + + return $found; + } + + /** + * @param list $ids + * @param callable(string): string $fetch + * @return array + */ + public function download(string $manifestPath, array $ids, string $outputDir, callable $fetch): array + { + $sources = $this->sources($manifestPath, $ids); + $data = []; + foreach ($sources as $id => $source) { + $bytes = $fetch($source['url']); + if (!is_string($bytes) || $bytes === '' + || strlen($bytes) >= SourceVerifier::MAX_SOURCE_BYTES + || !str_starts_with($bytes, "PK\x03\x04")) { + throw new \UnexpectedValueException('Invalid official XLSX download for ' . $id); + } + $hash = hash('sha256', $bytes); + if (!hash_equals($source['observed_sha256'], $hash)) { + throw new \UnexpectedValueException('Official source SHA-256 changed for ' . $id . ': ' . $hash); + } + $data[$id] = ['bytes' => $bytes, 'sha256' => $hash]; + } + + if (!is_dir($outputDir) && !mkdir($outputDir, 0o700, true) && !is_dir($outputDir)) { + throw new \RuntimeException('Cannot create official annex download directory'); + } + if (!is_dir($outputDir) || !is_writable($outputDir)) { + throw new \RuntimeException('Official annex download directory is not writable'); + } + $result = []; + foreach ($data as $id => $value) { + $path = rtrim($outputDir, '/') . '/' . $id . '.xlsx'; + if (is_link($path)) { + throw new \UnexpectedValueException('Refusing to overwrite symlink: ' . $path); + } + $temporary = tempnam($outputDir, '.nfse-'); + if ($temporary === false) { + throw new \RuntimeException('Cannot create temporary official annex file'); + } + try { + if (file_put_contents($temporary, $value['bytes']) !== strlen($value['bytes']) + || !rename($temporary, $path)) { + throw new \RuntimeException('Cannot write verified official annex ' . $id); + } + } finally { + if (is_file($temporary)) { + unlink($temporary); + } + } + $result[$id] = ['path' => $path, 'sha256' => $value['sha256']]; + } + + return $result; + } +} diff --git a/tools/src/PhpVersionPolicy.php b/tools/src/PhpVersionPolicy.php new file mode 100644 index 0000000..1743a4a --- /dev/null +++ b/tools/src/PhpVersionPolicy.php @@ -0,0 +1,110 @@ + */ + private array $matrix; + + private string $primary; + + /** + * @param array $composer + */ + public function __construct(array $composer) + { + $constraint = $composer['require']['php'] ?? null; + if (!is_string($constraint) + || preg_match('/^\\^([1-9][0-9]*)\\.([0-9]+)(?:\\.0)?$/D', $constraint, $parts) !== 1) { + throw new \UnexpectedValueException( + 'Unsupported root require.php constraint; expected ^MAJOR.MINOR, e.g. ^8.2', + ); + } + + $this->minimum = (int) $parts[1] . '.' . (int) $parts[2]; + $matrix = $composer['extra']['ci']['php-matrix'] ?? null; + $primary = $composer['extra']['ci']['php-default'] ?? null; + if (!is_array($matrix) || !array_is_list($matrix) || $matrix === [] + || !is_string($primary) || !in_array($primary, $matrix, true)) { + throw new \UnexpectedValueException('Invalid extra.ci PHP matrix/default in root composer.json'); + } + + $previous = null; + foreach ($matrix as $version) { + if (!is_string($version) || preg_match('/^[0-9]+\\.[0-9]+$/D', $version) !== 1 + || ($previous !== null && version_compare($version, $previous, '<='))) { + throw new \UnexpectedValueException('PHP matrix must contain unique ascending major.minor versions'); + } + if (!str_starts_with($version, $parts[1] . '.') + || version_compare($version, $this->minimum, '<')) { + throw new \UnexpectedValueException('PHP matrix version is outside the root Composer constraint'); + } + $previous = $version; + } + if ($matrix[0] !== $this->minimum) { + throw new \UnexpectedValueException('PHP matrix must begin with the Composer minimum'); + } + $this->matrix = $matrix; + $this->primary = $primary; + } + + public static function fromFile(string $filename): self + { + $contents = @file_get_contents($filename); + if ($contents === false) { + throw new \RuntimeException('Cannot read root Composer manifest: ' . $filename); + } + $decoded = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($decoded)) { + throw new \UnexpectedValueException('Root composer.json must contain a JSON object'); + } + + return new self($decoded); + } + + public function minimum(): string + { + return $this->minimum; + } + + /** + * @return list + */ + public function matrix(): array + { + return $this->matrix; + } + + public function primary(): string + { + return $this->primary; + } + + /** + * Isolated tooling may be installed separately, but must still run on a + * PHP interpreter supported by this repository's Composer constraint. + */ + public function assertRuntime(string $version): void + { + $major = (int) explode('.', $this->minimum)[0]; + if (version_compare($version, $this->minimum, '<') + || (int) explode('.', $version)[0] !== $major) { + throw new \UnexpectedValueException( + "PHP {$version} is not supported by root composer.json (requires ^{$this->minimum})", + ); + } + } +} diff --git a/tools/src/PortalIndexDiscovery.php b/tools/src/PortalIndexDiscovery.php new file mode 100644 index 0000000..e3efd2a --- /dev/null +++ b/tools/src/PortalIndexDiscovery.php @@ -0,0 +1,125 @@ + + */ + public function findNewer(string $manifestPath, callable $fetchPage): array + { + $raw = @file_get_contents($manifestPath); + if ($raw === false) { + throw new \RuntimeException('Cannot read source manifest'); + } + $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($data) || !isset($data['sources']) || !is_array($data['sources'])) { + throw new \UnexpectedValueException('Invalid source manifest'); + } + + $cache = []; + $newer = []; + foreach ($data['sources'] as $source) { + $page = $source['index_url'] ?? null; + $pattern = $source['file_pattern'] ?? null; + $original = $source['url'] ?? null; + $id = $source['id'] ?? null; + if ($page === null || $pattern === null) { + continue; + } + if (!is_string($page) || !is_string($pattern) || !is_string($original) + || !is_string($id) || !self::isOfficialUrl($page)) { + throw new \UnexpectedValueException('Invalid source index metadata'); + } + $originalVersion = self::versionFromUrl($original); + if ($originalVersion === null) { + throw new \UnexpectedValueException("Missing version for tracked source {$id}"); + } + if (!isset($cache[$page])) { + $contents = $fetchPage($page); + if ($contents === '' || strlen($contents) > 5 * 1024 * 1024) { + throw new \UnexpectedValueException('Empty or oversized official index: ' . $page); + } + $cache[$page] = self::links($contents); + } + + foreach ($cache[$page] as $url) { + if (!self::isOfficialUrl($url) || preg_match('~' . $pattern . '~i', $url) !== 1) { + continue; + } + $candidateVersion = self::versionFromUrl($url); + if ($candidateVersion === null + || version_compare($candidateVersion, $originalVersion) <= 0) { + continue; + } + $newer[$id . ':' . $url] = [ + 'id' => $id, + 'version' => $candidateVersion, + 'url' => $url, + ]; + } + } + + return array_values($newer); + } + + /** + * @return list + */ + private static function links(string $html): array + { + $previous = libxml_use_internal_errors(true); + try { + $doc = new \DOMDocument(); + if (!$doc->loadHTML($html, LIBXML_NONET)) { + throw new \UnexpectedValueException('Invalid official HTML index'); + } + $urls = []; + foreach ($doc->getElementsByTagName('a') as $anchor) { + $href = $anchor->getAttribute('href'); + if (str_starts_with($href, '/nfse/')) { + $href = 'https://www.gov.br' . $href; + } + // Plone may expose a download link and a /view preview of the same resource. + $href = preg_replace('~/view$~i', '', $href) ?? $href; + $urls[$href] = true; + } + + return array_keys($urls); + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + } + + private static function isOfficialUrl(string $url): bool + { + $parts = parse_url($url); + + return is_array($parts) && ($parts['scheme'] ?? '') === 'https' + && ($parts['host'] ?? '') === 'www.gov.br' + && str_starts_with($parts['path'] ?? '', '/nfse/'); + } + + private static function versionFromUrl(string $url): ?string + { + $path = parse_url($url, PHP_URL_PATH); + if (!is_string($path) + || preg_match('/[-_]v([0-9]+)[-_]([0-9]+)(?:[-_]([0-9]+))?/i', basename($path), $matches) !== 1) { + return null; + } + + return implode('.', [(int) $matches[1], (int) $matches[2], (int) ($matches[3] ?? 0)]); + } +} diff --git a/tools/src/SourceVerifier.php b/tools/src/SourceVerifier.php new file mode 100644 index 0000000..95d1fd3 --- /dev/null +++ b/tools/src/SourceVerifier.php @@ -0,0 +1,121 @@ + + */ + public function inspect(string $manifestPath, callable $fetch): array + { + $raw = @file_get_contents($manifestPath); + if ($raw === false) { + throw new \RuntimeException('Cannot read source manifest: ' . $manifestPath); + } + $manifest = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($manifest) || !isset($manifest['sources']) || !is_array($manifest['sources'])) { + throw new \UnexpectedValueException('Invalid source manifest'); + } + + $results = []; + $ids = []; + foreach ($manifest['sources'] as $entry) { + if (!is_array($entry)) { + throw new \UnexpectedValueException('Invalid source entry'); + } + $id = $entry['id'] ?? null; + $url = $entry['url'] ?? null; + $type = $entry['type'] ?? null; + $expected = $entry['observed_sha256'] ?? null; + if (!is_string($id) || !preg_match('/^[a-z0-9-]+$/D', $id) + || isset($ids[$id]) || !is_string($url) + || !in_array($type, ['xlsx', 'zip', 'pdf'], true) + || ($expected !== null && (!is_string($expected) + || !preg_match('/^[a-f0-9]{64}$/D', $expected)))) { + throw new \UnexpectedValueException('Invalid source metadata for ' . (string) $id); + } + // Restrict scheduled downloads to the official Sistema Nacional NFS-e portal. + $parts = parse_url($url); + if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' + || ($parts['host'] ?? '') !== 'www.gov.br' + || !str_starts_with($parts['path'] ?? '', '/nfse/')) { + throw new \UnexpectedValueException("Unexpected official source URL for {$id}"); + } + $ids[$id] = true; + $contents = $fetch($url); + if ($contents === '' || strlen($contents) >= self::MAX_SOURCE_BYTES + || !self::hasMagic($contents, $type)) { + throw new \UnexpectedValueException("Invalid {$type} download for {$id}"); + } + $observed = hash('sha256', $contents); + $status = $expected === null ? 'baseline-needed' + : (hash_equals($expected, $observed) ? 'unchanged' : 'changed'); + $results[] = [ + 'id' => $id, + 'version' => (string) ($entry['version'] ?? ''), + 'environment' => (string) ($entry['environment'] ?? ''), + 'status' => $status, + 'sha256' => $observed, + 'expected_sha256' => $expected, + ]; + } + + return $results; + } + + public static function fetchOfficial(string $url): string + { + $context = stream_context_create([ + 'http' => [ + 'timeout' => 45, + 'follow_location' => 1, + 'max_redirects' => 4, + 'header' => "User-Agent: nfse-php-source-monitor/1.0\r\nAccept: application/octet-stream\r\n", + ], + ]); + $content = @file_get_contents($url, false, $context, 0, self::MAX_SOURCE_BYTES); + if ($content === false) { + throw new \RuntimeException('Official source unavailable: ' . $url); + } + + return $content; + } + + private static function hasMagic(string $content, string $type): bool + { + return match ($type) { + 'xlsx', 'zip' => str_starts_with($content, "PK\x03\x04"), + 'pdf' => str_starts_with($content, '%PDF-'), + default => false, + }; + } +} diff --git a/tools/tests/DomainTableGeneratorTest.php b/tools/tests/DomainTableGeneratorTest.php new file mode 100644 index 0000000..ca7729b --- /dev/null +++ b/tools/tests/DomainTableGeneratorTest.php @@ -0,0 +1,194 @@ +dir = sys_get_temp_dir() . '/nfse-domain-' . bin2hex(random_bytes(8)); + mkdir($this->dir, 0o700); + } + + protected function tearDown(): void + { + foreach (glob($this->dir . '/*') ?: [] as $path) { + unlink($path); + } + rmdir($this->dir); + } + + public function testGeneratorUsesExplicitColumnsAndProducesDeterministicTables(): void + { + [$a, $b, $c] = $this->fixtureFiles(); + $generator = new DomainTableGenerator(); + $first = $generator->generate($a, $b, $c); + self::assertSame($first, $generator->generate($a, $b, $c)); + self::assertCount(5, $first); + self::assertStringContainsString("3304557\tRJ\tRio de Janeiro\n", $first['municipios-ibge-v1.00.tsv']); + self::assertStringContainsString("0000000\t\tÁGUAS MARÍTIMAS\n", $first['municipios-ibge-v1.00.tsv']); + self::assertStringContainsString("BR\tBrasil\n", $first['paises-iso2-v1.00.tsv']); + self::assertStringContainsString( + "010101\tAnálise e desenvolvimento de sistemas.\n", + $first['servicos-nacionais-v1.01.tsv'] + ); + self::assertStringContainsString("101011100\tServiço NBS válido\n", $first['nbs-v2.0.tsv']); + self::assertStringNotContainsString("10101100\t", $first['nbs-v2.0.tsv']); + self::assertStringContainsString( + "020101\tExecução sobre bem imóvel\tLocalidade do imóvel\n", + $first['indicadores-operacao-ibscbs-v1.01.tsv'] + ); + self::assertStringContainsString( + "030101\tExecução sobre bem imóvel\tEstabelecimento do fornecedor\n", + $first['indicadores-operacao-ibscbs-v1.01.tsv'] + ); + } + + public function testRefusesToAcceptOnlyCardinalityFromPartialOfficialWorkbooks(): void + { + [$a, $b, $c] = $this->fixtureFiles(); + $generator = new DomainTableGenerator(); + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('expected 5571 rows; generated 2'); + $generator->validateCounts($generator->generate($a, $b, $c)); + } + + public function testNewAnnexVIINeedsIndependentlyVerifiedCountAndKeepsOldSnapshot(): void + { + [$a, $b, $c] = $this->fixtureFiles(); + $generator = new DomainTableGenerator(); + $tables = $generator->generate($a, $b, $c, $c); + self::assertArrayHasKey('indicadores-operacao-ibscbs-v1.03.00.tsv', $tables); + self::assertArrayHasKey('indicadores-operacao-ibscbs-v1.01.tsv', $tables); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('independently verified'); + $generator->validateCounts($tables); + } + + public function testDuplicateCodeWithDifferentMeaningIsRejected(): void + { + [$a, $b, $c] = $this->fixtureFiles(); + $this->workbook('a-bad.xlsx', [ + 'TAB.MUN_IBGE' => [ + ['UF', '', 'Município', 'Código'], + ['', '', 'Rio de Janeiro', '3304557'], + ['', '', 'Outro nome', '3304557'], + ], + 'TAB.LOC.GERAL' => [['Código', 'Descrição']], + 'TAB.PAÍS_ISO2' => [['BR', 'Brasil']], + ]); + $generator = new DomainTableGenerator(); + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Conflicting duplicate official code 3304557'); + $generator->generate($this->dir . '/a-bad.xlsx', $b, $c); + } + + public function testSpreadsheetFormulaIsRejectedInsteadOfExecuted(): void + { + $path = $this->workbook('formula.xlsx', [ + 'indop' => [ + ['', '', '', '', '', 'Característica', 'Código', 'Local'], + ['', '', '', '', '', 'Serviço', '=SUM(1,2)', 'Localidade'], + ], + ]); + $reader = new AnnexReader($path); + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Formula not allowed'); + try { + $reader->rows('INDOP'); + } finally { + $reader->close(); + } + } + + /** + * @return list + */ + private function fixtureFiles(): array + { + $a = $this->workbook('a.xlsx', [ + 'TAB.MUN_IBGE' => [ + ['UF', '', 'Município', 'Código'], + ['', '', 'Rio de Janeiro', '3304557'], + ], + 'TAB.LOC.GERAL' => [ + ['Código', 'Descrição'], + ['0000000', 'ÁGUAS MARÍTIMAS'], + ], + 'TAB.PAÍS_ISO2' => [ + ['Código', 'País'], + ['BR', 'Brasil'], + ], + ]); + $b = $this->workbook('b.xlsx', [ + 'LISTA.SERV.NAC.' => [ + ['Código', 'Descrição'], + ['01.01.01', 'Análise e desenvolvimento de sistemas.'], + ['01.01', 'Cabeçalho'], + ], + 'LISTA.NBS_v2.0' => [ + ['Código', 'Descrição'], + ['1.0101.11.00', 'Serviço NBS válido'], + ['1.0101.11', 'Nível hierárquico'], + ], + ]); + $c = $this->workbook('c.xlsx', [ + 'IndOp' => [ + ['', '', '', 'Característica', '', '', 'Código', 'Local'], + ['', '', '', 'Execução sobre bem imóvel', '', '01', '020101', 'Localidade do imóvel'], + ['', '', '', '', '', '02', '030101', 'Estabelecimento do fornecedor'], + ], + ], ['IndOp' => ['D2:D3']]); + + return [$a, $b, $c]; + } + + /** + * @param array>> $sheets + * @param array> $merges + */ + private function workbook(string $name, array $sheets, array $merges = []): string + { + $workbook = new Spreadsheet(); + $first = true; + foreach ($sheets as $sheetName => $rows) { + if ($first) { + $sheet = $workbook->getActiveSheet(); + $sheet->setTitle($sheetName); + $first = false; + } else { + $sheet = new Worksheet($workbook, $sheetName); + $workbook->addSheet($sheet); + } + foreach ($rows as $rowNumber => $cells) { + foreach ($cells as $column => $value) { + if ($value !== '') { + $sheet->setCellValue([$column + 1, $rowNumber + 1], $value); + } + } + } + foreach ($merges[$sheetName] ?? [] as $range) { + $sheet->mergeCells($range); + } + } + $path = $this->dir . '/' . $name; + (new Xlsx($workbook))->save($path); + $workbook->disconnectWorksheets(); + + return $path; + } +} diff --git a/tools/tests/Nt009InspectorTest.php b/tools/tests/Nt009InspectorTest.php new file mode 100644 index 0000000..e12cbc1 --- /dev/null +++ b/tools/tests/Nt009InspectorTest.php @@ -0,0 +1,107 @@ +directory = sys_get_temp_dir() . '/nfse-nt009-' . bin2hex(random_bytes(8)); + mkdir($this->directory, 0o700); + } + + protected function tearDown(): void + { + foreach (glob($this->directory . '/*') ?: [] as $path) { + unlink($path); + } + rmdir($this->directory); + } + + public function testComparesCompleteTripletsAndPreservesLayoutRowEvidence(): void + { + $vi = $this->workbook('vi.xlsx', 'LEIAUTE DPS_NFS-e - RT', [ + ['Tag', 'Cardinalidade', 'Condição'], + ['IBSCBS', '0-1', 'condicional'], + ['cIndOp', '0-1', 'condicional'], + ['CST', '1-1', 'dentro de trib'], + ]); + $vii = $this->workbook('vii.xlsx', 'cIndOp Public', [ + ['Código indOp', 'Tipo de operação', 'Característica do fornecimento', + 'Local do fornecimento a ser identificado no DFe'], + ['020101', 'Bem imóvel', 'Descrição alterada', 'Imóvel'], + ['040101', 'Eventos', 'Serviço novo', 'Evento'], + ]); + $legacy = $this->directory . '/previous.tsv'; + file_put_contents($legacy, "# Previous production snapshot\n" + . "020101\tDescrição antiga\tImóvel\n" + . "030101\tOutro serviço\tPessoa\n"); + + $result = (new Nt009Inspector())->inspect($vi, $vii, $legacy); + self::assertSame(2, $result['annex_vii_count']); + self::assertSame(2, $result['previous_count']); + self::assertSame(['Serviço novo', 'Evento'], $result['added_codes']['040101']); + self::assertSame(['Outro serviço', 'Pessoa'], $result['removed_codes']['030101']); + self::assertSame(['Descrição antiga', 'Imóvel'], $result['changed_codes']['020101']['old']); + self::assertSame(['Descrição alterada', 'Imóvel'], $result['changed_codes']['020101']['new']); + self::assertSame('cIndOp', $result['layout_rows'][1]['tag']); + self::assertSame('0-1', $result['layout_rows'][1]['raw_columns']['B']); + } + + public function testFrozenIndicatorSnapshotIsCheckedAgainstAllOriginalTriplets(): void + { + $vii = $this->workbook('vii-verify.xlsx', 'cIndOp Public', [ + ['Código indOp', 'Tipo de operação', 'Característica do fornecimento', 'Local no DFe'], + ['020101', 'Imóvel', 'Operação com imóvel', 'Endereço do imóvel'], + ['040101', 'Evento', 'Evento presencial', 'Local do evento'], + ]); + $snapshot = $this->directory . '/vii.tsv'; + file_put_contents($snapshot, "# Official Annex VII\n" + . "020101\tOperação com imóvel\tEndereço do imóvel\n" + . "040101\tEvento presencial\tLocal do evento\n"); + + $inspector = new Nt009Inspector(); + self::assertSame(2, $inspector->verifyIndicatorSnapshot($vii, $snapshot, 2)); + file_put_contents($snapshot, "# Official Annex VII\n" + . "020101\tOperação com imóvel\tEndereço do imóvel\n" + . "040101\tEvento presencial\tWrong location\n"); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('does not match original XLSX triplets'); + $inspector->verifyIndicatorSnapshot($vii, $snapshot, 2); + } + + /** + * @param list> $rows + */ + private function workbook(string $basename, string $sheetName, array $rows): string + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle($sheetName); + foreach ($rows as $i => $row) { + foreach ($row as $j => $value) { + if ($value !== '') { + $sheet->setCellValue([$j + 1, $i + 1], $value); + } + } + } + $file = $this->directory . '/' . $basename; + (new Xlsx($spreadsheet))->save($file); + $spreadsheet->disconnectWorksheets(); + + return $file; + } +} diff --git a/tools/tests/OfficialAnnexDownloaderTest.php b/tools/tests/OfficialAnnexDownloaderTest.php new file mode 100644 index 0000000..432d498 --- /dev/null +++ b/tools/tests/OfficialAnnexDownloaderTest.php @@ -0,0 +1,162 @@ +directory = sys_get_temp_dir() . '/nfse-download-' . bin2hex(random_bytes(8)); + mkdir($this->directory, 0o700); + } + + protected function tearDown(): void + { + foreach (glob($this->directory . '/annexes/*') ?: [] as $path) { + unlink($path); + } + if (is_dir($this->directory . '/annexes')) { + rmdir($this->directory . '/annexes'); + } + foreach (glob($this->directory . '/*') ?: [] as $path) { + unlink($path); + } + rmdir($this->directory); + } + + public function testDownloadsOnlySelectedChecksumPinnedAnnexesWithoutNetwork(): void + { + $a = "PK\x03\x04" . 'official-source-a'; + $b = "PK\x03\x04" . 'official-source-b'; + $manifest = $this->manifest([ + $this->entry('annex-a', $a), + $this->entry('annex-b', $b), + ]); + $seen = []; + $results = (new OfficialAnnexDownloader())->download( + $manifest, + ['annex-b', 'annex-a'], + $this->directory . '/annexes', + static function (string $url) use (&$seen, $a, $b): string { + $seen[] = $url; + + return str_ends_with($url, '/annex-a.xlsx') ? $a : $b; + } + ); + self::assertCount(2, $seen); + self::assertSame($b, file_get_contents($results['annex-b']['path'])); + self::assertSame($a, file_get_contents($results['annex-a']['path'])); + self::assertSame(hash('sha256', $b), $results['annex-b']['sha256']); + } + + public function testAChangedOfficialChecksumIsRejectedBeforeWritingAnyFile(): void + { + $manifest = $this->manifest([$this->entry('annex-a', "PK\x03\x04original")]); + try { + (new OfficialAnnexDownloader())->download( + $manifest, + ['annex-a'], + $this->directory . '/annexes', + static fn (string $url): string => "PK\x03\x04changed" + ); + self::fail('Changed official source must be rejected'); + } catch (\UnexpectedValueException $error) { + self::assertStringContainsString('SHA-256 changed', $error->getMessage()); + self::assertDirectoryDoesNotExist($this->directory . '/annexes'); + } + } + + public function testRejectsHtmlDisguisedAsOfficialXlsx(): void + { + $manifest = $this->manifest([$this->entry('annex-a', "PK\x03\x04original")]); + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid official XLSX'); + (new OfficialAnnexDownloader())->download( + $manifest, + ['annex-a'], + $this->directory . '/annexes', + static fn (string $url): string => 'redirected portal' + ); + } + + public function testRejectsForeignAndUnknownManifestSourcesBeforeDownload(): void + { + $source = $this->entry('annex-a', "PK\x03\x04original"); + $source['url'] = 'https://example.com/nfse/annex-a.xlsx'; + $manifest = $this->manifest([$source]); + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Non-official source'); + (new OfficialAnnexDownloader())->download( + $manifest, + ['annex-a'], + $this->directory . '/annexes', + static fn (string $url): string => 'must-not-be-called' + ); + } + + public function testRefusesUnlistedOrDuplicatedIdentifiers(): void + { + $manifest = $this->manifest([$this->entry('annex-a', "PK\x03\x04original")]); + $downloader = new OfficialAnnexDownloader(); + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('missing from manifest'); + $downloader->sources($manifest, ['annex-x']); + } + + public function testRejectsDuplicateRequestedIdsBeforeFetching(): void + { + $manifest = $this->manifest([$this->entry('annex-a', "PK\x03\x04official")]); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('distinct official annex IDs'); + (new OfficialAnnexDownloader())->download( + $manifest, + ['annex-a', 'annex-a'], + $this->directory . '/annexes', + static fn (string $url): string => 'should not download' + ); + } + + public function testRequiresObservedSourceChecksumInTheManifest(): void + { + $source = $this->entry('annex-a', "PK\x03\x04official"); + $source['observed_sha256'] = ''; + $manifest = $this->manifest([$source]); + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Missing valid XLSX URL or SHA-256'); + (new OfficialAnnexDownloader())->sources($manifest, ['annex-a']); + } + + /** + * @return array{id:string,url:string,type:string,observed_sha256:string} + */ + private function entry(string $id, string $content): array + { + return [ + 'id' => $id, + 'url' => 'https://www.gov.br/nfse/annexes/' . $id . '.xlsx', + 'type' => 'xlsx', + 'observed_sha256' => hash('sha256', $content), + ]; + } + + /** + * @param list> $entries + */ + private function manifest(array $entries): string + { + $filename = $this->directory . '/sources.json'; + file_put_contents($filename, json_encode(['sources' => $entries], JSON_THROW_ON_ERROR)); + + return $filename; + } +} diff --git a/tools/tests/OfficialAnnexViiSnapshotTest.php b/tools/tests/OfficialAnnexViiSnapshotTest.php new file mode 100644 index 0000000..8f64204 --- /dev/null +++ b/tools/tests/OfficialAnnexViiSnapshotTest.php @@ -0,0 +1,39 @@ + + */ + private static function example(): array + { + return [ + 'require' => ['php' => '^8.2'], + 'extra' => [ + 'ci' => ['php-matrix' => ['8.2', '8.3', '8.4'], 'php-default' => '8.3'], + ], + ]; + } + + public function testRepositoryPolicyUsesRootComposerConstraint(): void + { + $policy = PhpVersionPolicy::fromFile(dirname(__DIR__, 2) . '/composer.json'); + $composer = json_decode( + (string) file_get_contents(dirname(__DIR__, 2) . '/composer.json'), + true, + 512, + JSON_THROW_ON_ERROR + ); + self::assertSame('^' . $policy->minimum(), $composer['require']['php']); + self::assertSame($composer['extra']['ci']['php-matrix'], $policy->matrix()); + self::assertSame($composer['extra']['ci']['php-default'], $policy->primary()); + self::assertSame($policy->minimum(), $policy->matrix()[0]); + self::assertContains($policy->primary(), $policy->matrix()); + } + + public function testRequirementChangeMustNotSilentlyLeaveOldMatrix(): void + { + $composer = self::example(); + $composer['require']['php'] = '^8.3'; + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('PHP matrix version is outside'); + new PhpVersionPolicy($composer); + } + + public function testRefusesToGuessFromComplexComposerConstraints(): void + { + $composer = self::example(); + $composer['require']['php'] = '>=8.2 <9.0'; + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Unsupported root require.php constraint'); + new PhpVersionPolicy($composer); + } + + public function testRejectsDuplicatedPhpVersions(): void + { + $composer = self::example(); + $composer['extra']['ci']['php-matrix'] = ['8.2', '8.3', '8.3']; + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('unique ascending'); + new PhpVersionPolicy($composer); + } + + public function testIsolatedToolingRuntimeMustRespectRootPhpConstraint(): void + { + $policy = new PhpVersionPolicy(self::example()); + $policy->assertRuntime('8.2.0'); + $policy->assertRuntime('8.4.24'); + try { + $policy->assertRuntime('8.1.33'); + self::fail('Runtime below the root requirement must be rejected'); + } catch (\UnexpectedValueException $error) { + self::assertStringContainsString('not supported', $error->getMessage()); + } + $this->expectException(\UnexpectedValueException::class); + $policy->assertRuntime('9.0.0'); + } + + public function testPrimaryVersionMustBelongToCompatibilityMatrix(): void + { + $composer = self::example(); + $composer['extra']['ci']['php-default'] = '8.5'; + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid extra.ci PHP matrix/default'); + new PhpVersionPolicy($composer); + } +} diff --git a/tools/tests/PortalIndexDiscoveryTest.php b/tools/tests/PortalIndexDiscoveryTest.php new file mode 100644 index 0000000..ce1aad0 --- /dev/null +++ b/tools/tests/PortalIndexDiscoveryTest.php @@ -0,0 +1,42 @@ + [[ + 'id' => 'annex-vii', + 'index_url' => 'https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/rtc', + 'url' => 'https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/rtc/anexovii-indop_v1-03-00.xlsx', + 'file_pattern' => 'anexovii-indop', + ]], + ], JSON_THROW_ON_ERROR)); + $html = 'old' + . 'new' + . 'other'; + $discovered = (new PortalIndexDiscovery())->findNewer( + $manifest, + static fn (string $url): string => $html + ); + self::assertCount(1, $discovered); + self::assertSame('1.4.0', $discovered[0]['version']); + self::assertStringEndsWith('v1-04-00.xlsx', $discovered[0]['url']); + } finally { + unlink($manifest); + } + } +} diff --git a/tools/tests/SourceVerifierTest.php b/tools/tests/SourceVerifierTest.php new file mode 100644 index 0000000..bb182c4 --- /dev/null +++ b/tools/tests/SourceVerifierTest.php @@ -0,0 +1,85 @@ +manifest = tempnam(sys_get_temp_dir(), 'nfse-sources-'); + self::assertIsString($this->manifest); + } + + protected function tearDown(): void + { + unlink($this->manifest); + } + + public function testByteLevelChecksDistinguishBaselineFromChangesWithoutNetwork(): void + { + $payload = "PK\x03\x04" . 'deterministic-xlsx-fixture'; + file_put_contents($this->manifest, json_encode([ + 'sources' => [ + ['id' => 'annex-a', 'url' => 'https://www.gov.br/nfse/official.xlsx', + 'type' => 'xlsx', 'version' => '1.00', 'environment' => 'production', + 'observed_sha256' => hash('sha256', $payload)], + ['id' => 'annex-b', 'url' => 'https://www.gov.br/nfse/missing-baseline.xlsx', + 'type' => 'xlsx', 'version' => '1.01', 'environment' => 'production', + 'observed_sha256' => null], + ], + ], JSON_THROW_ON_ERROR)); + + $verifier = new SourceVerifier(); + $results = $verifier->inspect($this->manifest, static fn (string $url): string => $payload); + self::assertSame('unchanged', $results[0]['status']); + self::assertSame('baseline-needed', $results[1]['status']); + self::assertSame(hash('sha256', $payload), $results[0]['sha256']); + $other = $verifier->inspect( + $this->manifest, + static fn (string $url): string => "PK\x03\x04changed" + ); + self::assertSame('changed', $other[0]['status']); + } + + public function testRefusesNonGovernmentalOrUntrustedManifestUrls(): void + { + file_put_contents($this->manifest, json_encode([ + 'sources' => [ + ['id' => 'a', 'url' => 'https://example.com/x.xlsx', + 'type' => 'xlsx', 'observed_sha256' => null], + ], + ], JSON_THROW_ON_ERROR)); + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Unexpected official source URL'); + (new SourceVerifier())->inspect( + $this->manifest, + static fn (string $url): string => 'should not be fetched' + ); + } + + public function testRejectsHtmlMasqueradingAsAWorkbook(): void + { + file_put_contents($this->manifest, json_encode([ + 'sources' => [ + ['id' => 'annex-a', 'url' => 'https://www.gov.br/nfse/a.xlsx', + 'type' => 'xlsx', 'observed_sha256' => null], + ], + ], JSON_THROW_ON_ERROR)); + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid xlsx download'); + (new SourceVerifier())->inspect( + $this->manifest, + static fn (string $url): string => 'service unavailable' + ); + } +} diff --git a/tools/tests/test_generate_domain_tables.py b/tools/tests/test_generate_domain_tables.py deleted file mode 100644 index b53babd..0000000 --- a/tools/tests/test_generate_domain_tables.py +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -from __future__ import annotations - -import importlib.util -import tempfile -import unittest -import zipfile -from pathlib import Path - -SCRIPT = Path(__file__).resolve().parents[1] / "generate_domain_tables.py" -SPEC = importlib.util.spec_from_file_location("domain_generator", SCRIPT) -assert SPEC and SPEC.loader -GEN = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(GEN) - - -def col(index: int) -> str: - out = "" - while index: - index, rem = divmod(index - 1, 26) - out = chr(65 + rem) + out - return out - - -def workbook(path: Path, sheets: dict[str, list[list[str]]], merges: dict[str, list[str]] | None = None) -> None: - merges = merges or {} - shared: list[str] = [] - index: dict[str, int] = {} - - def shared_id(value: str) -> int: - if value not in index: - index[value] = len(shared) - shared.append(value) - return index[value] - - workbook_xml = [ - '', - '', - ] - rels = [ - '', - '', - ] - sheet_xml: list[tuple[str, str]] = [] - - for idx, (name, rows) in enumerate(sheets.items(), start=1): - workbook_xml.append(f'') - rels.append( - f'' - ) - parts = [ - '', - '', - ] - for row_number, row in enumerate(rows, start=1): - parts.append(f'') - for column_number, value in enumerate(row, start=1): - if value == "": - continue - ref = f"{col(column_number)}{row_number}" - sid = shared_id(value) - parts.append(f'{sid}') - parts.append('') - parts.append('') - if merges.get(name): - parts.append(f'') - for ref in merges[name]: - parts.append(f'') - parts.append('') - parts.append('') - sheet_xml.append((f"xl/worksheets/sheet{idx}.xml", "".join(parts))) - - workbook_xml.append("") - rels.append("") - shared_xml = ( - '' - '' - + "".join(f"{value}" for value in shared) - + "" - ) - - with zipfile.ZipFile(path, "w") as archive: - archive.writestr("xl/workbook.xml", "".join(workbook_xml)) - archive.writestr("xl/_rels/workbook.xml.rels", "".join(rels)) - archive.writestr("xl/sharedStrings.xml", shared_xml) - for target, xml in sheet_xml: - archive.writestr(target, xml) - - -class DomainGeneratorTest(unittest.TestCase): - def test_generates_deterministic_normalized_snapshots(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - a = root / "a.xlsx" - b = root / "b.xlsx" - c = root / "c.xlsx" - - workbook(a, { - "TAB.MUN_IBGE": [ - ["UF", "", "Município", "Código"], - ["Rio de Janeiro", "", "Rio de Janeiro", "3304557"], - ], - "TAB.LOC.GERAL": [["Código", "Descrição"], ["0000000", "ÁGUAS MARÍTIMAS"]], - "TAB.PAÍS_ISO2": [["Código", "País"], ["BR", "Brasil"], ["US", "Estados Unidos"]], - }) - workbook(b, { - "LISTA.SERV.NAC.": [ - ["Código", "Descrição"], - ["01.01.01", "Análise e desenvolvimento de sistemas."], - ["01.01", "Cabeçalho que não é código utilizável"], - ], - "LISTA.NBS_v2.0": [ - ["Código", "Descrição"], - ["1.0101.11.00", "Serviço NBS válido"], - ["1.0101.11", "Nível hierárquico"], - ], - }) - workbook(c, { - "INDOP": [ - ["Código", "Característica", "Local"], - ["020101", "Execução sobre bem imóvel", "Localidade do imóvel (1)"], - ], - }) - - first = GEN.generate(a, b, c) - second = GEN.generate(a, b, c) - self.assertEqual(first, second) - self.assertIn("3304557\tRJ\tRio de Janeiro", first["municipios-ibge-v1.00.tsv"]) - self.assertIn("0000000\t\tÁGUAS MARÍTIMAS", first["municipios-ibge-v1.00.tsv"]) - self.assertIn("BR\tBrasil", first["paises-iso2-v1.00.tsv"]) - self.assertIn("010101\tAnálise e desenvolvimento de sistemas.", first["servicos-nacionais-v1.01.tsv"]) - service_codes = { - line.split("\\t", 1)[0] - for line in first["servicos-nacionais-v1.01.tsv"].splitlines() - if line and not line.startswith("#") - } - nbs_codes = { - line.split("\\t", 1)[0] - for line in first["nbs-v2.0.tsv"].splitlines() - if line and not line.startswith("#") - } - self.assertNotIn("0101", service_codes) - self.assertNotIn("10101100", nbs_codes) - self.assertIn("020101\tExecução sobre bem imóvel\tLocalidade do imóvel (1)", first["indicadores-operacao-ibscbs-v1.01.tsv"]) - - def test_cardinality_guard_rejects_partial_annexes(self) -> None: - partial = { - name: "\n".join(GEN.HEADERS[name] + ["000000\tfixture"]) + "\n" - for name in GEN.EXPECTED_COUNTS - } - with self.assertRaises(ValueError): - GEN.validate_counts(partial) - - -if __name__ == "__main__": - unittest.main() diff --git a/vendor-bin/domains/composer.json b/vendor-bin/domains/composer.json new file mode 100644 index 0000000..edc9ee7 --- /dev/null +++ b/vendor-bin/domains/composer.json @@ -0,0 +1,25 @@ +{ + "name": "librecodeoop/nfse-php-domain-tools", + "description": "Isolated CLI tools for reproducible official NFS-e annex processing", + "type": "project", + "license": "AGPL-3.0-or-later", + "require": { + "phpoffice/phpspreadsheet": "^5.10" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "autoload": { + "psr-4": { + "LibreCodeCoop\\NfsePHP\\Tools\\": "../../tools/src/" + } + }, + "scripts": { + "pre-install-cmd": "@php ../../tools/bin/ci-php.php assert-runtime", + "pre-update-cmd": "@php ../../tools/bin/ci-php.php assert-runtime", + "test": "vendor/bin/phpunit -c phpunit.xml" + }, + "config": { + "sort-packages": true + } +} diff --git a/vendor-bin/domains/composer.lock b/vendor-bin/domains/composer.lock new file mode 100644 index 0000000..5e1fc18 --- /dev/null +++ b/vendor-bin/domains/composer.lock @@ -0,0 +1,2207 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "4a135c9440d752fd10c90ebbd2ca9972", + "packages": [ + { + "name": "composer/pcre", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, + { + "name": "maennchen/zipstream-php", + "version": "3.1.2", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f", + "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.2" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.16", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^11.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2025-01-27T12:07:53+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, + { + "name": "phpoffice/phpspreadsheet", + "version": "5.10.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/eb18727acf6b1f4cc67145a52ab04f9fd4c28d53", + "reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53", + "shasum": "" + }, + "require": { + "composer/pcre": "^1||^2||^3", + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-filter": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": "^8.2", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "dompdf/dompdf": "^2.0 || ^3.0", + "ext-intl": "*", + "ext-openssl": "*", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.5", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1 || ^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0", + "phpstan/phpstan-phpunit": "^1.0 || ^2.0", + "phpunit/phpunit": "^10.5 || ^11.0", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": ">=6.8.0 <7.0.0" + }, + "suggest": { + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()", + "ext-openssl": "Handline Agile-encrypted Xlsx spreadsheets", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + }, + { + "name": "Owen Leibman" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.10.0" + }, + "time": "2026-09-17T03:54:50+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + } + ], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.9.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" + }, + "time": "2026-09-13T18:51:52+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.57", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "36b75bf56a8535623f5b3e24b27ea1823759da1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/36b75bf56a8535623f5b3e24b27ea1823759da1a", + "reference": "36b75bf56a8535623f5b3e24b27ea1823759da1a", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.57" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-10-05T05:47:54+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/vendor-bin/domains/phpunit.xml b/vendor-bin/domains/phpunit.xml new file mode 100644 index 0000000..499a141 --- /dev/null +++ b/vendor-bin/domains/phpunit.xml @@ -0,0 +1,13 @@ + + + + + + + ../../tools/tests + + +