From 10d0cfd4aeac044228894a08b3a267b80f84da10 Mon Sep 17 00:00:00 2001 From: Agent Date: Fri, 6 Nov 2026 17:29:59 +0100 Subject: [PATCH 1/2] chore(lint): extend number and cents rules and knip to scripts Scope no-loose-number, no-raw-cents and no-double-cast to scripts/**, add scripts/** to the knip project, and replace the flagged Number() calls and raw cents in the mocks and guard scripts with the src helpers. Co-Authored-By: Claude Opus 5.5 --- eslint.config.js | 6 ++-- knip.json | 4 +-- scripts/behavior-change.ts | 4 +-- scripts/check-eslint-disables.ts | 15 +++++++--- scripts/mock-api.ts | 51 ++++++++++++++++++-------------- scripts/mock-balances.ts | 6 ++-- scripts/mock-cards.ts | 27 +++++++++-------- scripts/mock-connections.ts | 2 +- scripts/mock-entries.ts | 40 +++++++++++++++++++------ scripts/private-terms-corpus.ts | 5 ++-- scripts/private-terms-history.ts | 3 +- scripts/private-terms.ts | 8 +++-- scripts/replay-gates.ts | 8 ++--- 13 files changed, 109 insertions(+), 70 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 33b74a8..beff0f9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -42,19 +42,19 @@ export default tseslint.config( }, { // Money is formatted and counted in cents in one place, signed by default. - files: ["src/**/*.ts", "test/**/*.ts"], + files: ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"], ignores: ["src/utils/currency.ts"], rules: { "local/no-raw-currency": "error", "local/no-raw-cents": "error" }, }, { // Strings become numbers only through the strict parse helpers. - files: ["src/**/*.ts", "test/**/*.ts"], + files: ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"], ignores: ["src/utils/parse.ts"], rules: { "local/no-loose-number": "error" }, }, { // User input payloads match their types instead of being cast. - files: ["src/**/*.ts", "test/**/*.ts"], + files: ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"], rules: { "local/no-double-cast": "error" }, }, { diff --git a/knip.json b/knip.json index 13ef0aa..e930c1d 100644 --- a/knip.json +++ b/knip.json @@ -1,7 +1,7 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "project": ["bin/**/*.ts", "src/**/*.ts"], - "entry": ["test/probes/*.ts", "src/lib/i18n/pt-BR.ts", "src/lib/i18n/en.ts"], + "project": ["bin/**/*.ts", "src/**/*.ts", "scripts/**/*.ts"], + "entry": ["test/probes/*.ts", "scripts/mock-api.ts", "scripts/private-terms-cli.ts", "src/lib/i18n/pt-BR.ts", "src/lib/i18n/en.ts"], "ignoreDependencies": ["bun-types"], "ignoreBinaries": ["op"] } diff --git a/scripts/behavior-change.ts b/scripts/behavior-change.ts index 297f897..5620743 100644 --- a/scripts/behavior-change.ts +++ b/scripts/behavior-change.ts @@ -3,7 +3,7 @@ // slice; the diff parsing, the comment lexer and the type-only detection live // here so a false positive is fixed once instead of in three copies. -export type LineKind = 'add' | 'remove' | 'context'; +type LineKind = 'add' | 'remove' | 'context'; export interface DiffLine { kind: LineKind; @@ -15,7 +15,7 @@ export interface DiffFile { hunks: DiffLine[][]; } -export type CommentStyle = 'js' | 'shell'; +type CommentStyle = 'js' | 'shell'; export interface BehaviorOptions { /** A declaration that only changes types is not behavior (risk's slice). */ diff --git a/scripts/check-eslint-disables.ts b/scripts/check-eslint-disables.ts index 21fb485..7509d81 100644 --- a/scripts/check-eslint-disables.ts +++ b/scripts/check-eslint-disables.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs'; import tseslint from 'typescript-eslint'; +import { parseInteger } from '../src/utils/parse.js'; // A copied eslint-disable is how the bare-exit pattern spread (#155, #187). // Compare the branch with main and fail on any added directive without a @@ -11,7 +12,13 @@ const DIRECTIVE = /^\s*eslint-disable(?:-next-line|-line)?\b/; const REASON = /\s--\s+\S/; const LINTED = /\.(?:[cm]?[jt]sx?)$/; -export interface AddedDisable { +function hunkStart(digits: string, header: string): number { + const line = parseInteger(digits); + if (line === undefined) throw new Error(`unreadable diff hunk header: ${header}`); + return line; +} + +interface AddedDisable { path: string; line: number; text: string; @@ -31,7 +38,7 @@ interface DiffLine { text: string; } -export type Side = 'old' | 'new'; +type Side = 'old' | 'new'; export type ContentReader = (path: string, side: Side) => string | undefined; function parseDiff(diff: string): { added: DiffLine[]; removed: DiffLine[] } { @@ -52,8 +59,8 @@ function parseDiff(diff: string): { added: DiffLine[]; removed: DiffLine[] } { } const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw); if (hunk) { - oldLine = Number(hunk[1]); - newLine = Number(hunk[2]); + oldLine = hunkStart(hunk[1], raw); + newLine = hunkStart(hunk[2], raw); continue; } if (path === '' || !LINTED.test(path)) continue; diff --git a/scripts/mock-api.ts b/scripts/mock-api.ts index 18e8bb4..3ae055d 100644 --- a/scripts/mock-api.ts +++ b/scripts/mock-api.ts @@ -3,7 +3,8 @@ import type { Account, AccountsResponse, CategoriesResponse, CreateEntryResponse, EntriesResponse, Entry } from '../src/types/index.js'; import { ACCOUNTS_RESPONSE, CATEGORIES_RESPONSE, CREDIT_CARD_ACCOUNT, SAVINGS_ACCOUNT, TAGS_RESPONSE } from '../test/fixtures.js'; import { plasticsRoute } from './mock-plastics.js'; -import { SEEDED_ENTRIES, checkCreatePayload, checkUpdateAccounts, createCardPurchase, createdEntry, entryType, mockEntryId, numberOrNull, statusLabel } from './mock-entries.js'; +import { SEEDED_ENTRIES, checkCreatePayload, checkUpdateAccounts, createCardPurchase, createdEntry, entryType, idOf, mockEntryId, numberOrNull, requiredId, requiredNumber, statusLabel } from './mock-entries.js'; +import { parseId, parseInteger } from '../src/utils/parse.js'; import { connectionsRoute, recordMockRequest } from './mock-connections.js'; import { IMPORT_CANDIDATES, importRoute, withRecentStatement } from './mock-import.js'; import { balancesRoute } from './mock-balances.js'; @@ -83,14 +84,17 @@ async function readJsonBody(req: Request): Promise | Res } } -function typeName(tipoNovo: unknown): string | undefined { - return MOCK_ACCOUNTS.meta.tipos.find((t) => t.id === tipoNovo)?.nome; +function accountType(tipoNovo: unknown) { + return MOCK_ACCOUNTS.meta.tipos.find((t) => t.id === tipoNovo); +} + +function accountOf(conta: unknown): Account | undefined { + const id = idOf(conta); + return MOCK_ACCOUNTS.items.find((a) => a.id === id); } function isCreditCardId(conta: unknown): boolean { - const id = Number(conta); - const account = MOCK_ACCOUNTS.items.find((a) => a.id === id); - return account?.tipo === 'CARTAOCREDITO'; + return accountOf(conta)?.tipo === 'CARTAOCREDITO'; } async function accountsRoute(req: Request, path: string): Promise { @@ -100,23 +104,24 @@ async function accountsRoute(req: Request, path: string): Promise { const body = await readJsonBody(req); if (body instanceof Response) return body; const nome = body.nome; - const tipo = typeName(body.tipoNovo); + const type = accountType(body.tipoNovo); if (typeof nome !== 'string' || !nome) return badRequest('nome is required'); - if (!tipo) return badRequest(`unknown tipoNovo ${String(body.tipoNovo)}`); + if (!type) return badRequest(`unknown tipoNovo ${String(body.tipoNovo)}`); + const tipo = type.nome; const created: Account = { id: NEW_ACCOUNT_ID, nome, tipo, - tipoNovo: Number(body.tipoNovo), + tipoNovo: type.id, status: true, encerrada: false, exibirBP: body.exibirBP !== false, apenasTransferencia: false, - saldoInicial: Number(body.saldoInicial ?? 0), - moeda: Number(body.moeda ?? 1), + saldoInicial: requiredNumber(body.saldoInicial ?? 0, 'saldoInicial'), + moeda: requiredNumber(body.moeda ?? 1, 'moeda'), dataSaldoInicial: typeof body.dataSaldoInicial === 'string' ? body.dataSaldoInicial : undefined, considerarCarteiraInvestimentos: 1, - liquidez: Number(body.liquidez ?? 1), + liquidez: requiredNumber(body.liquidez ?? 1, 'liquidez'), permissoes: { visivel: true, acoes: { editar: true, excluir: true } }, ...(typeof body.banco === 'string' && { banco: { id: body.banco, nome: body.banco, img: '', tipo: [tipo] } }), }; @@ -126,14 +131,14 @@ async function accountsRoute(req: Request, path: string): Promise { } const match = path.match(/^\/api\/v1\/cadastros\/contas\/(\d+)$/); - const existing = match && MOCK_ACCOUNTS.items.find((a) => a.id === Number(match[1])); + const existing = match && accountOf(match[1]); if (!existing) return notFound(); if (req.method === 'PUT') { const body = await readJsonBody(req); if (body instanceof Response) return body; const { banco: _banco, ...changes } = body; - const updated: Account = { ...existing, ...changes, id: existing.id, tipo: typeName(changes.tipoNovo) ?? existing.tipo }; + const updated: Account = { ...existing, ...changes, id: existing.id, tipo: accountType(changes.tipoNovo)?.nome ?? existing.tipo }; return Response.json(updated); } if (req.method === 'DELETE') { @@ -153,13 +158,14 @@ function entriesListRoute(url: URL): Response { const ids = (JSON.parse(url.searchParams.get('contas') ?? '{}') as { ids?: number[] }).ids ?? []; const from = url.searchParams.get('inicio') ?? ''; const to = url.searchParams.get('fim') ?? ''; - const repetition = Number(url.searchParams.get('tipoRepeticao') ?? 7); + const repetition = parseInteger(url.searchParams.get('tipoRepeticao') ?? '7'); + if (repetition === undefined) return badRequest('tipoRepeticao must be a whole number'); const candidates = url.searchParams.get('finalidade') === '3' ? IMPORT_CANDIDATES : []; const list = [...Object.values(SEEDED_ENTRIES), ...candidates].filter((e) => { const date = String(e.dataPrevista); // Like the real statement list (finalidade 1), card purchases are left out: // they only show up in the card's invoices. - return ids.includes(Number(e.conta)) && !isCreditCardId(e.conta) && date >= from && date <= to && (repetitionBit(e) & repetition) !== 0; + return ids.includes(e.conta) && !isCreditCardId(e.conta) && date >= from && date <= to && (repetitionBit(e) & repetition) !== 0; }); const response: EntriesResponse = { list, meta: { total: list.length, page: 1, pageSize: 200 } }; return Response.json(response); @@ -198,7 +204,8 @@ async function entriesRoute(req: Request, path: string): Promise { const match = path.match(/^\/api\/v1\/lancamentos\/(\d+)$/); if (!match) return notFound(); - const id = Number(match[1]); + const id = parseId(match[1]); + if (id === undefined) return notFound(); const existing = SEEDED_ENTRIES[id]; if (!existing) return notFound(); @@ -211,7 +218,7 @@ async function entriesRoute(req: Request, path: string): Promise { if (body.todos !== undefined && (typeof body.todos !== 'boolean' || !inSeries)) { return badRequest('todos only applies to an occurrence of a series, as a boolean'); } - const invalidAccounts = checkUpdateAccounts(body, (conta) => MOCK_ACCOUNTS.items.find((a) => a.id === Number(conta))?.tipo); + const invalidAccounts = checkUpdateAccounts(body, (conta) => accountOf(conta)?.tipo); if (invalidAccounts) return badRequest(invalidAccounts); const transfer = body.transferencia === true; const merged: Record = { ...existing, ...body, id }; @@ -227,8 +234,8 @@ async function entriesRoute(req: Request, path: string): Promise { agendaId: keepsSeries ? existing.agendaId : null, parcela: keepsSeries ? existing.parcela : null, tipo: entryType(merged.tipo), - valor: Number(merged.valor), - valorPrevisto: Number(merged.valor), + valor: requiredNumber(merged.valor, 'valor'), + valorPrevisto: requiredNumber(merged.valor, 'valor'), valorEfetivo: numberOrNull(merged.valorEfetivo), estorno: merged.estorno === true, data: String(merged.data ?? existing.data), @@ -237,9 +244,9 @@ async function entriesRoute(req: Request, path: string): Promise { dataCriacao: new Date().toISOString(), exibirCp: true, exibirCr: true, - conta: transfer ? Number(body.contaOrigem) : Number(merged.conta ?? existing.conta), + conta: transfer ? requiredId(body.contaOrigem, 'contaOrigem') : requiredId(merged.conta ?? existing.conta, 'conta'), plastico: numberOrNull(merged.plastico), - ...(transfer && { contaT: Number(body.contaDestino) }), + ...(transfer && { contaT: requiredId(body.contaDestino, 'contaDestino') }), }; return Response.json(updated); } diff --git a/scripts/mock-balances.ts b/scripts/mock-balances.ts index 3b6c8a9..600a322 100644 --- a/scripts/mock-balances.ts +++ b/scripts/mock-balances.ts @@ -1,9 +1,9 @@ // GET /v2/lancamentos type=sum for scripts/mock-api.ts (the web's account balances, #67). import type { AccountBalancesResponse, AccountBalanceSum } from '../src/types/index.js'; +import { roundToCents } from '../src/utils/currency.js'; import { SEEDED_ENTRIES } from './mock-entries.js'; -const round = (value: number): number => Math.round(value * 100) / 100; -const sum = (items: ReadonlyArray<{ valor: unknown }>): number => round(items.reduce((total, e) => total + Number(e.valor), 0)); +const sum = (items: ReadonlyArray<{ valor: number }>): number => roundToCents(items.reduce((total, e) => total + e.valor, 0)); const CONFIRMED_LABELS = ['conciliado', 'confirmado']; @@ -17,7 +17,7 @@ export function balancesRoute(url: URL, isCreditCardId: (conta: unknown) => bool const to = url.searchParams.get('fim') ?? ''; const entries = Object.values(SEEDED_ENTRIES).filter((e) => String(e.dataPrevista) <= to); const sumRows: AccountBalanceSum[] = ids.filter((id) => !isCreditCardId(id)).map((conta) => { - const own = entries.filter((e) => Number(e.conta) === conta); + const own = entries.filter((e) => e.conta === conta); return { conta, projetado: sum(own), diff --git a/scripts/mock-cards.ts b/scripts/mock-cards.ts index f2cc99b..b22d4c7 100644 --- a/scripts/mock-cards.ts +++ b/scripts/mock-cards.ts @@ -2,15 +2,16 @@ // and the first invoice date. import type { CardFutureResponse, CardInvoiceResponse } from '../src/types/index.js'; import { cardInvoice } from '../test/fixtures.js'; +import { roundToCents } from '../src/utils/currency.js'; +import { parseId } from '../src/utils/parse.js'; import { invoiceItems } from './mock-entries.js'; -const sum = (items: ReadonlyArray<{ valor: unknown }>): number => - Math.round(items.reduce((total, e) => total + Number(e.valor), 0) * 100) / 100; +const sum = (items: ReadonlyArray<{ valor: number }>): number => roundToCents(items.reduce((total, e) => total + e.valor, 0)); /** Closing date ~8 days before due, matching the unit-test fixtures. */ function closingFor(dueDate: string): string { - const [y, m, d] = dueDate.split('-').map(Number); - const closing = new Date(y, m - 1, d - 8); + const closing = new Date(`${dueDate}T12:00:00`); + closing.setDate(closing.getDate() - 8); const yy = closing.getFullYear(); const mm = String(closing.getMonth() + 1).padStart(2, '0'); const dd = String(closing.getDate()).padStart(2, '0'); @@ -24,7 +25,7 @@ function closingFor(dueDate: string): string { * payments listed in an invoice paid the previous one. */ function invoiceTotals( - lancamentos: ReadonlyArray<{ valor: unknown; tipo?: string; status?: string }>, + lancamentos: ReadonlyArray<{ valor: number; tipo?: string; status?: string }>, vencimento: string, fechamento: string, limite: number | undefined @@ -44,9 +45,9 @@ function invoiceTotals( totalPagar: totalFatura, conciliado: sum(purchases.filter((e) => e.status === 'conciliado')), nconciliado: sum(purchases.filter((e) => e.status !== 'conciliado')), - receitas: sum(purchases.filter((e) => Number(e.valor) > 0)), - despesas: sum(purchases.filter((e) => Number(e.valor) < 0)), - ...(limite === undefined ? {} : { disponivel: Math.round((limite + totalFatura) * 100) / 100 }), + receitas: sum(purchases.filter((e) => e.valor > 0)), + despesas: sum(purchases.filter((e) => e.valor < 0)), + ...(limite === undefined ? {} : { disponivel: roundToCents(limite + totalFatura) }), }, }; } @@ -55,23 +56,23 @@ function invoiceTotals( export function cardsRoute( req: Request, path: string, - isCreditCard: (id: number) => boolean, + isCreditCard: (id: unknown) => boolean, limitOf: (id: number) => number | undefined ): Response | null { if (req.method !== 'GET') return null; const first = path.match(/^\/api\/v1\/cartoes\/(\d+)\/primeiraFatura$/); if (first) { - if (!isCreditCard(Number(first[1]))) return Response.json({ message: 'not found' }, { status: 404 }); + if (!isCreditCard(first[1])) return Response.json({ message: 'not found' }, { status: 404 }); // ISO date string (what fetchFirstInvoiceDate expects). return Response.json('2026-01-16'); } const invoice = path.match(/^\/api\/v1\/cartoes\/(\d+)\/fatura\/(\d{4}-\d{2}-\d{2})$/); if (invoice) { - const id = Number(invoice[1]); + const id = parseId(invoice[1]); const due = invoice[2]; - if (!isCreditCard(id)) return Response.json({ message: 'not found' }, { status: 404 }); + if (id === undefined || !isCreditCard(id)) return Response.json({ message: 'not found' }, { status: 404 }); const lancamentos = invoiceItems(id, due); const invoiceBody: CardInvoiceResponse = { ...cardInvoice(due, closingFor(due)), @@ -83,7 +84,7 @@ export function cardsRoute( const future = path.match(/^\/api\/v1\/cartoes\/(\d+)\/parcelasFuturas$/); if (future) { - if (!isCreditCard(Number(future[1]))) return Response.json({ message: 'not found' }, { status: 404 }); + if (!isCreditCard(future[1])) return Response.json({ message: 'not found' }, { status: 404 }); // Real API: bare array (no { parcelas } wrapper, no parcelasRestantes). const installments: CardFutureResponse = [ { diff --git a/scripts/mock-connections.ts b/scripts/mock-connections.ts index 4d9c677..3525b16 100644 --- a/scripts/mock-connections.ts +++ b/scripts/mock-connections.ts @@ -13,7 +13,7 @@ const UPDATE_PATH = /^\/api\/v1\/integracao\/connection\/update\/(\d+)$/; // Fast enough for verify.sh, positive as the real statusCheckWindow. const MOCK_STATUS_CHECK_WINDOW_SECONDS = 0.05; -export const MOCK_CONNECTIONS: ConnectionsResponse = [ +const MOCK_CONNECTIONS: ConnectionsResponse = [ { id: 501, status: 'UPDATED', diff --git a/scripts/mock-entries.ts b/scripts/mock-entries.ts index fae6ee1..bf736ad 100644 --- a/scripts/mock-entries.ts +++ b/scripts/mock-entries.ts @@ -1,5 +1,7 @@ import type { CardInvoiceEntry, CreateEntryResponse, Entry, EntryId, OccurrenceId } from '../src/types/index.js'; import { localDay } from '../test/helpers.js'; +import { roundToCents } from '../src/utils/currency.js'; +import { isPositiveSafeInteger, parseId } from '../src/utils/parse.js'; // Seeded entries for GET/PUT/DELETE /v1/lancamentos/:id (parity with unit fixtures). export const SEEDED_ENTRIES: Record = { @@ -171,8 +173,8 @@ export function checkCreatePayload(body: Body): string | null { if (!Array.isArray(parcelas) || parcelas.length !== agenda.quantidade) { return 'an installment series needs one parcela per installment'; } - const sum = parcelas.reduce((acc, p) => acc + Math.round(p.valor * 100), 0); - if (sum !== Math.round(Number(body.valor) * 100)) return 'valor must be the sum of the parcelas'; + const sum = parcelas.reduce((acc, p) => acc + p.valor, 0); + if (roundToCents(sum) !== roundToCents(requiredNumber(body.valor, 'valor'))) return 'valor must be the sum of the parcelas'; } return null; } @@ -202,6 +204,26 @@ export function numberOrNull(value: unknown): number | null { return typeof value === 'number' ? value : null; } +// The routes check a payload before they build an answer from it, so a field +// that is still not a number here is a CLI regression: fail the request loudly +// instead of coercing it the way Number() would. +export function requiredNumber(value: unknown, field: string): number { + if (typeof value !== 'number') throw new Error(`mock: ${field} must be a number, got ${JSON.stringify(value)}`); + return value; +} + +/** An account ID as the CLI sends it: a JSON number, or a digit string in a create payload's conta. */ +export function idOf(value: unknown): number | undefined { + if (typeof value === 'string') return parseId(value); + return isPositiveSafeInteger(value) ? value : undefined; +} + +export function requiredId(value: unknown, field: string): number { + const id = idOf(value); + if (id === undefined) throw new Error(`mock: ${field} must be an ID, got ${JSON.stringify(value)}`); + return id; +} + function numberArray(value: unknown): number[] { return Array.isArray(value) ? value.filter((item): item is number => typeof item === 'number') : []; } @@ -210,7 +232,7 @@ function numberArray(value: unknown): number[] { export const mockEntryId = (id: number): EntryId => id as EntryId; /** An occurrence ID ("A") from the mock. */ -export const mockOccurrenceId = (id: string): OccurrenceId => id as OccurrenceId; +const mockOccurrenceId = (id: string): OccurrenceId => id as OccurrenceId; /** The API's answer to POST /v1/lancamentos for a payload that passed checkCreatePayload. */ export function createdEntry(body: Body, id: EntryId | OccurrenceId): CreateEntryResponse { @@ -227,8 +249,8 @@ export function createdEntry(body: Body, id: EntryId | OccurrenceId): CreateEntr status: statusLabel(body) ?? 'pendente', tipo: entryType(body.tipo), // The server stores one entry per installment; this is the first one. - valor: parcelas ? parcelas[0].valor : Number(body.valor), - valorPrevisto: parcelas ? parcelas[0].valor : Number(body.valorPrevisto ?? body.valor), + valor: parcelas ? parcelas[0].valor : requiredNumber(body.valor, 'valor'), + valorPrevisto: parcelas ? parcelas[0].valor : requiredNumber(body.valorPrevisto ?? body.valor, 'valorPrevisto'), valorEfetivo: numberOrNull(body.valorEfetivo), data: String(body.data ?? new Date().toISOString()), dataPrevista: String(body.dataPrevista ?? ''), @@ -238,13 +260,13 @@ export function createdEntry(body: Body, id: EntryId | OccurrenceId): CreateEntr exibirCr: true, permissoes: { visivel: true, acoes: { conciliar: true, confirmar: true, editar: true, excluir: true, clonar: true } }, estorno: false, - conta: Number(body.conta ?? body.contaOrigem), + conta: requiredId(body.conta ?? body.contaOrigem, 'conta'), categoria: numberOrNull(body.categoria), tags: numberArray(body.tags), observacoes: String(body.observacoes ?? ''), lembrete: '0', automatico: body.automatico === true, - ...(body.transferencia === true && { contaT: Number(body.contaDestino), valorT: Number(body.valorT) }), + ...(body.transferencia === true && { contaT: requiredId(body.contaDestino, 'contaDestino'), valorT: requiredNumber(body.valorT, 'valorT') }), ...(parcelas && { parcela: `${agenda?.inicial ?? 1}/${agenda?.quantidade}` }), }; } @@ -270,13 +292,13 @@ const cardPurchases: StoredCardPurchase[] = []; export function createCardPurchase(body: Body): CreateEntryResponse { const id = nextCardEntryId++; const parcelas = body.parcelas as { valor: number }[] | undefined; - const valor = parcelas ? parcelas[0].valor : Number(body.valor); + const valor = parcelas ? parcelas[0].valor : requiredNumber(body.valor, 'valor'); // A purchase has no effective date but is not pending: it sits on the invoice. const purchase: CreateEntryResponse = { ...createdEntry(body, mockEntryId(id)), status: 'confirmado' }; SEEDED_ENTRIES[id] = { ...purchase, status: 'confirmado', valor, lembrete: 0, tags: numberArray(body.tags), observacoes: String(body.observacoes ?? '') }; cardPurchases.push({ id, - conta: Number(body.conta), + conta: requiredId(body.conta, 'conta'), descricao: String(body.descricao), valor, data: String(body.dataCompetencia), diff --git a/scripts/private-terms-corpus.ts b/scripts/private-terms-corpus.ts index c09fa5d..8060d2d 100644 --- a/scripts/private-terms-corpus.ts +++ b/scripts/private-terms-corpus.ts @@ -18,7 +18,6 @@ import { import { deletedLines, git, headCorpus, type DeletedLine, type HeadCorpus } from './private-terms-history.js'; export { deletedLines } from './private-terms-history.js'; -export type { DeletedLine, HeadCorpus } from './private-terms-history.js'; export interface RemovedTermHit extends DeletedLine { hash: string; @@ -133,7 +132,7 @@ function coveredTokens( } /** True when the full phrase still occurs on one line of `head`. */ -export function hasPhrase(head: HeadCorpus, joined: string): boolean { +function hasPhrase(head: HeadCorpus, joined: string): boolean { const parts = joined.split(' '); const first = parts[0]!; if (parts.length === 1) return head.tokens.has(first); @@ -160,7 +159,7 @@ function isContentHash(joined: string, line: string): boolean { * single term. All of them skip code identifiers, dependency metadata and git * object ids. */ -export function uncoveredRemovedTerms( +function uncoveredRemovedTerms( deleted: readonly DeletedLine[], head: HeadCorpus, terms: ReadonlySet = PRIVATE_TERM_HASHES, diff --git a/scripts/private-terms-history.ts b/scripts/private-terms-history.ts index aaecd39..ea3f96d 100644 --- a/scripts/private-terms-history.ts +++ b/scripts/private-terms-history.ts @@ -1,3 +1,4 @@ +import { parseInteger } from '../src/utils/parse.js'; import { normalizeTerm } from './private-terms.js'; export interface DeletedLine { @@ -36,7 +37,7 @@ export function deletedLines(base: string, head: string, root: string): DeletedL } else if (raw.startsWith('@@ ')) { inHunk = true; const match = /^@@ -(\d+)(?:,\d+)? /.exec(raw); - line = match ? Number(match[1]) : 0; + line = match ? parseInteger(match[1]) ?? 0 : 0; } else if (inHunk) { if (!raw.startsWith('-')) continue; lines.push({ file, line, text: raw.slice(1) }); diff --git a/scripts/private-terms.ts b/scripts/private-terms.ts index dbc5578..ea66e70 100644 --- a/scripts/private-terms.ts +++ b/scripts/private-terms.ts @@ -2,6 +2,7 @@ import { createHash, createHmac } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { parseInteger } from '../src/utils/parse.js'; import { ALLOWED_PHRASE_HASHES, KEYED_TERM_HASHES, PRIVATE_TERM_HASHES } from './private-terms-hashes.js'; export { ALLOWED_PHRASE_HASHES, KEYED_TERM_HASHES, PRIVATE_TERM_HASHES } from './private-terms-hashes.js'; @@ -242,13 +243,14 @@ function blobContents(root: string, objects: string[]): Map { const newline = output.indexOf(0x0a, offset); if (newline === -1) throw new Error(`git cat-file output ended early at ${object}`); const header = output.subarray(offset, newline).toString(); - const [hash, type, size] = header.split(' '); + const [hash, type, sizeText] = header.split(' '); + const size = parseInteger(sizeText ?? ''); if (hash !== object || type !== 'blob' || size === undefined) { throw new Error(`unexpected git cat-file header: ${header}`); } offset = newline + 1; - contents.set(object, output.subarray(offset, offset + Number(size))); - offset += Number(size) + 1; + contents.set(object, output.subarray(offset, offset + size)); + offset += size + 1; } return contents; } diff --git a/scripts/replay-gates.ts b/scripts/replay-gates.ts index 07fc775..a2590f3 100644 --- a/scripts/replay-gates.ts +++ b/scripts/replay-gates.ts @@ -12,7 +12,7 @@ export interface CommitInput { nameStatus: string; } -export interface GateDecision { +interface GateDecision { gate: string; fired: boolean; detail: string; @@ -23,7 +23,7 @@ export interface Gate { decide(commit: CommitInput): GateDecision; } -export interface CommitReplay { +interface CommitReplay { sha: string; subject: string; decisions: GateDecision[]; @@ -107,7 +107,7 @@ const PROVA_GATE: Gate = { }; /** The registry a new gate joins, with its own decide() over a commit. */ -export const GATES: readonly Gate[] = [RISK_GATE, FAILS_ON_MAIN_GATE, PROVA_GATE]; +const GATES: readonly Gate[] = [RISK_GATE, FAILS_ON_MAIN_GATE, PROVA_GATE]; export function replayGates(commits: readonly CommitInput[], gates: readonly Gate[] = GATES): ReplayReport { const replayed = commits.map((commit) => ({ @@ -133,7 +133,7 @@ function gitQuiet(args: string[], root: string): string | undefined { return result.exitCode === 0 ? result.stdout.toString().trim() : undefined; } -export function defaultRevision(root: string): string { +function defaultRevision(root: string): string { for (const candidate of ['origin/main', 'main', 'HEAD']) { if (gitQuiet(['rev-parse', '--verify', '--quiet', candidate], root) !== undefined) return candidate; } From ad7575ca81467ed5eeb896f195c9886331c1876c Mon Sep 17 00:00:00 2001 From: Agent Date: Fri, 6 Nov 2026 17:44:15 +0100 Subject: [PATCH 2/2] fix(risk): flag src/utils/parse.ts as a private-data guard surface The guard now parses git output with parseInteger, so a change to src/utils/parse.ts changes what the guard reads without any guard file moving. Add it to SECURITY_SURFACES, and add a test that walks the runtime imports of the guard entry points and fails on any module risk.ts does not flag, so the next shared helper cannot slip past. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 +- scripts/risk.ts | 5 +- test/fixtures/risk/guard-helper-surface.diff | 10 ++++ test/risk-guard-imports.test.ts | 53 ++++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/risk/guard-helper-surface.diff create mode 100644 test/risk-guard-imports.test.ts diff --git a/AGENTS.md b/AGENTS.md index 036e504..04db963 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ Each rule below names the check that fails when it breaks. A rule marked *text o | A real term longer than the guard's four-word representation fails instead of being skipped | `private-terms corpus` step: a removed name-like run above four words fails unless every word falls inside a registered n-gram; the tests prove a short sub-phrase leaves the tail reported and surviving windows do not hide the run | `scripts/private-terms.ts`, `scripts/private-terms-corpus.ts`, `test/private-terms-corpus.test.ts` | | A low-entropy numeric term is stored only as a keyed hash | `private terms` and `private-terms corpus` steps: a numeric removed value is covered only by `KEYED_TERM_HASHES` (or an excuse), never by an unsalted hash; the keyed canary and the #177 replay prove detection with the key | `scripts/private-terms.ts`, `scripts/private-terms-hashes.ts`, `scripts/private-terms-corpus.ts`, `test/private-terms.test.ts`, `test/private-terms-corpus.test.ts`, `scripts/verify.sh` | | Security surfaces (the private-data guard, its test, the message guard, its test, `scripts/verify.sh`, `eslint-rules/`) enter the risky set | `security surfaces` tests | `test/risk.test.ts`, `test/risk-label.test.ts` | +| Every repository module the private-data guard loads is a security surface | `private-data guard imports` test: walks the runtime imports of the guard entry points and fails on any module `scripts/risk.ts` does not flag (only `scripts/private-terms-excused.ts` is exempt) | `test/risk-guard-imports.test.ts` | | The CLI follows the web app, and a conflict with Filipe's decision is reported (items 9 and 10) | `prova` (`scripts/prova.ts`) requires, for a PR that changes `src/`, a `Web:` line with the bundle snippet or `sem equivalente na web`; a conflict with Filipe's decision stays *text only* (review) | `scripts/prova.ts`, `.github/workflows/prova.yml` | | An implementation PR is opened and pushed on top of the current `main` (item 14) | *text only*: `git log HEAD..origin/main` is empty before `gh pr create` and before each push | review | | A new or changed gate is replayed over the recent merges before merge | *text only*: the PR Prova carries the output of `bun scripts/replay-gates.ts --gate ` | `scripts/replay-gates.ts` | @@ -57,7 +58,7 @@ Each rule below names the check that fails when it breaks. A rule marked *text o Every PR that changes `src/` fills the **Prova** section of the PR template. It shows the CLI commands run against the mock (`scripts/mock-api.ts`, fake config in `/tmp`), never against a real account, on `main` (before) and on the branch (after), with their outputs, proving the behavior the issue asks for. When it changes `src/` or `bin/`, the proof also shows the output of the 3 runs of `bun scripts/new-tests-fail-on-main.ts --base --files `; one red run under load is not proof (issue #235). A behavior-preserving PR that skips the gate cites the exemption (the `refactor`/`test`/`chore` title or the body reason) instead of the failing runs. The `prova` CI check reads the PR body and fails when the section, the `Web:` line or one of the two blocks is missing; it runs on `opened`, `synchronize`, `reopened` and `edited`. The `Web:` line carries a snippet from the web bundle (`$HOME/.mdcli-team/ref/mdb.js`) that shows the behavior, or `sem equivalente na web`. Paraphrasing the web inside the proof without that line no longer counts. -`bun scripts/risk.ts` classifies the diff against `origin/main` and prints `{ "risky": bool, "reasons": [...] }`; it exits 2 when the PR touches authentication, session or credentials, data writes (create, update, delete, `entries import run`, or any POST/PUT/DELETE call to the API), or a security surface: `scripts/private-terms.ts`, `scripts/private-terms-hashes.ts`, `scripts/private-terms-history.ts`, `scripts/private-terms-cli.ts`, `scripts/private-terms-corpus.ts`, `test/private-terms.test.ts`, `test/private-terms-corpus.test.ts`, `scripts/check-private-messages.ts`, `test/check-private-messages.test.ts`, `scripts/verify.sh`, or anything under `eslint-rules/`. A comment-only change in a security surface stays safe. A risky PR: +`bun scripts/risk.ts` classifies the diff against `origin/main` and prints `{ "risky": bool, "reasons": [...] }`; it exits 2 when the PR touches authentication, session or credentials, data writes (create, update, delete, `entries import run`, or any POST/PUT/DELETE call to the API), or a security surface: `scripts/private-terms.ts`, `scripts/private-terms-hashes.ts`, `scripts/private-terms-history.ts`, `scripts/private-terms-cli.ts`, `scripts/private-terms-corpus.ts`, `test/private-terms.test.ts`, `test/private-terms-corpus.test.ts`, `scripts/check-private-messages.ts`, `test/check-private-messages.test.ts`, `scripts/verify.sh`, `src/utils/parse.ts` (the guard parses git output with it), or anything under `eslint-rules/`. A comment-only change in a security surface stays safe. A risky PR: - runs `/blast-radius` and proves the security fact in a test or in `scripts/verify.sh`; - waits for the maintainer's decision before merge. diff --git a/scripts/risk.ts b/scripts/risk.ts index 14ebea0..6491a16 100644 --- a/scripts/risk.ts +++ b/scripts/risk.ts @@ -36,6 +36,9 @@ const SECURITY_SURFACES: readonly SecuritySurface[] = [ { path: /^test\/check-private-messages\.test\.ts$/, detail: 'private-data message guard test changed' }, { path: /^scripts\/verify\.sh$/, detail: 'verification script changed' }, { path: /^eslint-rules\//, detail: 'local ESLint rule changed' }, + // The guard parses git output with these helpers (#262), so a change here + // changes what the guard reads even when no guard file moves. + { path: /^src\/utils\/parse\.ts$/, detail: 'parse helper the private-data guard imports changed' }, ]; const TEST_PATH = /^(?:test\/|.*\.(?:test|spec)\.[cm]?[jt]sx?$)/; @@ -99,7 +102,7 @@ function addReason(reasons: RiskReason[], seen: Set, reason: RiskReason) reasons.push(reason); } -function securityDetail(path: string): string | undefined { +export function securityDetail(path: string): string | undefined { return SECURITY_SURFACES.find((surface) => surface.path.test(path))?.detail; } diff --git a/test/fixtures/risk/guard-helper-surface.diff b/test/fixtures/risk/guard-helper-surface.diff new file mode 100644 index 0000000..e8df988 --- /dev/null +++ b/test/fixtures/risk/guard-helper-surface.diff @@ -0,0 +1,10 @@ +diff --git a/src/utils/parse.ts b/src/utils/parse.ts +--- a/src/utils/parse.ts ++++ b/src/utils/parse.ts +@@ -10,5 +10,5 @@ const DIGITS = /^\d+$/; + /** A whole number written in plain decimal digits, at least `min`. */ + export function parseInteger(input: string, min = 0): number | undefined { +- if (!DIGITS.test(input)) return undefined; ++ if (!DIGITS.test(input.trim())) return undefined; + const value = Number.parseInt(input, 10); + return Number.isSafeInteger(value) && value >= min ? value : undefined; diff --git a/test/risk-guard-imports.test.ts b/test/risk-guard-imports.test.ts new file mode 100644 index 0000000..075b69d --- /dev/null +++ b/test/risk-guard-imports.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { classifyRisk, securityDetail } from '../scripts/risk.js'; + +const root = join(import.meta.dir, '..'); + +// The commands verify.sh and CI run for the private-data guard. +const GUARD_ENTRIES = ['scripts/private-terms-cli.ts', 'scripts/private-terms-corpus.ts', 'scripts/check-private-messages.ts']; + +// Not a security surface on purpose: AGENTS.md (Private data) sends non-account +// values there with a reason, and the corpus step still checks what it holds. +const NOT_SURFACES = new Set(['scripts/private-terms-excused.ts']); + +const transpiler = new Bun.Transpiler({ loader: 'ts' }); + +/** Every repository module the guard entry points load at runtime, entry points included. */ +function guardModules(): string[] { + const seen = new Set(); + const queue = [...GUARD_ENTRIES]; + while (queue.length > 0) { + const path = queue.pop()!; + if (seen.has(path)) continue; + seen.add(path); + for (const { path: specifier } of transpiler.scanImports(readFileSync(join(root, path), 'utf8'))) { + if (!specifier.startsWith('.')) continue; + const target = relative(root, join(root, dirname(path), specifier)).replace(/\.js$/, '.ts'); + queue.push(target); + } + } + return [...seen].sort(); +} + +describe('private-data guard imports', () => { + test('walks into the src helper the guard parses git output with', () => { + const modules = guardModules(); + expect(modules).toContain('scripts/private-terms.ts'); + expect(modules).toContain('src/utils/parse.ts'); + }); + + test('every module the guard loads is a security surface', () => { + const unguarded = guardModules().filter((path) => !NOT_SURFACES.has(path) && securityDetail(path) === undefined); + expect(unguarded).toEqual([]); + }); + + test('flags a behavior change in src/utils/parse.ts as security', () => { + const diff = readFileSync(join(import.meta.dir, 'fixtures', 'risk', 'guard-helper-surface.diff'), 'utf8'); + expect(classifyRisk(diff)).toEqual({ + risky: true, + reasons: [{ topic: 'security', path: 'src/utils/parse.ts', detail: 'parse helper the private-data guard imports changed' }], + }); + }); +});