teqo/tests/unit/codebaseConventions.unit.spec.ts
Francisco Ignácio Fontoura Solla 7b93c232ff C13: renomeia "Plano de Acao" para "Atividade" em toda a vertical /campanha
Collection actionPlan -> activity (labels "Atividade"/"Atividades"), campo
campaignDemand.actionPlan -> activity, rota /campanha/planos ->
/campanha/atividades (criacao em atividades/nova), sidebar "Atividades" e
rotulos alinhados a entidade ("Tipo de atividade", "Origem da atividade",
"Resultado da atividade"). Valores de enum kind/status/origin inalterados.

Schema movido por migration data-preserving escrita a mao
(20260725_213000_rename_action_plan_to_activity): 4 tabelas, 3 enums, 2
colunas, 39 indices (4 pkeys inclusos), 14 FKs e 2 sequences, cada statement
guardado na existencia do objeto de origem, com down() simetrico. O gerador do
Payload emitiria DROP+CREATE e nao ve o indice parcial escrito a mao
action_plan_upcoming_start_at_idx, entao a lista veio de
pg_indexes/pg_constraint/pg_type/pg_sequences e o snapshot .json foi derivado
do anterior por transformacao textual -- validado por migrate:create __probe
sem mudanca de schema. A mesma migration alinha os 8 indices fosseis %plaza%
que a remodelagem de municipios deixou (renomeou as colunas plaza_id, nao os
indices).

Guard: o describe "banned campaign terminology" de
codebaseConventions.unit.spec.ts virou tabela de termos varrendo src+tests+
scripts (migrations fora -- historia congelada), banindo os compostos
actionPlan, a copy "plano de acao" e a rota antiga, mas nao plan/plano isolado
(colidiria com os planos de assinatura da Fase 2). Allowlist: o proprio spec,
scripts/generate-remodel-municipalities-migration.mjs e o comentario historico
em campaignMigrationReconciliation.int.spec.ts.

Plano: docs/plans/renomear-plano-acao-para-atividade.md
2026-07-25 20:50:38 -03:00

157 lines
6.6 KiB
TypeScript

