From 87b6723c019609236dacba22b3c5a677c738fc85 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:15:41 -0300 Subject: [PATCH 1/4] build(api): check the public API against the last npm release instead of a committed report The committed reports/api/brazilian-utils.api.md made every pull request that touched the public API regenerate and commit the report, and every open pull request conflicted on it as soon as another one merged. It also compared against whatever main had last committed, not against what consumers actually install. scripts/api.ts (npm run check:api) keeps API Extractor for what it did well: ae-forgotten-export, ae-undocumented and compiler errors in the bundled declarations still fail. It runs as a local build and writes its report to a temporary directory outside the repository, so nothing is left to commit and check:api:update goes away. The committed baseline is replaced by the last release on npm. The script packs @brazilian-utils/brazilian-utils@latest and type-checks a generated file with the repository's tsc that only compiles when this build can replace it: every root and subpath export still exists, every value is assignable to the released one, return types neither widen nor narrow, and exported types keep accepting what they accepted (types consumers get back also keep rejecting what they rejected). A breaking change fails unless package.json is already on a higher major version. The declarations added, removed and changed since the release are printed and written to the GitHub job summary, so a reviewer still sees the API diff of a pull request without a file in it. No network is an error (exit 2), a package never published skips the comparison. --- .bestpractices.json | 6 +- .gitignore | 9 +- CONTRIBUTING.md | 72 +- api-extractor.json | 4 +- package.json | 3 +- reports/api/brazilian-utils.api.md | 1441 ---------------------------- scripts/api.ts | 981 +++++++++++++++++++ vite.config.ts | 3 +- 8 files changed, 1048 insertions(+), 1471 deletions(-) delete mode 100644 reports/api/brazilian-utils.api.md create mode 100644 scripts/api.ts diff --git a/.bestpractices.json b/.bestpractices.json index c4a30c112..4fa3a9ea2 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 29922d40c..760402ec3 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 51efb7e01..4b221a0ca 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/api-extractor.json b/api-extractor.json index 947a828bc..1911260b9 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/package.json b/package.json index 2c0201621..0743159ec 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 c610bf04a..000000000 --- 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 000000000..a7620a70f --- /dev/null +++ b/scripts/api.ts @@ -0,0 +1,981 @@ +#!/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 }); + + 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. + */ +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 { stdout: output } = 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(checkDir, 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); + } + + 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 f5c52e475..1d423dc54 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: [ From 0e0edb95359766183bf38a6c72e149f20c12c11b Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:15:46 -0300 Subject: [PATCH 2/4] docs: say the public API is checked against the last release The feature list promised an API report that the repository no longer keeps; what now guards the public API is the check against the last npm release that runs on every pull request. --- README.md | 2 +- docs/getting-started.md | 2 +- docs/pt-br/getting-started.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 59853d24e..cdb2a30db 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/docs/getting-started.md b/docs/getting-started.md index 0a3b3f67a..2ffecf098 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 0f1a3dd5f..023e76165 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. From 1a850ff4084b5ff215815af9063390ad5b2cd17d Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:25:22 -0300 Subject: [PATCH 3/4] fix(api): report a removed subpath as a breaking change instead of aborting The generated check still imported a subpath that is no longer built, so tsc failed on the import line, which maps to no assertion, and the script exited with code 2 ("could not run") without listing the removed files. The subpath is already reported as removed, so it no longer gets type assertions. --- scripts/api.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/api.ts b/scripts/api.ts index a7620a70f..27fb13785 100644 --- a/scripts/api.ts +++ b/scripts/api.ts @@ -586,6 +586,8 @@ const renderBreakingCheck = ({ 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); } From 1e1f5c2586c8086559df7cb2b0910646275cafe7 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Tue, 22 Sep 2026 04:20:45 -0300 Subject: [PATCH 4/4] fix(api): fail when the generated check does not compile at all typeCheck read the output of tsc and dropped its exit status. Every breaking change is a compiler error carrying a file and a line, so anything that fails without one, a tsconfig error, a missing input, a compiler that does not start, left the failure map empty and the script reported "No breaking change" and exited 0. The one check this script exists for passed because it never ran. A non-zero exit with no assertion behind it is now a check error (exit 2, the code for a check that could not run) and prints what the compiler said. The path of a reported error is also resolved against the directory tsc runs in rather than the directory holding the generated file. tsc writes it relative to its working directory, so the two only agreed because climbing out of a temporary directory clamps at the root. --- scripts/api.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/scripts/api.ts b/scripts/api.ts index 27fb13785..f8fd4725e 100644 --- a/scripts/api.ts +++ b/scripts/api.ts @@ -602,6 +602,9 @@ const renderBreakingCheck = ({ * @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, @@ -629,13 +632,11 @@ const typeCheck = async ( ); const tsc = join(rootDir, "node_modules", "typescript", "bin", "tsc"); - const { stdout: output } = await run(process.execPath, [ - tsc, - "-p", - checkDir, - "--pretty", - "false", - ]); + 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])); @@ -652,7 +653,7 @@ const typeCheck = async ( const [, errorFile = "", lineNumber = "0", message = ""] = match; - if (resolve(checkDir, errorFile) !== file || !byLine.has(Number(lineNumber))) { + if (resolve(rootDir, errorFile) !== file || !byLine.has(Number(lineNumber))) { throw new CheckError(`Unexpected compiler error in the generated check:\n${line}`); } @@ -661,6 +662,12 @@ const typeCheck = async ( 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]) => {