Skip to content
Merged
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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` | `scripts/replay-gates.ts` |
Expand All @@ -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 <pre-fix> --files <test>`; 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.
Expand Down
6 changes: 3 additions & 3 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
},
{
Expand Down
4 changes: 2 additions & 2 deletions knip.json
Original file line number Diff line number Diff line change
@@ -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"]
}
4 changes: 2 additions & 2 deletions scripts/behavior-change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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). */
Expand Down
15 changes: 11 additions & 4 deletions scripts/check-eslint-disables.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
Expand All @@ -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[] } {
Expand All @@ -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;
Expand Down
51 changes: 29 additions & 22 deletions scripts/mock-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -83,14 +84,17 @@ async function readJsonBody(req: Request): Promise<Record<string, unknown> | 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<Response> {
Expand All @@ -100,23 +104,24 @@ async function accountsRoute(req: Request, path: string): Promise<Response> {
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] } }),
};
Expand All @@ -126,14 +131,14 @@ async function accountsRoute(req: Request, path: string): Promise<Response> {
}

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') {
Expand All @@ -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);
Expand Down Expand Up @@ -198,7 +204,8 @@ async function entriesRoute(req: Request, path: string): Promise<Response> {

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();

Expand All @@ -211,7 +218,7 @@ async function entriesRoute(req: Request, path: string): Promise<Response> {
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<string, unknown> = { ...existing, ...body, id };
Expand All @@ -227,8 +234,8 @@ async function entriesRoute(req: Request, path: string): Promise<Response> {
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),
Expand All @@ -237,9 +244,9 @@ async function entriesRoute(req: Request, path: string): Promise<Response> {
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);
}
Expand Down
6 changes: 3 additions & 3 deletions scripts/mock-balances.ts
Original file line number Diff line number Diff line change
@@ -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'];

Expand All @@ -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),
Expand Down
Loading
Loading