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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 0 additions & 29 deletions .github/workflows/links.yml

This file was deleted.

14 changes: 0 additions & 14 deletions .lycheeignore

This file was deleted.

5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,9 @@ signatures are pinned by the `describe("<name> types")` blocks in the tests, and
requires one.
- Commit messages are checked with commitlint on every pull request, since release-please derives
the version bump and the changelog from them.
- The `Links` workflow checks every URL in the Markdown files and in the `@see` tags of the source
with [lychee](https://lychee.cli.rs) when a pull request touches them.
- The URLs cited in the Markdown files and in the `@see` tags of the source are checked by hand
when a citation is added or changed: an automated link check was tried and dropped, since the
government hosts the library cites time out or answer 403 to anything that is not a browser.

## Zero runtime dependencies

Expand Down
83 changes: 49 additions & 34 deletions scripts/cbo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,38 +7,52 @@ import { fetchSortedRecord } from "../src/_internals/fetch-sorted-record/fetch-s

const scriptsDir = import.meta.dirname;

type CboEntry = {
cbo: string;
descricao: string;
};
/** The official CSV is published in ISO-8859-1, not UTF-8. */
const CBO_CSV_DECODER = new TextDecoder("iso-8859-1");

const isCboEntry = (value: unknown): value is CboEntry =>
typeof value === "object" &&
value !== null &&
"cbo" in value &&
typeof value.cbo === "string" &&
"descricao" in value &&
typeof value.descricao === "string";
const CBO_CSV_HEADER = "CODIGO;TITULO";

const main = async (): Promise<void> => {
const sorted = await fetchSortedRecord(
"https://raw.githubusercontent.com/lucaashoff/lista-cbo-json/main/cbos.json",
"CBO mirror",
async (response) => {
const json: unknown = await response.json();
const CBO_CODE_REGEX = /^\d{6}$/;

if (!Array.isArray(json) || !json.every((entry) => isCboEntry(entry))) {
throw new Error("CBO mirror payload is not an array of cbo and descricao entries");
}
/**
* Reads the `CODIGO;TITULO` rows of the official CBO 2002 occupation CSV.
* @param {string} csv - The decoded CSV.
* @returns {Record<string, string>} One entry per 6 digit occupation code.
*/
const parseCsv = (csv: string): Record<string, string> => {
const [header, ...rows] = csv.split(/\r?\n/);

if (header?.trim() !== CBO_CSV_HEADER) {
throw new Error(`CBO CSV header is not "${CBO_CSV_HEADER}"`);
}

const data: Record<string, string> = {};

const data: Record<string, string> = {};
for (const row of rows) {
const separator = row.indexOf(";");

for (const entry of json) {
const code = /^\d{5}$/.test(entry.cbo) ? `0${entry.cbo}` : entry.cbo;
if (separator === -1) continue;

if (!/^\d{6}$/.test(code)) continue;
const code = row.slice(0, separator).trim();
const title = row.slice(separator + 1).trim();

if (!CBO_CODE_REGEX.test(code) || title === "") continue;

data[code] = title;
}

return data;
};

const main = async (): Promise<void> => {
const sorted = await fetchSortedRecord(
"https://www.gov.br/trabalho-e-emprego/pt-br/assuntos/cbo/servicos/downloads/cbo2002-ocupacao.csv",
"CBO CSV",
async (response) => {
const data = parseCsv(CBO_CSV_DECODER.decode(await response.arrayBuffer()));

data[code] = entry.descricao;
if (Object.keys(data).length === 0) {
throw new Error("CBO CSV holds no occupation");
}

return data;
Expand All @@ -50,25 +64,26 @@ const main = async (): Promise<void> => {
`/**
* CBO 2002 (Classificação Brasileira de Ocupações) titles, indexed by the raw 6 digit code.
*
* The MTE download at mtecbo.gov.br requires a browser session and cannot be fetched
* programmatically, so this table is generated from a public community mirror of the
* official table. Codes that are not purely numeric with 6 digits in the source (a small
* number of law enforcement and military ranks and a few sub-occupation codes suffixed
* with a letter) are normalized by left padding a 5 digit numeric code with a zero, or
* dropped when a letter is present, since \`Cbo.code\` only accepts 6 digits.
* Built from the official \`cbo2002-ocupacao.csv\` the Ministério do Trabalho e Emprego publishes
* on gov.br, a plain \`CODIGO;TITULO\` CSV in ISO-8859-1 whose codes are already the 6 digit
* form \`Cbo.code\` accepts, so no normalization is applied to them. The titles are the official
* sentence case spelling.
*
* Generated by \`node ./scripts/cbo.ts\`. Do not edit by hand.
*
* @see Official: https://www.gov.br/trabalho-e-emprego/pt-br/assuntos/cbo/servicos/downloads/cbo2002-ocupacao.csv
* The CBO 2002 occupation table, as published by the Ministério do Trabalho e Emprego.
* @see Based on: https://raw.githubusercontent.com/lucaashoff/lista-cbo-json/main/cbos.json
* @see Official: http://www.mtecbo.gov.br/cbosite/pages/downloads.jsf
* Community mirror of the same table, the fallback this script was generated from before the
* official CSV was used.
*/
export const CBO_TITLES: Record<string, string> = ${JSON.stringify(sorted)};

/**
* Shape a CBO code has to be written in: the 6 digits, optionally split into the printed
* groups of 4 and 2 by whitespace or the usual mask characters.
* groups of 4 and 2 by a single whitespace or mask character.
*/
export const CBO_FORMAT_REGEX = /^\\d{4}[\\s.\\-/]*\\d{2}$/;
export const CBO_FORMAT_REGEX = /^\\d{4}[\\s.\\-/]?\\d{2}$/;
`,
);
};
Expand Down
116 changes: 74 additions & 42 deletions scripts/cfop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,58 +7,76 @@ import { fetchSortedRecord } from "../src/_internals/fetch-sorted-record/fetch-s

const scriptsDir = import.meta.dirname;

const EMBEDDED_ENTRY_REGEX = /\s+(\d)\.(\d{3})\s+-\s+/g;
/**
* CONFAZ prints the text in force with the `A5-1TextoAcordo` class and keeps every superseded
* wording alongside it under `A8-3RedacaoAnt`, with the amendment notes under `Remisso`,
* `A8-1Remissao` and `A8-2RemissaoAnt`. Reading only the first class is what keeps the previous
* redaction of a re-worded code (7.667, re-worded by Ajuste SINIEF 39/25) out of the table.
*/
const CURRENT_TEXT_PARAGRAPH_REGEX = /<p class="A5-1TextoAcordo">([^<]*)<\/p>/g;

/** A code line, e.g. `1.101 - Compra para industrialização ou produção rural.`. */
const CODE_LINE_REGEX = /^(\d)\.(\d{3})\s*[-–]\s*(.+)$/;

/**
* Some rows of the mirror glue the next code into the description, e.g.
* `1305;"... energia elétrica 1.306 - Aquisição de serviço ..."`, which both corrupts the
* `1305` description and drops `1306`. Splits such a row into one entry per code.
* @param {string} code - The CFOP code the row started with.
* @param {string} description - The row description, possibly containing embedded codes.
* @returns {[string, string][]} One `[code, description]` entry per code found in the row.
* The sentence that opens the body of every operable code. Group and subgroup headings
* (1.000, 1.100, 1.150, ...) are printed in upper case and followed by "Classificam-se neste
* grupo" instead, so they carry no such body and are left out of the table.
*/
const splitEmbeddedEntries = (code: string, description: string): [string, string][] => {
const entries: [string, string][] = [];
let currentCode = code;
let lastIndex = 0;

for (const match of description.matchAll(EMBEDDED_ENTRY_REGEX)) {
entries.push([
currentCode,
description.slice(lastIndex, match.index).replaceAll(/\s+/g, " ").trim(),
]);
currentCode = `${match[1]}${match[2]}`;
lastIndex = match.index + match[0].length;
}
const OPERABLE_BODY = "Classificam-se neste código";

entries.push([currentCode, description.slice(lastIndex).replaceAll(/\s+/g, " ").trim()]);
const TRAILING_PUNCTUATION_REGEX = /[.\s]+$/;

return entries;
};
/**
* Reads the consolidated Anexo II out of the CONFAZ page.
*
* A handful of rows glue the body into the same paragraph as the code line, e.g.
* `1.255 - Compra de energia elétrica ... Classificam-se neste código as compras ...`, so the
* body sentence is looked for inside the code line first and only then in the paragraph that
* follows it.
* @param {string} html - The annex page.
* @returns {Record<string, string>} One entry per operable code, keyed by the 4 digits.
*/
const parseAnnex = (html: string): Record<string, string> => {
const paragraphs = [...html.matchAll(CURRENT_TEXT_PARAGRAPH_REGEX)].map((match) =>
(match[1] ?? "").replaceAll(/\s+/g, " ").trim(),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);

const main = async (): Promise<void> => {
const sorted = await fetchSortedRecord(
"https://raw.githubusercontent.com/jansenfelipe/cfop/master/cfop.csv",
"CFOP mirror",
async (response) => {
const csv = await response.text();
const data: Record<string, string> = {};

for (const [index, paragraph] of paragraphs.entries()) {
const match = CODE_LINE_REGEX.exec(paragraph);

const data: Record<string, string> = {};
if (!match) continue;

for (const line of csv.split("\n")) {
const match = /^(\d{4});"(.*)"\s*$/.exec(line);
const [, series, code, line] = match;

if (!match) continue;
if (series === undefined || code === undefined || line === undefined) continue;

const [, code, description] = match;
const bodyIndex = line.indexOf(OPERABLE_BODY);
const isOperable = bodyIndex !== -1 || (paragraphs[index + 1] ?? "").startsWith(OPERABLE_BODY);

if (code === undefined || description === undefined) continue;
if (!isOperable) continue;

for (const [entryCode, entryDescription] of splitEmbeddedEntries(code, description)) {
if (entryCode.endsWith("00") || entryCode.endsWith("50")) continue;
const description = (bodyIndex === -1 ? line : line.slice(0, bodyIndex))
.trim()
.replace(TRAILING_PUNCTUATION_REGEX, "");

data[entryCode] = entryDescription;
}
data[`${series}${code}`] = description;
}

return data;
};

const main = async (): Promise<void> => {
const sorted = await fetchSortedRecord(
"https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cfop_cvsn_1-6.24",
"CFOP annex",
async (response) => {
const data = parseAnnex(await response.text());

if (Object.keys(data).length === 0) {
throw new Error("CFOP annex page holds no operable code");
}

return data;
Expand All @@ -70,17 +88,31 @@ const main = async (): Promise<void> => {
`/**
* CFOP (Código Fiscal de Operações e Prestações) table, indexed by the 4 digit code.
*
* Built from the consolidated Anexo II of Convênio SINIEF s/nº of 15 December 1970, the text in
* force (given its current wording by Ajuste SINIEF 03/24 and last amended by Ajuste SINIEF
* 39/25), not from the frozen 2001 text of Ajuste SINIEF 07/01.
*
* Group and subgroup headers (codes ending in "00" or "50", e.g. "1000", "1100", "1150")
* are section titles from the official nomenclature rather than operable codes, so they
* are excluded: the Ajuste SINIEF 07/01 prints them in upper case with no "Classificam-se
* neste código" body, unlike the operable codes they head (1151, 1152, ...).
* are excluded: the annex prints them in upper case with no "Classificam-se neste código"
* body, unlike the operable codes they head (1151, 1152, ...).
*
* Generated by \`node ./scripts/cfop.ts\`. Do not edit by hand.
*
* @see Based on: https://raw.githubusercontent.com/jansenfelipe/cfop/master/cfop.csv
* @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cfop_cvsn_1-6.24
* Anexo II of Convênio SINIEF s/nº 1970, the CFOP table in force.
* @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cvsn_70
* Convênio SINIEF s/nº 1970, the consolidated text the annex belongs to.
* @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2001/AJ_007_01
* Ajuste SINIEF 07/01, the historical text that gave the CFOP its 4 digit form.
*/
export const CFOP_TABLE: Record<string, string> = ${JSON.stringify(sorted)};

/**
* Shape a CFOP code has to be written in: the 4 digits, optionally split after the series
* digit by a single whitespace or mask character, the way the annex prints them ("1.101").
*/
export const CFOP_FORMAT_REGEX = /^\\d[\\s.\\-/]?\\d{3}$/;
`,
);
};
Expand Down
4 changes: 2 additions & 2 deletions scripts/cnae.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,9 @@ export const CNAE_SUBCLASSES: Record<string, string> = ${JSON.stringify(data)};

/**
* Shape a CNAE subclass code has to be written in: the 7 digits, optionally split into the
* printed \`NNNN-N/NN\` groups by whitespace or the usual mask characters.
* printed \`NNNN-N/NN\` groups by a single whitespace or mask character.
*/
export const CNAE_FORMAT_REGEX = /^\\d{4}[\\s.\\-/]*\\d[\\s.\\-/]*\\d{2}$/;
export const CNAE_FORMAT_REGEX = /^\\d{4}[\\s.\\-/]?\\d[\\s.\\-/]?\\d{2}$/;
`,
);
};
Expand Down
6 changes: 6 additions & 0 deletions scripts/ncm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ const main = async (): Promise<void> => {
* @see Official: https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json
*/
export const NCM_CODES: readonly string[] = ${JSON.stringify(uniqueSortedCodes)};

/**
* Shape a complete NCM code has to be written in: the 8 digits, optionally split into the
* printed \`NNNN.NN.NN\` groups by a single whitespace or mask character.
*/
export const NCM_FORMAT_REGEX = /^\\d{4}[\\s.\\-/]?\\d{2}[\\s.\\-/]?\\d{2}$/;
`,
);
};
Expand Down
14 changes: 9 additions & 5 deletions src/_internals/constants/area-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@ import { type StateCode } from "./states";
* all but a handful of its municipalities, and `AREA_CODE_SECONDARY_STATES` carries the other
* states the four cross-border codes also serve.
*
* Resolução Anatel nº 749/2022, art. 15, defines the Código Nacional (area code); the gov.br
* page below lists the codes actually allocated and links, under "POR MUNICÍPIO", to the Anexo
* of Resolução Anatel nº 263/2001, which gives the Código Nacional of every municipality. That
* Anexo was parsed to derive both tables.
* Resolução Anatel nº 749/2022, art. 15, defines the Código Nacional (area code). The Plano
* Geral de Códigos Nacionais that art. 15 referred to was revoked by Resolução Anatel nº
* 755/2022, and the allocation in force is the one Despacho Decisório nº 17/2025/PRRE/SPR
* approved; Anatel publishes it on the gov.br page below, which lists the codes actually
* allocated and links, under "POR MUNICÍPIO", to the Anexo of Resolução Anatel nº 263/2001,
* giving the Código Nacional of every municipality. That Anexo was parsed to derive both
* tables.
*
* @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749
* @see Official: https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais
* @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2001/383-resolucao-263
* @see Based on: https://informacoes.anatel.gov.br/legislacao/resolucoes/2001/383-resolucao-263
* Anexo of Resolução nº 263/2001 (revoked; still the table Anatel's Códigos Nacionais page links to).
* @see Based on: https://brasilapi.com.br/docs#tag/DDD
*/
export const VALID_AREA_CODES: readonly number[] = [
Expand Down
Loading
Loading