diff --git a/.agents/skills/relatorio-cidade/SKILL.md b/.agents/skills/relatorio-cidade/SKILL.md
index cb302ccf..e9d253e5 100644
--- a/.agents/skills/relatorio-cidade/SKILL.md
+++ b/.agents/skills/relatorio-cidade/SKILL.md
@@ -119,6 +119,15 @@ URL por item) e emendas lidas da fonte oficial **em tempo de geração**. Págin
"transport": [ // rodovias, aeroporto, portos, ferrovia
{ "topic": "Rodovia federal (BR-101)", "detail": "…", "sourceUrl": "https://…", "sourceDate": "…" }
],
+ "opposition": [ // o que a oposição fez de errado na região (fato com fonte)
+ { "topic": "Emenda sob investigação (PF)", "detail": "…", "sourceUrl": "https://…", "sourceDate": "…" }
+ ],
+ "alliances": [ // dobradinhas: com quem somar na cidade
+ { "topic": "Dobradinha estadual (PT)", "detail": "…", "sourceUrl": "https://…", "sourceDate": "…" }
+ ],
+ "investments": [ // investimentos/obras de Solla, do estado e do federal (município e região)
+ { "topic": "Obra federal — saúde", "detail": "…", "sourceUrl": "https://…", "sourceDate": "…" }
+ ],
"gaps": [{ "id": "…", "label": "…", "reason": "…" }] // lacunas que o agente já sabe
}
```
@@ -151,6 +160,29 @@ ferrovia da região — FIOL/Porto Sul, deixando claro quando não serve
diretamente a cidade). O enquadramento é "onde falta × onde o estado e a União
têm acertado" — o candidato é da base.
+**Frente de oposição (pesquisa):** `opposition` é uma lista de
+`{topic, detail, sourceUrl, sourceDate}` (sem fonte → lacuna) com **fatos
+publicados e datados sobre o que a oposição fez de errado na região** — PF/MP em
+emendas, obras paradas, anúncio sem entrega, escândalos do campo adversário.
+Regras: (1) cada linha é fato publicado, **nunca** inferência de culpa — atribua
+à fonte ("segundo a PF / o jornal") e diferencie acusação de condenação; (2)
+priorize o **local** (operadores da oposição no município/região, ex.: emendas
+investigadas) sobre o ataque frontal ao líder estadual; (3) marque temas de
+**mão dupla** (casos que também citam o campo da base) para a coordenação não
+entrar desprevenida; (4) **não** recomende ataque pessoal nem onde a marca do
+adversário domina — o relatório é insumo ancorado, não roteiro de ataque. O item
+entra na seção 2 (junto aos concorrentes) e a seção 14 lista as URLs.
+
+**Dobradinhas e investimentos (pesquisa):** `alliances` é a lista de
+`{topic, detail, sourceUrl, sourceDate}` com **com quem Solla soma na cidade**
+(estadual/federal, chapa e lideranças que transferem voto) — insumo direto da
+`approach`, exibido como bloco "Dobradinhas (pesquisa)" na seção 3 (rede).
+`investments` lista **investimentos e obras de Solla, do governo do estado e do
+federal no município e na região**, com fase/valor quando a fonte informar
+(**empenho ≠ pagamento**), exibido como bloco "Investimentos e obras (pesquisa)"
+na seção 4 (conjuntura). Sem fonte, cada item vira lacuna; nenhuma das duas
+listas é obrigatória.
+
**Emendas oficiais:** sem `--emendas`, o builder consulta o Portal da
Transparência (`PORTAL_TRANSPARENCIA_API_KEY` no ambiente; sem chave → lacuna).
A API oficial **não filtra por município** (só UF/Nacional/Múltiplo na
@@ -170,7 +202,8 @@ cacheado em `data/relatorios-cidade/.emendas.json` para replay.
anunciar, riscos e pontos sem leitura.
- **Seções 2+:** `1. Conta eleitoral` · `2. Concorrentes no município (federal
e estadual)` — top 5 por votos de 2022 na base TSE, com série 2014/2018/2022
- e os prováveis candidatos do campo do prefeito (pesquisa) · `3. Rede e
+ e os prováveis candidatos do campo do prefeito (pesquisa), além da **frente de
+ oposição** quando pesquisada (fatos com fonte) · `3. Rede e
lideranças` — inclui as lideranças locais pesquisadas (ex-prefeitos/vices,
vereadores mais votados, com partido) · `4. Conjuntura` · `5. Sinais` · `6.
Demandas e visitas` · `7. Demografia` — IBGE Censo 2022 do artefato +
@@ -189,7 +222,14 @@ cacheado em `data/relatorios-cidade/.emendas.json` para replay.
limites`.
- **Links clicáveis:** todo URL no PDF é um link (``): células de
tabela, fontes por linha e a seção de fontes.
-- **Tabela da seção 11 (notícias) com larguras fixas:** `Data 9 · Veículo 14 ·
+- **Numeração dinâmica e omissão de seções vazias:** o builder numera as seções
+ depois de montá-las (o `id` é estável; o número é posicional). As seções
+ **Sinais recentes** e **Demandas e visitas** são **omitidas quando não há
+ dado na base** (mostrar um callout de "nada aqui" só gastaria página); as
+ seções de pesquisa (`opposition`, `alliances`, `investments`, demografia,
+ economia, transporte) mantêm a lacuna explícita quando o item falta. Nunca
+ referencie seção por número fixo — use o nome/`id`.
+- **Tabela de notícias com larguras fixas:** `Data 9 · Veículo 14 ·
Título 49 · Link 28` (%). As larguras explícitas tornam a tabela `table-fixed`
(a URL longa/crua quebra em vez de inflar a coluna) e dão ao **Título** — o
texto mais longo — a maior fatia. Sem largura, a URL sem espaços domina o
@@ -211,6 +251,11 @@ cacheado em `data/relatorios-cidade/.emendas.json` para replay.
- **Abordagem é análise ancorada**: as sugestões das personas (governo do PT na
região, projetos futuros, prioridades locais) saem do `approach` com fonte por
item — sem fonte, não entra; o PDF deixa claro que é análise, não fato novo.
+- **Frente de oposição é fato, não ataque**: `opposition` só recebe fato publicado
+ com URL + data; o PDF **não** afirma culpa, **não** infere dolo e **não** orienta
+ ataque pessoal. Prioriza o local (operadores na região) sobre o líder estadual e
+ sinaliza temas de mão dupla; onde a marca do adversário domina, o insumo é para
+ contraste, não para confronto frontal.
- **Artefato gitignored**: o repo é público; o PDF/MD com dado interno nunca é
commitado (só a skill/scripts/changelog).
diff --git a/docs/changelog/2026-09-15-c163-frente-oposicao.md b/docs/changelog/2026-09-15-c163-frente-oposicao.md
new file mode 100644
index 00000000..5f51e083
--- /dev/null
+++ b/docs/changelog/2026-09-15-c163-frente-oposicao.md
@@ -0,0 +1 @@
+**C163 — Relatório de cidade: frente de oposição como pesquisa ancorada (2026-09-15):** nova lista de pesquisa `opposition` no contrato do relatório de cidade (`{topic, detail, sourceUrl, sourceDate}` — sem fonte vira lacuna, igual a `demography`/`economy`/`transport`). Ela responde a "o que a oposição fez de errado na região que dá para usar a favor da campanha": PF/MP em emendas, obras paradas, anúncio sem entrega, escândalos do campo adversário. O bloco entra na **seção 2 (Concorrentes)**, ao lado dos prováveis candidatos do campo do prefeito, como tabela Tema/O que a fonte diz/Fonte, e a seção 14 lista as URLs. Guardrails fixos no `note` do bloco, na seção 14 e na skill: cada linha é fato publicado com URL+data, **nunca** inferência de culpa (diferencie acusação de condenação e atribua à fonte); priorize o **local** (operadores da oposição na região) sobre o ataque frontal ao líder estadual; marque temas de **mão dupla**; e não oriente ataque pessoal onde a marca do adversário domina. Cobertura unit nova no validador (fonte mantida, sem fonte vira lacuna) e no builder (bloco renderizado com fonte), sem nova seção numerada (mantém as 14 seções).
diff --git a/docs/changelog/2026-09-15-c163-secoes-dinamicas-dobradinhas-investimentos.md b/docs/changelog/2026-09-15-c163-secoes-dinamicas-dobradinhas-investimentos.md
new file mode 100644
index 00000000..eacf9768
--- /dev/null
+++ b/docs/changelog/2026-09-15-c163-secoes-dinamicas-dobradinhas-investimentos.md
@@ -0,0 +1 @@
+**C163 — Relatório de cidade: seções dinâmicas, dobradinhas e investimentos (2026-09-15):** o builder agora **numera as seções por posição** (o `id` é estável; o número é derivado) e **omite "Sinais recentes" e "Demandas e visitas" quando não há dado na base** — mostrar um callout de "nada aqui" só gastava página; as seções de pesquisa seguem com lacuna explícita quando o item falta. Duas listas de pesquisa novas, no mesmo contrato `{topic, detail, sourceUrl, sourceDate}` (sem fonte → lacuna): **`alliances`** (dobradinhas — com quem Solla soma na cidade: estadual/federal, chapa e lideranças que transferem voto), exibida na seção de rede como "Dobradinhas (pesquisa)" e insumo direto da `approach`; e **`investments`** (investimentos e obras de Solla, do governo do estado e do federal no município e na região, com fase/valor quando a fonte informar — empenho ≠ pagamento), exibida na seção de conjuntura como "Investimentos e obras (pesquisa)". A seção de fontes rotula as duas listas e a skill registra o contrato, os guardrails e a regra de numeração dinâmica/omissão. Cobertura unit nova: validador (fonte mantida, sem fonte vira lacuna) e builder (dobradinhas na rede, investimentos na conjuntura, omissão de 5/6 com renumeração contígua).
diff --git a/scripts/lib/cityReportBlocks.mjs b/scripts/lib/cityReportBlocks.mjs
index 49580974..c44873c6 100644
--- a/scripts/lib/cityReportBlocks.mjs
+++ b/scripts/lib/cityReportBlocks.mjs
@@ -399,6 +399,32 @@ const buildCompetitorsTable = ({ title, rows, referenceYear, readAt }) => ({
sources: [sourceTeqo('base Teqo — TSE 2014/2018/2022', readAt)],
})
+const buildOppositionBlock = (research) => {
+ const entries = research.opposition ?? []
+ if (!entries.length) return null
+ return {
+ kind: 'table',
+ title: 'Oposição — o que usar com fonte (pesquisa)',
+ columns: [
+ { key: 'topic', label: 'Tema', width: 22 },
+ { key: 'detail', label: 'O que a fonte diz', width: 62 },
+ { key: 'source', label: 'Fonte', width: 16 },
+ ],
+ rows: entries.map((item) => ({
+ topic: item.topic,
+ detail: item.detail,
+ source: formatDateBr(item.sourceDate),
+ })),
+ note: 'Fato publicado com fonte datada — não é condenação. Atribua à fonte e não infira culpa; tema nacional pode ser de mão dupla.',
+ sources: entries.map((item) => ({
+ kind: 'web',
+ label: `Oposição — ${item.topic}`,
+ url: item.sourceUrl,
+ date: item.sourceDate,
+ })),
+ }
+}
+
const buildCompetitorsSection = ({ snapshot, research }) => {
const competitors = snapshot.competitors ?? { referenceYear: 2022, federal: [], state: [] }
const offices = [
@@ -458,6 +484,9 @@ const buildCompetitorsSection = ({ snapshot, research }) => {
},
)
+ const opposition = buildOppositionBlock(research)
+ if (opposition) blocks.push(opposition)
+
return blocks
}
@@ -556,10 +585,18 @@ const buildNetworkSection = ({ snapshot, research }) => {
})
}
+ blocks.push(
+ ...buildSourcedListBlocks({
+ title: 'Dobradinhas (pesquisa)',
+ note: 'Com quem Solla soma na cidade em 2026 (estadual/federal, chapa e lideranças que transferem voto) — insumo direto da abordagem sugerida; cada item com URL.',
+ items: research?.alliances ?? [],
+ }),
+ )
+
return blocks
}
-const buildConjunctureSection = ({ snapshot }) => {
+const buildConjunctureSection = ({ snapshot, research }) => {
const conjuncture = snapshot.conjuncture
const trend = conjuncture?.politicalTrend
const rows = [
@@ -632,6 +669,14 @@ const buildConjunctureSection = ({ snapshot }) => {
})
}
+ blocks.push(
+ ...buildSourcedListBlocks({
+ title: 'Investimentos e obras (pesquisa)',
+ note: 'O que Solla, o governo do estado e o federal entregaram, anunciaram ou têm em obra no município e na região — com fase/valor quando a fonte informar; cada item com URL. Empenho ≠ pagamento.',
+ items: research?.investments ?? [],
+ }),
+ )
+
return blocks
}
@@ -1095,11 +1140,19 @@ const buildSourcesSection = ({ snapshot, research, emendas }) => {
date: item.sourceDate,
})
}
- for (const listKey of ['demography', 'economy', 'transport']) {
+ const listLabels = {
+ demography: 'Demografia',
+ economy: 'Economia',
+ transport: 'Transporte',
+ opposition: 'Oposição',
+ alliances: 'Dobradinhas',
+ investments: 'Investimentos e obras',
+ }
+ for (const listKey of Object.keys(listLabels)) {
for (const item of research[listKey] ?? []) {
items.push({
kind: 'web',
- label: `${listKey === 'demography' ? 'Demografia' : listKey === 'economy' ? 'Economia' : 'Transporte'} — ${item.topic}`,
+ label: `${listLabels[listKey]} — ${item.topic}`,
url: item.sourceUrl,
date: item.sourceDate,
})
@@ -1119,6 +1172,7 @@ const buildSourcesSection = ({ snapshot, research, emendas }) => {
'Emendas são lidas da fonte oficial em tempo de geração e não são persistidas na base.',
'Quando a fonte oficial não atribui emenda ao município, indícios web (município, região ou polo) entram como evidência datada — nunca somados como emenda da cidade.',
'Abordagem sugerida é análise das personas ancorada nas fontes listadas item a item, não fato verificado além delas.',
+ 'Oposição: cada linha é fato publicado com URL + data; o PDF não afirma culpa nem orienta ataque pessoal — use como contraste ancorado.',
],
},
]
@@ -1151,6 +1205,23 @@ const assertSnapshotShape = (snapshot) => {
}
}
+/**
+ * Sections whose only content would be an empty-state callout are omitted
+ * (sinais/demandas) so the report does not spend a page saying "nothing here".
+ * Research sections keep their explicit gap callout — a missing source is a
+ * product lacuna, not noise.
+ */
+const hasSectionData = (id, snapshot) => {
+ if (id === 'sinais') return (snapshot.signals?.rows?.length ?? 0) > 0
+ if (id === 'demandas') {
+ return (
+ (snapshot.demands?.rows?.length ?? 0) > 0 ||
+ (snapshot.activities?.upcoming?.length ?? 0) + (snapshot.activities?.recent?.length ?? 0) > 0
+ )
+ }
+ return true
+}
+
export const buildCityReport = ({ snapshot, research, emendas, generatedAt = new Date() }) => {
assertSnapshotShape(snapshot)
const generatedAtIso = generatedAt instanceof Date ? generatedAt.toISOString() : generatedAt
@@ -1158,63 +1229,69 @@ export const buildCityReport = ({ snapshot, research, emendas, generatedAt = new
const sections = [
{
id: 'conta-eleitoral',
- title: '1. Conta eleitoral completa (2014/2018/2022)',
+ title: 'Conta eleitoral completa (2014/2018/2022)',
blocks: buildElectoralSection({ snapshot }),
},
{
id: 'concorrentes',
- title: '2. Concorrentes no município (federal e estadual)',
+ title: 'Concorrentes no município (federal e estadual)',
blocks: buildCompetitorsSection({ snapshot, research }),
},
{
id: 'rede',
- title: '3. Rede e lideranças',
+ title: 'Rede e lideranças',
blocks: buildNetworkSection({ snapshot, research }),
},
- { id: 'conjuntura', title: '4. Conjuntura', blocks: buildConjunctureSection({ snapshot }) },
- { id: 'sinais', title: '5. Sinais recentes', blocks: buildSignalsSection({ snapshot }) },
+ {
+ id: 'conjuntura',
+ title: 'Conjuntura',
+ blocks: buildConjunctureSection({ snapshot, research }),
+ },
+ { id: 'sinais', title: 'Sinais recentes', blocks: buildSignalsSection({ snapshot }) },
{
id: 'demandas',
- title: '6. Demandas e visitas',
+ title: 'Demandas e visitas',
blocks: buildDemandsVisitsSection({ snapshot }),
},
{
id: 'demografia',
- title: '7. Demografia',
+ title: 'Demografia',
blocks: buildDemographicsSection({ snapshot, research }),
},
{
id: 'economia',
- title: '8. Atividade econômica (pesquisa)',
+ title: 'Atividade econômica (pesquisa)',
blocks: buildEconomySection({ research }),
},
{
id: 'transporte',
- title: '9. Transporte e conexões (pesquisa)',
+ title: 'Transporte e conexões (pesquisa)',
blocks: buildTransportSection({ research }),
},
- { id: 'falas', title: '10. Acervo de falas', blocks: buildSpeechesSection({ snapshot }) },
+ { id: 'falas', title: 'Acervo de falas', blocks: buildSpeechesSection({ snapshot }) },
{
id: 'noticias',
- title: '11. Notícias internas e imprensa local',
+ title: 'Notícias internas e imprensa local',
blocks: buildNewsSection({ research }),
},
{
id: 'regiao',
- title: '12. Panorama regional (Território de Identidade)',
+ title: 'Panorama regional (Território de Identidade)',
blocks: buildRegionSection({ snapshot }),
},
{
id: 'abordagem',
- title: '13. Abordagem sugerida (pesquisa)',
+ title: 'Abordagem sugerida (pesquisa)',
blocks: buildApproachSection({ research }),
},
{
id: 'fontes',
- title: '14. Fontes e limites',
+ title: 'Fontes e limites',
blocks: buildSourcesSection({ snapshot, research, emendas }),
},
]
+ .filter((section) => hasSectionData(section.id, snapshot))
+ .map((section, index) => ({ ...section, title: `${index + 1}. ${section.title}` }))
return {
meta: {
diff --git a/scripts/lib/cityReportResearch.mjs b/scripts/lib/cityReportResearch.mjs
index 90ad7c5e..3fb79af4 100644
--- a/scripts/lib/cityReportResearch.mjs
+++ b/scripts/lib/cityReportResearch.mjs
@@ -267,7 +267,14 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => {
})
}
- const sourcedLists = { demography: [], economy: [], transport: [] }
+ const sourcedLists = {
+ demography: [],
+ economy: [],
+ transport: [],
+ opposition: [],
+ alliances: [],
+ investments: [],
+ }
for (const key of Object.keys(sourcedLists)) {
for (const entry of Array.isArray(raw[key]) ? raw[key] : []) {
const topic = isNonEmptyString(entry?.topic) ? entry.topic.trim() : null
@@ -311,6 +318,9 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => {
demography: sourcedLists.demography,
economy: sourcedLists.economy,
transport: sourcedLists.transport,
+ opposition: sourcedLists.opposition,
+ alliances: sourcedLists.alliances,
+ investments: sourcedLists.investments,
gaps,
}
}
diff --git a/tests/unit/cityReportBlocks.unit.spec.ts b/tests/unit/cityReportBlocks.unit.spec.ts
index 4ba153b9..f0ff8024 100644
--- a/tests/unit/cityReportBlocks.unit.spec.ts
+++ b/tests/unit/cityReportBlocks.unit.spec.ts
@@ -94,6 +94,30 @@ const research = normalizeResearchInput(
sourceDate: '2026-09-10',
},
],
+ opposition: [
+ {
+ topic: 'Emenda sob investigação (PF)',
+ detail: 'Operação apura desvio em emendas do campo adversário.',
+ sourceUrl: 'https://exemplo.test/oposicao',
+ sourceDate: '2026-09-10',
+ },
+ ],
+ alliances: [
+ {
+ topic: 'Deputada Estadual (PT)',
+ detail: 'Dobradinha estadual indicada com Solla na cidade.',
+ sourceUrl: 'https://exemplo.test/dobradinha',
+ sourceDate: '2026-09-10',
+ },
+ ],
+ investments: [
+ {
+ topic: 'Obra federal no município',
+ detail: 'Investimento do governo federal em saúde; em execução.',
+ sourceUrl: 'https://exemplo.test/investimento',
+ sourceDate: '2026-09-10',
+ },
+ ],
gaps: [],
},
{ now: generatedAt },
@@ -398,6 +422,65 @@ describe('buildCityReport', () => {
)
})
+ it('renders the opposition front as sourced contrast, when researched', () => {
+ const concorrentes = report.sections.find((section) => section.id === 'concorrentes')!
+ const opposition = concorrentes.blocks.find(
+ (block) => block.title === 'Oposição — o que usar com fonte (pesquisa)',
+ )!
+ const rows = opposition.rows as unknown as TableRow[]
+ expect(rows[0].topic).toBe('Emenda sob investigação (PF)')
+ expect(rows[0].detail).toContain('desvio em emendas')
+ expect(opposition.sources![0]).toEqual(
+ expect.objectContaining({ url: 'https://exemplo.test/oposicao' }),
+ )
+ })
+
+ it('renders the dobradinhas research in the network section', () => {
+ const rede = report.sections.find((section) => section.id === 'rede')!
+ const dobradinhas = rede.blocks.find((block) => block.title === 'Dobradinhas (pesquisa)')!
+ const rows = dobradinhas.rows as unknown as TableRow[]
+ expect(rows[0].topic).toContain('Deputada Estadual')
+ expect(dobradinhas.sources![0]).toEqual(
+ expect.objectContaining({ url: 'https://exemplo.test/dobradinha' }),
+ )
+ })
+
+ it('renders the investments research in the conjuncture section', () => {
+ const conjuntura = report.sections.find((section) => section.id === 'conjuntura')!
+ const investments = conjuntura.blocks.find(
+ (block) => block.title === 'Investimentos e obras (pesquisa)',
+ )!
+ const rows = investments.rows as unknown as TableRow[]
+ expect(rows[0].detail).toContain('governo federal')
+ expect(investments.sources![0]).toEqual(
+ expect.objectContaining({ url: 'https://exemplo.test/investimento' }),
+ )
+ })
+
+ it('omits the empty signals/demands sections and renumbers the rest', () => {
+ const emptyReport = asReport(
+ buildCityReport({
+ snapshot: {
+ ...snapshot,
+ signals: { totalCount: 0, rows: [] },
+ demands: { totalCount: 0, rows: [] },
+ activities: { upcoming: [], recent: [] },
+ },
+ research,
+ emendas,
+ generatedAt,
+ }),
+ )
+ expect(emptyReport.sections.some((section) => section.id === 'sinais')).toBe(false)
+ expect(emptyReport.sections.some((section) => section.id === 'demandas')).toBe(false)
+ expect(
+ emptyReport.sections.every((section, index) => section.title.startsWith(`${index + 1}. `)),
+ ).toBe(true)
+ expect(emptyReport.sections.find((section) => section.id === 'demografia')!.title).toMatch(
+ /^5\. /,
+ )
+ })
+
it('lists the researched city leaders in the network section', () => {
const rede = report.sections.find((section) => section.id === 'rede')!
const leadersBlock = rede.blocks.find(
diff --git a/tests/unit/cityReportRender.unit.spec.ts b/tests/unit/cityReportRender.unit.spec.ts
index d0d467ba..a86e3785 100644
--- a/tests/unit/cityReportRender.unit.spec.ts
+++ b/tests/unit/cityReportRender.unit.spec.ts
@@ -114,10 +114,12 @@ describe('renderReportMd', () => {
it('carries the same sections as tables and headings', () => {
expect(md).toContain('## 1. Conta eleitoral completa')
expect(md).toContain('## 2. Concorrentes no município (federal e estadual)')
- expect(md).toContain('## 8. Atividade econômica (pesquisa)')
- expect(md).toContain('## 9. Transporte e conexões (pesquisa)')
- expect(md).toContain('## 13. Abordagem sugerida (pesquisa)')
- expect(md).toContain('## 14. Fontes e limites')
+ expect(md).not.toContain('## 5. Sinais recentes')
+ expect(md).not.toContain('Demandas e visitas')
+ expect(md).toContain('## 6. Atividade econômica (pesquisa)')
+ expect(md).toContain('## 7. Transporte e conexões (pesquisa)')
+ expect(md).toContain('## 11. Abordagem sugerida (pesquisa)')
+ expect(md).toContain('## 12. Fontes e limites')
expect(md).toContain('| Ano | Votos |')
})
diff --git a/tests/unit/cityReportResearch.unit.spec.ts b/tests/unit/cityReportResearch.unit.spec.ts
index 1bd8cdfd..d5f0c2cf 100644
--- a/tests/unit/cityReportResearch.unit.spec.ts
+++ b/tests/unit/cityReportResearch.unit.spec.ts
@@ -177,4 +177,50 @@ describe('normalizeResearchInput', () => {
expect.objectContaining({ id: 'inventado', reason: expect.stringMatching(/checklist/) }),
)
})
+
+ it('keeps sourced opposition facts and gaps entries without source', () => {
+ const research = normalizeResearchInput(
+ baseResearch({
+ opposition: [
+ {
+ topic: 'Emenda sob investigação (PF)',
+ detail: 'Operação apura desvio em emendas.',
+ sourceUrl: 'https://exemplo.test/oposicao',
+ sourceDate: '2026-09-10',
+ },
+ { topic: 'Sem fonte', detail: 'Não entra.' },
+ ],
+ }),
+ { now },
+ )
+ expect(research.opposition).toHaveLength(1)
+ const [firstOpposition] = research.opposition as unknown as Array<{ topic: string }>
+ expect(firstOpposition.topic).toBe('Emenda sob investigação (PF)')
+ expect(research.gaps).toContainEqual(
+ expect.objectContaining({
+ id: 'opposition_sem_fonte',
+ reason: expect.stringMatching(/URL e data/),
+ }),
+ )
+ })
+
+ it('keeps sourced dobradinhas and investments, gapping those without source', () => {
+ const research = normalizeResearchInput(
+ baseResearch({
+ alliances: [
+ {
+ topic: 'Dobradinha estadual',
+ detail: 'Deputada X (PT) indicada com Solla.',
+ sourceUrl: 'https://exemplo.test/dobradinha',
+ sourceDate: '2026-09-10',
+ },
+ ],
+ investments: [{ topic: 'Sem fonte', detail: 'Não entra.' }],
+ }),
+ { now },
+ )
+ expect(research.alliances).toHaveLength(1)
+ expect(research.investments).toHaveLength(0)
+ expect(research.gaps).toContainEqual(expect.objectContaining({ id: 'investments_sem_fonte' }))
+ })
})