diff --git a/.bestpractices.json b/.bestpractices.json index c4a30c11..4fa3a9ea 100644 --- a/.bestpractices.json +++ b/.bestpractices.json @@ -76,7 +76,7 @@ "osps_sa_01_01_status": "Met", "osps_sa_01_01_justification": "https://github.com/brazilian-utils/javascript/blob/main/CONTRIBUTING.md#architecture", "osps_sa_02_01_status": "Met", - "osps_sa_02_01_justification": "The public API is documented at https://brazilian-utils.com.br/utilities and tracked by an API Extractor report: https://github.com/brazilian-utils/javascript/blob/main/reports/api/brazilian-utils.api.md", + "osps_sa_02_01_justification": "The public API is documented at https://brazilian-utils.com.br/utilities and checked on every pull request against the last npm release, which fails on a breaking change and lists every added, removed and changed declaration: https://github.com/brazilian-utils/javascript/blob/main/CONTRIBUTING.md#public-api-validation", "osps_sa_03_01_status": "Met", "osps_sa_03_01_justification": "https://github.com/brazilian-utils/javascript/blob/main/SECURITY.md#threat-model", "osps_vm_01_01_status": "Met", @@ -144,7 +144,7 @@ "documentation_basics_status": "Met", "documentation_basics_justification": "https://brazilian-utils.com.br/getting-started", "documentation_interface_status": "Met", - "documentation_interface_justification": "https://brazilian-utils.com.br/utilities documents every exported function; the API report is https://github.com/brazilian-utils/javascript/blob/main/reports/api/brazilian-utils.api.md", + "documentation_interface_justification": "https://brazilian-utils.com.br/utilities documents every exported function; API Extractor fails CI on an undocumented export: https://github.com/brazilian-utils/javascript/blob/main/CONTRIBUTING.md#public-api-validation", "sites_https_status": "Met", "sites_https_justification": "The site, the repository and the npm package are HTTPS only.", "discussion_status": "Met", @@ -282,7 +282,7 @@ "documentation_quick_start_status": "Met", "documentation_quick_start_justification": "https://brazilian-utils.com.br/getting-started", "documentation_current_status": "Met", - "documentation_current_justification": "The docs are checked in CI: llms.txt, the site shells and the API report fail the Check workflow when stale, and a pull request that changes a utility must update both language versions.", + "documentation_current_justification": "The docs are checked in CI: llms.txt and the site shells fail the Check workflow when stale, an undocumented export fails the public API check, and a pull request that changes a utility must update both language versions.", "documentation_achievements_status": "Met", "documentation_achievements_justification": "The README links the OpenSSF Best Practices and Scorecard badges: https://github.com/brazilian-utils/javascript#readme", "accessibility_best_practices_status": "Unmet", diff --git a/.gitignore b/.gitignore index 29922d40..760402ec 100644 --- a/.gitignore +++ b/.gitignore @@ -12,13 +12,8 @@ coverage .env.* # The SBOM is generated by the release workflow right before publishing. *.cdx.json -# Every report is generated (stryker, coverage, jscpd) except the API Extractor baseline, which is -# committed so that `npm run check:api` compares the public API against the reviewed one instead of -# writing a new file on every run. -reports/* -!reports/api/ -reports/api/* -!reports/api/brazilian-utils.api.md +# Generated reports (Stryker writes its mutation reports there). +reports # Deno writes a lockfile next to jsr.json on every `deno test`/`deno publish`; the npm lockfile is # the one that pins this project's dependencies. deno.lock diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 51efb7e0..4b221a0c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,8 +53,7 @@ and is invoked through the `npm` scripts below, so you don't need to install any | `npm run check:unused` | Runs [knip](https://knip.dev): unused files, exports, types and dependencies fail. | | `npm run test:mutation` | Runs [Stryker](https://stryker-mutator.io) mutation tests (`stryker run`); pass `-- --mutate src//.ts` for one file. | | `npm run bench` | Runs the `describe("… benchmarks")` blocks with vitest in benchmark mode (`vp test bench --run`); they register nothing in test mode. | -| `npm run check:api` | Builds the package and runs API Extractor over `dist/brazilian-utils.d.ts`: a public type without a doc comment, a type the API refers to without exporting, or a public signature that differs from the committed baseline `reports/api/brazilian-utils.api.md`, fails. | -| `npm run check:api:update` | Rewrites the committed API Extractor baseline `reports/api/brazilian-utils.api.md` from the current build; run it when a public signature changes on purpose and commit the new report. | +| `npm run check:api` | Builds the package and runs `scripts/api.ts`: API Extractor fails on an undocumented public declaration or an unexported type the API refers to, a breaking change against the last release on npm fails, and the API diff is shown ([details](#public-api-validation)). | | `npm run check:commits` | Checks the commit messages since `origin/main` with commitlint (Conventional Commits). | | `npm run check:lockfile` | Checks `package-lock.json` only resolves to the npm registry over HTTPS with integrity hashes (lockfile-lint). | | `npm run check:vex` | Checks that every advisory suppressed for `audit-ci` and OSV-Scanner has a `not_affected` statement in `openvex.json`, and nothing else does. | @@ -302,16 +301,56 @@ pull request so the CI result is not a surprise. ## Public API validation -[API Extractor](https://api-extractor.com) runs over the bundled `dist/brazilian-utils.d.ts` in CI -(`npm run check:api`). It fails when a type the public API refers to is not itself exported (a -consumer could not name it) and when an exported function, type or class has no doc comment. It -also compares the public API against the reviewed baseline committed at -`reports/api/brazilian-utils.api.md` (the only file of the ignored `reports/` folder that is -committed) and fails when the two differ, so a change to a public signature has to be reviewed in -the diff of that report: run `npm run check:api:update` to write the new baseline and commit it -along with the change. On top of that, the public signatures are pinned by the -`describe(" types")` blocks in the tests, and the `src/index.test.ts` export map catches an -export that goes missing. +`npm run check:api` builds the package and runs `scripts/api.ts`, which CI runs on every pull +request and push to `main`. Nothing it writes lands in the repository: every report goes to a +temporary directory that is deleted at the end, so there is no file to regenerate or commit. + +1. [API Extractor](https://api-extractor.com) runs over the bundled `dist/brazilian-utils.d.ts` + with the settings of `api-extractor.json`. It fails when a type the public API refers to is not + itself exported (`ae-forgotten-export`: a consumer could not name it), when an exported + function, type or class has no doc comment (`ae-undocumented`), and on any compiler error in the + bundled declarations. +2. The script downloads the last release (`npm pack @brazilian-utils/brazilian-utils@latest`) and + generates a TypeScript file that only compiles when this build can replace it, type-checked with + the repository's `tsc`. A breaking change fails the job: + - an export of the release (value or type, at the root or in a subpath entry point such as + `@brazilian-utils/brazilian-utils/is-valid-cpf`) that is no longer exported, or a subpath + whose `.js`, `.cjs`, `.d.ts` or `.d.cts` file is no longer built; + - a value that is not assignable to the released one (`const _: typeof Old.x = New.x`): a + parameter that became required or narrower, a new required parameter, a return type that + widened (`boolean` to `boolean | null`), an overload that went away; + - a function whose return type narrowed (`Bank | null` to `Bank`) or whose returned object gained + a required property: a consumer that stored the result in an inferred variable + (`let bank = getBankByCode(code)`) and later assigns the old type to it (`bank = null`) stops + compiling. The returns of all overloads (up to four) are compared as one union; + - an exported type that rejects a value the released one accepts (a removed union member, a + property that became required or narrower, a new required property); + - an exported type that consumers get back (it appears in a return type, a thrown class, or a + type reached from one) or that no function uses, and that now accepts a value the released one + rejects (a new union member, a removed or widened property): code that reads it, such as an + exhaustive `switch`, does not handle the new value. A type that only appears in parameters (an + `*Options` or `*Params` type) may widen freely, and a deprecated alias + (`type GetHolidaysOptions = GetHolidaysParams`) follows the type it renames. + + What stays allowed: new exports, new optional parameters, new optional properties, parameters + and input types that accept more values. When `package.json` is already on a higher major + version than the release, breaking changes are listed but do not fail. The check needs network + access and exits with code 2 when the registry cannot be reached; a package that was never + published skips it. + +3. The declarations added, removed and changed since the release, taken from the API Extractor + reports of both builds, are printed as a diff and, in CI, added to the job summary of the + `Check` workflow, which is where a reviewer reads the API changes of a pull request. This part + never fails. + +The limits of a type-level check: it proves that code which compiled against the release still +compiles, not that it behaves the same (a changed default, a different result for the same input or +a new exception are caught only by the tests), and it assumes consumers use `strict` TypeScript. +Roles are inferred from where a type is written in the signatures, so a type that only appears +inside a callback parameter is treated as an input although it flows out. Generic types (none +today) cannot be named without type arguments and only show up in the diff. On top of that, the +public signatures are pinned by the `describe(" types")` blocks in the tests, and the +`src/index.test.ts` export map catches an export that goes missing. ## Supply chain @@ -443,7 +482,9 @@ This library is used in production by many projects, so please do not introduce (renamed/removed exports, changed function signatures, changed default behavior) without first opening an issue or discussion to align on the approach with maintainers. If a breaking change is unavoidable, call it out explicitly in the PR description (and use a `feat!`/`fix!` or -`BREAKING CHANGE:` footer in the commit, per Conventional Commits). +`BREAKING CHANGE:` footer in the commit, per Conventional Commits). `npm run check:api` fails on a +breaking change against the last release ([Public API validation](#public-api-validation)) until +`package.json` is on the next major version, so such a pull request is merged deliberately. ## Releasing @@ -504,7 +545,7 @@ to what no tool can judge. **What CI already decided.** A reviewer does not re-check these; a red check is a "not yet": formatting, lint and types (`vp check`), the tests on every runtime, 100% coverage and mutation -score, duplicated code (jscpd), unused files and exports (knip), the public API report, bundle size +score, duplicated code (jscpd), unused files and exports (knip), the public API check, bundle size per export (the tree-shaking report), the lockfile, known vulnerabilities (`audit-ci`, OSV-Scanner), CodeQL, the workflow linters, stale generated files (`llms.txt`, the site shells) and the commit messages. @@ -521,6 +562,9 @@ the commit messages. `false`, `format*`/`parse*` return `""`, getters return `null` or `[]`, and there is a property test that says so. No renamed or removed export, no changed default, no narrower accepted input ([Breaking changes](#breaking-changes)); a rename keeps the old name as a `@deprecated` alias. + The job summary of the `Check` workflow lists every public declaration the pull request adds, + removes or changes against the last release; read it to confirm the API change is the intended + one. 3. **Does it fit the project?** One utility per folder named after it, shared logic in `src/_internals/` instead of a copy, `XxxOptions`/`XxxParams` naming, datasets generated by a script and never edited by hand, and nothing in the change the pull request does not need. diff --git a/README.md b/README.md index 59853d24..cdb2a30d 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Brazilian Utils is a zero-dependency library of small utilities for the day-to-d - **Zero runtime dependencies.** Nothing else lands in your `node_modules` or in your bundle. - **Tree-shakeable, down to the function.** `import { isValidCpf }` costs about 1.4 KB minified (0.8 KB gzipped). Every util is also its own subpath entry, so the heavy ones can be lazy-loaded. - **Runs everywhere.** Node.js `^20.19.0 || >=22.12.0`, Bun, Deno and evergreen browsers, all tested in CI. -- **Written in TypeScript.** Types ship with the package, and an API report tracks the public API so nothing changes silently. +- **Written in TypeScript.** Types ship with the package, and every pull request is checked against the last release so the public API never changes silently. - **Validated against the official rules.** Every validator cites the specification, law or dataset it implements, and the test suite is mutation-tested, not just covered. - **Documented in English and Portuguese**, with an `llms.txt` for AI assistants. diff --git a/api-extractor.json b/api-extractor.json index 947a828b..1911260b 100644 --- a/api-extractor.json +++ b/api-extractor.json @@ -5,8 +5,8 @@ "newlineKind": "lf", "apiReport": { "enabled": true, - "reportFolder": "/reports/api/", - "reportTempFolder": "/reports/api/temp/", + "reportFolder": "/node_modules/.cache/api-extractor/", + "reportTempFolder": "/node_modules/.cache/api-extractor/temp/", "reportFileName": "brazilian-utils.api.md" }, "docModel": { diff --git a/docs/getting-started.md b/docs/getting-started.md index 0a3b3f67..2ffecf09 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -11,7 +11,7 @@ Brazilian Utils is a zero-dependency library of small utilities for the day-to-d - **Zero runtime dependencies.** Nothing else lands in your `node_modules` or in your bundle. - **Tree-shakeable, down to the function.** `import { isValidCpf }` costs about 1.4 KB minified (0.8 KB gzipped). Every util is also its own subpath entry, so the heavy ones can be lazy-loaded. - **Runs everywhere.** Node.js `^20.19.0 || >=22.12.0`, Bun, Deno and evergreen browsers, all tested in CI. -- **Written in TypeScript.** Types ship with the package, and an API report tracks the public API so nothing changes silently. +- **Written in TypeScript.** Types ship with the package, and every pull request is checked against the last release so the public API never changes silently. - **Validated against the official rules.** Every validator cites the specification, law or dataset it implements, and the test suite is mutation-tested, not just covered. - **Documented in English and Portuguese**, with an `llms.txt` for AI assistants. diff --git a/docs/pt-br/getting-started.md b/docs/pt-br/getting-started.md index 0f1a3dd5..023e7616 100644 --- a/docs/pt-br/getting-started.md +++ b/docs/pt-br/getting-started.md @@ -11,7 +11,7 @@ Brazilian Utils é uma biblioteca de utilitários, sem dependências, para os pr - **Zero dependências de runtime.** Nada além da biblioteca entra no seu `node_modules` ou no seu bundle. - **Tree-shakeable até a função.** `import { isValidCpf }` custa cerca de 1,4 KB minificado (0,8 KB com gzip). Cada utilitário também é um subpath próprio, então os pesados podem ser carregados sob demanda. - **Roda em qualquer lugar.** Node.js `^20.19.0 || >=22.12.0`, Bun, Deno e navegadores modernos, todos testados no CI. -- **Escrita em TypeScript.** Os tipos vêm no pacote, e um relatório de API acompanha a API pública para que nada mude em silêncio. +- **Escrita em TypeScript.** Os tipos vêm no pacote, e todo pull request é comparado com a última versão publicada para que a API pública nunca mude em silêncio. - **Validada contra as regras oficiais.** Cada validador cita a especificação, lei ou base de dados que implementa, e a suíte de testes passa por mutation testing, não só por cobertura. - **Documentada em inglês e português**, com um `llms.txt` para assistentes de IA. diff --git a/package.json b/package.json index 2c020162..0743159e 100644 --- a/package.json +++ b/package.json @@ -124,8 +124,7 @@ "check:vex": "node ./scripts/vex.ts", "test:mutation": "stryker run", "bench": "vp test bench --run", - "check:api": "npm run build && api-extractor run --verbose", - "check:api:update": "npm run build && api-extractor run --local --verbose", + "check:api": "npm run build && node ./scripts/api.ts", "check:commits": "commitlint --from origin/main --to HEAD --verbose", "check:lockfile": "lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity", "build:data": "node ./scripts/data.ts", diff --git a/reports/api/brazilian-utils.api.md b/reports/api/brazilian-utils.api.md deleted file mode 100644 index c610bf04..00000000 --- a/reports/api/brazilian-utils.api.md +++ /dev/null @@ -1,1441 +0,0 @@ -## API Report File for "@brazilian-utils/brazilian-utils" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -// @public -export const addBusinessDays: (date: Date, amount: number, options?: BusinessDayOptions) => Date | null; - -// @public -export type AddressInfo = { - cep: string; - state: string; - city: string; - neighborhood: string; - street: string; -}; - -// @public -export type AreaCodeInfo = { - areaCode: number; - stateCode: StateCode; - stateName: StateName; - regionCode: State["regionCode"]; - regionName: State["regionName"]; - stateCodes: StateCode[]; -}; - -// @public -export type Bank = { - code: string; - ispb: string; - name: string; -}; - -// @public -export type BoletoInfo = { - amount: number; - expirationDate: Date | null; - bankCode: string; - type?: "arrecadacao"; - segment?: number; - value?: number; - hasEffectiveValue?: boolean; -}; - -// @public -export type BusinessDayOptions = { - stateCode?: StateCode; - includeOptional?: boolean; - includeSaturday?: boolean; -}; - -// @public -export const capitalize: (value: string, options?: CapitalizeOptions) => string; - -// @public -export type CapitalizeOptions = { - lowerCaseWords?: string[]; - upperCaseWords?: string[]; -}; - -// @public -export type Cbo = { - code: string; - description: string; -}; - -// @public -export type CepAddressInfo = { - cep: string; - logradouro: string; - complemento: string; - unidade?: string; - bairro: string; - localidade: string; - uf: string; - estado?: string; - regiao?: string; - ibge?: string; - gia?: string; - ddd?: string; - siafi?: string; -}; - -// @public -export type CepProvider = "viacep" | "widenet" | "brasilapi"; - -// @public -export type CertidaoInfo = { - registryCns: string; - acervo: string; - service: string; - year: number; - type: CertidaoType; - typeCode: number; - book: string; - page: string; - term: string; - checkDigits: string; -}; - -// @public -export type CertidaoType = "birth" | "marriage" | "religious-marriage" | "death" | "stillbirth" | "banns" | "other" | "emancipation" | "interdiction"; - -// @public -export type Cest = { - code: string; - description: string; - segment: string; -}; - -// @public -export type Cfop = { - code: string; - description: string; -}; - -// @public -export type Cid10 = { - code: string; - description: string; -}; - -// @public -export type ClassTrib = { - code: string; - cst: string; - name: string; - description: string; -}; - -// @public -export type Cnae = { - code: string; - description: string; -}; - -// @public -export type CnpjInfo = { - root: string; - branch: string; - checkDigits: string; - isInitialHeadquarters: boolean; -}; - -// @public -export const convertCurrencyToWords: (value: number) => string; - -// @public -export const convertDateToWords: (value: Date | string, options?: ConvertDateToWordsOptions) => string; - -// @public -export type ConvertDateToWordsOptions = { - style?: "full" | "month"; - weekday?: boolean; -}; - -// @public -export const convertLicensePlateToMercosul: (value: string) => string; - -// @public -export const convertNumberToWords: (value: number, options?: ConvertNumberToWordsOptions) => string; - -// @public -export type ConvertNumberToWordsOptions = { - gender?: NumberToWordsGender; -}; - -// @public -export type CpfInfo = { - base: string; - fiscalRegion: string; - states: StateCode[]; - checkDigits: string; -}; - -// @public -export type CstIbsCbs = { - code: string; - description: string; -}; - -// @public -export const differenceInBusinessDays: (laterDate: Date, earlierDate: Date, options?: BusinessDayOptions) => number | null; - -// @public -export const formatBoleto: (value: string | number, options?: FormatBoletoOptions) => string; - -// @public -export type FormatBoletoOptions = { - pad?: boolean; -}; - -// @public -export const formatCaepf: (value: string | number, options?: FormatCaepfOptions) => string; - -// @public -export type FormatCaepfOptions = { - pad?: boolean; -}; - -// @public -export const formatCei: (value: string | number, options?: FormatCeiOptions) => string; - -// @public -export type FormatCeiOptions = { - pad?: boolean; -}; - -// @public @deprecated -export const formatCEP: typeof formatCep; - -// @public -export const formatCep: (value: string | number, options?: FormatCepOptions) => string; - -// @public -export type FormatCepOptions = { - pad?: boolean; -}; - -// @public -export const formatCertidao: (value: string | number, options?: FormatCertidaoOptions) => string; - -// @public -export type FormatCertidaoOptions = { - pad?: boolean; -}; - -// @public -export const formatCest: (value: string | number, options?: FormatCestOptions) => string; - -// @public -export type FormatCestOptions = { - pad?: boolean; -}; - -// @public -export const formatCid10: (value: string) => string; - -// @public -export const formatCnae: (value: string | number, options?: FormatCnaeOptions) => string; - -// @public -export type FormatCnaeOptions = { - pad?: boolean; -}; - -// @public -export const formatCnh: (value: string | number, options?: FormatCnhOptions) => string; - -// @public -export type FormatCnhOptions = { - pad?: boolean; - obfuscate?: boolean; -}; - -// @public -export const formatCno: (value: string | number, options?: FormatCnoOptions) => string; - -// @public -export type FormatCnoOptions = { - pad?: boolean; -}; - -// @public @deprecated -export const formatCNPJ: typeof formatCnpj; - -// @public -export const formatCnpj: (value: string | number, options?: FormatCnpjOptions) => string; - -// @public -export type FormatCnpjOptions = { - pad?: boolean; - version?: 1 | 2; - obfuscate?: boolean; -}; - -// @public -export const formatCns: (value: string | number, options?: FormatCnsOptions) => string; - -// @public -export type FormatCnsOptions = { - pad?: boolean; -}; - -// @public @deprecated -export const formatCPF: typeof formatCpf; - -// @public -export const formatCpf: (value: string | number, options?: FormatCpfOptions) => string; - -// @public -export type FormatCpfOptions = { - pad?: boolean; - obfuscate?: boolean; -}; - -// @public -export const formatCurrency: (value: string | number, options?: FormatCurrencyOptions) => string; - -// @public -export type FormatCurrencyOptions = { - symbol?: boolean; - precision?: number; -}; - -// @public -export const formatIban: (value: string) => string; - -// @public -export const formatLegalNature: (value: string | number, options?: FormatLegalNatureOptions) => string; - -// @public -export type FormatLegalNatureOptions = { - pad?: boolean; -}; - -// @public -export const formatLicensePlate: (value: string) => string; - -// @public -export const formatNbs: (value: string | number) => string; - -// @public -export const formatNcm: (value: string | number, options?: FormatNcmOptions) => string; - -// @public -export type FormatNcmOptions = { - pad?: boolean; -}; - -// @public -export const formatNfeKey: (value: string, options?: FormatNfeKeyOptions) => string; - -// @public -export type FormatNfeKeyOptions = { - pad?: boolean; -}; - -// @public -export const formatPassport: (passport: string) => string; - -// @public -export const formatPhone: (value: string | number, options?: FormatPhoneOptions) => string; - -// @public -export type FormatPhoneOptions = { - mask?: PhoneMask; - obfuscate?: boolean; -}; - -// @public -export const formatPis: (value: string | number, options?: FormatPisOptions) => string; - -// @public -export type FormatPisOptions = { - pad?: boolean; - obfuscate?: boolean; -}; - -// @public -export const formatProcessoJuridico: (value: string | number, options?: FormatProcessoJuridicoOptions) => string; - -// @public -export type FormatProcessoJuridicoOptions = { - pad?: boolean; -}; - -// @public -export const formatSuframa: (value: string | number, options?: FormatSuframaOptions) => string; - -// @public -export type FormatSuframaOptions = { - pad?: boolean; -}; - -// @public -export const formatVoterId: (value: string | number, options?: FormatVoterIdOptions) => string; - -// @public -export type FormatVoterIdOptions = { - obfuscate?: boolean; -}; - -// @public -export const generateBoleto: (params?: GenerateBoletoParams) => string; - -// @public -export type GenerateBoletoParams = { - type?: "bancario" | "arrecadacao"; -}; - -// @public -export const generateCep: () => string; - -// @public -export const generateCnh: () => string; - -// @public @deprecated -export const generateCNPJ: typeof generateCnpj; - -// @public -export const generateCnpj: (versionOrParams?: 1 | 2 | GenerateCnpjParams) => string; - -// @public -export type GenerateCnpjParams = { - version?: 1 | 2; - branch?: number; -}; - -// @public @deprecated -export const generateCPF: typeof generateCpf; - -// @public -export const generateCpf: (state?: StateCode) => string; - -// @public -export const generateLegalNature: () => string; - -// @public -export const generateLicensePlate: (format?: GenerateLicensePlateFormat) => string; - -// @public -export type GenerateLicensePlateFormat = LicensePlateFormat; - -// @public -export const generatePassport: () => string; - -// @public -export const generatePhone: (type?: GeneratePhoneType) => string; - -// @public -export type GeneratePhoneType = "mobile" | "landline" | "service"; - -// @public -export const generatePis: () => string; - -// @public -export const generatePixPayload: (params: GeneratePixPayloadParams) => string | null; - -// @public -export type GeneratePixPayloadParams = { - key?: string; - url?: string; - merchantName: string; - merchantCity: string; - amount?: number; - txid?: string; - description?: string; -}; - -// @public -export const generateProcessoJuridico: (options?: GenerateProcessoJuridicoParams) => string | null; - -// @public @deprecated -export type GenerateProcessoJuridicoOptions = GenerateProcessoJuridicoParams; - -// @public -export type GenerateProcessoJuridicoParams = { - year?: number; - court?: number; -}; - -// @public -export const generateRenavam: () => string; - -// @public -export const generateSuframa: () => string; - -// @public -export const generateVoterId: (state?: StateCode | "ZZ") => string; - -// @public -export const getAddressInfoByCep: (cep: string | number, options?: GetAddressInfoByCepOptions) => Promise; - -// @public -export class GetAddressInfoByCepError extends Error { - constructor(message: string); -} - -// @public -export class GetAddressInfoByCepNotFoundError extends GetAddressInfoByCepError { - constructor(message: string); -} - -// @public -export type GetAddressInfoByCepOptions = { - providers?: CepProvider[]; -}; - -// @public -export class GetAddressInfoByCepServiceError extends GetAddressInfoByCepError { - constructor(message: string); -} - -// @public -export class GetAddressInfoByCepValidationError extends GetAddressInfoByCepError { - constructor(message: string); -} - -// @public -export const getAreaCodeInfo: (areaCode: string | number) => AreaCodeInfo | null; - -// @public -export const getAreaCodesByState: (stateCode: string) => number[]; - -// @public -export const getBankByCode: (code: string | number) => Bank | null; - -// @public -export const getBankByIspb: (value: string | number) => Bank | null; - -// @public -export const getBanks: () => Bank[]; - -// @public -export const getBoletoInfo: (value: string, options?: GetBoletoInfoOptions) => BoletoInfo | null; - -// @public -export type GetBoletoInfoOptions = { - referenceDate?: Date; -}; - -// @public -export const getCbo: (value: string | number) => Cbo | null; - -// @public -export const getCepInfoByAddress: (params: GetCepInfoByAddressParams) => Promise; - -// @public -export class GetCepInfoByAddressError extends Error { - constructor(message: string); -} - -// @public -export class GetCepInfoByAddressNotFoundError extends GetCepInfoByAddressError { - constructor(message: string); -} - -// @public @deprecated -export type GetCepInfoByAddressOptions = GetCepInfoByAddressParams; - -// @public -export type GetCepInfoByAddressParams = { - federalUnit: string; - city: string; - street: string; -}; - -// @public -export class GetCepInfoByAddressValidationError extends GetCepInfoByAddressError { - constructor(message: string); -} - -// @public -export const getCertidaoInfo: (value: string) => CertidaoInfo | null; - -// @public -export const getCest: (value: string | number) => Cest | null; - -// @public -export const getCfop: (value: string | number) => Cfop | null; - -// @public -export const getCid10: (value: string) => Cid10 | null; - -// @public @deprecated -export const getCities: (state?: StateCode) => string[]; - -// @public -export const getClassTrib: (value: string | number) => ClassTrib | null; - -// @public -export const getCnae: (value: string | number) => Cnae | null; - -// @public -export const getCnpjInfo: (value: string, options?: GetCnpjInfoOptions) => CnpjInfo | null; - -// @public -export type GetCnpjInfoOptions = Pick; - -// @public -export const getCpfInfo: (value: string) => CpfInfo | null; - -// @public -export const getCstIbsCbs: (value: string | number) => CstIbsCbs | null; - -// @public -export const getFormatLicensePlate: (value: string) => LicensePlateFormat | null; - -// @public -export const getGtinInfo: (value: string) => GtinInfo | null; - -// @public -export function getHolidays(year: number): Holiday[]; - -// @public -export function getHolidays(options: GetHolidaysParams): Holiday[]; - -// @public @deprecated -export type GetHolidaysOptions = GetHolidaysParams; - -// @public -export type GetHolidaysParams = { - year: number; - stateCode?: StateCode; -}; - -// @public -export const getIbanInfo: (value: string) => IbanInfo | null; - -// @public -export const getLastBusinessDayOfMonth: (date: Date, options?: BusinessDayOptions) => Date | null; - -// @public -export const getLegalNature: (value: string | number) => LegalNature | null; - -// @public -export const getLegalNatures: (params?: GetLegalNaturesParams) => Record; - -// @public -export const getLegalNaturesByCategory: (category: string | number, options?: GetLegalNaturesByCategoryOptions) => LegalNature[]; - -// @public -export type GetLegalNaturesByCategoryOptions = { - includeLegacy?: boolean; -}; - -// @public -export type GetLegalNaturesParams = { - includeLegacy?: boolean; -}; - -// @public -export const getMunicipalities: (stateCode?: StateCode) => Municipality[]; - -// @public @deprecated -export function getMunicipality(options: GetMunicipalityByCodeParams): Promise<[string, string] | null>; - -// @public @deprecated -export function getMunicipality(options: GetMunicipalityByNameParams): Promise; - -// @public @deprecated -export function getMunicipality(options: GetMunicipalityParams): Promise<[string, string] | string | null>; - -// @public -export const getMunicipalityByCode: (code: string | number) => Municipality | null; - -// @public @deprecated -export type GetMunicipalityByCodeOptions = GetMunicipalityByCodeParams; - -// @public -export type GetMunicipalityByCodeParams = { - code: string | number; -}; - -// @public @deprecated -export type GetMunicipalityByNameOptions = GetMunicipalityByNameParams; - -// @public -export type GetMunicipalityByNameParams = { - municipalityName: string; - uf: string; -}; - -// @public @deprecated -export type GetMunicipalityOptions = GetMunicipalityParams; - -// @public -export type GetMunicipalityParams = GetMunicipalityByCodeParams | GetMunicipalityByNameParams; - -// @public -export const getNbs: (value: string | number) => Nbs | null; - -// @public -export const getNextBusinessDay: (date: Date, options?: BusinessDayOptions) => Date | null; - -// @public -export const getNfeKeyInfo: (value: string) => NfeKeyInfo | null; - -// @public -export const getNfseKeyInfo: (value: string) => NfseKeyInfo | null; - -// @public -export const getNthBusinessDay: (date: Date, n: number, options?: BusinessDayOptions) => Date | null; - -// @public -export const getPixKeyInfo: (value: string) => PixKeyInfo | null; - -// @public -export const getPixPayloadInfo: (value: string) => PixPayloadInfo | null; - -// @public -export const getServiceItem: (value: string | number) => ServiceItem | null; - -// @public -export const getStateByCep: (value: string | number) => State | null; - -// @public -export const getStateByIbgeCode: (code: string | number) => State | null; - -// @public -export const getStateCodeByName: (name: string) => StateCode | null; - -// @public -export const getStateNameByCode: (code: string) => StateName | null; - -// @public -export const getStates: () => State[]; - -// @public -export const getTimezoneByState: (stateCode: string) => string | null; - -// @public -export type GtinInfo = { - type: GtinType; - length: GtinLength; - prefix: string; - isBrazilian: boolean; - isRestrictedCirculation: boolean; - checkDigit: number; -}; - -// @public -export type GtinLength = 8 | 12 | 13 | 14; - -// @public -export type GtinType = "GTIN-8" | "GTIN-12" | "GTIN-13" | "GTIN-14"; - -// @public -export type Holiday = { - name: string; - date: Date; - type: HolidayType; -}; - -// @public -export type HolidayType = "national" | "state" | "optional" | "religious"; - -// @public -export type IbanInfo = { - countryCode: "BR"; - checkDigits: string; - bankIspb: string; - branch: string; - account: string; - accountType: string; - owner: string; -}; - -// @public -export const isBusinessDay: (value: Date, options?: BusinessDayOptions) => boolean; - -// @public -export const isHoliday: (options?: IsHolidayParams) => boolean; - -// @public @deprecated -export type IsHolidayOptions = IsHolidayParams; - -// @public -export type IsHolidayParams = { - targetDate: Date; - stateCode?: StateCode; -}; - -// @public -export const isValidBankAccount: (params: IsValidBankAccountParams) => boolean; - -// @public @deprecated -export type IsValidBankAccountOptions = IsValidBankAccountParams; - -// @public -export type IsValidBankAccountParams = { - bankCode: string; - agency: string; - account: string; - digit: string; -}; - -// @public -export const isValidBoleto: (value: string) => boolean; - -// @public -export const isValidCaepf: (value: string | number) => boolean; - -// @public -export const isValidCbo: (value: string | number) => boolean; - -// @public -export const isValidCei: (value: string | number) => boolean; - -// @public @deprecated -export const isValidCEP: typeof isValidCep; - -// @public -export const isValidCep: (cep: string | number) => boolean; - -// @public -export const isValidCertidao: (value: string, options?: IsValidCertidaoOptions) => boolean; - -// @public -export type IsValidCertidaoOptions = { - accept?: CertidaoType[]; -}; - -// @public -export const isValidCest: (value: string | number) => boolean; - -// @public -export const isValidCfop: (value: string | number) => boolean; - -// @public -export const isValidCid10: (value: string) => boolean; - -// @public -export const isValidClassTrib: (value: string | number, options?: IsValidClassTribOptions) => boolean; - -// @public -export type IsValidClassTribOptions = { - cst?: string | number; -}; - -// @public -export const isValidCnae: (value: string | number) => boolean; - -// @public -export const isValidCnh: (value: string) => boolean; - -// @public -export const isValidCno: (value: string | number) => boolean; - -// @public @deprecated -export const isValidCNPJ: typeof isValidCnpj; - -// @public -export const isValidCnpj: (cnpj: string, options?: IsValidCnpjOptions) => boolean; - -// @public -export type IsValidCnpjOptions = { - version?: 1 | 2; -}; - -// @public -export const isValidCns: (value: string | number) => boolean; - -// @public @deprecated -export const isValidCPF: typeof isValidCpf; - -// @public -export const isValidCpf: (cpf: string) => boolean; - -// @public -export const isValidCreditCard: (value: string | number) => boolean; - -// @public -export const isValidCsosn: (value: string | number) => boolean; - -// @public -export const isValidCst: (value: string | number, options?: IsValidCstOptions) => boolean; - -// @public -export const isValidCstIbsCbs: (value: string | number) => boolean; - -// @public -export type IsValidCstOptions = { - tax?: "icms" | "ipi" | "pis" | "cofins"; -}; - -// @public -export const isValidEmail: (value: string) => boolean; - -// @public -export const isValidGtin: (value: string, options?: IsValidGtinOptions) => boolean; - -// @public -export type IsValidGtinOptions = { - lengths?: GtinLength[]; -}; - -// @public -export const isValidIban: (value: string) => boolean; - -// @public @deprecated -export const isValidIE: typeof isValidIe; - -// @public -export function isValidIe(params: IsValidIeParams): boolean; - -// @public @deprecated -export function isValidIe(stateCode: StateCode, ie: string): boolean; - -// @public -export type IsValidIeParams = { - value: string; - stateCode: StateCode; -}; - -// @public -export const isValidLandlinePhone: (value: string) => boolean; - -// @public -export const isValidLegalNature: (code: string) => boolean; - -// @public -export const isValidLicensePlate: (value: string) => boolean; - -// @public -export const isValidMobilePhone: (value: string, options?: IsValidMobilePhoneOptions) => boolean; - -// @public -export type IsValidMobilePhoneOptions = { - version?: PhoneVersion; -}; - -// @public -export const isValidNbs: (value: string | number) => boolean; - -// @public -export const isValidNcm: (value: string | number) => boolean; - -// @public -export const isValidNfeKey: (value: string) => boolean; - -// @public -export const isValidNfseKey: (value: string) => boolean; - -// @public -export const isValidPassport: (passport: string | number) => boolean; - -// @public -export const isValidPhone: (value: string, options?: IsValidPhoneOptions) => boolean; - -// @public -export type IsValidPhoneOptions = { - version?: PhoneVersion; - accept?: PhoneType[]; -}; - -// @public @deprecated -export const isValidPIS: typeof isValidPis; - -// @public -export const isValidPis: (pis: string) => boolean; - -// @public -export const isValidPixKey: (value: string, options?: IsValidPixKeyOptions) => boolean; - -// @public -export type IsValidPixKeyOptions = { - accept?: PixKeyType[]; -}; - -// @public -export const isValidPixPayload: (value: string) => boolean; - -// @public -export const isValidProcessoJuridico: (value: string) => boolean; - -// @public -export const isValidRegistroProfissional: (params: IsValidRegistroProfissionalParams) => boolean; - -// @public -export type IsValidRegistroProfissionalParams = { - value: string; - council: RegistroProfissionalCouncil; - stateCode?: StateCode; -}; - -// @public -export const isValidRenavam: (renavam: string | number) => boolean; - -// @public -export const isValidServiceItem: (value: string | number) => boolean; - -// @public -export const isValidServicePhone: (value: string) => boolean; - -// @public -export const isValidSuframa: (suframa: string) => boolean; - -// @public -export const isValidVin: (value: string) => boolean; - -// @public -export const isValidVoterId: (value: string) => boolean; - -// @public -export type LegalNature = { - code: string; - description: string; - category: LegalNatureCategory; -} & ({ - legacy: false; -} | { - legacy: true; - currentCode: string | null; -}); - -// @public -export type LegalNatureCategory = { - code: "1" | "2" | "3" | "4" | "5"; - description: string; -}; - -// @public -export type LicensePlateFormat = "LLLNNNN" | "LLLNLNN"; - -// @public -export type Municipality = { - code: string; - name: string; - stateCode: StateCode; -}; - -// @public -export type Nbs = { - code: string; - description: string; -}; - -// @public -export type NfeKeyInfo = { - stateCode: StateCode; - year: number; - month: number; - taxId: string; - model: NfeKeyModel; - series: number; - number: number; - emissionType: number; - authorizationSite?: number; - code: string; - checkDigit: number; -}; - -// @public -export type NfeKeyModel = "55" | "57" | "58" | "62" | "63" | "64" | "65" | "66" | "67"; - -// @public -export type NfseKeyGeneratorEnvironment = 1 | 2; - -// @public -export type NfseKeyInfo = { - municipalityCode: string; - stateCode: StateCode; - generatorEnvironment: NfseKeyGeneratorEnvironment; - taxIdType: NfseKeyTaxIdType; - taxId: string; - number: number; - year: number; - month: number; - code: string; - checkDigit: number; -}; - -// @public -export type NfseKeyTaxIdType = "cpf" | "cnpj"; - -// @public -export type NumberToWordsGender = "masculine" | "feminine"; - -// @public -export const obfuscateEmail: (value: string) => string; - -// @public -export const obfuscatePixKey: (value: string) => string; - -// @public -export const parseBoleto: (value: string | number) => string; - -// @public -export const parseCaepf: (value: string | number) => string; - -// @public -export const parseCbo: (value: string | number) => string; - -// @public -export const parseCei: (value: string | number) => string; - -// @public -export const parseCep: (value: string | number) => string; - -// @public -export const parseCertidao: (value: string | number) => string; - -// @public -export const parseCest: (value: string | number) => string; - -// @public -export const parseCfop: (value: string | number) => string; - -// @public -export const parseCid10: (value: string) => string; - -// @public -export const parseCnae: (value: string | number) => string; - -// @public -export const parseCnh: (value: string | number) => string; - -// @public -export const parseCno: (value: string | number) => string; - -// @public -export const parseCnpj: (value: string | number, options?: ParseCnpjOptions) => string; - -// @public -export type ParseCnpjOptions = Pick; - -// @public -export const parseCns: (value: string | number) => string; - -// @public -export const parseCpf: (value: string | number) => string; - -// @public -export const parseCurrency: (value: string, options?: ParseCurrencyOptions) => number; - -// @public -export type ParseCurrencyOptions = { - precision?: number; -}; - -// @public -export const parseIban: (value: string | number) => string; - -// @public -export const parseLegalNature: (value: string | number) => string; - -// @public -export const parseLicensePlate: (value: string) => string; - -// @public -export const parseNcm: (value: string | number) => string; - -// @public -export const parseNfeKey: (value: string | number) => string; - -// @public -export const parseNfseKey: (value: string | number) => string; - -// @public -export const parsePassport: (passport: string) => string; - -// @public -export const parsePhone: (value: string | number) => string; - -// @public -export const parsePis: (value: string | number) => string; - -// @public -export const parseProcessoJuridico: (value: string | number) => string; - -// @public -export const parseSuframa: (value: string | number) => string; - -// @public -export const parseVoterId: (value: string | number) => string; - -// @public -export type PhoneMask = "auto" | "e164" | "international" | "service" | "sn" | "nanp"; - -// @public -export type PhoneType = "mobile" | "landline" | "service"; - -// @public -export type PhoneVersion = 1 | 2; - -// @public -export type PixKeyInfo = { - type: PixKeyType; - value: string; -}; - -// @public -export type PixKeyType = "cpf" | "cnpj" | "email" | "phone" | "evp"; - -// @public -export type PixPayloadInfo = { - key?: string; - url?: string; - description?: string; - withdrawalFacilitator?: string; - merchantName: string; - merchantCity: string; - amount?: number; - txid?: string; - pointOfInitiation: PixPointOfInitiation; -}; - -// @public -export type PixPointOfInitiation = "static" | "dynamic"; - -// @public -export type RegistroProfissionalCouncil = "OAB" | "CRM" | "CRO" | "CRP" | "CRC"; - -// @public -export const removeAccents: (value: string) => string; - -// @public -export type ServiceItem = { - code: string; - description: string; -}; - -// @public -export type StandardSchemaV1 = { - readonly "~standard": StandardSchemaV1Props; -}; - -// @public -export type StandardSchemaV1FailureResult = { - readonly issues: readonly StandardSchemaV1Issue[]; -}; - -// @public -export type StandardSchemaV1Issue = { - readonly message: string; - readonly path?: readonly (PropertyKey | StandardSchemaV1PathSegment)[]; -}; - -// @public -export type StandardSchemaV1Options = { - readonly libraryOptions?: Record; -}; - -// @public -export type StandardSchemaV1PathSegment = { - readonly key: PropertyKey; -}; - -// @public -export type StandardSchemaV1Props = { - readonly version: 1; - readonly vendor: string; - readonly validate: (value: unknown, options?: StandardSchemaV1Options) => StandardSchemaV1Result | Promise>; - readonly types?: StandardSchemaV1Types; -}; - -// @public -export type StandardSchemaV1Result = StandardSchemaV1SuccessResult | StandardSchemaV1FailureResult; - -// @public -export type StandardSchemaV1SuccessResult = { - readonly value: Output; - readonly issues?: undefined; -}; - -// @public -export type StandardSchemaV1Types = { - readonly input: Input; - readonly output: Output; -}; - -// @public -export type State = { - readonly code: "AC"; - readonly name: "Acre"; - readonly regionCode: "N"; - readonly regionName: "Norte"; - readonly ibgeCode: 12; -} | { - readonly code: "AL"; - readonly name: "Alagoas"; - readonly regionCode: "NE"; - readonly regionName: "Nordeste"; - readonly ibgeCode: 27; -} | { - readonly code: "AP"; - readonly name: "Amapá"; - readonly regionCode: "N"; - readonly regionName: "Norte"; - readonly ibgeCode: 16; -} | { - readonly code: "AM"; - readonly name: "Amazonas"; - readonly regionCode: "N"; - readonly regionName: "Norte"; - readonly ibgeCode: 13; -} | { - readonly code: "BA"; - readonly name: "Bahia"; - readonly regionCode: "NE"; - readonly regionName: "Nordeste"; - readonly ibgeCode: 29; -} | { - readonly code: "CE"; - readonly name: "Ceará"; - readonly regionCode: "NE"; - readonly regionName: "Nordeste"; - readonly ibgeCode: 23; -} | { - readonly code: "DF"; - readonly name: "Distrito Federal"; - readonly regionCode: "CO"; - readonly regionName: "Centro-Oeste"; - readonly ibgeCode: 53; -} | { - readonly code: "ES"; - readonly name: "Espírito Santo"; - readonly regionCode: "SE"; - readonly regionName: "Sudeste"; - readonly ibgeCode: 32; -} | { - readonly code: "GO"; - readonly name: "Goiás"; - readonly regionCode: "CO"; - readonly regionName: "Centro-Oeste"; - readonly ibgeCode: 52; -} | { - readonly code: "MA"; - readonly name: "Maranhão"; - readonly regionCode: "NE"; - readonly regionName: "Nordeste"; - readonly ibgeCode: 21; -} | { - readonly code: "MT"; - readonly name: "Mato Grosso"; - readonly regionCode: "CO"; - readonly regionName: "Centro-Oeste"; - readonly ibgeCode: 51; -} | { - readonly code: "MS"; - readonly name: "Mato Grosso do Sul"; - readonly regionCode: "CO"; - readonly regionName: "Centro-Oeste"; - readonly ibgeCode: 50; -} | { - readonly code: "MG"; - readonly name: "Minas Gerais"; - readonly regionCode: "SE"; - readonly regionName: "Sudeste"; - readonly ibgeCode: 31; -} | { - readonly code: "PA"; - readonly name: "Pará"; - readonly regionCode: "N"; - readonly regionName: "Norte"; - readonly ibgeCode: 15; -} | { - readonly code: "PB"; - readonly name: "Paraíba"; - readonly regionCode: "NE"; - readonly regionName: "Nordeste"; - readonly ibgeCode: 25; -} | { - readonly code: "PR"; - readonly name: "Paraná"; - readonly regionCode: "S"; - readonly regionName: "Sul"; - readonly ibgeCode: 41; -} | { - readonly code: "PE"; - readonly name: "Pernambuco"; - readonly regionCode: "NE"; - readonly regionName: "Nordeste"; - readonly ibgeCode: 26; -} | { - readonly code: "PI"; - readonly name: "Piauí"; - readonly regionCode: "NE"; - readonly regionName: "Nordeste"; - readonly ibgeCode: 22; -} | { - readonly code: "RJ"; - readonly name: "Rio de Janeiro"; - readonly regionCode: "SE"; - readonly regionName: "Sudeste"; - readonly ibgeCode: 33; -} | { - readonly code: "RN"; - readonly name: "Rio Grande do Norte"; - readonly regionCode: "NE"; - readonly regionName: "Nordeste"; - readonly ibgeCode: 24; -} | { - readonly code: "RS"; - readonly name: "Rio Grande do Sul"; - readonly regionCode: "S"; - readonly regionName: "Sul"; - readonly ibgeCode: 43; -} | { - readonly code: "RO"; - readonly name: "Rondônia"; - readonly regionCode: "N"; - readonly regionName: "Norte"; - readonly ibgeCode: 11; -} | { - readonly code: "RR"; - readonly name: "Roraima"; - readonly regionCode: "N"; - readonly regionName: "Norte"; - readonly ibgeCode: 14; -} | { - readonly code: "SC"; - readonly name: "Santa Catarina"; - readonly regionCode: "S"; - readonly regionName: "Sul"; - readonly ibgeCode: 42; -} | { - readonly code: "SP"; - readonly name: "São Paulo"; - readonly regionCode: "SE"; - readonly regionName: "Sudeste"; - readonly ibgeCode: 35; -} | { - readonly code: "SE"; - readonly name: "Sergipe"; - readonly regionCode: "NE"; - readonly regionName: "Nordeste"; - readonly ibgeCode: 28; -} | { - readonly code: "TO"; - readonly name: "Tocantins"; - readonly regionCode: "N"; - readonly regionName: "Norte"; - readonly ibgeCode: 17; -}; - -// @public -export type StateCode = State["code"]; - -// @public -export type StateName = State["name"]; - -// @public -export const subBusinessDays: (date: Date, amount: number, options?: BusinessDayOptions) => Date | null; - -// @public -export const toStandardSchema: (validate: (value: Value, options?: Options) => boolean, config?: ToStandardSchemaOptions) => StandardSchemaV1; - -// @public -export type ToStandardSchemaOptions = { - options?: Options; - message?: string; -}; - -// (No @packageDocumentation comment for this package) - -``` diff --git a/scripts/api.ts b/scripts/api.ts new file mode 100644 index 00000000..f8fd4725 --- /dev/null +++ b/scripts/api.ts @@ -0,0 +1,990 @@ +#!/usr/bin/env node + +/** + * Validates the public API of the local build (`dist/brazilian-utils.d.ts`, so run + * `npm run build` first) and compares it with the last release published on npm. + * + * Usage: + * node scripts/api.ts + * + * 1. Runs API Extractor with the settings of `api-extractor.json`: a type the public API refers to + * without exporting (`ae-forgotten-export`), a public declaration without a doc comment + * (`ae-undocumented`) and a compiler error in the bundled declarations fail. It runs as a local + * build, so it never compares against a committed report: the report is written to a temporary + * directory outside the repository and deleted at the end. + * 2. Downloads `@brazilian-utils/brazilian-utils@latest` with `npm pack` into the same temporary + * directory and checks, with the repository's `tsc`, that this build can replace it without + * breaking a consumer (see `renderBreakingCheck` for the rules). A breaking change fails, unless + * `package.json` is already on a higher major version than the published one. + * 3. Prints the declarations added, removed and changed since that release (from the two API + * Extractor reports) to stdout and, in GitHub Actions, to the job summary. Informational only. + * + * Exit code 1 means a check failed; exit code 2 means the check itself could not run (no build, + * registry unreachable, unreadable tarball). A package that was never published skips steps 2-3. + */ + +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { appendFile, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, relative, resolve } from "node:path"; + +import { + Extractor, + ExtractorConfig, + ExtractorLogLevel, + ExtractorMessageCategory, +} from "@microsoft/api-extractor"; + +const rootDir = resolve(import.meta.dirname, ".."); + +/** The outcome of a child process: its exit status and output. */ +type RunResult = { + ok: boolean; + stdout: string; + stderr: string; +}; + +const run = (command: string, args: string[], cwd = rootDir): Promise => + new Promise((_resolve) => { + execFile(command, args, { cwd, maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => { + _resolve({ ok: error === null, stdout, stderr: stderr || (error?.message ?? "") }); + }); + }); + +const packageName = "@brazilian-utils/brazilian-utils"; +const entryName = "brazilian-utils"; +const reportFileName = `${entryName}.api.md`; + +/** Exit code of a check that ran and failed. */ +const FAILURE_EXIT_CODE = 1; + +/** Exit code of a check that could not run (no build, registry unreachable, bad tarball). */ +const ERROR_EXIT_CODE = 2; + +/** One exported declaration of an API Extractor report, overloads merged into one entry. */ +type Declaration = { + name: string; + kind: "value" | "type" | "class"; + text: string; + generic: boolean; +}; + +/** How consumers receive a named type: as something they pass in, get back, or both. */ +type Role = "input" | "output"; + +/** The subpath entry points of a package, and every declaration file of it joined. */ +type Subpaths = { + entries: Map; + sources: string; +}; + +/** One assertion of the generated type-check file, so a compiler error maps back to a reason. */ +type Assertion = { + code: string; + reason: string; +}; + +class CheckError extends Error {} + +const readJson = async (path: string): Promise => JSON.parse(await readFile(path, "utf8")); + +const readVersion = async (packageJsonPath: string): Promise => { + const parsed = await readJson(packageJsonPath); + + if ( + typeof parsed !== "object" || + parsed === null || + !("version" in parsed) || + typeof parsed.version !== "string" + ) { + throw new CheckError(`${packageJsonPath} has no version`); + } + + return parsed.version; +}; + +const major = (version: string): number => Number.parseInt(version.split(".")[0] ?? "", 10); + +/** + * Runs API Extractor over one `.d.ts` entry point and returns the path of the report it wrote. + * @param {object} options - What to extract and where. + * @param {string} options.configPath - The `api-extractor.json` whose settings are used. + * @param {string} options.reportDir - The directory the report is written to. + * @param {string} [options.packageDir] - The root of a package other than this repository (the + * published tarball): its entry point is used, its compiler errors and messages are ignored. + * @returns {Promise<{ succeeded: boolean; reportPath: string }>} Whether the run had no error, and + * the report path. + */ +const extract = async ({ + configPath, + reportDir, + packageDir, +}: { + configPath: string; + reportDir: string; + packageDir?: string; +}): Promise<{ succeeded: boolean; reportPath: string }> => { + await mkdir(reportDir, { recursive: true }); + + const configObject = ExtractorConfig.loadFile(configPath); + configObject.apiReport = { + enabled: true, + reportFolder: reportDir, + reportTempFolder: join(reportDir, "temp"), + reportFileName, + }; + + if (packageDir !== undefined) { + configObject.projectFolder = packageDir; + configObject.mainEntryPointFilePath = join(packageDir, "dist", `${entryName}.d.ts`); + configObject.compiler = { + overrideTsconfig: { + compilerOptions: { + lib: ["ESNext", "DOM"], + target: "ESNext", + module: "ESNext", + moduleResolution: "bundler", + strict: true, + skipLibCheck: true, + }, + }, + }; + configObject.messages = { + compilerMessageReporting: { default: { logLevel: ExtractorLogLevel.None } }, + extractorMessageReporting: { + default: { logLevel: ExtractorLogLevel.None, addToApiReportFile: false }, + }, + tsdocMessageReporting: { default: { logLevel: ExtractorLogLevel.None } }, + }; + } + + const config = ExtractorConfig.prepare({ + configObject, + configObjectFullPath: packageDir === undefined ? configPath : join(packageDir, "api.json"), + packageJsonFullPath: join(packageDir ?? rootDir, "package.json"), + }); + const result = Extractor.invoke(config, { + localBuild: true, + messageCallback: (message) => { + message.handled = true; + + const reported = + message.category !== ExtractorMessageCategory.Console && + (message.logLevel === ExtractorLogLevel.Error || + message.logLevel === ExtractorLogLevel.Warning); + + if (packageDir === undefined && reported) { + console.error(`${message.logLevel}: ${message.formatMessageWithLocation(rootDir)}`); + } + }, + }); + + return { succeeded: result.succeeded, reportPath: join(reportDir, reportFileName) }; +}; + +const DECLARATION_PATTERN = + /^export (?:declare )?(?:abstract )?(const|let|var|function|class|enum|type|interface|namespace) ([\w$]+)(<)?/m; + +const kindOf = (keyword: string): Declaration["kind"] => { + if (keyword === "class") return "class"; + return keyword === "type" || keyword === "interface" ? "type" : "value"; +}; + +/** + * Reads the declarations of an API Extractor report: the fenced block is one declaration per + * paragraph, each preceded by its `// @public` line; overloads are consecutive paragraphs. + * @param {string} report - The report file contents. + * @returns {Map} The declarations by exported name. + */ +const parseReport = (report: string): Map => { + const body = /```ts\n([\s\S]*)\n```/.exec(report)?.[1] ?? ""; + const declarations = new Map(); + + for (const paragraph of body.split(/\n{2,}/)) { + const match = DECLARATION_PATTERN.exec(paragraph); + + if (match === null) continue; + + const [, keyword = "", name = "", generic] = match; + const text = paragraph.trim(); + const existing = declarations.get(name); + + if (existing === undefined) { + declarations.set(name, { name, kind: kindOf(keyword), text, generic: generic === "<" }); + } else { + existing.text = `${existing.text}\n\n${text}`; + } + } + + if (declarations.size === 0) { + throw new CheckError( + "The API Extractor report has no declarations; its format may have changed", + ); + } + + return declarations; +}; + +const identifiers = (text: string): Set => new Set(text.match(/[A-Za-z_$][\w$]*/g) ?? []); + +/** + * Splits the text of a function declaration into its parameter list and its return type. + * @param {string} signature - One signature, from the first `(` to the end. + * @returns {{ params: string; returns: string } | null} The two parts, or null when the text is not + * a call signature. + */ +const splitSignature = (signature: string): { params: string; returns: string } | null => { + const start = signature.indexOf("("); + + if (start === -1) return null; + + let depth = 0; + for (let index = start; index < signature.length; index++) { + const char = signature[index]; + + if (char === "(") depth += 1; + if (char === ")") depth -= 1; + if (depth === 0) { + return { + params: signature.slice(start + 1, index), + returns: signature.slice(index + 1).replace(/^\s*(?:=>|:)/, ""), + }; + } + } + + return null; +}; + +/** + * Marks the types one signature of a value export refers to: in a parameter (of a function or a + * constructor) as input, in a return type or a non-function type as output. An alias of another + * export (`typeof formatCep`) marks nothing, the export it names does. + * @param {string} signature - One paragraph of the report, a single signature. + * @param {Function} mark - Marks the exported types the given text refers to with a role. + */ +const markSignature = (signature: string, mark: (text: string, role: Role) => void): void => { + const code = signature.replaceAll(/^\/\/.*\n/gm, ""); + const typeText = code.replace(DECLARATION_PATTERN, "").replace(/^\s*:/, ""); + const constructors = code.match(/constructor\([^)]*\)/g); + + if (/^\s*typeof /.test(typeText)) return; + if (constructors !== null) { + for (const constructorText of constructors) mark(constructorText, "input"); + return; + } + + const parts = splitSignature(typeText); + + if (parts === null) { + mark(typeText, "output"); + return; + } + + mark(parts.params, "input"); + mark(parts.returns, "output"); +}; + +/** + * Finds which exported types consumers pass in (they appear in a parameter of an exported + * function or constructor) and which they get back (a return type, a thrown class, or a type + * reached from a non-function export), following the types each type refers to. A type nothing + * refers to that only renames another one (`type GetHolidaysOptions = GetHolidaysParams`) takes the + * roles of the type it renames. + * @param {Map} declarations - The published declarations. + * @returns {Map>} The roles of every type that has one. + */ +const typeRoles = (declarations: Map): Map> => { + const typeNames = new Set( + [...declarations.values()].filter((d) => d.kind !== "value").map((d) => d.name), + ); + const roles = new Map>(); + const pending: [string, Role][] = []; + const mark = (text: string, role: Role): void => { + for (const name of identifiers(text)) { + if (typeNames.has(name)) pending.push([name, role]); + } + }; + + for (const declaration of declarations.values()) { + if (declaration.kind === "type") continue; + if (declaration.kind === "class") pending.push([declaration.name, "output"]); + + for (const signature of declaration.text.split(/\n\n/)) markSignature(signature, mark); + } + + while (pending.length > 0) { + const [name, role] = pending.pop() ?? ["", "input"]; + const known = roles.get(name) ?? new Set(); + + if (known.has(role)) continue; + + known.add(role); + roles.set(name, known); + mark(declarations.get(name)?.text.replace(DECLARATION_PATTERN, "") ?? "", role); + } + + for (const declaration of declarations.values()) { + const target = /^export type [\w$]+ = ([\w$]+);$/m.exec(declaration.text)?.[1]; + const targetRoles = target === undefined ? undefined : roles.get(target); + + if (!roles.has(declaration.name) && targetRoles !== undefined) { + roles.set(declaration.name, targetRoles); + } + } + + return roles; +}; + +/** + * Lists the subpath entry points of a package (`dist/.d.ts`, imported as + * `@brazilian-utils/brazilian-utils/`): every declaration file but the root and the shared + * chunks the others import. + * @param {string} distDir - The `dist` directory. + * @returns {Promise} The contents of each entry by subpath, and every declaration file + * joined (to find what a re-exported name is). + */ +const readSubpaths = async (distDir: string): Promise => { + const allFiles = await readdir(distDir); + const files = allFiles.filter((file) => file.endsWith(".d.ts")).sort(); + const texts = await Promise.all(files.map((file) => readFile(join(distDir, file), "utf8"))); + const entries = new Map( + files.map((file, index) => [file.slice(0, -".d.ts".length), texts[index] ?? ""]), + ); + + entries.delete(entryName); + for (const text of texts) { + for (const [, chunk = ""] of text.matchAll(/from "\.\/([^"]+)\.js"/g)) entries.delete(chunk); + } + + return { entries, sources: texts.join("\n") }; +}; + +const EXPORT_PATTERN = + /^export (?:declare )?(?:abstract )?(const|let|var|function|class|enum|type|interface|namespace) ([\w$]+)/gm; + +/** + * Reads the names a subpath declaration file exports, split into values and types. A name + * re-exported from a shared chunk without the `type` modifier is looked up in `sources`. + * @param {string} subpath - The subpath, for the error message. + * @param {string} text - The declaration file contents. + * @param {string} sources - Every declaration file of the package, joined. + * @returns {{ values: string[]; types: string[] }} The exported names. + */ +const subpathExports = ( + subpath: string, + text: string, + sources: string, +): { values: string[]; types: string[] } => { + const values = new Set(); + const types = new Set(); + const isTypeDeclaration = (name: string): boolean => { + const escaped = name.replaceAll("$", String.raw`\$`); + return new RegExp( + String.raw`^(?:export )?(?:declare )?(?:type|interface) ${escaped}\b`, + "m", + ).test(sources); + }; + + for (const [, keyword = "", name = ""] of text.matchAll(EXPORT_PATTERN)) { + (kindOf(keyword) === "type" ? types : values).add(name); + } + for (const [, typeOnly, list = ""] of text.matchAll(/^export (type )?\{([^}]*)\}/gm)) { + for (const item of list.split(",")) { + const parts = item.trim().split(/\s+/); + const name = parts.at(-1) ?? ""; + const isType = typeOnly !== undefined || parts[0] === "type" || isTypeDeclaration(name); + + if (name !== "") (isType ? types : values).add(name); + } + } + + if (values.size === 0 && types.size === 0) { + throw new CheckError( + `Found no export in the published ${subpath}.d.ts; its format may have changed`, + ); + } + + return { values: [...values].sort(), types: [...types].sort() }; +}; + +const identifier = (value: string): string => value.replaceAll(/[^\w$]/g, "_"); + +const moduleSpecifier = (fromDir: string, file: string): string => { + const path = relative(fromDir, file).replaceAll("\\", "/"); + return path.startsWith(".") ? path : `./${path}`; +}; + +/** + * Lists what a published subpath entry point no longer ships in this build: its files, then the + * names it exported. + * @param {object} options - The subpath to compare. + * @param {string} options.subpath - The subpath. + * @param {string} options.text - Its published declaration file. + * @param {string | undefined} options.current - Its declaration file in this build, if any. + * @param {Subpaths} options.baseSubpaths - The published subpath entry points. + * @param {Subpaths} options.headSubpaths - The subpath entry points of this build. + * @param {string[]} options.removed - Receives one line per missing file or name. + * @returns {string[]} The published value exports this build still has, to type-check. + */ +const compareSubpath = ({ + subpath, + text, + current, + baseSubpaths, + headSubpaths, + removed, +}: { + subpath: string; + text: string; + current: string | undefined; + baseSubpaths: Subpaths; + headSubpaths: Subpaths; + removed: string[]; +}): string[] => { + for (const extension of [".js", ".cjs", ".d.ts", ".d.cts"]) { + if (!existsSync(join(rootDir, "dist", `${subpath}${extension}`))) { + removed.push(`subpath "${subpath}": dist/${subpath}${extension} is no longer built`); + } + } + if (current === undefined) return []; + + const published = subpathExports(subpath, text, baseSubpaths.sources); + const exported = subpathExports(subpath, current, headSubpaths.sources); + const currentNames = new Set([...exported.values, ...exported.types]); + + for (const name of [...published.values, ...published.types]) { + if (!currentNames.has(name)) + removed.push(`subpath "${subpath}": \`${name}\` is no longer exported`); + } + + return published.values.filter((name) => currentNames.has(name)); +}; + +/** + * Builds the assertions a consumer of the published version relies on, as TypeScript that only + * compiles when this build can replace it: + * - every value export still exists and is assignable to the published one, so a parameter can + * only be widened or become optional, a new parameter must be optional, and a return type can + * only keep or narrow its values; + * - a function's return type does not narrow either (union of all overloads): a consumer that + * stored the result in an inferred variable (`let bank = getBankByCode(code)`) and later assigns + * the old type to it (`bank = null`) would stop compiling; + * - every type export still exists under the same name; one only passed in may widen (the published + * type is assignable to the new one), any other must be equivalent (assignable both ways), so a + * type consumers receive or build keeps its shape (new properties must be optional); + * - every subpath entry point still exists and exports the same names, its values with the same + * guarantees (its types are the root ones, checked above). + * @param {object} options - What to compare. + * @param {string} options.checkDir - The directory the generated file is written to. + * @param {string} options.baseDir - The published package root. + * @param {Map} options.base - The published declarations. + * @param {Map} options.head - The declarations of this build. + * @param {Subpaths} options.baseSubpaths - The published subpath entry points. + * @param {Subpaths} options.headSubpaths - The subpath entry points of this build. + * @returns {{ source: string; assertions: Assertion[]; removed: string[]; skipped: string[] }} The + * generated file, the assertion behind each line, what this build no longer ships, and the generic + * types whose shape is only shown in the diff (they cannot be named without type arguments). + */ +const renderBreakingCheck = ({ + checkDir, + baseDir, + base, + head, + baseSubpaths, + headSubpaths, +}: { + checkDir: string; + baseDir: string; + base: Map; + head: Map; + baseSubpaths: Subpaths; + headSubpaths: Subpaths; +}): { source: string; assertions: Assertion[]; removed: string[]; skipped: string[] } => { + const header = [ + "type Assignable = [Target, Source];", + "type Returns = F extends {", + "\t(...args: never[]): infer R1;", + "\t(...args: never[]): infer R2;", + "\t(...args: never[]): infer R3;", + "\t(...args: never[]): infer R4;", + "}", + "\t? R1 | R2 | R3 | R4", + "\t: F extends (...args: never[]) => infer R", + "\t\t? R", + "\t\t: never;", + ]; + const assertions: Assertion[] = []; + const removed: string[] = []; + const skipped: string[] = []; + const imports: string[] = []; + const roles = typeRoles(base); + + const addModule = (label: string, alias: string, subpath: string, names: string[]): void => { + const oldAlias = `Old${alias}`; + const newAlias = `New${alias}`; + + const file = `${subpath}.js`; + const oldSpecifier = moduleSpecifier(checkDir, join(baseDir, "dist", file)); + const newSpecifier = moduleSpecifier(checkDir, join(rootDir, "dist", file)); + + imports.push( + `import type * as ${oldAlias} from "${oldSpecifier}";`, + `import * as ${newAlias} from "${newSpecifier}";`, + ); + for (const name of names) { + const id = `${alias}_${identifier(name)}`; + assertions.push( + { + code: `const _value_${id}: typeof ${oldAlias}.${name} = ${newAlias}.${name};`, + reason: `${label}: \`${name}\` is not assignable to the published one (a parameter became required or narrower, a required parameter was added, or the return type widened)`, + }, + { + code: `type _returns_${id} = Assignable, Returns>;`, + reason: `${label}: \`${name}\` can no longer return everything the published one did (the return type narrowed, or a returned object gained a required property), so a variable inferred from its result no longer holds the published type`, + }, + ); + } + }; + + for (const declaration of base.values()) { + if (!head.has(declaration.name)) { + removed.push(`root: \`${declaration.name}\` is no longer exported`); + } + } + + addModule( + "root", + "", + entryName, + [...base.values()].filter((d) => d.kind !== "type" && head.has(d.name)).map((d) => d.name), + ); + + for (const type of base.values()) { + const name = type.name; + const typeRole = roles.get(name); + + if (type.kind === "value" || !head.has(name)) continue; + if (type.generic) { + skipped.push(name); + continue; + } + + assertions.push({ + code: `type _widens_${identifier(name)} = Assignable;`, + reason: `root: type \`${name}\` rejects values the published type accepts (a union member was removed, a property became required or narrower, or a required property was added)`, + }); + if (typeRole === undefined || typeRole.has("output")) { + assertions.push({ + code: `type _narrows_${identifier(name)} = Assignable;`, + reason: `root: type \`${name}\` (${typeRole === undefined ? "used by no function" : "returned or thrown"}, so it keeps its shape) accepts values the published type rejects (a union member was added, or a property was removed or widened)`, + }); + } + } + + for (const [subpath, text] of baseSubpaths.entries) { + const current = headSubpaths.entries.get(subpath); + const kept = compareSubpath({ subpath, text, current, baseSubpaths, headSubpaths, removed }); + + if (current === undefined) continue; + + addModule(`subpath "${subpath}"`, `_${identifier(subpath)}`, subpath, kept); + } + + const source = [...header, ...imports, ...assertions.map((a) => a.code), ""].join("\n"); + return { source, assertions, removed, skipped }; +}; + +/** + * Type-checks the generated file with the repository's `tsc` and maps each error to its reason. + * @param {string} checkDir - The directory holding the generated file. + * @param {string} source - The generated file contents. + * @param {Assertion[]} assertions - The assertion behind each line, in file order. + * @param {Map} labels - Short names for the absolute paths in compiler messages. + * @returns {Promise} One message per failed assertion, empty when none failed. + * @throws {CheckError} When `tsc` reports an error the generated file cannot explain, and when it + * exits non-zero without reporting one at all (a tsconfig error, a missing input, a compiler that + * did not start): an empty result would otherwise read as "no breaking change". + */ +const typeCheck = async ( + checkDir: string, + source: string, + assertions: Assertion[], + labels: Map, +): Promise => { + const file = join(checkDir, "check.ts"); + await writeFile(file, source); + await writeFile( + join(checkDir, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + lib: ["ESNext", "DOM"], + target: "ESNext", + module: "ESNext", + moduleResolution: "bundler", + strict: true, + noEmit: true, + skipLibCheck: true, + types: [], + }, + files: ["check.ts"], + }), + ); + + const tsc = join(rootDir, "node_modules", "typescript", "bin", "tsc"); + const { + ok, + stdout: output, + stderr, + } = await run(process.execPath, [tsc, "-p", checkDir, "--pretty", "false"]); + + const lines = source.split("\n"); + const byLine = new Map(assertions.map((a) => [lines.indexOf(a.code) + 1, a])); + const failures = new Map(); + let current: string[] | undefined; + + for (const line of output.split("\n")) { + const match = /^(.+?)\((\d+),\d+\): error (TS\d+: .*)$/.exec(line); + + if (match === null) { + if (current !== undefined && line.trim() !== "") current.push(line.trim()); + continue; + } + + const [, errorFile = "", lineNumber = "0", message = ""] = match; + + if (resolve(rootDir, errorFile) !== file || !byLine.has(Number(lineNumber))) { + throw new CheckError(`Unexpected compiler error in the generated check:\n${line}`); + } + + current = failures.get(Number(lineNumber)) ?? []; + current.push(message); + failures.set(Number(lineNumber), current); + } + + if (!ok && failures.size === 0) { + throw new CheckError( + `The generated check did not compile and reported no assertion:\n${output}${stderr}`.trim(), + ); + } + + return [...failures] + .sort(([a], [b]) => a - b) + .map(([lineNumber, messages]) => { + const assertion = byLine.get(lineNumber); + const details = messages.map((message) => { + let short = message; + for (const [path, label] of labels) short = short.replaceAll(path, label); + return ` ${short}`; + }); + return `- ${assertion?.reason ?? ""}\n${details.join("\n")}`; + }); +}; + +/** + * A line diff (longest common subsequence) of two declarations, for the reviewer summary. + * @param {string} before - The published text. + * @param {string} after - The current text. + * @returns {string} The lines prefixed with `-`, `+` or a space. + */ +const lineDiff = (before: string, after: string): string => { + const a = before.split("\n"); + const b = after.split("\n"); + const lengths = Array.from({ length: a.length + 1 }, () => + Array.from({ length: b.length + 1 }).fill(0), + ); + + for (let i = a.length - 1; i >= 0; i--) { + for (let j = b.length - 1; j >= 0; j--) { + const row = lengths[i] ?? []; + row[j] = + a[i] === b[j] + ? (lengths[i + 1]?.[j + 1] ?? 0) + 1 + : Math.max(lengths[i + 1]?.[j] ?? 0, row[j + 1] ?? 0); + } + } + + const lines: string[] = []; + let i = 0; + let j = 0; + const removesNext = (): boolean => + j >= b.length || (lengths[i + 1]?.[j] ?? 0) >= (lengths[i]?.[j + 1] ?? 0); + + while (i < a.length || j < b.length) { + if (i < a.length && j < b.length && a[i] === b[j]) { + lines.push(` ${a[i]}`); + i += 1; + j += 1; + } else if (i < a.length && removesNext()) { + lines.push(`- ${a[i]}`); + i += 1; + } else { + lines.push(`+ ${b[j]}`); + j += 1; + } + } + + return lines.join("\n"); +}; + +const fence = (language: string, blocks: string[]): string[] => [ + `\`\`\`${language}`, + blocks.join("\n\n"), + "```", + "", +]; + +/** + * Renders the declarations added, removed and changed between two reports as Markdown. + * @param {string} version - The published version compared against. + * @param {Map} base - The published declarations. + * @param {Map} head - The current declarations. + * @returns {string} The Markdown summary. + */ +const renderDiff = ( + version: string, + base: Map, + head: Map, +): string => { + const added = [...head.values()].filter((d) => !base.has(d.name)); + const removed = [...base.values()].filter((d) => !head.has(d.name)); + const changed = [...head.values()].filter((d) => { + const before = base.get(d.name); + return before !== undefined && before.text !== d.text; + }); + const lines = [`## Public API changes since ${version}`, ""]; + + if (added.length + removed.length + changed.length === 0) { + lines.push(`No declaration of the public API changed since ${version}.`, ""); + return lines.join("\n"); + } + + lines.push( + `${added.length} added, ${removed.length} removed, ${changed.length} changed (from the API Extractor reports of \`${packageName}@${version}\` and of this build).`, + "", + ); + if (added.length > 0) { + lines.push( + `### Added (${added.length})`, + "", + ...fence( + "ts", + added.map((d) => d.text), + ), + ); + } + if (removed.length > 0) { + lines.push( + `### Removed (${removed.length})`, + "", + ...fence( + "ts", + removed.map((d) => d.text), + ), + ); + } + if (changed.length > 0) { + lines.push( + `### Changed (${changed.length})`, + "", + ...fence( + "diff", + changed.map((d) => lineDiff(base.get(d.name)?.text ?? "", d.text)), + ), + ); + } + + return lines.join("\n"); +}; + +/** + * Asks the registry for the version behind the `latest` tag. + * @returns {Promise} The version, or null when the package was never published. + */ +/** + * Keeps the lines of an npm failure that say what went wrong, without the stack and log path. + * @param {string} stderr - The npm error output. + * @returns {string} The error code and message lines. + */ +const npmError = (stderr: string): string => { + const lines = stderr + .split("\n") + .filter((line) => /^npm error (?:code |\d{3} |FetchError|request to|network)/.test(line)); + + return lines.length > 0 ? lines.join("\n") : stderr.trim(); +}; + +const latestVersion = async (): Promise => { + const result = await run("npm", ["view", `${packageName}@latest`, "version", "--json"]); + + if (!result.ok) { + if (/\bE404\b/.test(`${result.stdout}${result.stderr}`)) return null; + + throw new CheckError( + `Could not fetch ${packageName}@latest from the npm registry, which the breaking-change check compares against. Check the network connection and retry.\n${npmError(result.stderr)}`, + ); + } + + const parsed: unknown = JSON.parse(result.stdout); + + if (typeof parsed !== "string") { + throw new CheckError(`Unexpected \`npm view\` output: ${result.stdout}`); + } + + return parsed; +}; + +const packedFilename = (stdout: string): string => { + const parsed: unknown = JSON.parse(stdout); + const first: unknown = Array.isArray(parsed) ? parsed.at(0) : undefined; + + if (typeof first === "object" && first !== null && "filename" in first) { + return String(first.filename); + } + + throw new CheckError(`Unexpected \`npm pack\` output: ${stdout}`); +}; + +/** + * Downloads and extracts one published version of the package. + * @param {string} version - The version to download. + * @param {string} dir - The directory to extract it into. + * @returns {Promise} The root of the extracted package. + */ +const downloadPackage = async (version: string, dir: string): Promise => { + await mkdir(dir, { recursive: true }); + + const packed = await run( + "npm", + ["pack", `${packageName}@${version}`, "--json", "--pack-destination", dir], + dir, + ); + + if (!packed.ok) { + throw new CheckError( + `Could not download ${packageName}@${version}. Check the network connection and retry.\n${npmError(packed.stderr)}`, + ); + } + + const extracted = await run("tar", ["-xzf", join(dir, packedFilename(packed.stdout)), "-C", dir]); + const packageDir = join(dir, "package"); + + if (!extracted.ok || !existsSync(join(packageDir, "dist", `${entryName}.d.ts`))) { + throw new CheckError( + `Could not extract dist/${entryName}.d.ts from ${packageName}@${version}.\n${extracted.stderr.trim()}`, + ); + } + + return packageDir; +}; + +const writeSummary = async (markdown: string): Promise => { + console.log(`\n${markdown}`); + + const summaryPath = process.env["GITHUB_STEP_SUMMARY"]; + + if (summaryPath !== undefined && summaryPath !== "") { + await appendFile(summaryPath, `${markdown}\n`); + } +}; + +const main = async (workDir: string): Promise => { + if (!existsSync(join(rootDir, "dist", `${entryName}.d.ts`))) { + throw new CheckError(`Missing dist/${entryName}.d.ts. Run \`npm run build\` first.`); + } + + const configPath = join(rootDir, "api-extractor.json"); + const head = await extract({ configPath, reportDir: join(workDir, "head") }); + + if (!head.succeeded) { + console.error( + "\nAPI Extractor reported errors in the public API (see above): export every type the API refers to, document every public declaration, fix any compiler error.", + ); + return FAILURE_EXIT_CODE; + } + + console.log("API Extractor: every public declaration is documented and exported."); + + const version = await latestVersion(); + + if (version === null) { + console.log( + `${packageName} was never published: nothing to compare the public API against, skipping the breaking-change check.`, + ); + return 0; + } + + const localVersion = await readVersion(join(rootDir, "package.json")); + console.log( + version === localVersion + ? `Comparing against ${packageName}@${version}, the version package.json is on: the release is the contract, so every change since it is checked.` + : `Comparing against ${packageName}@${version} (package.json is at ${localVersion}).`, + ); + + const baseDir = await downloadPackage(version, join(workDir, "base")); + const baseRun = await extract({ + configPath, + reportDir: join(workDir, "base-report"), + packageDir: baseDir, + }); + + if (!baseRun.succeeded) { + throw new CheckError(`API Extractor could not read ${packageName}@${version}`); + } + + const base = parseReport(await readFile(baseRun.reportPath, "utf8")); + const current = parseReport(await readFile(head.reportPath, "utf8")); + const checkDir = join(workDir, "check"); + await mkdir(checkDir, { recursive: true }); + + const { source, assertions, removed, skipped } = renderBreakingCheck({ + checkDir, + baseDir, + base, + head: current, + baseSubpaths: await readSubpaths(join(baseDir, "dist")), + headSubpaths: await readSubpaths(join(rootDir, "dist")), + }); + const labels = new Map([ + [join(baseDir, "dist"), `${packageName}@${version}/dist`], + [join(rootDir, "dist"), "dist"], + ]); + const failures = [ + ...removed.map((reason) => `- ${reason}`), + ...(await typeCheck(checkDir, source, assertions, labels)), + ]; + if (skipped.length > 0) { + console.log(`Generic types compared in the diff only: ${skipped.join(", ")}.`); + } + + const allowed = major(localVersion) > major(version); + const diff = renderDiff(version, base, current); + + if (failures.length === 0) { + await writeSummary(diff); + console.log( + `\nNo breaking change against ${version}: ${assertions.length} type assertions hold.`, + ); + return 0; + } + + const acceptance = allowed + ? `, accepted because package.json is on a new major version (${localVersion})` + : ""; + const verdict = `${failures.length} breaking change(s) against ${version}${acceptance}:`; + + await writeSummary(`${diff}\n### Breaking changes\n\n${verdict}\n\n${failures.join("\n")}\n`); + + return allowed ? 0 : FAILURE_EXIT_CODE; +}; + +const workDir = await mkdtemp(join(tmpdir(), "brazilian-utils-api-")); + +try { + process.exitCode = await main(workDir); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = ERROR_EXIT_CODE; +} finally { + await rm(workDir, { recursive: true, force: true }); +} diff --git a/vite.config.ts b/vite.config.ts index f5c52e47..1d423dc5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -145,8 +145,7 @@ const sharedPack = { export default defineConfig({ fmt: { - // `reports` holds generated output only, the committed API Extractor baseline included: - // reformatting its code block would make every `check:api` run report a changed API. + // `reports` holds generated output only (the Stryker mutation reports). // `CHANGELOG.md` is written by release-please (`* ` bullets); formatting it would fail the // Check workflow on every release PR. ignorePatterns: [