import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
legacyCamelCaseFilenameIgnores,
legacyComponentFilenameIgnores,
legacyComponentSyntaxIgnores,
legacyFrameworkExportIgnores,
} from '../../eslint-legacy-ignores.mjs'
// Programmatic guards for conventions that used to be enforced only by prose
// (codebase-map.mdc / docs/ARCHITECTURE.md) and drifted during Pass 2.
const repoRoot = process.cwd()
const walkSourceFiles = (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))
const repoPath = (absolute: string) => relative(repoRoot, absolute)
describe('server-only boundary in src/utilities', () => {
// Files that value-import Payload run server code; if they ever land in a
// client bundle the failure must be a build error, not a runtime surprise.
// W2 marked 21 loaders by hand — this pins the rule for every new module.
// Deliberate exceptions go here, each with a justification:
const allowlist = new Set<string>([])
const isTypeOnlyClause = (clause: string): boolean => {
if (/^type\b/.test(clause.trim())) return true
const inner = clause.trim().match(/^\{([\s\S]*)\}$/)?.[1]
if (inner === undefined) return false
return inner
.split(',')
.map((specifier) => specifier.trim())
.filter(Boolean)
.every((specifier) => specifier.startsWith('type '))
}
it('marks every Payload-coupled utilities module with import server-only', () => {
const offenders: string[] = []
for (const file of walkSourceFiles(resolve(repoRoot, 'src/utilities'), ['.ts', '.tsx'])) {
const source = readFileSync(file, 'utf8')
// Side-effect imports (`import 'x'`) never match: the clause group
// cannot contain quotes, so it cannot swallow a bare specifier.
const importsPayloadValues = [
...source.matchAll(/^import\s+([^'"]+?)\s+from\s+['"](payload|@payload-config)['"]/gm),
].some(([, clause]) => !isTypeOnlyClause(clause!))
if (!importsPayloadValues) continue
if (/^import 'server-only'/m.test(source)) continue
if (allowlist.has(repoPath(file))) continue
offenders.push(repoPath(file))
}
expect(offenders, "add `import 'server-only'` (or a justified allowlist entry)").toEqual([])
})
})
describe('banned campaign terminology', () => {
// Every rename of an operational concept leaves prose behind: the 2026-07-23
// remodel ("Praça"/"Núcleo" → "Município") needed a manual W4f sweep that
// still missed 4 consent drafts, and C13 ("Plano de Ação" → "Atividade")
// touched 62 files. Each retired term gets a row here so it cannot come back.
// Migrations are frozen history and stay out of scope; allowlisted files
// carry legitimate data or frozen SQL, not live copy.
const bannedTerms = [
{
id: 'Praça/Núcleo',
pattern: /praç|núcleo/i,
remedy: 'use "Município" (or allowlist genuine data)',
allowlist: [
'src/lib/cities.ts', // real locality names (e.g. "Núcleo Bandeirante" — DF)
// Emits the frozen remodel migration, which names the term it retired.
'scripts/generate-remodel-municipalities-migration.mjs',
],
},
{
id: 'Plano de Ação',
pattern:
/actionPlan|ActionPlan|ACTION_PLAN|action_plan|action-plan|planos? de aç[aã]o|\/campanha\/planos/i,
remedy: 'use "Atividade" / activity (or allowlist frozen history)',
allowlist: [
// Emits the frozen remodel migration, whose SQL predates the rename.
'scripts/generate-remodel-municipalities-migration.mjs',
// Asserts against the frozen migration chain by its historical names.
'tests/int/campaignMigrationReconciliation.int.spec.ts',
],
},
] as const
const searchRoots = ['src', 'tests', 'scripts'] as const
it.each(bannedTerms.map((term) => [term.id, term] as const))(
'keeps %s out of src, tests and scripts',
(_id, term) => {
// This spec quotes the banned literals itself.
const allowlist = new Set<string>([...term.allowlist, repoPath(import.meta.filename)])
const offenders: string[] = []
for (const root of searchRoots) {
for (const file of walkSourceFiles(resolve(repoRoot, root), ['.ts', '.tsx', '.mjs'])) {
const path = repoPath(file)
if (path.startsWith('src/migrations/')) continue
if (allowlist.has(path)) continue
const lines = readFileSync(file, 'utf8').split('\n')
for (const [index, line] of lines.entries()) {
if (term.pattern.test(line)) offenders.push(`${path}:${index + 1}`)
}
}
}
expect(offenders, term.remedy).toEqual([])
},
)
})
describe('eslint legacy ignore lists', () => {
// ESLint never validates ignore paths, so a file move silently orphans its
// exemption (campaign-logo.tsx broke this way during Pass 2 W2).
const unescapeGlob = (pattern: string) => pattern.replaceAll('\\[', '[').replaceAll('\\]', ']')
it.each([
['legacyComponentFilenameIgnores', legacyComponentFilenameIgnores],
['legacyFrameworkExportIgnores', legacyFrameworkExportIgnores],
['legacyComponentSyntaxIgnores', legacyComponentSyntaxIgnores],
['legacyCamelCaseFilenameIgnores', legacyCamelCaseFilenameIgnores],
])('every %s entry still exists on disk', (_name, entries) => {
const missing = entries.filter((entry) => !existsSync(resolve(repoRoot, unescapeGlob(entry))))
expect(missing, 'file moved or deleted — update eslint-legacy-ignores.mjs').toEqual([])
})
})
describe('campaign formActions convention', () => {
// Every route-level formActions.ts goes through the shared wrappers
// (runCampaignFormAction / runCampaignRedirectFormAction) so error mapping
// and revalidation cannot drift per route (Pass 2 W4d). A file that truly
// cannot use them gets an allowlist entry documenting why:
const allowlist = new Set<string>([])
it('routes every formActions.ts through a shared wrapper', () => {
const offenders = walkSourceFiles(resolve(repoRoot, 'src/app/(campaign)'), ['.ts'])
.filter((file) => file.endsWith('/formActions.ts'))
.map(repoPath)
.filter((path) => !allowlist.has(path))
.filter(
(path) =>
!/runCampaign(Redirect)?FormAction/.test(readFileSync(resolve(repoRoot, path), 'utf8')),
)
expect(offenders, 'wrap with runCampaignFormAction/runCampaignRedirectFormAction').toEqual([])
})
})