From 0991c04cd723f6162d10c228cf96189f4fc355f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 05:09:36 +0000 Subject: [PATCH 1/6] fix(seed-posts): import dieWithLabel before calling it (Pass 4 P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The P3-H CLI consolidation left seed-posts calling dieWithLabel with no import — a ReferenceError on the empty-fetch abort path, invisible to lint/typecheck/knip. Recurrence guard in the same delivery: scriptCliConventions.unit.spec.ts fails the build when a script calls dieWithLabel/dieAgent without importing (or defining) it, and bans re-spelled 'const DAY_MS =' outside src/lib/text.ts (P3-K's fold leaked one private copy in engagementLevel.ts). --- scripts/seed-posts.mjs | 2 +- tests/unit/scriptCliConventions.unit.spec.ts | 63 ++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 tests/unit/scriptCliConventions.unit.spec.ts diff --git a/scripts/seed-posts.mjs b/scripts/seed-posts.mjs index 15c7040d..beb7d38e 100644 --- a/scripts/seed-posts.mjs +++ b/scripts/seed-posts.mjs @@ -27,7 +27,7 @@ import { convertHTMLToLexical, editorConfigFactory } from '@payloadcms/richtext- import { del as blobDel } from '@vercel/blob' import { JSDOM } from 'jsdom' import { getPayload } from 'payload' -import { loadCliEnv } from './lib/cli.mjs' +import { dieWithLabel, loadCliEnv } from './lib/cli.mjs' import { assertLocalDatabase } from './assert-local-database.mjs' diff --git a/tests/unit/scriptCliConventions.unit.spec.ts b/tests/unit/scriptCliConventions.unit.spec.ts new file mode 100644 index 00000000..58b5e8b8 --- /dev/null +++ b/tests/unit/scriptCliConventions.unit.spec.ts @@ -0,0 +1,63 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Pass 4 (2026-07-31): `scripts/seed-posts.mjs` called `dieWithLabel(…)` +// without importing it — a ReferenceError on the empty-fetch abort path that +// lint, typecheck and knip all let through (P3-H half-adopted in the same pass +// that added the guard). The CLI skeleton is only a skeleton when the symbols +// it exports are actually imported. +// +// `DAY_MS` is single-sourced in `src/lib/text.ts` (P3-K); a private re-spell +// survived in `engagementLevel.ts` until this pass — ban the declaration form. + +const repoRoot = process.cwd() +const repoPath = (absolute: string) => relative(repoRoot, absolute) + +const walkFiles = (root: string, extensions: readonly string[]): string[] => + readdirSync(root, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && extensions.some((ext) => entry.name.endsWith(ext))) + .map((entry) => resolve(entry.parentPath, entry.name)) + +describe('script CLI skeleton (Pass 4)', () => { + const scriptFiles = walkFiles(resolve(repoRoot, 'scripts'), ['.mjs']) + + it('imports dieWithLabel / dieAgent wherever they are called', () => { + const offenders: string[] = [] + + for (const file of scriptFiles) { + const source = readFileSync(file, 'utf8') + for (const symbol of ['dieWithLabel', 'dieAgent'] as const) { + const callsIt = new RegExp(`\\b${symbol}\\s*\\(`).test(source) + if (!callsIt) continue + const definesIt = new RegExp(`export const ${symbol}\\b`).test(source) + const importsIt = new RegExp(`^import\\s*\\{[^}]*\\b${symbol}\\b[^}]*\\}`, 'm').test(source) + if (!definesIt && !importsIt) offenders.push(`${repoPath(file)} (${symbol})`) + } + } + + expect( + offenders, + 'import the CLI die helper from scripts/lib instead of calling it bare', + ).toEqual([]) + }) + + it('keeps DAY_MS single-sourced in src/lib/text.ts', () => { + const offenders: string[] = [] + const declaration = /const DAY_MS\s*=/ // this spec quotes the shape itself + + for (const root of ['src', 'tests', 'scripts'] as const) { + for (const file of walkFiles(resolve(repoRoot, root), ['.ts', '.tsx', '.mjs'])) { + const path = repoPath(file) + if (path === 'src/lib/text.ts') continue + if (path === repoPath(import.meta.filename)) continue + const lines = readFileSync(file, 'utf8').split('\n') + for (const [index, line] of lines.entries()) { + if (declaration.test(line)) offenders.push(`${path}:${index + 1}`) + } + } + } + + expect(offenders, 'import DAY_MS from @/lib/text instead of re-spelling it').toEqual([]) + }) +}) From 9ae062c77c903d794eca7faf471f8f2f640d5f4d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 05:09:36 +0000 Subject: [PATCH 2/6] =?UTF-8?q?refactor(lib):=20close=20the=20lib=E2=86=92?= =?UTF-8?q?utilities=20type-import=20hole=20+=20Local=20API=20omission=20g?= =?UTF-8?q?uard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - suggestionCatalog type-imports MunicipalityTerritorialClass from lib/territorialClassAnchors (the single source) instead of the utilities re-export — direction inversion introduced in Pass 3. - ESLint src/lib zone: @/utilities|components|app restriction now rejects type imports too (allowTypeImports: false); shared types live in lib contract modules by policy. - engagementLevel imports DAY_MS from lib/text (fold missed in P3-K). - searchHomeAdvisors: delete stale 'Intentional admin bypass' comment — the call is overrideAccess: false under the staff gate. - New guard localApiOverrideAccessConventions.unit.spec.ts: payload calls with user must declare overrideAccess (baseline measured: 59 calls, 0 missing), and runtime dynamic import/require of payload/next server modules is banned in utilities (dodges the server-only sweep). --- eslint.config.mjs | 9 +- src/lib/engagementLevel.ts | 4 +- src/lib/suggestionCatalog.ts | 2 +- .../homeSearch/searchHomeAdvisors.ts | 3 +- ...lApiOverrideAccessConventions.unit.spec.ts | 86 +++++++++++++++++++ 5 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 tests/unit/localApiOverrideAccessConventions.unit.spec.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 9953f30e..953ca8d4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -119,7 +119,10 @@ const eslintConfig = [ { // src/lib is the pure, client-safe layer (docs/ARCHITECTURE.md): it must // never depend on Payload/Next server code, on utilities/, or on higher - // layers. Sharing types across the boundary is always fine. + // layers. Types shared with utilities live in lib contract modules and + // utilities re-export FROM lib — a lib module type-importing utilities + // inverts the direction anyway (Pass 4: suggestionCatalog did exactly + // that for a type whose source already lived in lib). files: ['src/lib/**/*.{ts,tsx}'], rules: { '@typescript-eslint/no-restricted-imports': [ @@ -129,8 +132,8 @@ const eslintConfig = [ { group: ['@/utilities/**', '@/components/**', '@/app/**'], message: - 'src/lib is the pure layer — it must not depend on utilities/components/app. Move the pure logic into lib/ or this module into utilities/.', - allowTypeImports: true, + 'src/lib is the pure layer — it must not depend on utilities/components/app. Move the pure logic into lib/ or this module into utilities/. Shared types live in lib contract modules.', + allowTypeImports: false, }, { group: ['payload', 'payload/**', '@payload-config', 'next', 'next/**'], diff --git a/src/lib/engagementLevel.ts b/src/lib/engagementLevel.ts index 84fffbc1..cd93387c 100644 --- a/src/lib/engagementLevel.ts +++ b/src/lib/engagementLevel.ts @@ -9,6 +9,8 @@ * one object so recalibration is a one-line diff. */ +import { DAY_MS } from '@/lib/text' + export const engagementLevels = ['n0', 'n1', 'n2', 'n3', 'n4'] as const /** `allocationDecision.patternId` under which every level movement is filed. */ @@ -94,8 +96,6 @@ export type EngagementLevelMovementInput = { triangulatedShock: boolean } -const DAY_MS = 24 * 60 * 60 * 1000 - const daysBetween = (from: Date, to: Date): number => (to.getTime() - from.getTime()) / DAY_MS const isSameCalendarMonth = (left: Date, right: Date): boolean => diff --git a/src/lib/suggestionCatalog.ts b/src/lib/suggestionCatalog.ts index c55dcdc6..e13f87bc 100644 --- a/src/lib/suggestionCatalog.ts +++ b/src/lib/suggestionCatalog.ts @@ -10,9 +10,9 @@ import { } from '@/lib/electionYears' import type { EngagementLevel } from '@/lib/engagementLevel' import { engagementLevelRank } from '@/lib/engagementLevel' +import type { MunicipalityTerritorialClass } from '@/lib/territorialClassAnchors' import { DAY_MS } from '@/lib/text' import type { AllocationDecision } from '@/payload-types' -import type { MunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass' /** * E11 "motor de sugestões" — the versioned, curated catalog of data→decision diff --git a/src/utilities/homeSearch/searchHomeAdvisors.ts b/src/utilities/homeSearch/searchHomeAdvisors.ts index 6876bb99..c60f9fd5 100644 --- a/src/utilities/homeSearch/searchHomeAdvisors.ts +++ b/src/utilities/homeSearch/searchHomeAdvisors.ts @@ -29,7 +29,8 @@ export const searchHomeAdvisors = async ( const normalizedQuery = normalizeHomeSearchName(query) - // Intentional admin bypass — unrestricted staff gate already ran in the route. + // Under access (`overrideAccess: false`): the unrestricted-staff gate ran + // above, so the advisor directory is only queried for coordinator/candidate. const result = await payload.find({ collection: 'campaignUser', where: { role: { equals: 'advisor' } }, diff --git a/tests/unit/localApiOverrideAccessConventions.unit.spec.ts b/tests/unit/localApiOverrideAccessConventions.unit.spec.ts new file mode 100644 index 00000000..7adb5c08 --- /dev/null +++ b/tests/unit/localApiOverrideAccessConventions.unit.spec.ts @@ -0,0 +1,86 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Pass 4 (2026-07-31): the engineering standard is "Local API with `user` +// always with `overrideAccess: false`; bypass admin só com comentário +// justificando" — but until now nothing failed the build when a call passed +// `user` and simply omitted `overrideAccess` (Payload then bypasses access +// silently). Measured baseline at adoption: 59 calls with `user`, 0 missing +// (the undocumented-bypass ratchet in codebaseConventions covers the `true` +// side; this guard covers the omission side). +// +// Companion rule: the `server-only` sweep in codebaseConventions only sees +// static imports, so a runtime `await import('payload')` in utilities would +// dodge the mark. Banned outright — use a static import and the mark. + +const repoRoot = process.cwd() +const repoPath = (absolute: string) => relative(repoRoot, absolute) + +const srcFiles = readdirSync(resolve(repoRoot, 'src'), { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && /\.(ts|tsx)$/.test(entry.name)) + .map((entry) => resolve(entry.parentPath, entry.name)) + +const CALL_PATTERN = + /payload\.(find|create|update|delete|count|findByID|findGlobal|updateGlobal)\s*\(\s*\{/g + +/** Brace-matches the first `{…}` object literal starting at `openIndex`. */ +const callObjectBody = (source: string, openIndex: number): string => { + let depth = 0 + for (let index = openIndex; index < source.length; index++) { + if (source[index] === '{') depth++ + else if (source[index] === '}') { + depth-- + if (depth === 0) return source.slice(openIndex, index + 1) + } + } + return source.slice(openIndex) +} + +describe('Local API overrideAccess discipline (Pass 4)', () => { + it('declares overrideAccess on every payload call that passes user', () => { + const offenders: string[] = [] + + for (const file of srcFiles) { + const source = readFileSync(file, 'utf8') + CALL_PATTERN.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = CALL_PATTERN.exec(source)) !== null) { + const openIndex = source.indexOf('{', match.index) + const body = callObjectBody(source, openIndex) + if (!/\buser\s*:/.test(body)) continue + if (/overrideAccess\s*:/.test(body)) continue + const line = source.slice(0, match.index).split('\n').length + offenders.push(`${repoPath(file)}:${line} (payload.${match[1]})`) + } + } + + expect( + offenders, + 'Local API with `user` must declare overrideAccess (false under access; true only with the justifying comment)', + ).toEqual([]) + }) + + it('bans runtime dynamic import/require of server-bound modules in utilities', () => { + const offenders: string[] = [] + const dynamicServerImport = + /\bawait\s+import\(\s*['"](?:payload|@payload-config|next\/(?:cache|server|headers))['"]\s*\)/ + const requireServerModule = /\brequire\(\s*['"](?:payload|@payload-config)['"]\s*\)/ + + for (const file of srcFiles) { + const path = repoPath(file) + if (!path.startsWith('src/utilities/')) continue + const lines = readFileSync(file, 'utf8').split('\n') + for (const [index, line] of lines.entries()) { + if (dynamicServerImport.test(line) || requireServerModule.test(line)) { + offenders.push(`${path}:${index + 1}`) + } + } + } + + expect( + offenders, + "use a static import plus `import 'server-only'` — dynamic imports dodge the boundary sweep", + ).toEqual([]) + }) +}) From 6f42bc3bf332653168416b608c84f1be6c195860 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 05:50:41 +0000 Subject: [PATCH 3/6] fix(miss-52): one shared Drawer for the chip-relation lists too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The B42 fix reached the municípios mobile cards, but the three chip-relation surfaces still mounted a Drawer root per OPENED cell on coarse pointers (lideranças 50 cells, dobradinhas 50, assessores 25): the exact hundreds-of-hidden-instances class that tripped React #130 in prod. Each now wraps its table in CampaignListSheetProvider — cell bodies portal into the single host Drawer, fine-pointer popover path untouched (behavior delta: after opening N cells sequentially on touch, N drawer roots -> 1). --- .../campanha/(app)/dobradinhas/page.tsx | 21 +- .../campanha/(app)/liderancas/page.tsx | 21 +- .../campaign/advisor/AdvisorsTable.tsx | 493 +++++++++--------- 3 files changed, 276 insertions(+), 259 deletions(-) diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx index 55ade6e5..c8e9a3f9 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx @@ -10,6 +10,7 @@ import { CampaignListResults, CampaignTransitionAnchor, } from '@/components/campaign/shared/CampaignListPending' +import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost' import { CampaignTable, CampaignTableHead, @@ -249,14 +250,18 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP

{sortSummary}

- row.id} - empty={} - /> + {/* One shared Drawer for every chip-cell sheet on coarse pointers + (miss #52 — never a Drawer root per opened cell). */} + + row.id} + empty={} + /> + {rows.length ? ( {sortSummary}

- row.id} - empty={} - /> + {/* One shared Drawer for every chip-cell sheet on coarse pointers + (miss #52 — never a Drawer root per opened cell). */} + + row.id} + empty={} + /> + {rows.length ? ( -
- {editing ? ( - - ) : ( - - )} - -
- - {showEmpty ? ( - - {!hasQuery ? ( - - ) : null} - - ) : ( -
- - - - Nome - E-mail - Celular - Municípios - - Ações - - - - - {draft ? ( - - - { - const name = event.currentTarget.value - setDraft((current) => (current ? { ...current, name } : current)) - }} - placeholder="Nome" - aria-label="Nome do novo assessor" - className={campaignInlineInputClassName} - autoFocus - /> - - - { - const email = event.currentTarget.value - setDraft((current) => (current ? { ...current, email } : current)) - }} - placeholder="E-mail" - aria-label="E-mail do novo assessor" - className={campaignInlineInputClassName} - /> - - - { - const phone = formatBrazilianPhoneInput( - sanitizeBrazilianPhoneInput(event.currentTarget.value), - ) - setDraft((current) => (current ? { ...current, phone } : current)) - }} - placeholder="Celular" - aria-label="Celular do novo assessor" - className={campaignInlineInputClassName} - /> - - - - setDraft((current) => (current ? { ...current, municipalityIds } : current)) - } - commitAction={municipalitiesAction} - drawerTitle="Carteira do assessor" - updateErrorMessage="Não foi possível atualizar a carteira." - /> - - -
- - -
-
+ ) : ( + + )} + + + + {showEmpty ? ( + + {!hasQuery ? ( + + ) : null} + + ) : ( +
+
+ + + Nome + E-mail + Celular + Municípios + + Ações + - ) : null} - - {rows.map((row) => { - const whatsAppHref = whatsAppHrefForPhone(row.phone) - const emailShown = displayEmail(row.email) - - return ( - + + + {draft ? ( + - {editing ? ( - - ) : ( - - {row.name} - - )} + { + const name = event.currentTarget.value + setDraft((current) => (current ? { ...current, name } : current)) + }} + placeholder="Nome" + aria-label="Nome do novo assessor" + className={campaignInlineInputClassName} + autoFocus + /> - {editing ? ( - - ) : ( - - )} + { + const email = event.currentTarget.value + setDraft((current) => (current ? { ...current, email } : current)) + }} + placeholder="E-mail" + aria-label="E-mail do novo assessor" + className={campaignInlineInputClassName} + /> - {editing ? ( - - ) : ( - - )} + { + const phone = formatBrazilianPhoneInput( + sanitizeBrazilianPhoneInput(event.currentTarget.value), + ) + setDraft((current) => (current ? { ...current, phone } : current)) + }} + placeholder="Celular" + aria-label="Celular do novo assessor" + className={campaignInlineInputClassName} + /> + setDraft((current) => + current ? { ...current, municipalityIds } : current, + ) + } commitAction={municipalitiesAction} drawerTitle="Carteira do assessor" updateErrorMessage="Não foi possível atualizar a carteira." @@ -379,45 +277,154 @@ export const AdvisorsTable = ({
- {whatsAppHref ? ( - - ) : ( - - )} - + +
- ) - })} -
-
-
- )} - + ) : null} + + {rows.map((row) => { + const whatsAppHref = whatsAppHrefForPhone(row.phone) + const emailShown = displayEmail(row.email) + + return ( + + + {editing ? ( + + ) : ( + + {row.name} + + )} + + + {editing ? ( + + ) : ( + + )} + + + {editing ? ( + + ) : ( + + )} + + + + + +
+ {whatsAppHref ? ( + + ) : ( + + )} + +
+
+
+ ) + })} + + + + )} + + ) } From c83cf284970fbd27dc11953cc0249b4347362ae5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 05:50:41 +0000 Subject: [PATCH 4/6] test(miss-52): sheet cells without the shared host fail loud MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CampaignCellEditOverlay: dev-mode console.error when a variant=sheet cell mounts without CampaignListSheetProvider (prod keeps the lazy-mount fallback for resilience; unit tests exercise it, so the invariant is development-only). - sharedSheetHostConventions.unit.spec.ts: any tsx rendering { + if (variant !== 'sheet' || sharedSheet) return + if (process.env.NODE_ENV !== 'development') return + console.error( + 'CampaignCellEditOverlay: variant="sheet" sem CampaignListSheetProvider — ' + + 'cada célula aberta mantém um Drawer próprio (miss #52). Envolva a lista no provider.', + ) + }, [variant, sharedSheet]) + /** * The region is mounted from the first open onwards and never unmounted * again — a live region has to exist BEFORE its text changes or most screen diff --git a/tests/unit/sharedSheetHostConventions.unit.spec.ts b/tests/unit/sharedSheetHostConventions.unit.spec.ts new file mode 100644 index 00000000..de42e059 --- /dev/null +++ b/tests/unit/sharedSheetHostConventions.unit.spec.ts @@ -0,0 +1,41 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Miss #52 (2026-07-30): the municipalities list hydrated ~125 idle Drawer +// roots (25 rows × 5 sheet controls) and prod tripped React #130 — CSS +// `display:none` does not remove hydration cost. The fix pattern is ONE +// shared Drawer per list surface via `CampaignListSheetProvider`, with cell +// bodies portaled into it. The provider reached the municípios mobile cards +// and this pass (2026-07-31) the three chip-relation lists (lideranças, +// dobradinhas, assessores) — this guard keeps the next chip-cell table from +// mounting sheet overlays providerless. +describe('shared sheet host for chip-relation cells (miss #52)', () => { + const componentRoots = ['src/components', 'src/app'] as const + const chipCellUsage = / { + const offenders: string[] = [] + + for (const root of componentRoots) { + const files = readdirSync(resolve(process.cwd(), root), { + recursive: true, + withFileTypes: true, + }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.tsx')) + .map((entry) => resolve(entry.parentPath, entry.name)) + + for (const file of files) { + const source = readFileSync(file, 'utf8') + if (!chipCellUsage.test(source)) continue + if (source.includes('CampaignListSheetProvider')) continue + offenders.push(relative(process.cwd(), file)) + } + } + + expect( + offenders, + 'chip-relation cells open sheet overlays on coarse pointers — wrap the table in CampaignListSheetProvider (one shared Drawer per list)', + ).toEqual([]) + }) +}) From d2791050da28f66319b6975fe065f1733a30c0f5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 06:34:12 +0000 Subject: [PATCH 5/6] =?UTF-8?q?docs(pass-4):=20audit=20artifacts=20?= =?UTF-8?q?=E2=80=94=20plan=20section,=20ledger=20reconcile,=20delivery=20?= =?UTF-8?q?plan,=20GUARDRAILS.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autonomous engineering-audit 2026-07-31 (precheck: gh-doctor OK, pool desligado). Delta anchor Pass 3 (880 files in 3 days). Baseline green (tsc/lint/knip/cycles; 774 files madge). - IMPROVE-CODE-QUALITY-PLAN: Pass 4 section — 1 P0 (globals Boolean(user) -> PR #91), 1 P1 (seed-posts dieWithLabel -> PR #92), 5 misses -> 5 guardrails (PRs #93-#95, #83 verified+closed), decisions D1-D6. - TECH-DEBT: rows 33/34 verified fixed and closed (P3-A/P3-B); 17/19/23/ 35/40/45 updated with measured counts and trigger status; 15 new rows with evidence (homeSearch twins, notification write path, wizard drawers, B14-class labels import, bypass-header dodge, dev-mode chip failure with instrumentation, god modules, fixture honesty...). - docs/plans/entrega-engenharia-p4.md: waves P4-A..P4-L, guard map (9 new guards, 2 hardened, judgment-only residue declared), 12 defer+trigger, 6 rejected look-alikes, execution rules. - docs/GUARDRAILS.md (new, OPS2 scope): one row per guardrail — origin, determinism class 1-6, mechanism, status; active, precedents, queue. - CHANGELOG-AGENTS: one delivery entry per convention. Aikido not run (MCP unavailable in this Cloud env — ledger row noted). Closes #43 --- docs/CHANGELOG-AGENTS.md | 5 + docs/GUARDRAILS.md | 43 +++++++ docs/IMPROVE-CODE-QUALITY-PLAN.md | 51 +++++++- docs/TECH-DEBT.md | 33 +++-- docs/plans/entrega-engenharia-p4.md | 187 ++++++++++++++++++++++++++++ 5 files changed, 310 insertions(+), 9 deletions(-) create mode 100644 docs/GUARDRAILS.md create mode 100644 docs/plans/entrega-engenharia-p4.md diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 84233583..7bc8377c 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,11 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- + +**Recently resolved (2026-07-31):** **Pass 4 — auditoria de engenharia autônoma + guardrails das misses** — primeira execução em modo autônomo (Cursor Cloud) da skill engineering-audit, com precheck solitário (pool desligado) e primeiro harvest de `kind:agent-miss` (escopo OPS2: nasce `docs/GUARDRAILS.md`). **P0:** os 4 globals públicos (`site-settings`/`home`/`metadata`/`privacy-policy`) aceitavam qualquer autenticado no `update` — JWT de campanha (liderança inclusa) editava o site público via `/api/globals/*`; lockdown `payloadAdminOnly` + pins + guarda ([PR #91](https://github.com/fsolla/teqo/pull/91); editores perdem escrita nos globals de config deliberadamente). **P1:** `seed-posts.mjs` chamava `dieWithLabel` sem importar ([PR #92](https://github.com/fsolla/teqo/pull/92), com guarda de presença de import + `DAY_MS` single-source + ESLint lib→utilities sem type-import + guarda de `overrideAccess` omitido). **Misses:** #73 (deadlock `municipality_rels` — classe reproduzida: 7 violações int + 11 deep links e2e; migração para o alocador com teste de classe reescrito classe-agnóstico + guarda de 3 regras, [PR #93](https://github.com/fsolla/teqo/pull/93)); #52 (Drawer por célula em 3 listas de chips → `CampaignListSheetProvider` + invariante dev + guarda, [PR #94](https://github.com/fsolla/teqo/pull/94)); #53 (portão de readiness do autenticador virtual) + #54 (guarda de goto não assentado + convenção TESTING.md) em [PR #95](https://github.com/fsolla/teqo/pull/95); #83 fechada (guarda `agent:ship` já viva). **Registrado, não retry-fixed:** spec de chips de `campaignLeaderships` falha deterministicamente em dev na árvore limpa (nenhum POST dispara; prod 48/48 verde) — causa raiz via debug workflow (P4-L). Artefatos: seção Pass 4 no plano de qualidade, 15 linhas novas no ledger (2 stale fechadas: P3-A/P3-B), plano [`entrega-engenharia-p4.md`](docs/plans/entrega-engenharia-p4.md) com ondas P4-A→P4-L + mapa de guardas + defer+gatilho. + +**Recently resolved (2026-07-31):** **CI build skip para diffs fora da árvore buildável** — `scripts/build-affected.mjs` + `classifyBuildScope` (núcleo em `test-affected-core.mjs`) decidem `run|none`; job `build-scope` barato no `ci-pr.yml` e o job `build` (Postgres + `pnpm build`) só roda em `mode=run`. Docs/tests/tooling/workflows pulam o build; `src/`, `public/` e configs do Next (`next.config`/`tsconfig`/`tailwind`/`postcss`/`components.json`/lockfile) forçam. `gate:ci` espelha. Push em `main` (`ci.yml`) continua sempre full. + **Recently resolved (2026-07-31):** **Consolidação de Issues (project-status)** — merge **B68+** (#39) → **O0+** (#35) (mesmo `model: kimi-k3-low`); **não** merge D3/D4/D5 (modelos mistos kimi vs composer); **não** merge B53–B55 (já `done`); fatia **B79** → chassis (#15) + **B91** busca no drawer (#76, `depends: [B79]`). Planos: `escala-dry-pos-onda0.md`, `busca-global-bottom-drawer.md`, `chassis-bottom-drawer-acoes-rapidas.md`. ## Known Gaps (as of 2026-07-24 — resolve before relying on this file for onboarding new devs) diff --git a/docs/GUARDRAILS.md b/docs/GUARDRAILS.md new file mode 100644 index 00000000..443d6115 --- /dev/null +++ b/docs/GUARDRAILS.md @@ -0,0 +1,43 @@ +# Guardrails + +Ledger dos guardrails determinísticos colhidos de `kind:agent-miss` (fluxo `pnpm agent:file-miss` → harvest no engineering-audit → guarda programática) e dos endurecimentos de guarda existentes. Uma linha por guarda: origem, classe na escada de determinismo, mecanismo, status. + +**Classes (escada do Pass 3 D6 / skill engineering-audit passo 4b):** 1 tipo (estado ruim irrepresentável) · 2 ESLint · 3 spec de convenção · 4 análise estática em CI (knip/madge) · 5 pin comportamental · 6 doc/convenção (judgment-only declarado — não fingir que doc é guarda). + +## Ativos + +| Guarda | Origem | Classe | Mecanismo | Status | +| ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| `agent:ship` exige stamp de /simplify casado com HEAD; PR Ready + auto-merge + watch até MERGED | [#83](https://github.com/fsolla/teqo/issues/83) | 3 (script + hooks) | `scripts/agent-ship.mjs`, `.cursor/hooks/guard-pr-create.mjs` (bloqueia `gh pr create` manual), `.cursor/hooks/pr-merge-watchdog.mjs` (re-engata com PR aberto) | vivo desde 2026-07-31 (PR #89); miss fechada no Pass 4 | +| Alocador de municípios em specs — sem slug fixo + mutação; sem `municipio=` em e2e; regex de nome de catálogo ancorada | [#73](https://github.com/fsolla/teqo/issues/73) | 3 | `tests/unit/testMunicipalityAllocatorConventions.unit.spec.ts` (3 regras; mutações = assign/touch/create\*/`payload.update` municipality) | PR [#93](https://github.com/fsolla/teqo/pull/93) (Pass 4) | +| Sheet compartilhada por lista — chip cells exigem `CampaignListSheetProvider`; invariante dev no overlay | [#52](https://github.com/fsolla/teqo/issues/52) | 3 + 5 | `tests/unit/sharedSheetHostConventions.unit.spec.ts`; `console.error` development-only em `CampaignCellEditOverlay` | PR [#94](https://github.com/fsolla/teqo/pull/94) (Pass 4) | +| Navegação e2e assentada — goto→goto sem settle falha o build | [#54](https://github.com/fsolla/teqo/issues/54) | 3 | `tests/unit/e2eNavigationConventions.unit.spec.ts` (settle = await expect/waitForURL/click/reload/login/catch) | PR [#95](https://github.com/fsolla/teqo/pull/95) (Pass 4) | +| Readiness de autenticador virtual antes da cerimônia WebAuthn | [#53](https://github.com/fsolla/teqo/issues/53) | 3 | mesma spec (spec com `WebAuthn.addVirtualAuthenticator` deve chamar `expectCampaignBiometricsReady(`) | PR [#95](https://github.com/fsolla/teqo/pull/95) (Pass 4) | +| Convenção de navegação e2e (goto = cold load/contrato; in-shell = continuidade) | [#54](https://github.com/fsolla/teqo/issues/54) | 6 (**judgment-only**) | parágrafo em `docs/TESTING.md` | PR [#95](https://github.com/fsolla/teqo/pull/95) (Pass 4) | +| Update de globals públicos nunca "qualquer autenticado" | P0 do Pass 4 (sem issue) | 3 | `tests/unit/globalAccessConventions.unit.spec.ts` ban `Boolean(user)` em `src/globals` | PR [#91](https://github.com/fsolla/teqo/pull/91) (Pass 4) | +| Loaders de notificação tipados pelo ator (recipientID arbitrário irrepresentável) | P0 adjacente do Pass 4 | 1 | assinatura `(payload, user: CampaignUser)` + `overrideAccess: false` sob `canReadOwnNotifications` | PR [#91](https://github.com/fsolla/teqo/pull/91) (Pass 4) | +| Die-helpers da CLI importados onde usados; `DAY_MS` single-source | P1 do Pass 4 (sem issue) | 3 | `tests/unit/scriptCliConventions.unit.spec.ts` | PR [#92](https://github.com/fsolla/teqo/pull/92) (Pass 4) | +| `src/lib` não type-importa `@/utilities/**` (direção única também para tipos) | dodge do Pass 3 (suggestionCatalog) | 2 | ESLint `no-restricted-imports` `allowTypeImports: false` na zona `src/lib` | PR [#92](https://github.com/fsolla/teqo/pull/92) (Pass 4) | +| Local API com `user` sempre declara `overrideAccess`; import dinâmico de módulos server banido em utilities | guarda ausente (medida baseline 59/0) | 3 | `tests/unit/localApiOverrideAccessConventions.unit.spec.ts` | PR [#92](https://github.com/fsolla/teqo/pull/92) (Pass 4) | + +## Precedentes vivos (anteriores ao primeiro harvest) + +| Guarda | Origem | Classe | Mecanismo | +| ------------------------------------------------------------------------ | ----------------- | ------ | ------------------------------------------------------------------------------------------------ | +| Vocabulário banido (Praça/Núcleo/Plano de Ação, accent-tolerant) | C13 / W4f / P3-K | 3 | `codebaseConventions.unit.spec.ts` | +| `server-only` em utilities acopladas (sweep payload/next/\*) | Pass 2 W2 / P3-K | 3 | `codebaseConventions.unit.spec.ts` | +| Escadas de formActions nos wrappers (`runCampaignFormAction`/`Redirect`) | W4d / C8 F4b | 3 | `codebaseConventions.unit.spec.ts` (filename `*FormActions.ts` — dodge `actions/*.ts` ledgerado) | +| Ratchet de bypass admin não documentado | P3-E | 3 | `codebaseConventions.unit.spec.ts` (por arquivo, só diminui) | +| Mensagens de recusa como constantes + correspondência throw↔safelist | P3-A | 3 | `codebaseConventions.unit.spec.ts:216-263` | +| `as never` banido | Pass 1 | 2 | ESLint `no-restricted-syntax` | +| `src/lib` não importa `utilities/` (valores) | Pass 2 W2 | 2 | ESLint `no-restricted-imports` | +| 0 ciclos de import | Pass 2 | 4 | madge (`pnpm check:cycles`) | +| Dead code (arquivos/deps/exports em erro) | Pass 2 W4b | 4 | knip em CI | +| `migration-lock` (≤1 PR aberto tocando schema) | agentes paralelos | 4 | job em `ci-pr.yml` | +| Seed mínimo obrigatório com migration (contrato manifest) | OPS fluxo PR | 3 + 5 | `tests/unit/seedMinimalManifest.unit.spec.ts` + job CI migrate→seed→int | +| Regex de template ancorada em e2e | P3-C | 3 | `codebaseConventions.unit.spec.ts` (ampliada para literais no Pass 4) | +| Consent fail-closed por chave estável | D3/Onda 0 | 5 | pins int `submitWhatsapp`/liderança/apoiador | + +## Misses colhidas aguardando guarda (fila do harvest) + +(nenhuma — todas as 5 abertas em 2026-07-31 foram classificadas: 4 com guarda shippada + 1 com guarda já viva. Novas `kind:agent-miss` entram aqui até o próximo audit.) diff --git a/docs/IMPROVE-CODE-QUALITY-PLAN.md b/docs/IMPROVE-CODE-QUALITY-PLAN.md index 360c9230..5e4b943d 100644 --- a/docs/IMPROVE-CODE-QUALITY-PLAN.md +++ b/docs/IMPROVE-CODE-QUALITY-PLAN.md @@ -1,6 +1,6 @@ # Improve Code Quality Plan -Three passes so far: **Pass 1** (2026-07-23/24, phases 0–6 below, all done), **Pass 2** (2026-07-25, see [Pass 2](#pass-2--engineering-consolidation-2026-07-25)) and **Pass 3** (2026-07-28, audit + plan, see [Pass 3](#pass-3--engineering-audit--consolidation-2026-07-28)). +Four passes so far: **Pass 1** (2026-07-23/24, phases 0–6 below, all done), **Pass 2** (2026-07-25, see [Pass 2](#pass-2--engineering-consolidation-2026-07-25)), **Pass 3** (2026-07-28, audit + plan, see [Pass 3](#pass-3--engineering-audit--consolidation-2026-07-28)) and **Pass 4** (2026-07-31, audit + P0/P1 remediation + miss guardrails in-session, see [Pass 4](#pass-4--engineering-audit--remediation--guardrails-2026-07-31)). ## Context @@ -162,3 +162,52 @@ Statuses: pending · in-progress · awaiting-evidence · done · deferred: \`) with ~8 admin bypasses missing the mandatory justification comment (plus ~10 more across WebAuthn ceremony, import token, phone invariant, consent, invite, map/page data) | `campaignDashboardData.ts:57-74`, `leadershipData.ts:56-72`, `organizationData.ts:64-79`, `campaignDemandData.ts:67-133,250-259`, `stateDeputyData.ts:251-266`, `votePledgeData.ts:130-146`, `activityDetailPageData.ts:25-51`, `municipalityUpdatePageData.ts:86-99` | duplication + convention breach | low-medium | M | P2 | closed 2026-07-29 (P3-E): typed wrappers in `loadNamesByIds.ts` (bypass comment once; the `Record` union died); ratchet guard in `codebaseConventions` pins per-file undocumented-bypass counts (only shrinks); plan-named sites documented | P3-E | | Pledge aggregate fold spelled twice — list vs dossier surfaces must never disagree (E9 had to add `lastPledgeAt` to both); staff scope-read proliferation ×3 sharing a 5-field base select (E11's own-read documented) | `votePledgeData.ts:53-107` vs `votePledgeViews.ts:80-108`; `campaignMunicipalityScope.ts:39-59`, `municipalityTriggers.ts:202-243`, `visitPlannerData.ts:162-188` | duplication | medium — two surfaces can silently diverge | M | P2 | closed 2026-07-29 (P3-E): `aggregatePledgesWhere` delegates the bucket fold to `aggregateMunicipalityPledgesFromRows` (int pin: dossiê === lista); `loadMunicipalityScope(payload, user, where, { extraSelect })` with extras in the React `cache()` key (unit pin: cache-key separation), adopted by `municipalityTriggers` — its own município read died | P3-E | | `MUNICIPALITY_CATALOG_CACHE_TAG` lives beside a `next/cache` import (no `server-only`) and is imported by a pure module — latent bundle hazard of the B14/B34+ class; `electionCache.ts` (tag alone) is the correct precedent | `src/utilities/municipalityRevalidation.ts:1,11` + importers `revalidateRequest.ts:3`, `municipalityPortfolioIndex.ts:9` | boundary | low | S | P2 | closed 2026-07-29 (P3-E): tag moved to `municipalityCatalogCache.ts` (tag alone, `server-only`, the electionCache precedent); `municipalityRevalidation.ts` marked `server-only` | P3-E | -| List-URL machine family spelled per domain: sort-toggle ×4, sort-value codec ×3, multi-toggle+page-reset ×4, canonicalize-round-trip ×6, serialization tail ×4; plus `firstValue` ×3 spellings, `XNotFoundError` ×3, `parseRegionsParam`+`canonicalTerritoryBySearchValue` ×2 (bundle-constrained — can't join `campaignListUrl`) | `municipalityListUrl.ts:426,440,451,508`, `leadershipListUrl.ts:192,203,216`, `stateDeputyListUrl.ts:158,165,178,202`, `territoryListUrl.ts:130,138,150,189`, `detailTabUi.ts:8-10`, `municipalityUpdatePageData.ts:39-42` | duplication | medium | M | P2 | partially closed 2026-07-29 (P3-F): `createSortToggleHref` adopted ×4; `territoryRegionParam.ts` (byte-identical parser died ×2, bundle-constrained as planned); `createEntityNotFoundError` adopted ×3. **Remaining open (defer + trigger: 3rd sort dropdown or a 5th multi-filter):** sort-value codec ×3, multi-toggle+page-reset ×4, canonicalize-round-trip ×6, serialization tail ×4, `firstValue` private spellings ×3 | P3-F | +| List-URL machine family spelled per domain: sort-toggle ×4, sort-value codec ×3, multi-toggle+page-reset ×4, canonicalize-round-trip ×6, serialization tail ×4; plus `firstValue` ×3 spellings, `XNotFoundError` ×3, `parseRegionsParam`+`canonicalTerritoryBySearchValue` ×2 (bundle-constrained — can't join `campaignListUrl`) | `municipalityListUrl.ts:426,440,451,508`, `leadershipListUrl.ts:192,203,216`, `stateDeputyListUrl.ts:158,165,178,202`, `territoryListUrl.ts:130,138,150,189`, `detailTabUi.ts:8-10`, `municipalityUpdatePageData.ts:39-42` | duplication | medium | M | P2 | partially closed 2026-07-29 (P3-F): `createSortToggleHref` adopted ×4; `territoryRegionParam.ts` (byte-identical parser died ×2, bundle-constrained as planned); `createEntityNotFoundError` adopted ×3. **Remaining open (defer + trigger: 3rd sort dropdown or a 5th multi-filter):** sort-value codec ×3, multi-toggle+page-reset ×4, canonicalize-round-trip ×6, serialization tail ×4, `firstValue` private spellings ×3. **Verified 2026-07-31 (Pass 4): trigger NOT fired** — still 3 codecs / 4 multi-toggles; leadership has no sort dropdown codec | P3-F | | Legacy filter shells `ActivityFilters`↔`SupporterFilters` duplicate ~50 lines each and bypass `useCampaignListFilterNavigation` (they lose its no-op navigation guard — an RSC round trip fires even when re-selecting the active value); `CampaignSearchForm` hand-serializes `?q=` past the canonical builders (latent: first future filter param silently stripped on search submit); search idiom split (4 lists debounce vs 3 explicit submit, no recorded decision) | `components/campaign/activity/ActivityFilters.tsx:80-146,185-213`, `components/campaign/supporter/SupporterFilters.tsx:109-146,156-227`, `components/campaign/shared/CampaignSearchForm.tsx:30-31,41-43` | duplication + latent bug | medium | M | P2 | closed 2026-07-29 (P3-F): `useCampaignFilterValues` (committed-href no-op guard) + `CampaignCollapsibleFilterPanel` extracted and adopted by both shells; `CampaignSearchForm.filterParams` carries the canonical serialization of active filters (organizações adopted — `?kind=` survives a search submit); search idiom unified on 1 s debounce everywhere (`SEARCH_DEBOUNCE_MS`), explicit submit kept as the immediate path in `CampaignSearchForm` — decision recorded here | P3-F | | Scripts re-spell the CLI skeleton: `die` ×7, `sha256` ×5, download→cache→sha256 ×5 (~20 lines each), `writeJson/writeText` ×5 (the newest script copied the OLD form — live drift), `cacheDir` ×5, dotenv preamble ×6; **two municipality-name resolvers diverged** — the supporter/CSV path lacks the TSE alias table and rejects 4 of 5 curated spellings ("Camacã", "QUINJINGUE", "Santa Terezinha", "Muquém do São Francisco"); `LOCAL_HOSTS` ×2 diverged; one-shot `generate-remodel-municipalities-migration.mjs` left behind holding 2 allowlist entries | `scripts/seed-tse-results.mjs`, `build-bahia-geometries.mjs`, `build-municipality-zone-geometries.mjs`, `build-bahia-demographics.mjs`, `build-bahia-tse-city-codes.mjs`, `seed-posts.mjs`, `db-pull.mjs`; `lib/schemas/supporter.ts:19-28` vs `lib/electionResults.ts:120-184` | duplication + latent import bug | medium | M | P2 | closed 2026-07-29 (P3-H): `scripts/lib/cli.mjs` (`dieWithLabel`/`sha256Hex`/`loadCliEnv`/`ensureCachedDownload`/`writeRepoFile`/`LOCAL_HOSTS`/`isRemoteDbOverrideSet`) adopted by 10 scripts, artifacts proven byte-identical (only `accessed` dates moved, reverted); **latent bug fixed**: single fold `municipalityNameAliases.ts` — supporter import now accepts all 5 curated spellings, pinned for BOTH paths in `municipalityNameAliases.unit.spec.ts`; db-pull gained the `postgres` host (deliberate); one-shot deleted + 2 allowlist entries freed; CLI-skeleton ban guard (die/sha256/dotenv/LOCAL_HOSTS) | P3-H | | **`utilities/` subfoldering trigger (Pass 2 D1) has FIRED:** 122 top-level modules (was 92); new in 30 days (`git --diff-filter=A`): municipality +21, supporter +10, territory +5, leadership +5, webauthn +5, visit +4 — 6 domains past "3rd new module in a month". Pure modules stranded in the layer: `campaignTime.ts` (140 lines, zero imports), `relationship.ts` (33 lines, zero imports) → `lib/` | `src/utilities/` | organization | low (path churn) | M | P2 | closed 2026-07-29 (P3-J): 41 modules moved — `municipality/` (incl. `campaignMunicipalityScope`, `goalCoverage`), `supporter/`, `territory/` (+`loadTerritoryOverview`), `leadership/`, `visit/`, `webauthn/`; direct imports, no barrels; `campaignTime` + `relationship` → `lib/`; top-level of `src/utilities/` pinned in `codebaseConventions` (D1 trigger encoded — new top-level module fails the build unregistered); `codebase-map.mdc` + `ARCHITECTURE.md` updated in the same delivery | P3-J | | `createdBy`/actor-stamp field duplicated verbatim across 5–7 collections + stamping hook ×3 (organic drift already: Supporter/Leadership grew `read:`, CampaignInvite is `required`); a field factory precedent exists (`voteEstimateScenarioFields`); `trimmedText` ×4 byte-identical in collection configs (pure helper outside `lib/`) | `CampaignDemand.ts:382-394`, `Organization.ts:121-133`, `StateDeputy.ts:105-117`, `AllocationDecision.ts:140-151`, `VotePledge.ts:211-222`, `Supporter.ts:214-227`, `Leadership.ts:222-234`; hooks `Organization.ts:54-62`, `StateDeputy.ts:54-62`, `Leadership.ts:63-69` | duplication | low | S | P3 | closed 2026-07-29 (P3-I): `campaignAuditFields.ts` (`systemStampedActorField` with `required`/`indexed`/`readAccess` data + `stampCampaignCreatedBy`) adopted by 9 collections — zero schema delta (`declaredBy` keeps `indexed: false`, no migration); `trimmedText` → `lib/text.ts` ×4; guard bans hand-spelled actor fields | P3-I | -| Route layer: W4d filename guard escapes — 7 hand-rolled form-action ladders in `actions/*.ts` invisible to the `*FormActions.ts` guard (3 expressible today with zero delta: profile avatar ×2, leaderSupporter); **convite-login exception stale** (not allowlisted; now expressible via `runCampaignRedirectFormAction`); `password.ts`/`auth.ts` genuinely bespoke but unregistered; staff-page prologue ×22 (~110 lines) with `return null` vs `redirect` divergence ×5; route body schema re-spelled instead of omit/extend (`municipios/advisors/route.ts:16-20`); `requireConsentByKey` called raw where the named wrapper exists (`leaderSupporter.ts:99-104`) | `actions/profile.ts:37-105,107-151`, `actions/leaderSupporter.ts:99-104,151-190`, `campanha/convite/[token]/formActions.ts:68-98`, 22 `(app)` pages | duplication + convention breach | low-medium | M | P3 | partially closed 2026-07-29 (P3-I): `requireCampaignPageActor({gate, redirectTo})` adopted by all 30 `(app)` pages — the `return null` ×5 divergence died (missing session now always redirects to login); guard bans `getCampaignUser()` in route pages. **Remaining open (defer + trigger: the next bespoke ladder or route schema):** 7 hand-rolled `actions/*.ts` ladders (3 expressible: profile avatar ×2, leaderSupporter), stale convite-login exception, password.ts/auth.ts registration in the W4d guard widening, `municipios/advisors/route.ts` omit/extend, `requireConsentByKey` raw call in leaderSupporter | P3-I | +| Route layer: W4d filename guard escapes — 7 hand-rolled form-action ladders in `actions/*.ts` invisible to the `*FormActions.ts` guard (3 expressible today with zero delta: profile avatar ×2, leaderSupporter); **convite-login exception stale** (not allowlisted; now expressible via `runCampaignRedirectFormAction`); `password.ts`/`auth.ts` genuinely bespoke but unregistered; staff-page prologue ×22 (~110 lines) with `return null` vs `redirect` divergence ×5; route body schema re-spelled instead of omit/extend (`municipios/advisors/route.ts:16-20`); `requireConsentByKey` called raw where the named wrapper exists (`leaderSupporter.ts:99-104`) | `actions/profile.ts:37-105,107-151`, `actions/leaderSupporter.ts:99-104,151-190`, `campanha/convite/[token]/formActions.ts:68-98`, 22 `(app)` pages | duplication + convention breach | low-medium | M | P3 | partially closed 2026-07-29 (P3-I): `requireCampaignPageActor({gate, redirectTo})` adopted by all 30 `(app)` pages — the `return null` ×5 divergence died (missing session now always redirects to login); guard bans `getCampaignUser()` in route pages. **Remaining open (defer + trigger: the next bespoke ladder or route schema):** 7 hand-rolled `actions/*.ts` ladders (3 expressible: profile avatar ×2, leaderSupporter), stale convite-login exception, password.ts/auth.ts registration in the W4d guard widening, `municipios/advisors/route.ts` omit/extend. **Verified 2026-07-31 (Pass 4):** new actions (notifications/webauthn/suggestion/wizardLeadership) DO use the shared wrappers — count holds at 7; the raw `requireConsentByKey` call is gone (leaderSupporter uses `requireSupporterRegistrationConsent`); 33/34 pages on `requireCampaignPageActor` + 1 redirect (`assessores/novo`, guard covers only `getCampaignUser`) | P3-I | | Convention guards dodgeable: banned-vocabulary regex `/praç|núcleo/i` misses unaccented `praca`/`nucleo` (the sibling "Plano de Ação" row was hardened accent-tolerant); `server-only` guard sweeps only `payload\|@payload-config` value imports — `next/cache`/`next/server` importers (`municipalityRevalidation.ts`, `campaignJsonMutationRoute.ts`) are server-bound and invisible; no mechanism stops a client component value-importing the three client-safe modules that drag the 623 KB TSE artifact (`municipalityTerritorialClass`, `municipalityPotential`, `territoryIntraCaptureBenchmark` — B13 avoided by discipline) | `tests/unit/codebaseConventions.unit.spec.ts:50,71-80` | tooling gap | low | S | P3 | closed 2026-07-29 (P3-K): vocabulary regex now `/pra[çc]\|n[úu]cleo/i` (2 genuine-data hits allowlisted); `server-only` sweep widened to `next/cache`/`next/server`/`next/headers` (`campaignJsonMutationRoute` marked); ESLint `no-restricted-imports` bars value-imports of the 3 artifact carriers + the artifact itself from `src/components/**` (type imports safe — measured: the 2 current consumers are type-only) | P3-K | | Constants/types single-sourcing cluster: territorial-class union declared twice across the lib/utilities boundary (adding a class compiles green and mis-sorts); `DAY_MS` ×3 (one exported from `suggestionCatalog`'s public interface); `1_000_000` vote-count ceiling ×6 across two validation layers; password `8/128` + token `20/256` policies ×2; TSE baseline years mirrored (documented excuse dies with a leaf module); `formatBrazilianPhoneDisplay` near-twin (1 call site); `advisorMunicipalitiesBatchSchema` missing the dedup transform its 3 siblings have; `voteEstimate` formatters re-spell the pt-BR format without the E18 zero-fraction pin; `hasAnyEstimate` weaker twin of `hasAnyVoteEstimate` | `lib/territorialClassSortWeight.ts:10-13`, `lib/suggestionCatalog.ts:15-19,175`, `lib/engagementLevel.ts:97`, `utilities/municipalitySignal.ts:32`, `lib/formData.ts:123-133`, `lib/schemas/votePledge.ts:9,32`, `lib/schemas/invite.ts:11,39`, `lib/voteEstimate.ts:27,102-126`, `lib/schemas/advisor.ts:69-72` | shotgun surgery | low-medium | S each | P3 | closed 2026-07-29 (P3-K): territorial-class union → `lib/territorialClassAnchors.ts` (type error on new class now); `DAY_MS` → `lib/text.ts` (out of `suggestionCatalog`'s public interface); `MAX_VOTE_COUNT` in `lib/schemas/primitives.ts` ×6 folded; `campaignPasswordSchema` exported (invite delegates); `lib/electionYears.ts` leaf (mirror + documented excuse died); `formatBrazilianPhoneDisplay` delegates; batch-schema dedup transform (action's `[...new Set]` died); `formatElectionNumber` in the 3 `voteEstimate` formatters; `hasAnyEstimate` deleted | P3-K | | Form-action feedback JSX at 15+ call sites with `aria-live` on only 5 — **10 error alerts silent to AT**; success→toast effect ×7; borderless inline-input class ×4 verbatim; deprecated `showLabels` prop with zero call sites (knip can't see props) | 15 forms incl. `LeadershipForm.tsx:162`, `DemandForm.tsx:152`, `MunicipalityUpdateForm.tsx:168`, `PledgeEstimateForm.tsx:74`; `VoteEstimateScenarioStrip.tsx:18,27,32` | a11y + duplication | medium (a11y) | S–M | P3 | closed 2026-07-29 (P3-G): `CampaignFormActionMessage` (aria-live on both variants + `formFieldErrors`) adopted by all 15+ sites — every alert now announces; `useCampaignFormSuccessToast` adopted ×7; `campaignInlineInputClassName` ×4; `showLabels` deleted; JSX ban guard + a11y pin (`campaignFormActionMessage.unit.spec.tsx`, unit include extended to `.tsx`) | P3-G | +| homeSearch text-search pipeline re-spelled per domain (gate staff → normalize → `contains` + `limit: 0` → word-start → relevance → cap), plus **unbounded `limit: 0` reads** ×5 before the in-memory refine | `src/utilities/homeSearch/searchHomeActivities.ts` (88) ↔ `searchHomeDemands.ts` (100), sim 0.72 (~63 shared lines); leaderships 0.53; `limit: 0` at `searchHomeActivities.ts:43`, `Demands:49`, `Leaderships:61`, `Advisors:37`, `StateDeputies:45` | duplication + scale | medium — every keystroke can pull a full collection slice | M | P2 | open — Pass 4 | P4-A | +| HomeSearch result groups ×6 near-verbatim (Advisor↔Leadership, Activity↔Demand almost line-for-line; differ only in id/title/href/secondary) | `src/components/campaign/dashboard/HomeSearch*Group.tsx` (40–63 lines each; Municipality is the real variant) | duplication | low | M | P2 | open — Pass 4 | P4-B | +| `notificationEvents` re-spells names-by-id ×7 instead of the P3-E wrappers | `src/utilities/notification/notificationEvents.ts` (`:41-57`, `:143-174`, `:212-236` — `findByID` + `select:{name}`/`{name,slug}`) | duplication | low | S | P2 | open — Pass 4 | P4-C | +| Notification write path: `markAllCampaignNotificationsRead` updates up to 200 rows with no transaction; `subscribeCampaignPush` validates with `.trim()` only and always creates (re-subscribe → unique violation → generic error); `unsubscribeCampaignPush` deletes with `overrideAccess: true` while `canDeleteOwnPushSubscriptions` already scopes the owner | `src/app/(campaign)/campanha/actions/notifications.ts:69-91,100-143,172-176` | correctness | medium — partial mark-read on mid-batch failure; hostile re-subscribe UX; bypass surface | S | P2 | open — Pass 4 | P4-D | +| Wizard info Drawer chrome spelled ×3 (button → bottom sheet with title+description+body); skip trailing spelled ×2 (13 lines each, both `{label, href}`) | `WizardSignalTypeStep.tsx:95-112`, `WizardTrendChoiceStep.tsx:115-132`, `WizardLeadershipStep.tsx:167-179`; `WizardSignalSkipTrailing.tsx` ↔ `WizardTrendSkipTrailing.tsx` | duplication | low | S | P2 | open — Pass 4 | P4-E | +| `MunicipalityListMobileCards` (client) value-imports `municipalityColumnLabels` from `municipalityListUrl` — a ~19 KB URL module (pulls `isMunicipalitySlug` → `municipalityCatalog`) for a string map whose type already lives in `municipalityLabels` | `src/components/campaign/municipality/MunicipalityListMobileCards.tsx:26` | boundary (B14 class) | low | S | P2 | open — Pass 4 | P4-F | +| `HomeSearchProvider` wraps the whole chrome incl. `actions` + `summarySlot` non-consumers, and the provider value changes on every keystroke | `src/components/campaign/dashboard/CampaignHomeStaffChrome.tsx:41-56`, `useHomeSearchQuery.ts:63-74` | state placement | low — actions/summary re-render per keypress | S | P2 | open — Pass 4 | P4-G | +| Unconditional `aria-live="polite"` per chip cell (~50 always-registered regions on lideranças); the overlay family already lazy-mounts its region from first open | `src/components/campaign/shared/RelationChipCell.tsx:821-823` vs `CampaignCellEditOverlay.tsx:114-120` | a11y scale | low | S | P2 | open — Pass 4 | P4-G | +| Bypass ratchet dodge: one "bypass" comment in the first 40 lines documents **every** `overrideAccess: true` in the file — `notification/*` has 20, `campaignWebAuthnCeremony` 4, zero per-call justifications | `codebaseConventions.unit.spec.ts:541-623`; `src/utilities/notification/*`; `src/utilities/webauthn/campaignWebAuthnCeremony.ts` | guard dodge | medium — the P3-E "comment justifying" intent is satisfied in letter, not spirit | M | P2 | open — Pass 4 | P4-H | +| `campaignLeaderships` chip-cell spec fails **deterministically in dev mode** on clean stage (4/4 runs; stash-and-compare 2026-07-31): instrumented with console/response listeners — **no POST fires in 60 s**; signatures vary per run (option visible + click no-ops; option count 1 → 0 after first fill). Prod build green (48/48 full suite ×3 the same day). NOT the P3-C hydration class (input onChange works, listbox renders); a `toPass` retry was attempted and reverted — retrying cannot fix what is not a race | `tests/e2e/campaignLeaderships.e2e.spec.ts:91-101`; suspect surface `RelationChipCell` suggestion state vs dev-mode RSC streaming of `municipalityIndex` | test-infra / possible dev-mode cell bug | medium — a red local signal teaches people to skip the spec | M | P2 | open — Pass 4 (debug workflow, not retry; `E2E_PROD=1` is the honest local mode meanwhile) | P4-L | +| God modules on the delta: `municipalityPageData` 548 lines / `loadMunicipalityListPageBundle` 168; `municipalityListUrl` 500 lines / 23 exports (widest interface in utilities); `RelationChipCell` 826 lines / ~11 state hooks (measurement 80, commit 140, render 190) | `src/utilities/municipality/municipalityPageData.ts:307`, `src/utilities/municipality/municipalityListUrl.ts`, `src/components/campaign/shared/RelationChipCell.tsx` | bloater | low | L | P2 | open — Pass 4 (defer + trigger: next facet/sort; next chip relation or AT-scale complaint) | | +| `createSupporter` does not mark `touchedMunicipalities` — a supporter-only fixture path can leave strategy fields dirty on the row | `tests/helpers/campaignFixtures.ts:769-799` | test-infra honesty | low | S | P3 | open — Pass 4 | | +| `(app)/assessores/novo/page.tsx` redirects without `requireCampaignPageActor` — the P3-I guard bans `getCampaignUser()` in pages but not a missing actor helper (1 of 34 pages) | `src/app/(campaign)/campanha/(app)/assessores/novo/page.tsx:1-6` | convention gap | low (layout still session-gates) | S | P3 | open — Pass 4 | | +| `deriveDecisionAuthor` re-spells the actor stamp (`data.decidedBy = req.user.id`) while `campaignAuditFields.ts` owns the pattern (`stampCampaignCreatedBy` hardcodes `createdBy`) | `src/collections/AllocationDecision.ts:53-57` | duplication | low | S | P3 | open — Pass 4 | | +| homeSearch int suites cloned ×7 (656 LOC of same-shaped `it('returns empty…'/'scopes…'/'rejects leaders…')` — `describe.each` over domains would own the shared contract | `tests/int/homeSearch*.int.spec.ts` | duplication | low | M | P3 | open — Pass 4 | | +| Flake inventory: fixed timeouts (`CEREMONY_TIMEOUT=60s`, HomeActions `timeout: 15000` ×many, ColumnPicker `REFRESH=20s`), `Date.now()` markers in fixture names, e2e deep links now claimed (Pass 4 PR #93) but shared leased consent rows remain load-bearing | `tests/e2e/*`, `tests/int/municipalityPageData.int.spec.ts` | flake risk | low | M | P3 | open — Pass 4 (prefer event waits; allocate municipalities — both guarded since Pass 4) | | + ## Smell Inventory | Smell | Location | Refactoring | Status | diff --git a/docs/plans/entrega-engenharia-p4.md b/docs/plans/entrega-engenharia-p4.md new file mode 100644 index 00000000..7c3a0063 --- /dev/null +++ b/docs/plans/entrega-engenharia-p4.md @@ -0,0 +1,187 @@ +# Pass 4 — Auditoria de engenharia, remediação P0/P1 e guardrails das misses + +## Contexto e manchetes + +- **Executado em 2026-07-31, no modo autônomo (Cursor Cloud)** da skill `engineering-audit`: precheck solitário verde (`agent:gh-doctor` OK; pool **desligado**), varredura completa, remediações P0/P1 e guardrails das misses colhidas na mesma sessão, cada um em PR próprio com gate completo. O sign-off dos artefatos é o merge humano deste PR. +- **Âncora de delta:** Pass 3 (2026-07-28). Em 3 dias o paradigma de agentes tocou **880 arquivos** — homeSearch (8 módulos + ~20 componentes), notificações/push (2 collections + 5 módulos), wizard (~12 componentes), WebAuthn (4 rotas + 4 módulos), 5 migrations. A varredura cobriu o repo inteiro com esforço concentrado no delta. +- **Baseline verde:** `tsc` 0 erros, `lint` 0 warnings, `knip` 0 findings (ruído conhecido do `payload.config.ts`, ledger P3), madge 0 ciclos (**774 arquivos**, +124 desde o Pass 3). +- **Manchetes (todas medidas):** + - **1 P0:** os 4 globals públicos (`site-settings`, `home`, `metadata`, `privacy-policy`) com `update: Boolean(user)` — JWT de campanha (liderança inclusa) podia **editar o site público** via `/api/globals/*`. Coleções foram travadas na Fase 0; os globals ficaram. Remediado ([PR #91](https://github.com/fsolla/teqo/pull/91)). + - **1 P1:** `seed-posts.mjs` chamava `dieWithLabel` **sem importar** — ReferenceError no caminho de abort de fetch vazio, invisível a lint/tsc/knip. Meia-adoção do P3-H na mesma passada que criou a guarda da CLI. Remediado ([PR #92](https://github.com/fsolla/teqo/pull/92)). + - **Harvest de 5 `kind:agent-miss` → 5 guardrails:** #73 (classe **reproduzível** — 7 violações int + 11 deep links e2e; migração para o alocador + guarda de 3 regras, [PR #93](https://github.com/fsolla/teqo/pull/93)); #52 (resíduo de Drawer por célula em 3 listas de chips; provider ×3 + invariante dev + guarda, [PR #94](https://github.com/fsolla/teqo/pull/94)); #53 (probe one-shot correndo com o autenticador CDP; portão de readiness + pin, [PR #95](https://github.com/fsolla/teqo/pull/95)); #54 (0 pares goto→goto não assentados medidos; guarda calibrada + convenção, [PR #95](https://github.com/fsolla/teqo/pull/95)); #83 (guarda `agent:ship` **já viva** via PR #89 — verificada e fechada sem guardrail novo). + - **Honestidade de flake:** `campaignLeaderships` (chips) falha **deterministicamente em dev** na árvore limpa (instrumentado: nenhum POST dispara; não é a classe de hidratação P3-C). Prod verde 48/48. **Registrado, não "consertado" com retry** — retry mascara o que não é corrida. + - **2 linhas do ledger stale fechadas:** safeMessages (P3-A) e consentId de petição (P3-B), verificadas no código. + - **Duplicação medida para o plano:** gêmeos de busca homeSearch (~63 de ~90 linhas compartilhadas ×2), HomeSearch\*Group ×6 (2 pares quase verbatim), Wizard info Drawer ×3, WizardSkipTrailing ×2, names-by-id ×7 em notificationEvents, shell de sucesso vazio ×2. + - **12 dodges de guarda registrados** (mapa de guardas abaixo) — 3 endurecidos na sessão, o resto vira workstream P2. + +## Protocolo de delta de comportamento (inalterado do Pass 3) + +Consolidação pode mudar saídas pequenas (copy, ordenação, timing) desde que **cada delta seja listado por item**; pins existentes são a rede de caracterização, atualizados **deliberadamente** na mesma entrega; contratos congelados (URL B18, schema, Consent/LGPD) fora de alcance sem item nomeado. + +## Ondas já executadas na sessão (P0/P1 + misses) + +| PR | Conteúdo | Guarda (classe) | +| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| [#91](https://github.com/fsolla/teqo/pull/91) | **P0** globals públicos → `payloadAdminOnly` ×4; bell loaders de notificação tipados pelo ator, sem bypass | `globalAccessConventions` (3); assinatura por ator (1); pins int de lockdown ×17 | +| [#92](https://github.com/fsolla/teqo/pull/92) | **P1** import do `dieWithLabel`; inversão tipo lib→utilities; `DAY_MS` fold; comentário de bypass stale | `scriptCliConventions` (3); ESLint lib `allowTypeImports:false` (2); `localApiOverrideAccessConventions` (3) | +| [#93](https://github.com/fsolla/teqo/pull/93) | **#73** migração int+e2e para o alocador (classe-agnóstico no teste de classe); 11 deep links → `claimMunicipality()` | `testMunicipalityAllocatorConventions`: fixed-slug+mutação, `municipio=`, regex de nome não ancorada (3) | +| [#94](https://github.com/fsolla/teqo/pull/94) | **#52** `CampaignListSheetProvider` em lideranças/dobradinhas/assessores | `sharedSheetHostConventions` (3); invariante dev no overlay (5); fallback lazy mantido | +| [#95](https://github.com/fsolla/teqo/pull/95) | **#53** `expectCampaignBiometricsReady` ×2; **#54** guarda de goto não assentado + convenção TESTING.md | `e2eNavigationConventions` (3); convenção doc declarada judgment-only (6) | + +Gate de cada PR: `pnpm push` (espelho serial do ci-pr: lint, format, typecheck, knip, cycles, unit, int, build, e2e afetado ou full conforme blast radius). Evidência: full e2e prod **48/48** em 3 das 5 execuções; int full **71/529+** em todas. + +## Ondas do plano (P2, ordenadas por risco × churn) + +### Onda 1 — Consolidação do delta (homeSearch + notificações) + +#### P4-A Runner único de busca textual do homeSearch + +- **Meta:** `runHomeSearchTextHits({ collection, where, select, mapDoc, tieBreak, cap })` + mappers finos por domínio. +- **Evidência:** `searchHomeActivities.ts` (88 linhas) ↔ `searchHomeDemands.ts` (100) com similaridade 0,72 (~63 linhas compartilhadas: gate staff → normalize → `contains` + `limit: 0` + `overrideAccess: false` → word-start → `compareHomeSearchNameRelevance` → cap); leaderships 0,53. +- **Conhecimento duplicado (nomeado):** o pipeline staff de hits textuais da busca global. +- **Delta de comportamento:** nenhum se caracterizado pelos pins int existentes ×7 suites; caps/rankings inalterados. +- **Guarda determinística:** convenção — novo `searchHome*` deve usar o runner (classe 3); pin de acesso por papel por grupo (classe 5, já existe em parte). +- **Na mesma entrega:** helper `emptyHomeSearchSuccess(resultKind)` (2 shells idênticos, `searchHomeMunicipalities:23-33` vs `loadHomeSearchSuggestions:77-87`). + +#### P4-B HomeSearch\*Group ×6 → grupo configurável + +- **Meta:** `HomeSearchHitGroup` como dado (`groupId`, `title`, `selectHits`, `rowProps`); Municipality fica como variação estendida (territórios + trailing de votos). +- **Evidência:** 6 componentes de 40–63 linhas; Advisor↔Leadership e Activity↔Demand quase linha a linha (diferem id/título/href/secundário). +- **Conhecimento:** grupo de hits orçado → seção → `HomeSearchHitRow`. +- **Guarda:** snapshot/unit por groupId (aria-label + href) — classe 5. + +#### P4-C notificationEvents → `loadNamesByIds` (P3-E) + +- **Meta:** os 7 `findByID` + `select:{name}`/`{name,slug}` de `notificationEvents.ts` (254 linhas) viram os wrappers tipados do P3-E (estender se slug precisar). +- **Conhecimento:** resolver nomes de exibição por ids já autorizados — dono único desde 2026-07-29. +- **Guarda:** convenção banindo `findByID`+`select:{name}` ad hoc fora do allowlist (classe 3, mesma família do ratchet P3-E). + +#### P4-D Write-path de notificações/push fail-closed + +- **Meta:** (a) `markAllCampaignNotificationsRead` em `withPayloadTransaction` (até 200 updates — hoje falha parcial visível); (b) `subscribeCampaignPush` com schema zod em `lib/schemas` + upsert por `(user, endpoint)` (re-subscribe hoje estoura unique → erro genérico); (c) `unsubscribeCampaignPush` delete com `overrideAccess: false` (a policy `canDeleteOwnPushSubscriptions` já escopa o dono — bypass desnecessário). +- **Evidência:** `actions/notifications.ts:69-91,100-143,172-176`. +- **Guarda:** int — falha injetada no N-ésimo update ⇒ 0 ou todos marcados (5); segundo subscribe com o mesmo endpoint ⇒ sucesso (5). + +### Onda 2 — Wizard + camada de apresentação do delta + +#### P4-E Wizard info Drawer ×3 → `WizardInfoDrawer` + +- **Evidência:** chrome idêntico em `WizardSignalTypeStep.tsx:95-112`, `WizardTrendChoiceStep.tsx:115-132`, `WizardLeadershipStep.tsx:167-179` (botão info → bottom sheet com título + descrição + corpo); grids de tiles ficam locais (dado de domínio). Na mesma entrega: `WizardSignalSkipTrailing`/`WizardTrendSkipTrailing` (13 linhas cada, tipos ambos `{label, href}`) → um `CampaignWizardSkipTrailing`. +- **Guarda:** pin visual/a11y do botão info → nome do dialog (5). + +#### P4-F `municipalityColumnLabels` fora do `municipalityListUrl` (fronteira B14) + +- **Meta:** mover o mapa de labels para `municipalityLabels` (onde o tipo já vive) ou folha só-de-labels. +- **Evidência:** `MunicipalityListMobileCards.tsx:26` (client) value-importa do módulo de URL (~19 KB, que puxa `isMunicipalitySlug` → `municipalityCatalog`) para ler um mapa de strings — a lição dos 21 kB do B14 na versão mobile. +- **Guarda:** convenção/ESLint — client components não value-importam de `*ListUrl` fora de href builders allowlistados (classe 3). + +#### P4-G HomeSearchProvider estreito + regiões vivas em escala + +- **Meta:** (a) `HomeSearchProvider` envolve só o slot de busca — hoje envolve `actions`+`summarySlot` não-consumidores e o valor muda a cada tecla (`CampaignHomeStaffChrome.tsx:41-56`); (b) a região `aria-live` incondicional por célula de chips (`RelationChipCell.tsx:821-823` — ~50 regiões polite registradas em lideranças) monta no primeiro anúncio não-vazio, seguindo o overlay. +- **Guarda:** pin de árvore React (ações fora do provider) (5); convenção de teto de regiões polite por lista (3). + +### Onda 3 — Endurecimento de guardas (dodges medidos) + +#### P4-H Ratchet de bypass: fim da "cobertura de cabeçalho" + +- **Meta:** comentário com "bypass" nas primeiras 40 linhas deixa de zerar a contagem do arquivo inteiro; vale por-chamada (janela) ou política de módulo nomeada com contagem declarada. `notification/*` (20 bypasses) e `campaignWebAuthnCeremony` (4) recebem justificativas por bloco na mesma entrega. +- **Evidência:** `codebaseConventions.unit.spec.ts:541-623`; P3-E pretendia "comentário justificando", a forma atual satisfaz a letra sem o espírito. +- **Guarda:** o próprio ratchet endurecido (classe 3). + +#### P4-I `canReadCampaignUsers` sem enumeração para liderança + +- **Meta:** lista de staff restrita a staff/unrestricted; liderança lê a si mesma. Auditar call sites primeiro (homeSearch advisors já tem gate próprio — verificado na varredura). +- **Evidência:** `access/campaignUsers.ts:33-34` = admin OU qualquer `isCampaignUser` (inclui `leader`); ledger P2 pré-existente, re-verificado 2026-07-31. +- **Guarda:** pins int de acesso — líder nega lista, self permitido (5). + +#### P4-J `authenticateCampaignToken` sem segunda leitura (RS+) + +- **Meta:** `depth: 0` + `select` mínimo; avatar resolvido sem doc inteiro. Pins: `removePrivateAuthFields` + freshness de rebaixamento de papel (pré-requisito do ledger, mantém). +- **Evidência:** `campaignAuth.ts:87-109` — `payload.auth` (a JWT strategy já lê o doc) + `findByID(depth: 1)` full em toda página. + +#### P4-K Fixtures int: restos do P3-C + +- **Meta:** (a) 28 blocos `.create({ collection: 'campaignUser' })` em 6 arquivos → `fixtures.createCampaignUser` (salvo mints de sessão legítimos); (b) `fixtures.own()` manual — 49 sites (subiu de 43) — ratchet de contagem; (c) invite-origin: pin unit é o dono (~92 linhas puras), o re-pin int (~158 linhas em `campaignInvite.int.spec.ts:394-552`) vira smoke de integração. +- **Guarda:** convenção banindo create crua de campaignUser fora de helpers (3); ratchet `fixtures.own(` ≤ N (3). + +### Onda 4 — Debug com evidência (não é retry, não é palpite) + +#### P4-L `campaignLeaderships` chips em dev — causa raiz + +- **Evidência registrada (2026-07-31):** na árvore limpa de stage, dev mode, 4/4 execuções falham no passo sugestão/commit. Instrumentação com listeners: **nenhum POST em 60s**; assinaturas variando por run — opção visível + clique sem efeito; contagem de options 1 → 0 após o primeiro fill. **Prod verde** (48/48 full). Não é a classe P3-C (onChange do input funciona, listbox renderiza). `toPass` tentado e **revertido** — retry não conserta o que não é corrida. +- **Método:** debug subagent com hipótese + instrumentação no código da célula (estado `open`/`suggesting`, streaming RSC dev vs prod do `municipalityIndex`, `hitsKey`), não especulação. +- **Guarda:** a pin que nascer do fix; até lá, TESTING.md registra `E2E_PROD=1` como modo honesto para o spec (feito no PR #95). + +## Mapa de guardas (prevenção de recorrência) + +### Guardas NOVAS na sessão (já mergeadas ou em PR próprio) + +| Guarda | Classe | Origem | +| ----------------------------------------------------------------------------------------- | ------ | -------------- | +| `globalAccessConventions` — ban `Boolean(user)` em update de globals | 3 | P0 encontrado | +| Bell loaders tipados pelo ator (recipientID não representável) | 1 | P0 adjacente | +| `scriptCliConventions` — die-helper importado + `DAY_MS` single-source | 3 | P1 encontrado | +| ESLint `src/lib`: `@/utilities/**` rejeita type-import | 2 | dodge P3-K | +| `localApiOverrideAccessConventions` — `user` sem `overrideAccess`; import dinâmico server | 3 | guarda ausente | +| `testMunicipalityAllocatorConventions` (3 regras) | 3 | miss #73 | +| `sharedSheetHostConventions` + invariante dev no overlay | 3 + 5 | miss #52 | +| `e2eNavigationConventions` (goto assentado + readiness WebAuthn) | 3 | misses #54/#53 | +| Convenção de navegação e2e em TESTING.md (**judgment-only declarado**) | 6 | miss #54 | + +### Guardas existentes endurecidas na sessão + +- `codebaseConventions` — a família de regex ancorada ganhou a irmã de literais (regra 3 do spec #73, keyed no catálogo). +- ESLint `src/lib` — comentário da zona agora codifica a política de módulos de contrato. + +### Resíduo judgment-only (declarado, sem fingir que doc é guarda) + +- Gatilhos de abstração dos itens defer+trigger (tabela abaixo) — convenção em rules/codebase-map. +- Migração `AdvisorsTable` → `CampaignTable` e fim do modo "Editar nome e contato" — decisão de produto (edit-in-place), não de guarda. +- `relationMembershipDelta` vs `leadershipMunicipalityMembership` (set+floor) — look-alike, não duplicação. + +### Dodges medidos pendentes (viram workstream ou ficam registrados) + +| Guarda | Dodge medido | Destino | +| ----------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------ | +| Ratchet de bypass (P3-E) | header de 40 linhas cobre 20+ chamadas (`notification/*`, `webauthnCeremony`) | P4-H | +| `*FormActions.ts` (W4d) | 7 escadas à mão em `actions/*.ts` (profile ×2, leaderSupporter, password ×4) invisíveis ao filename | defer+trigger | +| Mensagens de recusa (P3-A) | const lançada sem presença em safelist de rota (órfã → erro genérico) | defer+trigger | +| `getCampaignUser` em páginas (P3-I) | só cobre `page.tsx`; `assessores/novo` redirect sem `requireCampaignPageActor` (1 de 34) | defer+trigger | +| Pin de top-level de `utilities/` | é allowlist, não disciplina de domínio | doc (codebase-map) | +| `campaignJsonMutationRoute` | `export const POST = handler` indireto | registrado | +| Vocabulário banido | tokens quebrados por concatenação; `.md`/`.sql` fora de migrations | registrado | + +## Registrados como defer + gatilho (sem ID novo) + +| Par/candidato | Medida | Gatilho | +| ----------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- | +| Split de `RelationChipCell` (826 linhas; máquinas overflow/membership/chrome) | 11 states, blocos de 80/140/190 linhas | próxima relação por chips OU queixa de escala de AT | +| Codec de sort ×3 + multi-toggle ×4 + round-trip ×6 (P3-F) | verificado 2026-07-31: **gatilho NÃO disparou** | 4º dropdown de sort / 5º multi-filtro | +| Escadas `actions/*.ts` (7) + convite-login + schema advisors route | novas actions (notifications/webauthn/suggestion) **usam** os wrappers | próxima escada bespoke → ampliar guarda W4d | +| `wizardSignalHref`/`wizardTrendHref` (4–5 params posicionais) | 2 call sites com `undefined` placeholder | próximo href da família → options object | +| Split de `suggestionCatalog` (646 linhas, 18 exports) | patternP1 tem 52 linhas | 9º pattern OU 2º consumidor do avaliador | +| Extrair `import-projecao.mjs` (1213 linhas) | maior script do repo | 3ª família de planilha | +| `municipalityPageData` (548) / `municipalityListUrl` (500, 23 exports) | god modules sob contrato congelado B18 | próxima faceta/sort | +| `BahiaMap` (608) + `MunicipalityMapPanel` (734) | clients god, churn alto | próxima feature de mapa que toque ambos | +| `ActivityForm` (515) | formulário único | próxima feature de atividade | +| `agent-pool.mjs` tick (~118) + `derivePoolClaims` (~70) | já fatorado em `scripts/lib/agent-pool-*` | próxima fase do tick | +| `compare ?compare=` sem `unstable_cache` (ledger P3) | 3 anos × scan por candidato | profiling mandar (ladder rung 2) | +| Posts `limit: 0` + filtro em memória (ledger P3) | corpus ~39 artigos | crescimento do corpus | + +## Look-alikes rejeitados (não reabrir sem evidência nova) + +- `toggleMunicipalityMulti*` × `toggleLeadershipMulti*` (sim 0,24 textual — mesma família de conhecimento do ×4 deferido, não mergear antes do 5º). +- `assertLocalUrl` (stage-snapshot/db-pull, sem override) × `assertLocalDatabase` (com override) — políticas diferentes de propósito. +- `parseEnv` (gate-ci/db-doctor, não muta `process.env`) × `loadCliEnv` (muta) — não forçar. +- `homeSearch*GroupHasHits` ×5 — `array.length > 0` fino demais para abstrair. +- `ui/Sheet` × `ui/Drawer` — consumidor único (Sidebar) não é duplicação. +- `createdBy` em `Notification`/`PushSubscription` — notificações são emitidas pelo sistema; push já carrega hash+timestamp de consentimento. + +## Regras de execução (valem para cada workstream) + +- Uma consolidação por entrega; gate completo bare (`tsc`, `lint`, `format:check`, `knip`, `check:cycles`, `test`, `build`) + Aikido quando o MCP estiver disponível (nesta sessão o ambiente Cloud não tinha o servidor — o row do ledger segue). +- Commits de estrutura nunca misturam comportamento; teste vermelho no meio do refactor = revert, não debug. +- Contratos congelados: URL (B18), schema (migration = entrega própria), Consent/LGPD fail-closed. +- Lista rejected-with-reason do Pass 3 continua valendo. +- Sobras → ledger via `capture-review-debts`; nada vive só no chat. +- Nenhum item fecha sem a classe de prevenção registrada no PR; guarda chega com o fix. From 836a81907442d258e1e5e938f5b4fa88582ea47e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 06:46:50 +0000 Subject: [PATCH 6/6] docs(testing): suite counts truth-up (117 unit / 71 int / 17 e2e, 48 cases) + allocator contract --- docs/CHANGELOG-AGENTS.md | 1 - docs/TESTING.md | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 7bc8377c..86482849 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,7 +4,6 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- - **Recently resolved (2026-07-31):** **Pass 4 — auditoria de engenharia autônoma + guardrails das misses** — primeira execução em modo autônomo (Cursor Cloud) da skill engineering-audit, com precheck solitário (pool desligado) e primeiro harvest de `kind:agent-miss` (escopo OPS2: nasce `docs/GUARDRAILS.md`). **P0:** os 4 globals públicos (`site-settings`/`home`/`metadata`/`privacy-policy`) aceitavam qualquer autenticado no `update` — JWT de campanha (liderança inclusa) editava o site público via `/api/globals/*`; lockdown `payloadAdminOnly` + pins + guarda ([PR #91](https://github.com/fsolla/teqo/pull/91); editores perdem escrita nos globals de config deliberadamente). **P1:** `seed-posts.mjs` chamava `dieWithLabel` sem importar ([PR #92](https://github.com/fsolla/teqo/pull/92), com guarda de presença de import + `DAY_MS` single-source + ESLint lib→utilities sem type-import + guarda de `overrideAccess` omitido). **Misses:** #73 (deadlock `municipality_rels` — classe reproduzida: 7 violações int + 11 deep links e2e; migração para o alocador com teste de classe reescrito classe-agnóstico + guarda de 3 regras, [PR #93](https://github.com/fsolla/teqo/pull/93)); #52 (Drawer por célula em 3 listas de chips → `CampaignListSheetProvider` + invariante dev + guarda, [PR #94](https://github.com/fsolla/teqo/pull/94)); #53 (portão de readiness do autenticador virtual) + #54 (guarda de goto não assentado + convenção TESTING.md) em [PR #95](https://github.com/fsolla/teqo/pull/95); #83 fechada (guarda `agent:ship` já viva). **Registrado, não retry-fixed:** spec de chips de `campaignLeaderships` falha deterministicamente em dev na árvore limpa (nenhum POST dispara; prod 48/48 verde) — causa raiz via debug workflow (P4-L). Artefatos: seção Pass 4 no plano de qualidade, 15 linhas novas no ledger (2 stale fechadas: P3-A/P3-B), plano [`entrega-engenharia-p4.md`](docs/plans/entrega-engenharia-p4.md) com ondas P4-A→P4-L + mapa de guardas + defer+gatilho. **Recently resolved (2026-07-31):** **CI build skip para diffs fora da árvore buildável** — `scripts/build-affected.mjs` + `classifyBuildScope` (núcleo em `test-affected-core.mjs`) decidem `run|none`; job `build-scope` barato no `ci-pr.yml` e o job `build` (Postgres + `pnpm build`) só roda em `mode=run`. Docs/tests/tooling/workflows pulam o build; `src/`, `public/` e configs do Next (`next.config`/`tsconfig`/`tailwind`/`postcss`/`components.json`/lockfile) forçam. `gate:ci` espelha. Push em `main` (`ci.yml`) continua sempre full. diff --git a/docs/TESTING.md b/docs/TESTING.md index 6114d009..fb1f0a93 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -4,9 +4,9 @@ Three layers, all local-database-only (see `local-database` skill; tests refuse any database not ending in `_test`): -- **Unit** (`tests/unit/**/*.unit.spec.ts`, 47 specs as of 2026-07-25 (tooling guards)): pure logic; runs with a deliberately invalid `DATABASE_URL` so nothing can touch a database. -- **Integration** (`tests/int/**/*.int.spec.ts`, 48 specs): real Payload + Postgres (`teqo_test`); campaign fixtures allocate seeded municipalities via a Postgres sequence so parallel spec files never share one, and purge residue on claim. Shared consent rows with stable keys are leased (`tests/helpers/testDatabaseLease.ts`), never owned. -- **E2E** (`tests/e2e`, Playwright, 7 specs): smoke-level; most campaign behavior is pinned at the int layer. +- **Unit** (`tests/unit/**/*.unit.spec.ts`, 117 specs as of 2026-07-31, Pass 4 — +6 convention guards land with the Pass 4 remediation PRs): pure logic; runs with a deliberately invalid `DATABASE_URL` so nothing can touch a database. +- **Integration** (`tests/int/**/*.int.spec.ts`, 71 specs): real Payload + Postgres (`teqo_test`); campaign fixtures allocate seeded municipalities via a Postgres sequence so parallel spec files never share one, and purge residue on claim. Since the miss #73 guardrail (Pass 4), specs that mutate municipalities must use the allocator (no pinned slugs) — enforced by `testMunicipalityAllocatorConventions`. Shared consent rows with stable keys are leased (`tests/helpers/testDatabaseLease.ts`), never owned. +- **E2E** (`tests/e2e`, Playwright, 17 specs / 48 cases): smoke-level; most campaign behavior is pinned at the int layer. CI (`.github/workflows/ci-pr.yml`) runs parallel jobs — lint (zero warnings), format, typecheck, knip, cycles, unit/int (**affected by the PR diff**: `vitest --changed` against the PR base via `scripts/test-affected.mjs`, full-suite fallback on high-risk paths), e2e (manifest path→spec via `scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`, blocking; production build + `list`/`github` reporters in CI) and build — against a Postgres 17 service with **`db:seed:minimal`**. Push to `main` (`ci.yml`) always runs the full net including e2e.