#![allow(dead_code)]
use anyhow::{anyhow, bail, Context, Result};
use askama::Template;
use chrono::{SecondsFormat, Utc};
use sha2::{Digest, Sha256};
use std::{collections::BTreeMap, fs, path::Path};
use crate::domain::artifact::{ArtifactGenerator, GenerationContext};
use crate::domain::discovery::DiscoveryReport;
const BRAND_LOGO_WEBP: &[u8] =
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/public/logo.webp"));
fn encode_base64(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let first = chunk[0];
let second = chunk.get(1).copied().unwrap_or_default();
let third = chunk.get(2).copied().unwrap_or_default();
let value = ((first as u32) << 16) | ((second as u32) << 8) | third as u32;
encoded.push(ALPHABET[((value >> 18) & 0x3f) as usize] as char);
encoded.push(ALPHABET[((value >> 12) & 0x3f) as usize] as char);
encoded.push(if chunk.len() > 1 {
ALPHABET[((value >> 6) & 0x3f) as usize] as char
} else {
'='
});
encoded.push(if chunk.len() > 2 {
ALPHABET[(value & 0x3f) as usize] as char
} else {
'='
});
}
encoded
}
fn brand_logo_data_uri() -> String {
format!("data:image/webp;base64,{}", encode_base64(BRAND_LOGO_WEBP))
}
#[derive(Debug, Clone)]
pub struct TocEntry {
pub id: String,
pub label: String,
pub level: u8,
}
#[derive(Debug, Clone)]
pub struct TraceabilityItem {
pub key: String,
pub value: String,
}
#[derive(Template)]
#[template(path = "html/discovery.html")]
pub struct DiscoveryArtifactTemplate {
pub title: String,
pub stage: String,
pub toc_entries: Vec<TocEntry>,
pub has_mermaid: bool,
pub traceability_items: Vec<TraceabilityItem>,
pub content_html: String,
pub brand_logo_data_uri: String,
}
pub struct DiscoveryHtmlGenerator<'a> {
pub report: &'a DiscoveryReport,
pub name: &'a str,
pub input: &'a str,
pub state: &'a str,
}
impl<'a> DiscoveryHtmlGenerator<'a> {
pub fn new(report: &'a DiscoveryReport, name: &'a str, input: &'a str, state: &'a str) -> Self {
Self {
report,
name,
input,
state,
}
}
fn build_toc(&self) -> Vec<TocEntry> {
let mut toc = vec![
TocEntry {
id: "resumo".into(),
label: "Resumo".into(),
level: 1,
},
TocEntry {
id: "stack".into(),
label: "Stack Utilizada".into(),
level: 1,
},
TocEntry {
id: "arquitetura".into(),
label: "Arquitetura Inferida".into(),
level: 1,
},
TocEntry {
id: "mapa-projeto".into(),
label: "Mapa do projeto".into(),
level: 1,
},
TocEntry {
id: "manifests".into(),
label: "Manifests e Dependências".into(),
level: 1,
},
TocEntry {
id: "paths".into(),
label: "Paths Observados".into(),
level: 1,
},
TocEntry {
id: "comandos".into(),
label: "Comandos".into(),
level: 1,
},
TocEntry {
id: "qualidade".into(),
label: "Qualidade".into(),
level: 1,
},
TocEntry {
id: "operacao".into(),
label: "Operação".into(),
level: 1,
},
TocEntry {
id: "dados-integracoes".into(),
label: "Dados e integrações".into(),
level: 1,
},
TocEntry {
id: "linguagem-inteligencia".into(),
label: "Domínio e inteligência".into(),
level: 1,
},
TocEntry {
id: "capabilities".into(),
label: "Mapa de capacidades".into(),
level: 1,
},
];
if !self.report.risks.is_empty() {
toc.push(TocEntry {
id: "riscos".into(),
label: "Riscos".into(),
level: 1,
});
}
toc.push(TocEntry {
id: "rastreabilidade".into(),
label: "Rastreabilidade".into(),
level: 1,
});
toc
}
fn build_traceability(&self, ctx: &GenerationContext<'_>) -> Vec<TraceabilityItem> {
let mut items = vec![
TraceabilityItem {
key: "Estado".into(),
value: escape_html(status_label_pt_br(self.state)),
},
TraceabilityItem {
key: "Projeto Root".into(),
value: escape_html(&redact_text(&self.report.project_root)),
},
TraceabilityItem {
key: "Entrada".into(),
value: {
let s = self.input.trim();
if s.is_empty() {
"(inventário geral)".into()
} else {
escape_html(&redact_text(s))
}
},
},
TraceabilityItem {
key: "Lifecycle".into(),
value: escape_html(&self.report.identity.lifecycle),
},
TraceabilityItem {
key: "Tipo".into(),
value: escape_html(&self.report.identity.project_type),
},
];
items.extend([
TraceabilityItem {
key: "Provider".into(),
value: escape_html(&ctx.provider.id),
},
TraceabilityItem {
key: "Modelo".into(),
value: escape_html(ctx.provider.effective_model_for_stage(ctx.stage)),
},
TraceabilityItem {
key: "Effort".into(),
value: escape_html(ctx.provider.effective_effort_for_stage(ctx.stage)),
},
TraceabilityItem {
key: "Offline".into(),
value: ctx.provider.offline.to_string(),
},
]);
items
}
fn build_content_html(&self) -> String {
let r = self.report;
let mut out = String::new();
out.push_str("<section class=\"grid\" id=\"resumo\" aria-label=\"Resumo executivo\">\n");
out.push_str(&metric_card(
"Tipo",
&r.identity.project_type,
"Classificação operacional.",
));
out.push_str(&metric_card(
"Lifecycle",
&r.identity.lifecycle,
"Sinal greenfield/brownfield.",
));
out.push_str(&metric_card(
"Stack",
&r.identity.stack.len().to_string(),
"Tecnologias detectadas.",
));
out.push_str(&metric_card(
"Riscos",
&r.risks.len().to_string(),
"Riscos que pedem atenção.",
));
out.push_str("</section>\n");
out.push_str("<section class=\"panel\" id=\"stack\">\n");
out.push_str(" <h2>Stack Utilizada</h2>\n");
out.push_str(" <div class=\"badge-row\">\n");
if r.identity.stack.is_empty() {
out.push_str(" <span class=\"badge\">não detectada</span>\n");
} else {
for item in &r.identity.stack {
out.push_str(&format!(
" <span class=\"badge\">{}</span>\n",
escape_html(item)
));
}
}
out.push_str(" </div>\n");
out.push_str(&format!(
" <p class=\"muted\">Root: <code>{}</code></p>\n",
escape_html(&r.project_root)
));
out.push_str("</section>\n");
out.push_str("<section class=\"panel\" id=\"arquitetura\">\n");
out.push_str(" <h2>Arquitetura Inferida</h2>\n");
out.push_str(&format!(
" <p class=\"muted\">Confiança: {}</p>\n",
escape_html(&r.architecture.confidence)
));
if !r.architecture.summary.is_empty() {
out.push_str(" <ul>\n");
for line in &r.architecture.summary {
out.push_str(&format!(" <li>{}</li>\n", escape_html(line)));
}
out.push_str(" </ul>\n");
}
if !r.architecture.entrypoints.is_empty() {
out.push_str(" <details open>\n <summary>Entrypoints</summary>\n");
out.push_str(" <table><thead><tr><th>Path</th><th>Tipo</th><th>Papel</th></tr></thead><tbody>\n");
for e in &r.architecture.entrypoints {
out.push_str(&format!(
" <tr><td><code>{}</code></td><td>{}</td><td>{}</td></tr>\n",
escape_html(&e.path),
escape_html(&e.kind),
escape_html(&e.role)
));
}
out.push_str(" </tbody></table>\n </details>\n");
}
if !r.architecture.layers.is_empty() {
out.push_str(" <details>\n <summary>Camadas e boundaries</summary>\n");
out.push_str(" <table><thead><tr><th>Path</th><th>Tipo</th><th>Papel</th></tr></thead><tbody>\n");
for e in &r.architecture.layers {
out.push_str(&format!(
" <tr><td><code>{}</code></td><td>{}</td><td>{}</td></tr>\n",
escape_html(&e.path),
escape_html(&e.kind),
escape_html(&e.role)
));
}
out.push_str(" </tbody></table>\n </details>\n");
}
if !r.architecture.routing.is_empty() {
out.push_str(" <details>\n <summary>Rotas e comandos</summary>\n");
out.push_str(" <table><thead><tr><th>Path</th><th>Tipo</th><th>Papel</th></tr></thead><tbody>\n");
for e in &r.architecture.routing {
out.push_str(&format!(
" <tr><td><code>{}</code></td><td>{}</td><td>{}</td></tr>\n",
escape_html(&e.path),
escape_html(&e.kind),
escape_html(&e.role)
));
}
out.push_str(" </tbody></table>\n </details>\n");
}
out.push_str("</section>\n");
let mermaid_source = format!(
"flowchart LR\n root[\"{}\"] --> manifests[\"{} manifests\"]\n manifests --> paths[\"{} paths\"]\n paths --> commands[\"Comandos detectados\"]\n commands --> capabilities[\"{} capacidades\"]",
escape_html(&r.identity.project_type),
r.manifests.len(),
r.paths.source.len()
+ r.paths.tests.len()
+ r.paths.docs.len()
+ r.paths.migrations.len()
+ r.paths.infra.len()
+ r.paths.config.len()
+ r.paths.artifacts.len(),
r.capabilities.len(),
);
out.push_str("<section class=\"panel\" id=\"mapa-projeto\">\n");
out.push_str(" <h2>Mapa do projeto</h2>\n");
out.push_str(" <p class=\"muted\">Leitura visual derivada do inventário. Selecione uma estação para abrir sua evidência.</p>\n");
out.push_str(
" <div class=\"mermaid-wrap discovery-mermaid is-enhanced\" data-discovery-mermaid>\n",
);
out.push_str(" <div class=\"diagram-toolbar\"><strong>Mermaid · Fluxo do discovery</strong><div class=\"diagram-toolbar-actions\"><button type=\"button\" data-diagram-view=\"visual\" aria-pressed=\"true\">Visual</button><button type=\"button\" data-diagram-view=\"code\" aria-pressed=\"false\">Código</button></div></div>\n");
out.push_str(" <div class=\"mermaid-diagram\"><div class=\"discovery-flow-canvas\">\n");
let discovery_nodes = [
(
"#arquitetura",
"01",
"Projeto",
r.identity.project_type.clone(),
),
(
"#manifests",
"02",
"Manifests",
format!("{} detectados", r.manifests.len()),
),
(
"#paths",
"03",
"Paths",
format!(
"{} observados",
r.paths.source.len()
+ r.paths.tests.len()
+ r.paths.docs.len()
+ r.paths.migrations.len()
+ r.paths.infra.len()
+ r.paths.config.len()
+ r.paths.artifacts.len()
),
),
("#comandos", "04", "Comandos", "execução local".to_string()),
(
"#capabilities",
"05",
"Capacidades",
format!("{} recomendadas", r.capabilities.len()),
),
];
for (index, (href, number, label, detail)) in discovery_nodes.iter().enumerate() {
if index > 0 {
out.push_str(" <span class=\"discovery-flow-edge\" aria-hidden=\"true\"><i></i></span>\n");
}
out.push_str(&format!(
" <a class=\"discovery-flow-node\" href=\"{}\"><span>{}</span><strong>{}</strong><small>{}</small></a>\n",
href,
number,
escape_html(label),
escape_html(detail),
));
}
out.push_str(" </div></div>\n");
out.push_str(&format!(
" <pre class=\"mermaid-fallback\">{}</pre>\n",
escape_html(&mermaid_source)
));
out.push_str(" </div>\n");
out.push_str("</section>\n");
out.push_str("<section class=\"panel\" id=\"manifests\">\n");
out.push_str(" <h2>Manifests e Dependências</h2>\n");
if r.manifests.is_empty() {
out.push_str(" <p class=\"muted\">Nenhum manifest detectado.</p>\n");
} else {
out.push_str(" <table><thead><tr><th>Path</th><th>Tipo</th><th>Nome</th><th>Versão</th></tr></thead><tbody>\n");
for m in &r.manifests {
out.push_str(&format!(
" <tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td></tr>\n",
escape_html(&m.path),
escape_html(&m.kind),
m.name.as_deref().map(escape_html).unwrap_or_default(),
m.version.as_deref().map(escape_html).unwrap_or_default(),
));
}
out.push_str(" </tbody></table>\n");
}
out.push_str("</section>\n");
out.push_str("<section class=\"panel\" id=\"paths\">\n");
out.push_str(" <h2>Paths Observados</h2>\n");
out.push_str(" <table><thead><tr><th>Categoria</th><th>Paths</th></tr></thead><tbody>\n");
let path_groups = [
("Fonte", &r.paths.source),
("Testes", &r.paths.tests),
("Docs", &r.paths.docs),
("Migrations", &r.paths.migrations),
("Infra", &r.paths.infra),
("Config", &r.paths.config),
("Artefatos", &r.paths.artifacts),
];
for (label, paths) in &path_groups {
out.push_str(&format!(
" <tr><td>{}</td><td>{}</td></tr>\n",
label,
if paths.is_empty() {
"<span class=\"muted\">não encontrado</span>".to_string()
} else {
paths
.iter()
.map(|p| format!("<code>{}</code>", escape_html(p)))
.collect::<Vec<_>>()
.join(" ")
}
));
}
out.push_str(" </tbody></table>\n");
out.push_str("</section>\n");
out.push_str("<section class=\"panel\" id=\"comandos\">\n");
out.push_str(" <h2>Comandos</h2>\n");
out.push_str(" <table><thead><tr><th>Grupo</th><th>Comandos</th></tr></thead><tbody>\n");
let cmd_groups = [
("install", &r.commands.install),
("lint", &r.commands.lint),
("typecheck", &r.commands.typecheck),
("test", &r.commands.test),
("build", &r.commands.build),
("não verificados", &r.commands.not_verified),
];
for (label, cmds) in &cmd_groups {
out.push_str(&format!(
" <tr><td>{}</td><td>{}</td></tr>\n",
label,
if cmds.is_empty() {
"<span class=\"muted\">ausente</span>".to_string()
} else {
cmds.iter()
.map(|c| format!("<code>{}</code>", escape_html(c)))
.collect::<Vec<_>>()
.join(" ")
}
));
}
out.push_str(" </tbody></table>\n");
out.push_str("</section>\n");
out.push_str("<section class=\"panel\" id=\"qualidade\">\n");
out.push_str(" <h2>Qualidade</h2>\n");
if r.quality.test_frameworks.is_empty() && r.quality.test_paths.is_empty() {
out.push_str(" <p class=\"muted\">Nenhum framework de testes detectado.</p>\n");
} else {
out.push_str(" <ul>\n");
for fw in &r.quality.test_frameworks {
out.push_str(&format!(
" <li>Framework: <code>{}</code></li>\n",
escape_html(fw)
));
}
for tp in &r.quality.test_paths {
out.push_str(&format!(
" <li>Path: <code>{}</code></li>\n",
escape_html(tp)
));
}
for g in &r.quality.gaps {
out.push_str(&format!(
" <li class=\"warn\">Gap: {}</li>\n",
escape_html(g)
));
}
out.push_str(" </ul>\n");
}
out.push_str("</section>\n");
out.push_str("<section class=\"panel\" id=\"operacao\">\n");
out.push_str(" <h2>Operação</h2>\n");
if !r.operations.ci.is_empty() {
out.push_str(" <p><strong>CI:</strong> ");
out.push_str(
&r.operations
.ci
.iter()
.map(|s| format!("<code>{}</code>", escape_html(s)))
.collect::<Vec<_>>()
.join(", "),
);
out.push_str("</p>\n");
}
if !r.operations.deploy.is_empty() {
out.push_str(" <p><strong>Deploy:</strong> ");
out.push_str(
&r.operations
.deploy
.iter()
.map(|s| format!("<code>{}</code>", escape_html(s)))
.collect::<Vec<_>>()
.join(", "),
);
out.push_str("</p>\n");
}
if !r.operations.env_vars.is_empty() {
out.push_str(" <p><strong>Env vars:</strong> ");
out.push_str(
&r.operations
.env_vars
.iter()
.map(|s| format!("<code>{}</code>", escape_html(s)))
.collect::<Vec<_>>()
.join(", "),
);
out.push_str("</p>\n");
}
if r.operations.ci.is_empty() && r.operations.deploy.is_empty() {
out.push_str(" <p class=\"muted\">Sem dados de CI/deploy detectados.</p>\n");
}
out.push_str("</section>\n");
out.push_str("<section class=\"panel\" id=\"dados-integracoes\">\n");
out.push_str(" <h2>Dados e integrações</h2>\n");
out.push_str(" <div class=\"discovery-columns\">\n");
out.push_str(" <div><h3>Contratos de dados</h3><ul>\n");
for item in r
.data_contracts
.migrations
.iter()
.chain(r.data_contracts.schemas.iter())
.chain(r.data_contracts.api_contracts.iter())
.chain(r.data_contracts.orm.iter())
{
out.push_str(&format!(
" <li><code>{}</code></li>\n",
escape_html(item)
));
}
if r.data_contracts.migrations.is_empty()
&& r.data_contracts.schemas.is_empty()
&& r.data_contracts.api_contracts.is_empty()
&& r.data_contracts.orm.is_empty()
{
out.push_str(" <li class=\"muted\">Nenhum contrato detectado.</li>\n");
}
out.push_str(" </ul></div>\n");
out.push_str(" <div><h3>Integrações</h3><ul>\n");
for item in r
.integrations
.external_services
.iter()
.chain(r.integrations.auth.iter())
.chain(r.integrations.webhooks.iter())
.chain(r.integrations.queues_jobs.iter())
.chain(r.integrations.storage.iter())
{
out.push_str(&format!(
" <li><code>{}</code></li>\n",
escape_html(item)
));
}
if r.integrations.external_services.is_empty()
&& r.integrations.auth.is_empty()
&& r.integrations.webhooks.is_empty()
&& r.integrations.queues_jobs.is_empty()
&& r.integrations.storage.is_empty()
{
out.push_str(" <li class=\"muted\">Nenhuma integração detectada.</li>\n");
}
out.push_str(" </ul></div>\n </div>\n</section>\n");
out.push_str("<section class=\"panel\" id=\"linguagem-inteligencia\">\n");
out.push_str(" <h2>Domínio e inteligência de código</h2>\n");
out.push_str(&format!(
" <p>{}</p>\n",
escape_html(&r.domain_language.recommendation)
));
if let Some(context_map) = &r.domain_language.context_map {
out.push_str(&format!(
" <p><strong>Context map:</strong> <code>{}</code></p>\n",
escape_html(context_map)
));
}
out.push_str(" <div class=\"intelligence-grid\">\n");
for status in &r.code_intelligence {
out.push_str(&format!(
" <article class=\"intelligence-card\" data-state=\"{}\"><strong>{}</strong><span>{}</span><code>{}</code><p>{}</p></article>\n",
if status.available && status.indexed {
"ready"
} else {
"fallback"
},
escape_html(&status.id),
if status.available && status.indexed {
"ready"
} else {
"fallback"
},
escape_html(&status.command),
escape_html(&status.fallback),
));
}
out.push_str(" </div>\n</section>\n");
out.push_str("<section class=\"panel\" id=\"capabilities\">\n");
out.push_str(" <h2>Mapa de capacidades</h2>\n");
out.push_str(" <p class=\"muted\">Filtre por etapa e explore quais skills entram quando o sinal correspondente aparece.</p>\n");
out.push_str(" <div class=\"capability-filters\" role=\"toolbar\" aria-label=\"Filtrar capacidades por etapa\"><button type=\"button\" data-capability-filter=\"all\" aria-pressed=\"true\">Todas</button>");
for (stage, label) in [
("project-discovery", "discovery"),
("idea", "idea"),
("prd", "prd"),
("techspec", "techspec"),
("tasks", "tasks"),
("execution", "execution"),
("review", "review"),
("qa", "qa"),
] {
out.push_str(&format!(
"<button type=\"button\" data-capability-filter=\"{}\" aria-pressed=\"false\">{}</button>",
stage,
label
));
}
out.push_str("</div>\n <div class=\"capability-canvas\">\n");
for capability in &r.capabilities {
out.push_str(&format!(
" <article class=\"capability-node\" data-stages=\"{}\"><span>{}</span><strong>{}</strong><p>{}</p><code>{}</code></article>\n",
escape_html(&capability.stages.join(" ")).to_lowercase(),
escape_html(&capability.mode),
escape_html(&capability.title),
escape_html(&capability.trigger),
escape_html(&capability.local_skill),
));
}
if r.capabilities.is_empty() {
out.push_str(" <p class=\"muted\">Nenhuma recomendação adicional detectada.</p>\n");
}
out.push_str(" </div>\n</section>\n");
if !r.risks.is_empty() {
out.push_str("<section class=\"panel\" id=\"riscos\">\n");
out.push_str(" <h2>Riscos</h2>\n");
out.push_str(" <ul>\n");
for risk in &r.risks {
out.push_str(&format!(
" <li class=\"risk\">{}</li>\n",
escape_html(risk)
));
}
out.push_str(" </ul>\n");
out.push_str("</section>\n");
}
out
}
}
impl ArtifactGenerator for DiscoveryHtmlGenerator<'_> {
fn generate_markdown(&self, _ctx: &GenerationContext<'_>) -> Result<String> {
anyhow::bail!("DiscoveryHtmlGenerator: use generate_html para o formato HTML ou o DiscoveryMarkdownGenerator para Markdown.")
}
fn generate_html(&self, ctx: &GenerationContext<'_>) -> Result<String> {
let toc = self.build_toc();
let traceability = self.build_traceability(ctx);
let content = self.build_content_html();
let tmpl = DiscoveryArtifactTemplate {
title: format!("Project Discovery — {}", redact_text(self.name)),
stage: "project-discovery".to_string(),
toc_entries: toc,
has_mermaid: true,
traceability_items: traceability,
content_html: content,
brand_logo_data_uri: brand_logo_data_uri(),
};
tmpl.render()
.map_err(|e| anyhow::anyhow!("Erro ao renderizar template HTML de discovery: {e}"))
}
}
#[cfg(feature = "html-gen")]
pub fn render_discovery_html(
report: &DiscoveryReport,
name: &str,
input: &str,
state: &str,
ctx: &GenerationContext<'_>,
) -> Result<String> {
DiscoveryHtmlGenerator::new(report, name, input, state).generate_html(ctx)
}
#[derive(Template)]
#[template(path = "html/stage.html")]
pub struct StageArtifactTemplate {
pub title: String,
pub stage: String,
pub toc_entries: Vec<TocEntry>,
pub has_mermaid: bool,
pub traceability_items: Vec<TraceabilityItem>,
pub content_html: String,
pub brand_logo_data_uri: String,
}
#[derive(Debug, Clone)]
pub struct PlanningExecutiveSignal {
pub label: String,
pub value: String,
pub description: String,
pub tone: String,
}
#[derive(Debug, Clone)]
pub struct PlanningExecutiveSection {
pub index: String,
pub label: String,
pub source: String,
pub content_html: String,
pub tone: String,
}
#[derive(Debug, Clone)]
pub struct PlanningSourceRow {
pub label: String,
pub path: String,
pub state: String,
pub state_label: String,
pub revision: String,
pub sha256_short: String,
}
#[derive(Debug, Clone)]
pub struct PlanningDocumentSection {
pub stage: String,
pub label: String,
pub summary: String,
pub content_html: String,
pub required: bool,
}
#[derive(Debug, Clone)]
pub struct PlanningLifecycleStep {
pub index: String,
pub label: String,
pub state: String,
pub state_label: String,
pub path: String,
pub summary: String,
pub tone: String,
}
#[derive(Template)]
#[template(path = "html/planning.html")]
pub struct PlanningArtifactTemplate {
pub title: String,
pub stage: String,
pub toc_entries: Vec<TocEntry>,
pub has_mermaid: bool,
pub fingerprint: String,
pub generator_version: u32,
pub orchestration_slug: String,
pub generated_at: String,
pub generated_summary: String,
pub freshness_summary: String,
pub artifact_filename: String,
pub compiler_name: String,
pub is_delivery: bool,
pub executive_signals: Vec<PlanningExecutiveSignal>,
pub executive_sections: Vec<PlanningExecutiveSection>,
pub source_rows: Vec<PlanningSourceRow>,
pub sections: Vec<PlanningDocumentSection>,
pub lifecycle_steps: Vec<PlanningLifecycleStep>,
pub brand_logo_data_uri: String,
}
pub struct StageHtmlGenerator<'a> {
pub markdown: &'a str,
pub stage: &'a str,
pub name: &'a str,
}
impl<'a> StageHtmlGenerator<'a> {
pub fn new(markdown: &'a str, stage: &'a str, name: &'a str) -> Self {
Self {
markdown,
stage,
name,
}
}
pub fn render(&self) -> Result<String> {
let (toc, has_mermaid, content_html) = md_to_html(self.markdown);
let traceability_items = extract_traceability_items(self.markdown);
let title = format!("{} — {}", stage_label_display(self.stage), self.name);
let tmpl = StageArtifactTemplate {
title,
stage: self.stage.to_string(),
toc_entries: toc,
has_mermaid,
traceability_items,
content_html,
brand_logo_data_uri: brand_logo_data_uri(),
};
tmpl.render().map_err(|e| {
anyhow::anyhow!(
"Erro ao renderizar template HTML de stage '{stage}': {e}",
stage = self.stage
)
})
}
}
fn stage_label_display(stage: &str) -> &str {
match stage {
"idea" => "Ideia",
"prd" => "PRD",
"techspec" => "Tech Spec",
"tasks" => "Tasks",
"refinement" => "Refinement",
"execution" => "Execução",
"review" => "Review",
"qa" => "QA",
"memory" => "Memória",
"project-discovery" => "Project Discovery",
"risk-classifier" => "Risk Classification",
other => other,
}
}
pub fn md_to_html(md: &str) -> (Vec<TocEntry>, bool, String) {
let ordered_markdown = move_traceability_section_to_end(md);
let mut toc: Vec<TocEntry> = Vec::new();
let mut has_mermaid = false;
let mut html = String::new();
let mut in_code_block = false;
let mut code_lang = String::new();
let mut code_buf: Vec<String> = Vec::new();
let mut in_list = false;
let mut list_ordered = false;
let mut in_table = false;
let mut table_header_done = false;
let mut paragraph_buf: Vec<String> = Vec::new();
let flush_paragraph = |buf: &mut Vec<String>, out: &mut String| {
if !buf.is_empty() {
let text = buf.join(" ");
let trimmed = text.trim().to_string();
if !trimmed.is_empty() {
out.push_str("<p>");
out.push_str(&inline_md(&trimmed));
out.push_str("</p>\n");
}
buf.clear();
}
};
let flush_list = |in_list: &mut bool, list_ordered: &mut bool, out: &mut String| {
if *in_list {
out.push_str(if *list_ordered { "</ol>\n" } else { "</ul>\n" });
*in_list = false;
*list_ordered = false;
}
};
let flush_table = |in_table: &mut bool, table_header_done: &mut bool, out: &mut String| {
if *in_table {
out.push_str("</tbody></table>\n");
*in_table = false;
*table_header_done = false;
}
};
let lines: Vec<&str> = ordered_markdown.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
if line.starts_with("```") {
if in_code_block {
in_code_block = false;
let code_content = code_buf.join("\n");
if code_lang == "mermaid" {
has_mermaid = true;
html.push_str("<div class=\"mermaid-wrap\">\n");
html.push_str(" <div class=\"mermaid-diagram\">");
html.push_str(&escape_html(&code_content));
html.push_str("</div>\n");
html.push_str(" <pre class=\"mermaid-fallback\">");
html.push_str(&escape_html(&code_content));
html.push_str("</pre>\n");
html.push_str("</div>\n");
} else if code_lang.is_empty() {
html.push_str("<pre><code>");
html.push_str(&escape_html(&code_content));
html.push_str("</code></pre>\n");
} else {
html.push_str(&format!(
"<pre><code class=\"language-{}\">",
escape_html(&code_lang)
));
html.push_str(&escape_html(&code_content));
html.push_str("</code></pre>\n");
}
code_buf.clear();
code_lang.clear();
i += 1;
continue;
} else {
{
let buf = &mut paragraph_buf;
if !buf.is_empty() {
let text = buf.join(" ");
let trimmed = text.trim().to_string();
if !trimmed.is_empty() {
html.push_str("<p>");
html.push_str(&inline_md(&trimmed));
html.push_str("</p>\n");
}
buf.clear();
}
}
flush_list(&mut in_list, &mut list_ordered, &mut html);
flush_table(&mut in_table, &mut table_header_done, &mut html);
in_code_block = true;
code_lang = line.trim_start_matches('`').trim().to_lowercase();
i += 1;
continue;
}
}
if in_code_block {
code_buf.push(line.to_string());
i += 1;
continue;
}
if line.starts_with('#') {
{
let buf = &mut paragraph_buf;
if !buf.is_empty() {
let text = buf.join(" ");
let trimmed = text.trim().to_string();
if !trimmed.is_empty() {
html.push_str("<p>");
html.push_str(&inline_md(&trimmed));
html.push_str("</p>\n");
}
buf.clear();
}
}
flush_list(&mut in_list, &mut list_ordered, &mut html);
flush_table(&mut in_table, &mut table_header_done, &mut html);
let level = line.chars().take_while(|c| *c == '#').count();
let text = line.trim_start_matches('#').trim();
let id = heading_id(text);
let display_text = if heading_id(text) == "checklist-macro" {
"Checklist"
} else {
text
};
match level {
1 => {
html.push_str(&format!(
"<h1 id=\"{}\">{}</h1>\n",
id,
escape_html(display_text)
));
}
2 => {
html.push_str(&format!(
"<h2 id=\"{}\">{}</h2>\n",
id,
escape_html(display_text)
));
toc.push(TocEntry {
id,
label: display_text.to_string(),
level: 1,
});
}
3 => {
html.push_str(&format!(
"<h3 id=\"{}\">{}</h3>\n",
id,
escape_html(display_text)
));
toc.push(TocEntry {
id,
label: display_text.to_string(),
level: 2,
});
}
4 => {
html.push_str(&format!(
"<h4 id=\"{}\">{}</h4>\n",
id,
escape_html(display_text)
));
toc.push(TocEntry {
id,
label: display_text.to_string(),
level: 3,
});
}
_ => {
html.push_str(&format!(
"<h{level} id=\"{id}\">{}</h{level}>\n",
escape_html(text)
));
}
}
i += 1;
continue;
}
if line.trim() == "---" || line.trim() == "***" || line.trim() == "___" {
{
let buf = &mut paragraph_buf;
if !buf.is_empty() {
let text = buf.join(" ");
let trimmed = text.trim().to_string();
if !trimmed.is_empty() {
html.push_str("<p>");
html.push_str(&inline_md(&trimmed));
html.push_str("</p>\n");
}
buf.clear();
}
}
flush_list(&mut in_list, &mut list_ordered, &mut html);
flush_table(&mut in_table, &mut table_header_done, &mut html);
html.push_str("<hr>\n");
i += 1;
continue;
}
if line.trim().is_empty() {
flush_paragraph(&mut paragraph_buf, &mut html);
flush_list(&mut in_list, &mut list_ordered, &mut html);
flush_table(&mut in_table, &mut table_header_done, &mut html);
i += 1;
continue;
}
let ordered_prefix = {
let t = line.trim_start();
let dot_pos = t.find(". ");
match dot_pos {
Some(p) if p > 0 && t[..p].chars().all(|c| c.is_ascii_digit()) => Some(p + 2),
_ => None,
}
};
if let Some(content_start) = ordered_prefix {
flush_paragraph(&mut paragraph_buf, &mut html);
flush_table(&mut in_table, &mut table_header_done, &mut html);
if !in_list || !list_ordered {
if in_list {
html.push_str("</ul>\n");
}
html.push_str("<ol>\n");
in_list = true;
list_ordered = true;
}
let item_text = line.trim_start()[content_start..].trim();
html.push_str("<li>");
html.push_str(&inline_md(item_text));
html.push_str("</li>\n");
i += 1;
continue;
}
let unordered_content = {
let t = line.trim_start();
if t.starts_with("- ") || t.starts_with("* ") || t.starts_with("+ ") {
Some(&t[2..])
} else {
None
}
};
if let Some(item_text) = unordered_content {
flush_paragraph(&mut paragraph_buf, &mut html);
flush_table(&mut in_table, &mut table_header_done, &mut html);
if !in_list || list_ordered {
if in_list {
html.push_str("</ol>\n");
}
html.push_str("<ul>\n");
in_list = true;
list_ordered = false;
}
let item_text = item_text.trim();
let checklist = item_text
.strip_prefix("[ ] ")
.map(|text| (false, text))
.or_else(|| item_text.strip_prefix("[x] ").map(|text| (true, text)))
.or_else(|| item_text.strip_prefix("[X] ").map(|text| (true, text)));
if let Some((checked, checklist_text)) = checklist {
let stable_key = format!(
"check-{}",
&sha256_hex(checklist_text.trim().to_lowercase().as_bytes())[..16]
);
html.push_str("<li class=\"living-check-item\"><label>");
html.push_str(&format!(
"<input type=\"checkbox\" data-living-check=\"{}\" data-official-state=\"{}\"{}>",
stable_key,
if checked { "complete" } else { "pending" },
if checked { " checked" } else { "" }
));
html.push_str("<span>");
html.push_str(&inline_md(checklist_text.trim()));
html.push_str("</span></label></li>\n");
} else {
html.push_str("<li>");
html.push_str(&inline_md(item_text));
html.push_str("</li>\n");
}
i += 1;
continue;
}
if line.trim_start().starts_with('|') {
flush_paragraph(&mut paragraph_buf, &mut html);
flush_list(&mut in_list, &mut list_ordered, &mut html);
let is_separator = line
.trim()
.chars()
.all(|c| c == '|' || c == '-' || c == ':' || c == ' ');
if is_separator && line.contains('-') {
if in_table && !table_header_done {
html.push_str("</thead><tbody>\n");
table_header_done = true;
}
i += 1;
continue;
}
let cells: Vec<&str> = line
.trim()
.trim_start_matches('|')
.trim_end_matches('|')
.split('|')
.map(|c| c.trim())
.collect();
if !in_table {
html.push_str("<table><thead>\n");
in_table = true;
table_header_done = false;
}
if !table_header_done {
html.push_str("<tr>");
for cell in &cells {
html.push_str("<th>");
html.push_str(&inline_md(cell));
html.push_str("</th>");
}
html.push_str("</tr>\n");
} else {
html.push_str("<tr>");
for cell in &cells {
html.push_str("<td>");
html.push_str(&inline_md(cell));
html.push_str("</td>");
}
html.push_str("</tr>\n");
}
i += 1;
continue;
}
flush_list(&mut in_list, &mut list_ordered, &mut html);
flush_table(&mut in_table, &mut table_header_done, &mut html);
paragraph_buf.push(line.to_string());
i += 1;
}
{
let buf = &mut paragraph_buf;
if !buf.is_empty() {
let text = buf.join(" ");
let trimmed = text.trim().to_string();
if !trimmed.is_empty() {
html.push_str("<p>");
html.push_str(&inline_md(&trimmed));
html.push_str("</p>\n");
}
}
}
if in_list {
html.push_str(if list_ordered { "</ol>\n" } else { "</ul>\n" });
}
if in_table {
html.push_str("</tbody></table>\n");
}
(toc, has_mermaid, html)
}
fn move_traceability_section_to_end(md: &str) -> std::borrow::Cow<'_, str> {
let lines = md.lines().collect::<Vec<_>>();
let mut in_fence = false;
let mut start = None;
for (index, line) in lines.iter().enumerate() {
if line.trim_start().starts_with("```") {
in_fence = !in_fence;
continue;
}
if !in_fence
&& line
.trim()
.strip_prefix("## ")
.is_some_and(|title| title.trim().eq_ignore_ascii_case("Rastreabilidade"))
{
start = Some(index);
break;
}
}
let Some(start) = start else {
return std::borrow::Cow::Borrowed(md);
};
in_fence = false;
let mut end = lines.len();
for (index, line) in lines.iter().enumerate().skip(start + 1) {
if line.trim_start().starts_with("```") {
in_fence = !in_fence;
continue;
}
if !in_fence && line.starts_with("## ") {
end = index;
break;
}
}
if end == lines.len() {
return std::borrow::Cow::Borrowed(md);
}
let narrative = lines[..start]
.iter()
.chain(lines[end..].iter())
.copied()
.collect::<Vec<_>>()
.join("\n");
let traceability = lines[start..end].join("\n");
let mut reordered = format!(
"{}\n\n{}",
narrative.trim_end(),
traceability.trim_matches('\n')
);
if md.ends_with('\n') {
reordered.push('\n');
}
std::borrow::Cow::Owned(reordered)
}
fn inline_md(text: &str) -> String {
let mut out = String::with_capacity(text.len() * 2);
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
let mut j = 0;
while j < len {
if chars[j] == '`' {
if let Some(end) = find_closing(&chars, j + 1, '`') {
let code: String = chars[j + 1..end].iter().collect();
out.push_str("<code>");
out.push_str(&escape_html(&code));
out.push_str("</code>");
j = end + 1;
continue;
}
}
if chars[j] == '[' {
if let Some((label, url, after)) = parse_link(&chars, j) {
if let Some(url) = sanitize_link_url(&url) {
out.push_str("<a href=\"");
out.push_str(&escape_html(&url));
out.push_str("\" rel=\"noopener noreferrer\">");
out.push_str(&escape_html(&redact_text(&label)));
out.push_str("</a>");
} else {
out.push_str(
"<span class=\"blocked-link\" aria-label=\"Link inseguro bloqueado\">",
);
out.push_str(&escape_html(&redact_text(&label)));
out.push_str("</span>");
}
j = after;
continue;
}
}
if j + 1 < len && chars[j] == '*' && chars[j + 1] == '*' {
if let Some(end) = find_str_closing(&chars, j + 2, "**") {
let inner: String = chars[j + 2..end].iter().collect();
out.push_str("<strong>");
out.push_str(&escape_html(&inner));
out.push_str("</strong>");
j = end + 2;
continue;
}
}
if chars[j] == '*' {
if let Some(end) = find_closing(&chars, j + 1, '*') {
let inner: String = chars[j + 1..end].iter().collect();
out.push_str("<em>");
out.push_str(&escape_html(&inner));
out.push_str("</em>");
j = end + 1;
continue;
}
}
if j + 1 < len && chars[j] == '_' && chars[j + 1] == '_' {
if let Some(end) = find_str_closing(&chars, j + 2, "__") {
let inner: String = chars[j + 2..end].iter().collect();
out.push_str("<strong>");
out.push_str(&escape_html(&inner));
out.push_str("</strong>");
j = end + 2;
continue;
}
}
if chars[j] == '_' {
if let Some(end) = find_closing(&chars, j + 1, '_') {
let inner: String = chars[j + 1..end].iter().collect();
out.push_str("<em>");
out.push_str(&escape_html(&inner));
out.push_str("</em>");
j = end + 1;
continue;
}
}
match chars[j] {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
c => out.push(c),
}
j += 1;
}
out
}
pub fn sanitize_link_url(url: &str) -> Option<String> {
let trimmed = url.trim();
if trimmed.is_empty() || trimmed.chars().any(char::is_control) {
return None;
}
let scheme_end = trimmed.find(':').filter(|colon| {
let boundary = trimmed.find(['/', '?', '#']).unwrap_or(trimmed.len());
*colon < boundary
});
if let Some(colon) = scheme_end {
let normalized_scheme = trimmed[..colon]
.chars()
.filter(|ch| !ch.is_ascii_whitespace())
.collect::<String>()
.to_ascii_lowercase();
if matches!(
normalized_scheme.as_str(),
"javascript" | "data" | "vbscript"
) {
return None;
}
if !matches!(normalized_scheme.as_str(), "http" | "https" | "mailto") {
return None;
}
}
Some(redact_text(trimmed))
}
pub fn redact_text(input: &str) -> String {
crate::runtime::redaction::redact_text(input)
}
fn find_closing(chars: &[char], start: usize, delimiter: char) -> Option<usize> {
chars[start..]
.iter()
.position(|&c| c == delimiter)
.map(|p| start + p)
}
fn find_str_closing(chars: &[char], start: usize, delimiter: &str) -> Option<usize> {
let dchars: Vec<char> = delimiter.chars().collect();
let dl = dchars.len();
for k in start..chars.len().saturating_sub(dl - 1) {
if &chars[k..k + dl] == dchars.as_slice() {
return Some(k);
}
}
None
}
fn parse_link(chars: &[char], start: usize) -> Option<(String, String, usize)> {
let close_bracket = find_closing(chars, start + 1, ']')?;
if close_bracket + 1 >= chars.len() || chars[close_bracket + 1] != '(' {
return None;
}
let close_paren = find_closing(chars, close_bracket + 2, ')')?;
let label: String = chars[start + 1..close_bracket].iter().collect();
let url: String = chars[close_bracket + 2..close_paren].iter().collect();
Some((label, url, close_paren + 1))
}
fn heading_id(text: &str) -> String {
text.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c.to_lowercase().next().unwrap()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
fn extract_traceability_items(md: &str) -> Vec<TraceabilityItem> {
let mut items = Vec::new();
let mut in_section = false;
for line in md.lines() {
if line.trim_start().starts_with("## Rastreabilidade") {
in_section = true;
continue;
}
if in_section {
if line.starts_with("## ") {
break;
}
let t = line.trim();
if t.starts_with("- ") || t.starts_with("* ") {
let content = &t[2..];
let content = content.trim_start_matches("**").replacen("**", "", 1);
if let Some(colon) = content.find(':') {
let key = content[..colon].trim().trim_matches('*').to_string();
let value = content[colon + 1..].trim().to_string();
if !key.is_empty() {
items.push(TraceabilityItem { key, value });
}
}
}
}
}
items
}
pub fn render_markdown_file_to_html(
md_path: &std::path::Path,
stage: &str,
name: &str,
) -> Result<String> {
let markdown = std::fs::read_to_string(md_path).map_err(|e| {
anyhow::anyhow!(
"Não foi possível ler '{path}': {e}",
path = md_path.display()
)
})?;
StageHtmlGenerator::new(&markdown, stage, name).render()
}
pub const PLANNING_HTML_FILENAME: &str = "05-planning.html";
pub const PLANNING_HTML_GENERATOR_VERSION: u32 = 6;
pub const DELIVERY_HTML_FILENAME: &str = "10-delivery.html";
pub const DELIVERY_HTML_GENERATOR_VERSION: u32 = 5;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PlanningHtmlSource {
pub stage: String,
pub path: String,
pub state: String,
pub revision: Option<u64>,
pub sha256: String,
pub required: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PlanningHtmlFingerprint {
pub generator_version: u32,
pub sources: Vec<PlanningHtmlSource>,
pub sha256: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PlanningHtmlOutput {
pub html: String,
pub fingerprint: PlanningHtmlFingerprint,
pub sha256: String,
pub generated_at: String,
pub sources: Vec<PlanningHtmlSource>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PlanningHtmlCheck {
Current,
Missing { target: String },
Stale { reason: PlanningHtmlStaleReason },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PlanningHtmlStaleReason {
SourceHashMismatch {
stage: String,
path: String,
expected: String,
actual: String,
},
FingerprintMismatch {
expected: String,
actual: String,
},
HtmlHashMismatch {
expected: String,
actual: String,
},
MetadataIncomplete {
field: String,
},
}
pub struct PlanningHtmlCompiler;
impl PlanningHtmlCompiler {
pub fn compile(store: &Path) -> Result<PlanningHtmlOutput> {
let document = read_planning_traceability(store)?;
let sources = planning_sources(store, &document)?;
if let Some(reason) = first_source_hash_mismatch(store, &sources)? {
bail!("fonte de planejamento divergente: {reason:?}");
}
let generated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
build_planning_output(store, &document, sources, generated_at)
}
pub fn check(store: &Path) -> Result<PlanningHtmlCheck> {
let target = store.join(PLANNING_HTML_FILENAME);
if !target.is_file() {
return Ok(PlanningHtmlCheck::Missing {
target: PLANNING_HTML_FILENAME.to_string(),
});
}
let document = read_planning_traceability(store)?;
let sources = planning_sources(store, &document)?;
if let Some(reason) = first_source_hash_mismatch(store, &sources)? {
return Ok(PlanningHtmlCheck::Stale { reason });
}
let derived = yaml_path(&document, &["derived_artifacts", "planning_html"])
.ok_or_else(|| anyhow!("derived_artifacts.planning_html ausente"))?;
let expected_fingerprint = yaml_str(derived, "fingerprint").ok_or_else(|| {
anyhow!("derived_artifacts.planning_html.fingerprint ausente no traceability-map.yaml")
})?;
let expected_html_sha = yaml_str(derived, "sha256").ok_or_else(|| {
anyhow!("derived_artifacts.planning_html.sha256 ausente no traceability-map.yaml")
})?;
let expected_generator_version =
yaml_u64(derived, "generator_version").ok_or_else(|| {
anyhow!(
"derived_artifacts.planning_html.generator_version ausente no traceability-map.yaml"
)
})?;
if expected_generator_version != PLANNING_HTML_GENERATOR_VERSION as u64 {
return Ok(PlanningHtmlCheck::Stale {
reason: PlanningHtmlStaleReason::MetadataIncomplete {
field: "generator_version".to_string(),
},
});
}
let generated_at = yaml_str(derived, "generated_at").ok_or_else(|| {
anyhow!("derived_artifacts.planning_html.generated_at ausente no traceability-map.yaml")
})?;
let output = build_planning_output(store, &document, sources, generated_at.to_string())?;
if expected_fingerprint != output.fingerprint.sha256 {
return Ok(PlanningHtmlCheck::Stale {
reason: PlanningHtmlStaleReason::FingerprintMismatch {
expected: expected_fingerprint.to_string(),
actual: output.fingerprint.sha256,
},
});
}
validate_planning_metadata(derived, &output.sources)?;
let actual_html_sha = sha256_hex(
&fs::read(&target)
.with_context(|| format!("lendo HTML derivado {}", target.display()))?,
);
if expected_html_sha != actual_html_sha {
return Ok(PlanningHtmlCheck::Stale {
reason: PlanningHtmlStaleReason::HtmlHashMismatch {
expected: expected_html_sha.to_string(),
actual: actual_html_sha,
},
});
}
if output.sha256 != actual_html_sha {
return Ok(PlanningHtmlCheck::Stale {
reason: PlanningHtmlStaleReason::HtmlHashMismatch {
expected: output.sha256,
actual: actual_html_sha,
},
});
}
Ok(PlanningHtmlCheck::Current)
}
}
pub struct DeliveryHtmlCompiler;
impl DeliveryHtmlCompiler {
pub fn compile(store: &Path) -> Result<PlanningHtmlOutput> {
let document = read_planning_traceability(store)?;
let sources = delivery_sources(store, &document)?;
let execution = sources
.iter()
.find(|source| source.stage == "execution")
.ok_or_else(|| anyhow!("artifacts.execution ausente no traceability-map.yaml"))?;
if !execution.required || !source_is_ready(execution) {
bail!(
"artifacts.execution precisa estar materializado para compilar {}",
DELIVERY_HTML_FILENAME
);
}
if let Some(reason) = first_source_hash_mismatch(store, &sources)? {
bail!("fonte do registro de entrega divergente: {reason:?}");
}
let generated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
let fingerprint = compilation_fingerprint(&sources, DELIVERY_HTML_GENERATOR_VERSION);
let html = render_compilation_html(
store,
&document,
&sources,
&fingerprint,
&generated_at,
CompilationKind::Delivery,
)?;
let sha256 = sha256_hex(html.as_bytes());
Ok(PlanningHtmlOutput {
html,
fingerprint,
sha256,
generated_at,
sources,
})
}
}
fn read_planning_traceability(store: &Path) -> Result<serde_yaml::Value> {
let path = store.join("traceability-map.yaml");
let text = fs::read_to_string(&path)
.with_context(|| format!("lendo traceability-map {}", path.display()))?;
serde_yaml::from_str(&text).with_context(|| format!("parseando {}", path.display()))
}
fn delivery_sources(store: &Path, document: &serde_yaml::Value) -> Result<Vec<PlanningHtmlSource>> {
let mut sources = Vec::new();
for (stage, expected_path) in [
("project-discovery", "00-project-discovery.md"),
("risk", "00-risk-classification.md"),
("idea", "01-idea.md"),
] {
sources.push(delivery_traced_source(document, stage, expected_path)?);
}
sources.extend(discover_adr_sources(store)?);
for (stage, expected_path) in [
("prd", "02-prd.md"),
("techspec", "03-techspec.md"),
("tasks", "04-tasks.md"),
("refinement", "05-refinement.md"),
("execution", "06-execution.md"),
("review", "07-review.md"),
("qa", "08-qa.md"),
("memory", "09-memory.md"),
] {
sources.push(delivery_traced_source(document, stage, expected_path)?);
}
Ok(sources)
}
fn delivery_traced_source(
document: &serde_yaml::Value,
stage: &str,
expected_path: &str,
) -> Result<PlanningHtmlSource> {
let Some(artifact) = yaml_path(document, &["artifacts", stage]) else {
return Ok(PlanningHtmlSource {
stage: stage.to_string(),
path: expected_path.to_string(),
state: "pending".to_string(),
revision: None,
sha256: String::new(),
required: false,
});
};
let state = yaml_str(artifact, "state").unwrap_or("pending");
let materialized = !matches!(state, "pending" | "optional" | "skipped")
&& !yaml_str(artifact, "sha256").unwrap_or("").is_empty();
traced_source(artifact, stage, expected_path, materialized)
}
fn planning_sources(store: &Path, document: &serde_yaml::Value) -> Result<Vec<PlanningHtmlSource>> {
let mut sources = Vec::new();
if let Some(artifact) = yaml_path(document, &["artifacts", "idea"]) {
let source = traced_source(artifact, "idea", "01-idea.md", true)?;
if !source_is_ready(&source) {
bail!(
"artifacts.idea.state precisa estar recorded|approved para compilar planejamento"
);
}
sources.push(source);
}
sources.extend(discover_adr_sources(store)?);
for stage in ["prd", "techspec", "tasks", "refinement"] {
let artifact = yaml_path(document, &["artifacts", stage])
.ok_or_else(|| anyhow!("artifacts.{stage} ausente no traceability-map.yaml"))?;
let expected_path = match stage {
"prd" => "02-prd.md",
"techspec" => "03-techspec.md",
"tasks" => "04-tasks.md",
"refinement" => "05-refinement.md",
_ => unreachable!(),
};
let state = yaml_str(artifact, "state").unwrap_or("pending");
let required = !(stage == "refinement" && matches!(state, "optional" | "skipped"));
let source = traced_source(artifact, stage, expected_path, required)?;
let ready = source_is_ready(&source);
if required && !ready {
let expected = if matches!(stage, "prd" | "techspec") {
"approved"
} else {
"recorded|approved"
};
bail!("artifacts.{stage}.state precisa estar {expected} para compilar planejamento");
}
sources.push(source);
}
Ok(sources)
}
fn traced_source(
artifact: &serde_yaml::Value,
stage: &str,
expected_path: &str,
required: bool,
) -> Result<PlanningHtmlSource> {
let state = yaml_str(artifact, "state").unwrap_or("pending").to_string();
let path = yaml_str(artifact, "file")
.unwrap_or(expected_path)
.to_string();
if path != expected_path {
bail!(
"artifacts.{stage}.file/path inválido: esperado `{expected_path}`, recebido `{path}`"
);
}
let sha256 = yaml_str(artifact, "sha256").unwrap_or("").to_string();
if required && sha256.is_empty() {
bail!("artifacts.{stage}.sha256 ausente para fonte obrigatória");
}
Ok(PlanningHtmlSource {
stage: stage.to_string(),
path,
state,
revision: yaml_u64(artifact, "revision"),
sha256,
required,
})
}
fn discover_adr_sources(store: &Path) -> Result<Vec<PlanningHtmlSource>> {
let directory = store.join("adrs");
if !directory.is_dir() {
return Ok(Vec::new());
}
let mut paths = fs::read_dir(&directory)
.with_context(|| format!("lendo diretório de ADRs {}", directory.display()))?
.filter_map(std::result::Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.extension().and_then(|value| value.to_str()) == Some("md")
&& path
.file_name()
.and_then(|value| value.to_str())
.is_some_and(|filename| filename.starts_with("adr-"))
})
.collect::<Vec<_>>();
paths.sort();
paths
.into_iter()
.map(|path| {
let filename = path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| anyhow!("nome de ADR inválido em {}", path.display()))?;
let stage = path
.file_stem()
.and_then(|value| value.to_str())
.ok_or_else(|| anyhow!("stage de ADR inválido em {}", path.display()))?;
let relative = format!("adrs/{filename}");
let sha256 = sha256_hex(
&fs::read(&path).with_context(|| format!("lendo ADR {}", path.display()))?,
);
Ok(PlanningHtmlSource {
stage: stage.to_string(),
path: relative,
state: "accepted".to_string(),
revision: None,
sha256,
required: true,
})
})
.collect()
}
fn source_is_ready(source: &PlanningHtmlSource) -> bool {
match source.stage.as_str() {
"prd" | "techspec" => source.state == "approved",
_ => matches!(
source.state.as_str(),
"accepted" | "recorded" | "approved" | "current" | "complete" | "completed" | "passed"
),
}
}
fn first_source_hash_mismatch(
store: &Path,
sources: &[PlanningHtmlSource],
) -> Result<Option<PlanningHtmlStaleReason>> {
for source in sources.iter().filter(|source| source.required) {
let path = resolve_planning_source_path(store, source)?;
let actual = sha256_hex(
&fs::read(&path).with_context(|| format!("lendo fonte {}", path.display()))?,
);
if actual != source.sha256 {
return Ok(Some(PlanningHtmlStaleReason::SourceHashMismatch {
stage: source.stage.clone(),
path: source.path.clone(),
expected: source.sha256.clone(),
actual,
}));
}
}
Ok(None)
}
fn resolve_planning_source_path(
store: &Path,
source: &PlanningHtmlSource,
) -> Result<std::path::PathBuf> {
let canonical_store = fs::canonicalize(store)
.with_context(|| format!("resolvendo artifact store {}", store.display()))?;
let candidate = store.join(&source.path);
let canonical_candidate = fs::canonicalize(&candidate)
.with_context(|| format!("resolvendo fonte {}", candidate.display()))?;
if !canonical_candidate.starts_with(&canonical_store) {
bail!(
"artifacts.{}.file/path escapa do artifact store: `{}`",
source.stage,
source.path
);
}
Ok(canonical_candidate)
}
fn validate_planning_metadata(
derived: &serde_yaml::Value,
sources: &[PlanningHtmlSource],
) -> Result<()> {
let expected_scalars = [
("file", PLANNING_HTML_FILENAME),
("state", "current"),
("generator", "PlanningHtmlCompiler"),
];
for (field, expected) in expected_scalars {
if yaml_str(derived, field) != Some(expected) {
bail!("derived_artifacts.planning_html.{field} precisa ser `{expected}`");
}
}
if yaml_bool(derived, "canonical") != Some(false) {
bail!("derived_artifacts.planning_html.canonical precisa ser false");
}
let recorded_sources = derived
.get(serde_yaml::Value::String("sources".to_string()))
.and_then(serde_yaml::Value::as_sequence)
.ok_or_else(|| anyhow!("derived_artifacts.planning_html.sources ausente"))?;
if recorded_sources.len() != sources.len() {
bail!("derived_artifacts.planning_html.sources diverge das fontes atuais");
}
for (recorded, expected) in recorded_sources.iter().zip(sources) {
let revision = yaml_u64(recorded, "revision");
if yaml_str(recorded, "stage") != Some(expected.stage.as_str())
|| yaml_str(recorded, "path") != Some(expected.path.as_str())
|| yaml_str(recorded, "state") != Some(expected.state.as_str())
|| revision != expected.revision
|| yaml_str(recorded, "sha256") != Some(expected.sha256.as_str())
{
bail!(
"derived_artifacts.planning_html.sources diverge na etapa `{}`",
expected.stage
);
}
}
Ok(())
}
fn build_planning_output(
store: &Path,
document: &serde_yaml::Value,
sources: Vec<PlanningHtmlSource>,
generated_at: String,
) -> Result<PlanningHtmlOutput> {
let fingerprint = compilation_fingerprint(&sources, PLANNING_HTML_GENERATOR_VERSION);
let html = render_compilation_html(
store,
document,
&sources,
&fingerprint,
&generated_at,
CompilationKind::Planning,
)?;
let sha256 = sha256_hex(html.as_bytes());
Ok(PlanningHtmlOutput {
html,
fingerprint,
sha256,
generated_at,
sources,
})
}
fn compilation_fingerprint(
sources: &[PlanningHtmlSource],
generator_version: u32,
) -> PlanningHtmlFingerprint {
let mut canonical = format!("generator_version={generator_version}\n");
for source in sources {
let revision = source
.revision
.map(|value| value.to_string())
.unwrap_or_default();
canonical.push_str(&format!(
"{}|{}|{}|{}|{}|{}\n",
source.stage, source.path, source.state, revision, source.sha256, source.required
));
}
PlanningHtmlFingerprint {
generator_version,
sources: sources.to_vec(),
sha256: sha256_hex(canonical.as_bytes()),
}
}
#[derive(Clone, Copy)]
enum CompilationKind {
Planning,
Delivery,
}
fn render_compilation_html(
store: &Path,
document: &serde_yaml::Value,
sources: &[PlanningHtmlSource],
fingerprint: &PlanningHtmlFingerprint,
generated_at: &str,
kind: CompilationKind,
) -> Result<String> {
let title = yaml_path(document, &["orchestration"])
.and_then(|value| yaml_str(value, "name"))
.unwrap_or("Planejamento SDD");
let orchestration_slug = yaml_path(document, &["orchestration"])
.and_then(|value| yaml_str(value, "slug"))
.map(ToString::to_string)
.unwrap_or_else(|| crate::artifact_slug(title));
let display_title = humanize_orchestration_title(title, &orchestration_slug);
let mut sections = Vec::new();
let mut source_rows = Vec::new();
let mut markdown_by_stage = BTreeMap::new();
let mut has_mermaid = false;
for source in sources {
let label = planning_stage_label(&source.stage);
source_rows.push(PlanningSourceRow {
label: redact_text(&label),
path: redact_text(&source.path),
state: redact_text(&source.state),
state_label: status_label_pt_br(&source.state).to_string(),
revision: source
.revision
.map(|value| value.to_string())
.unwrap_or_else(|| "—".to_string()),
sha256_short: if source.sha256.is_empty() {
"—".to_string()
} else {
source.sha256.chars().take(12).collect()
},
});
if source.required {
let source_path = resolve_planning_source_path(store, source)?;
let markdown = fs::read_to_string(&source_path)
.with_context(|| format!("lendo fonte {}", source.path))?;
let redacted_markdown = redact_text(&markdown);
let (_, section_has_mermaid, body) = md_to_html(&redacted_markdown);
let body = embed_excalidraw_payloads(store, &source_path, &redacted_markdown, body)?;
has_mermaid |= section_has_mermaid;
markdown_by_stage.insert(source.stage.clone(), redacted_markdown);
sections.push(PlanningDocumentSection {
stage: redact_text(&source.stage),
label,
summary: format!(
"Fonte canônica `{}` em estado {}.",
redact_text(&source.path),
status_label_pt_br(&source.state).to_lowercase()
),
content_html: body,
required: true,
});
} else {
let pending_delivery = matches!(kind, CompilationKind::Delivery);
sections.push(PlanningDocumentSection {
stage: redact_text(&source.stage),
label,
summary: if pending_delivery {
"Etapa ainda não materializada no artifact store.".to_string()
} else {
"Refinement dispensado ou opcional para este fluxo.".to_string()
},
content_html: if pending_delivery {
"<p>Ainda não materializado. Este estágio será incorporado quando o artefato canônico for salvo.</p>".to_string()
} else {
"<p>Refinement dispensado. Não aplicável neste planejamento.</p>".to_string()
},
required: false,
});
}
}
let executive_sections = build_executive_sections(&markdown_by_stage, sources);
let required_count = sources.iter().filter(|source| source.required).count();
let current_count = sources
.iter()
.filter(|source| source.required && source_is_ready(source))
.count();
let refinement_state = sources
.iter()
.find(|source| source.stage == "refinement")
.map(|source| source.state.as_str())
.unwrap_or("ausente");
let executive_signals = vec![
PlanningExecutiveSignal {
label: "Prontidão".to_string(),
value: status_label_pt_br("current").to_string(),
description: "Fontes canônicas conferem com o fingerprint derivado.".to_string(),
tone: "ok".to_string(),
},
PlanningExecutiveSignal {
label: "Fontes".to_string(),
value: format!("{current_count}/{required_count}"),
description: match kind {
CompilationKind::Planning => {
"Idea, ADRs e fontes de planejamento conferidas.".to_string()
}
CompilationKind::Delivery => {
"Planejamento e evidências de entrega materializadas até aqui.".to_string()
}
},
tone: "ok".to_string(),
},
PlanningExecutiveSignal {
label: "Fingerprint".to_string(),
value: fingerprint.sha256.chars().take(12).collect(),
description: "Identificador determinístico do conjunto de fontes.".to_string(),
tone: "ok".to_string(),
},
PlanningExecutiveSignal {
label: "Refinement".to_string(),
value: if matches!(refinement_state, "optional" | "skipped") {
"Dispensado".to_string()
} else {
status_label_pt_br(refinement_state).to_string()
},
description: "Tratado como documento quando presente; caso contrário, não aplicável."
.to_string(),
tone: "ok".to_string(),
},
];
let mut toc_entries = vec![
TocEntry {
id: "modo-executivo".to_string(),
label: "Visão executiva".to_string(),
level: 1,
},
TocEntry {
id: "modo-documentos".to_string(),
label: "Documentos".to_string(),
level: 1,
},
];
toc_entries.extend(sections.iter().map(|section| TocEntry {
id: format!("stage-{}", section.stage),
label: section.label.clone(),
level: 2,
}));
let lifecycle_steps = sources
.iter()
.enumerate()
.map(|(index, source)| PlanningLifecycleStep {
index: format!("{:02}", index + 1),
label: planning_stage_label(&source.stage),
state: source.state.clone(),
state_label: status_label_pt_br(&source.state).to_string(),
path: source.path.clone(),
summary: if source.required {
"Artefato materializado e incluído neste compilado.".to_string()
} else {
"Ainda não materializado; será incorporado na próxima geração.".to_string()
},
tone: if source.required && source_is_ready(source) {
"ok".to_string()
} else if source.required {
"warn".to_string()
} else {
"pending".to_string()
},
})
.collect::<Vec<_>>();
if matches!(kind, CompilationKind::Delivery) {
toc_entries.insert(
1,
TocEntry {
id: "modo-lifecycle".to_string(),
label: "Linha do tempo".to_string(),
level: 1,
},
);
}
let (artifact_filename, compiler_name, generator_version, stage, generated_summary) =
match kind {
CompilationKind::Planning => (
PLANNING_HTML_FILENAME,
"PlanningHtmlCompiler",
PLANNING_HTML_GENERATOR_VERSION,
"planning",
format!(
"{} é um artefato derivado e autocontido. Markdown e traceability-map.yaml continuam canônicos.",
PLANNING_HTML_FILENAME
),
),
CompilationKind::Delivery => (
DELIVERY_HTML_FILENAME,
"DeliveryHtmlCompiler",
DELIVERY_HTML_GENERATOR_VERSION,
"delivery",
format!(
"{} é o registro vivo da entrega. Ele cresce com Execution, Review, QA e Memory sem substituir os artefatos canônicos.",
DELIVERY_HTML_FILENAME
),
),
};
let template = PlanningArtifactTemplate {
title: redact_text(&display_title),
stage: stage.to_string(),
toc_entries,
has_mermaid,
fingerprint: fingerprint.sha256.clone(),
generator_version,
orchestration_slug: redact_text(&orchestration_slug),
generated_at: generated_at.to_string(),
generated_summary,
freshness_summary: format!(
"{} fontes rastreadas · fingerprint {}",
sources.len(),
fingerprint.sha256.chars().take(12).collect::<String>()
),
artifact_filename: artifact_filename.to_string(),
compiler_name: compiler_name.to_string(),
is_delivery: matches!(kind, CompilationKind::Delivery),
executive_signals,
executive_sections,
source_rows,
sections,
lifecycle_steps,
brand_logo_data_uri: brand_logo_data_uri(),
};
template.render().map_err(Into::into)
}
const MAX_EXCALIDRAW_COMPANION_BYTES: u64 = 2 * 1024 * 1024;
fn embed_excalidraw_payloads(
store: &Path,
source_path: &Path,
markdown: &str,
mut body: String,
) -> Result<String> {
let canonical_store = fs::canonicalize(store)
.with_context(|| format!("resolvendo artifact store {}", store.display()))?;
let source_dir = source_path.parent().unwrap_or(store);
let chars = markdown.chars().collect::<Vec<_>>();
let mut cursor = 0;
let mut embedded = std::collections::BTreeSet::new();
while cursor < chars.len() {
if chars[cursor] != '[' {
cursor += 1;
continue;
}
let Some((_label, raw_url, after)) = parse_link(&chars, cursor) else {
cursor += 1;
continue;
};
cursor = after;
let asset_path = raw_url.split(['?', '#']).next().unwrap_or_default().trim();
if !asset_path.to_ascii_lowercase().ends_with(".excalidraw")
|| asset_path.contains("://")
|| Path::new(asset_path).is_absolute()
|| !embedded.insert(asset_path.to_string())
{
continue;
}
if Path::new(asset_path)
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
continue;
}
let canonical_candidate = [source_dir.join(asset_path), store.join(asset_path)]
.into_iter()
.find_map(|candidate| fs::canonicalize(candidate).ok());
let Some(canonical_candidate) = canonical_candidate else {
continue;
};
if !canonical_candidate.starts_with(&canonical_store) {
continue;
}
let metadata = fs::metadata(&canonical_candidate)
.with_context(|| format!("lendo metadados de {}", canonical_candidate.display()))?;
if !metadata.is_file() || metadata.len() > MAX_EXCALIDRAW_COMPANION_BYTES {
continue;
}
let raw = fs::read_to_string(&canonical_candidate)
.with_context(|| format!("lendo companion {}", canonical_candidate.display()))?;
let scene = serde_json::from_str::<serde_json::Value>(&raw)
.with_context(|| format!("validando companion {}", canonical_candidate.display()))?;
let payload = serde_json::to_string(&scene)?
.replace('&', "\\u0026")
.replace('<', "\\u003c")
.replace('>', "\\u003e");
body.push_str(&format!(
"\n<script type=\"application/json\" data-excalidraw-path=\"{}\">{}</script>\n",
escape_html(asset_path),
payload
));
}
Ok(body)
}
fn build_executive_sections(
markdown_by_stage: &BTreeMap<String, String>,
sources: &[PlanningHtmlSource],
) -> Vec<PlanningExecutiveSection> {
let excerpt = |stage: &str, headings: &[&str], fallback: &str| {
markdown_by_stage
.get(stage)
.and_then(|markdown| extract_markdown_sections(markdown, headings))
.unwrap_or_else(|| fallback.to_string())
};
let mut product_decisions = excerpt(
"idea",
&["problema", "proposta de valor", "objetivo", "visão", "mvp"],
"A visão de produto não foi materializada neste fluxo.",
);
for (stage, markdown) in markdown_by_stage
.iter()
.filter(|(stage, _)| stage.starts_with("adr-"))
{
if let Some(decision) = extract_markdown_sections(markdown, &["decisão", "decisao"]) {
product_decisions.push_str(&format!(
"\n\n### {}\n\n{}",
planning_stage_label(stage),
decision
));
}
}
let scope = excerpt(
"prd",
&[
"objetivos",
"não objetivos",
"nao objetivos",
"escopo",
"fora de escopo",
],
"Escopo e exclusões não foram explicitados no PRD.",
);
let decisions = excerpt(
"techspec",
&["decisões", "decisoes", "arquitetura"],
"Decisões técnicas não foram explicitadas na Tech Spec.",
);
let mut risks = excerpt("prd", &["riscos"], "Nenhum risco explícito no PRD.");
if let Some(technical_risks) = markdown_by_stage
.get("techspec")
.and_then(|markdown| extract_markdown_sections(markdown, &["riscos"]))
{
risks.push_str("\n\n");
risks.push_str(&technical_risks);
}
let tasks = excerpt(
"tasks",
&["backlog", "dependências", "dependencias", "ordem sugerida"],
"Tarefas e dependências não foram detalhadas.",
);
let refinement_required = sources
.iter()
.find(|source| source.stage == "refinement")
.is_some_and(|source| source.required);
let readiness = if refinement_required {
excerpt(
"refinement",
&[
"definition of ready",
"dor",
"bloqueios",
"impedimentos",
"pontos de observação",
"pontos de observacao",
],
"DoR, bloqueios e impedimentos não foram explicitados no Refinement.",
)
} else {
"Refinement dispensado; bloqueios explícitos não registrados.".to_string()
};
let questions = excerpt(
"prd",
&[
"perguntas abertas",
"questões abertas",
"questoes abertas",
"dúvidas",
"duvidas",
],
"Nenhuma pergunta aberta foi explicitada no PRD.",
);
let checkpoint_markdown = {
let mut value = String::from("### Estado consolidado\n\n");
for source in sources {
value.push_str(&format!(
"- **{}:** `{}`{}\n",
planning_stage_label(&source.stage),
status_label_pt_br(&source.state),
if source.required {
""
} else {
" (não aplicável)"
}
));
}
value
};
vec![
(
"Visão e decisões de produto",
"Idea + ADRs",
product_decisions,
"neutral",
),
("Escopo e fora de escopo", "PRD", scope, "neutral"),
("Decisões ativas", "Tech Spec", decisions, "neutral"),
("Riscos", "PRD + Tech Spec", risks, "warn"),
("Tarefas e dependências", "Tasks", tasks, "neutral"),
(
"DoR, bloqueios e impedimentos",
"Refinement",
readiness,
"neutral",
),
("Perguntas abertas", "PRD", questions, "neutral"),
(
"Estado dos checkpoints",
"traceability-map.yaml",
checkpoint_markdown,
"ok",
),
]
.into_iter()
.enumerate()
.map(|(index, (label, source, markdown, tone))| {
let markdown = remove_redundant_section_heading(&markdown, label);
let (_, _, content_html) = md_to_html(&markdown);
PlanningExecutiveSection {
index: format!("{:02}", index + 1),
label: label.to_string(),
source: source.to_string(),
content_html,
tone: tone.to_string(),
}
})
.collect()
}
fn remove_redundant_section_heading(markdown: &str, section_label: &str) -> String {
let normalized_label = section_label.trim().to_lowercase();
markdown
.lines()
.filter(|line| {
let trimmed = line.trim();
if !trimmed.starts_with('#') {
return true;
}
trimmed.trim_start_matches('#').trim().to_lowercase() != normalized_label
})
.collect::<Vec<_>>()
.join("\n")
}
fn extract_markdown_sections(markdown: &str, headings: &[&str]) -> Option<String> {
let normalized_headings = headings
.iter()
.map(|heading| heading.to_lowercase())
.collect::<Vec<_>>();
let mut selected = Vec::new();
let mut active = false;
for line in markdown.lines() {
if line.starts_with('#') {
let heading = line.trim_start_matches('#').trim();
let normalized = heading.to_lowercase();
active = normalized_headings
.iter()
.any(|candidate| normalized.contains(candidate));
if active {
selected.push(format!("### {heading}"));
}
continue;
}
if active && !line.trim().is_empty() {
selected.push(line.to_string());
}
if selected.iter().map(String::len).sum::<usize>() >= 1_600 {
break;
}
}
(!selected.is_empty()).then(|| selected.join("\n"))
}
fn planning_stage_label(stage: &str) -> String {
match stage {
"project-discovery" => "Project Discovery",
"risk" => "Risco",
"idea" => "Ideia",
"prd" => "PRD",
"techspec" => "Tech Spec",
"tasks" => "Tasks",
"refinement" => "Refinement",
"execution" => "Execução",
"review" => "Review",
"qa" => "QA",
"memory" => "Memória",
value if value.starts_with("adr-") => return value.to_uppercase(),
_ => stage,
}
.to_string()
}
fn humanize_orchestration_title(title: &str, slug: &str) -> String {
let trimmed = title.trim();
if !trimmed.is_empty() && trimmed != slug && trimmed.contains(char::is_whitespace) {
return trimmed.to_string();
}
let source = if slug.trim().is_empty() {
trimmed
} else {
slug.trim()
};
let mut words = source
.split(['-', '_'])
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
let removed_hash = words.last().is_some_and(|part| {
part.len() >= 8 && part.chars().all(|character| character.is_ascii_hexdigit())
});
if removed_hash {
words.pop();
if words
.last()
.is_some_and(|part| matches!(*part, "com" | "de" | "da" | "do" | "e"))
{
words.pop();
}
}
let human = words.join(" ");
let mut characters = human.chars();
match characters.next() {
Some(first) => first.to_uppercase().collect::<String>() + characters.as_str(),
None => "Planejamento SDD".to_string(),
}
}
fn status_label_pt_br(status: &str) -> &str {
match status {
"pending" => "Pendente",
"optional" => "Opcional",
"draft" => "Rascunho",
"recorded" => "Gravado",
"approved" => "Aprovado",
"skipped" => "Dispensado",
"current" => "Atual",
"accepted" => "Aceito",
"complete" | "completed" => "Concluído",
"passed" => "Aprovado",
"failed" => "Reprovado",
"blocked" => "Bloqueado",
"rejected" => "Rejeitado",
"in_progress" => "Em andamento",
other => other,
}
}
fn yaml_path<'a>(value: &'a serde_yaml::Value, path: &[&str]) -> Option<&'a serde_yaml::Value> {
let mut current = value;
for key in path {
current = current.get(serde_yaml::Value::String((*key).to_string()))?;
}
Some(current)
}
fn yaml_str<'a>(value: &'a serde_yaml::Value, key: &str) -> Option<&'a str> {
value
.get(serde_yaml::Value::String(key.to_string()))
.and_then(serde_yaml::Value::as_str)
}
fn yaml_u64(value: &serde_yaml::Value, key: &str) -> Option<u64> {
value
.get(serde_yaml::Value::String(key.to_string()))
.and_then(serde_yaml::Value::as_u64)
}
fn yaml_bool(value: &serde_yaml::Value, key: &str) -> Option<bool> {
value
.get(serde_yaml::Value::String(key.to_string()))
.and_then(serde_yaml::Value::as_bool)
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
pub fn escape_html(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}
fn metric_card(label: &str, value: &str, description: &str) -> String {
format!(
" <div class=\"card\">\n <span class=\"metric\">{}</span>\n <span class=\"label\">{}</span>\n <p class=\"muted\" style=\"margin-top:6px;font-size:0.82rem;\">{}</p>\n </div>\n",
escape_html(value),
escape_html(label),
escape_html(description),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::artifact::{ArtifactFormat, GenerationContext};
use crate::domain::discovery::{
ArchitectureEvidence, ArchitectureReport, CapabilityRecommendation, CodeIntelligenceStatus,
DataContractReport, DiscoveryCommands, DiscoveryPaths, DomainLanguageReport,
IntegrationReport, OperationsReport, ProjectIdentity, ProjectManifest, QualityReport,
};
use crate::domain::providers::ProviderSelection;
#[test]
fn base64_encoder_matches_standard_padding_vectors() {
assert_eq!(encode_base64(b""), "");
assert_eq!(encode_base64(b"f"), "Zg==");
assert_eq!(encode_base64(b"fo"), "Zm8=");
assert_eq!(encode_base64(b"foo"), "Zm9v");
assert_eq!(encode_base64(b"foobar"), "Zm9vYmFy");
assert!(brand_logo_data_uri().starts_with("data:image/webp;base64,UklGR"));
}
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[test]
fn humanizes_technical_slug_without_losing_the_canonical_identifier() {
assert_eq!(
humanize_orchestration_title(
"refinamento-conversacional-efemero-depois-da-primeira-geracao-permitir-comandos-com-5f73852afb1e",
"refinamento-conversacional-efemero-depois-da-primeira-geracao-permitir-comandos-com-5f73852afb1e"
),
"Refinamento conversacional efemero depois da primeira geracao permitir comandos"
);
assert_eq!(
humanize_orchestration_title(
"Central de Exceções Logísticas",
"central-de-excecoes-logisticas"
),
"Central de Exceções Logísticas"
);
}
fn dummy_report() -> DiscoveryReport {
DiscoveryReport {
project_root: "/tmp/test-project".into(),
identity: ProjectIdentity {
name: "test-project".into(),
lifecycle: "brownfield".into(),
stack: vec!["Rust".into(), "Cargo".into()],
project_type: "cli".into(),
},
manifests: vec![ProjectManifest {
path: "Cargo.toml".into(),
kind: "cargo".into(),
name: Some("test-project".into()),
version: Some("0.1.0".into()),
package_manager: None,
workspace: false,
scripts: vec![],
dependencies: vec!["anyhow".into()],
signals: vec![],
}],
paths: DiscoveryPaths {
source: vec!["src/".into()],
tests: vec!["tests/".into()],
docs: vec![],
migrations: vec![],
infra: vec![],
config: vec!["Cargo.toml".into()],
artifacts: vec![],
},
commands: DiscoveryCommands {
install: vec![],
lint: vec!["cargo clippy".into()],
typecheck: vec![],
test: vec!["cargo test".into()],
build: vec!["cargo build".into()],
not_verified: vec![],
missing: vec![],
},
architecture: ArchitectureReport {
summary: vec!["CLI Rust com monolito em src/main.rs".into()],
entrypoints: vec![ArchitectureEvidence {
path: "src/main.rs".into(),
kind: "binary".into(),
role: "entrypoint".into(),
evidence: vec![],
}],
components: vec![],
layers: vec![],
routing: vec![],
conventions: vec![],
confidence: "alta".into(),
},
quality: QualityReport {
test_frameworks: vec!["cargo test".into()],
test_paths: vec!["tests/".into()],
commands: vec!["cargo test".into()],
coverage: vec![],
gaps: vec![],
},
operations: OperationsReport {
ci: vec![".github/workflows/".into()],
deploy: vec![],
containers: vec![],
env_files: vec![],
env_vars: vec![],
observability: vec![],
},
data_contracts: DataContractReport {
migrations: vec![],
schemas: vec![],
api_contracts: vec![],
orm: vec![],
seeds_fixtures: vec![],
},
integrations: IntegrationReport {
external_services: vec![],
auth: vec![],
webhooks: vec![],
queues_jobs: vec![],
storage: vec![],
},
domain_language: DomainLanguageReport {
context_files: vec![],
context_map: None,
adr_dirs: vec![],
recommendation: "nenhuma".into(),
},
code_intelligence: vec![CodeIntelligenceStatus {
id: "codegraph".into(),
available: true,
indexed: true,
command: "codegraph".into(),
index_marker: ".codegraph/".into(),
suggested_commands: vec![],
fallback: "grep".into(),
}],
capabilities: vec![CapabilityRecommendation {
id: "html-gen".into(),
title: "Geração HTML".into(),
source: "tech-spec".into(),
source_url: String::new(),
trigger: String::new(),
stages: vec![],
local_skill: String::new(),
mode: String::new(),
risk: String::new(),
fallback: String::new(),
}],
risks: vec!["Monolito grande em src/main.rs".into()],
}
}
fn dummy_ctx<'a>(provider: &'a ProviderSelection, root: &'a Path) -> GenerationContext<'a> {
GenerationContext {
stage: "project-discovery",
name: "test-project",
input: "teste de integração",
root,
provider,
format: ArtifactFormat::Html,
real_telemetry: None,
no_telemetry: true,
}
}
fn dummy_provider() -> ProviderSelection {
ProviderSelection {
id: "anthropic".into(),
kind: "claude".into(),
model: "claude-sonnet-4-5".into(),
effort: "normal".into(),
offline: false,
enabled: true,
auth_env: None,
auth_present: None,
auth_methods: vec![],
models: vec![],
efforts: vec![],
stage_models: BTreeMap::new(),
stage_efforts: BTreeMap::new(),
capabilities: vec![],
token_budget: None,
usage_limits: vec![],
explicit: false,
}
}
#[test]
fn discovery_html_uses_fixed_light_color_scheme() {
let report = dummy_report();
let provider = dummy_provider();
let root = PathBuf::from("/tmp");
let ctx = dummy_ctx(&provider, &root);
let gen = DiscoveryHtmlGenerator::new(&report, "test-project", "teste", "recorded");
let html = gen
.generate_html(&ctx)
.expect("generate_html não deve falhar");
assert!(html.contains("color-scheme: light;"));
assert!(
!html.contains("prefers-color-scheme: dark"),
"a identidade editorial clara deve permanecer estável independentemente do sistema"
);
}
#[test]
fn discovery_html_has_nav_toc() {
let report = dummy_report();
let provider = dummy_provider();
let root = PathBuf::from("/tmp");
let ctx = dummy_ctx(&provider, &root);
let gen = DiscoveryHtmlGenerator::new(&report, "test-project", "teste", "recorded");
let html = gen
.generate_html(&ctx)
.expect("generate_html não deve falhar");
assert!(
html.contains("<nav") && html.contains("toc-list"),
"HTML deve conter <nav> com índice (toc-list)"
);
}
#[test]
fn discovery_html_embeds_self_contained_mermaid_map_with_fallback() {
let report = dummy_report();
let provider = dummy_provider();
let root = PathBuf::from("/tmp");
let ctx = dummy_ctx(&provider, &root);
let gen = DiscoveryHtmlGenerator::new(&report, "test-project", "teste", "recorded");
let html = gen
.generate_html(&ctx)
.expect("generate_html não deve falhar");
assert!(
!html.contains("mermaid.min.js"),
"HTML de discovery não deve carregar Mermaid por CDN"
);
assert!(html.contains("data-discovery-mermaid"));
assert!(html.contains("Mermaid · Fluxo do discovery"));
assert!(html.contains("<pre class=\"mermaid-fallback\">"));
assert!(html.contains("data-diagram-view=\"visual\""));
assert!(html.contains("data-diagram-view=\"code\""));
}
#[test]
fn discovery_html_mermaid_fallback_structure_in_css() {
let report = dummy_report();
let provider = dummy_provider();
let root = PathBuf::from("/tmp");
let ctx = dummy_ctx(&provider, &root);
let gen = DiscoveryHtmlGenerator::new(&report, "test-project", "teste", "recorded");
let html = gen
.generate_html(&ctx)
.expect("generate_html não deve falhar");
assert!(
html.contains("mermaid-fallback"),
"CSS do base.html deve definir .mermaid-fallback mesmo quando has_mermaid=false"
);
}
#[test]
fn discovery_html_has_traceability_section() {
let report = dummy_report();
let provider = dummy_provider();
let root = PathBuf::from("/tmp");
let ctx = dummy_ctx(&provider, &root);
let gen = DiscoveryHtmlGenerator::new(&report, "test-project", "teste", "recorded");
let html = gen
.generate_html(&ctx)
.expect("generate_html não deve falhar");
assert!(
html.contains("id=\"rastreabilidade\""),
"HTML deve conter seção de rastreabilidade com id='rastreabilidade'"
);
assert!(
html.contains("Estado"),
"Bloco de rastreabilidade deve conter o campo 'Estado'"
);
}
#[test]
fn discovery_html_structure_well_formed_minimal() {
let report = dummy_report();
let provider = dummy_provider();
let root = PathBuf::from("/tmp");
let ctx = dummy_ctx(&provider, &root);
let gen = DiscoveryHtmlGenerator::new(&report, "test-project", "teste", "recorded");
let html = gen
.generate_html(&ctx)
.expect("generate_html não deve falhar");
for tag in &[
"<!doctype html>",
"<html",
"<head>",
"</head>",
"<body>",
"</body>",
"</html>",
"<nav",
"</nav>",
"<main",
"</main>",
] {
assert!(
html.to_lowercase().contains(&tag.to_lowercase()),
"HTML deve conter tag obrigatória: {tag}"
);
}
}
#[test]
fn discovery_html_escapes_special_chars() {
let mut report = dummy_report();
report
.risks
.push("Risco com <script>alert('xss')</script>".into());
let provider = dummy_provider();
let root = PathBuf::from("/tmp");
let ctx = dummy_ctx(&provider, &root);
let gen = DiscoveryHtmlGenerator::new(&report, "test-project", "teste", "recorded");
let html = gen
.generate_html(&ctx)
.expect("generate_html não deve falhar");
assert!(
!html.contains("<script>alert("),
"HTML deve escapar conteúdo de risco (XSS prevention)"
);
assert!(
html.contains("<script>"),
"HTML deve conter versão escapada da tag script"
);
}
#[test]
fn discovery_html_redacts_secrets_from_name_and_input() {
let report = dummy_report();
let provider = dummy_provider();
let root = PathBuf::from("/tmp");
let ctx = dummy_ctx(&provider, &root);
let secret = "sk-test-secret-value";
let name = format!("Discovery {secret}");
let input = format!("OPENAI_API_KEY={secret}");
let gen = DiscoveryHtmlGenerator::new(&report, &name, &input, "recorded");
let html = gen.generate_html(&ctx).unwrap();
assert!(!html.contains(secret), "{html}");
assert!(html.contains("[REDACTED]"), "{html}");
}
#[test]
fn md_to_html_extracts_h2_toc_entries() {
let md = "## Objetivo\n\nTexto do objetivo.\n\n## Requisitos\n\nLista de req.\n";
let (toc, _, _) = md_to_html(md);
assert_eq!(toc.len(), 2);
assert_eq!(toc[0].label, "Objetivo");
assert_eq!(toc[0].level, 1);
assert_eq!(toc[1].label, "Requisitos");
assert_eq!(toc[1].level, 1);
}
#[test]
fn md_to_html_places_traceability_after_the_document_narrative() {
let md = "# Artefato\n\n## Rastreabilidade\n\n- Origem: 01-idea.md\n\n## Contexto\n\nExplicação.\n\n## Decisão\n\nAceita.\n";
let (toc, _, html) = md_to_html(md);
assert_eq!(
toc.iter()
.map(|entry| entry.label.as_str())
.collect::<Vec<_>>(),
vec!["Contexto", "Decisão", "Rastreabilidade"]
);
assert!(html.find(">Decisão</h2>").unwrap() < html.find("id=\"rastreabilidade\"").unwrap());
}
#[test]
fn md_to_html_does_not_move_traceability_text_inside_code_fences() {
let md = "## Exemplo\n\n```markdown\n## Rastreabilidade\n```\n\n## Decisão\n\nAceita.\n";
let (toc, _, html) = md_to_html(md);
assert_eq!(
toc.iter()
.map(|entry| entry.label.as_str())
.collect::<Vec<_>>(),
vec!["Exemplo", "Decisão"]
);
assert!(html.contains("## Rastreabilidade"));
}
#[test]
fn md_to_html_mermaid_block_emits_both_diagram_and_fallback() {
let md = "## Diagrama\n\n```mermaid\ngraph TD\n A --> B\n```\n";
let (_, has_mermaid, html) = md_to_html(md);
assert!(
has_mermaid,
"has_mermaid deve ser true quando há bloco mermaid"
);
assert!(
html.contains("<div class=\"mermaid-diagram\">"),
"deve emitir elemento mermaid-diagram"
);
assert!(
html.contains("<pre class=\"mermaid-fallback\">"),
"deve emitir elemento mermaid-fallback"
);
}
#[test]
fn md_to_html_no_mermaid_when_no_block() {
let md = "## Seção\n\nApenas texto sem mermaid.\n";
let (_, has_mermaid, html) = md_to_html(md);
assert!(!has_mermaid);
assert!(!html.contains("mermaid-diagram"));
}
#[test]
fn md_to_html_table_renders_thead_tbody() {
let md = "| Col A | Col B |\n|-------|-------|\n| val1 | val2 |\n";
let (_, _, html) = md_to_html(md);
assert!(html.contains("<table>"), "deve gerar <table>");
assert!(html.contains("<thead>"), "deve gerar <thead>");
assert!(html.contains("<tbody>"), "deve gerar <tbody>");
assert!(html.contains("<th>"), "deve gerar <th>");
assert!(html.contains("val1"), "deve conter valor da célula");
}
#[test]
fn md_to_html_renders_living_checklist_controls_with_stable_keys() {
let md =
"## Checklist macro\n\n- [ ] Persistência e idempotência.\n- [x] Contratos revisados.\n";
let (_, _, html) = md_to_html(md);
assert!(html.contains("<h2 id=\"checklist-macro\">Checklist</h2>"));
assert_eq!(html.matches("data-living-check=").count(), 2);
assert!(html.contains("class=\"living-check-item\""));
assert!(html.contains("type=\"checkbox\""));
assert!(html.contains("checked"));
assert!(!html.contains("[ ]"));
assert!(!html.contains("[x]"));
}
#[test]
fn stage_html_generator_renders_full_html_structure() {
let md = "# PRD — Minha Feature\n\n## Objetivo\n\nDescrição do objetivo.\n\n## Rastreabilidade\n\n- Origem: 01-idea.md\n- Estado: aprovado\n";
let gen = StageHtmlGenerator::new(md, "prd", "minha-feature");
let html = gen.render().expect("render não deve falhar");
assert!(
html.to_lowercase().contains("<!doctype html>"),
"deve ter DOCTYPE"
);
assert!(html.contains("<nav"), "deve ter nav");
assert!(html.contains("toc-list"), "deve ter toc-list");
assert!(html.contains("Objetivo"), "deve conter heading h2");
assert!(
html.contains("id=\"rastreabilidade\""),
"deve ter seção de rastreabilidade"
);
}
#[test]
fn stage_html_generator_prd_with_mermaid_has_script_and_fallback() {
let md = "## Fluxo\n\n```mermaid\nsequenceDiagram\n A->>B: mensagem\n```\n";
let gen = StageHtmlGenerator::new(md, "prd", "teste-orq");
let html = gen.render().expect("render não deve falhar");
assert!(
!html.contains("mermaid.min.js"),
"não deve depender de Mermaid CDN essencial"
);
assert!(
html.contains("<div class=\"mermaid-diagram\">"),
"deve ter elemento mermaid-diagram"
);
assert!(
html.contains("<pre class=\"mermaid-fallback\">"),
"deve ter fallback pre"
);
}
#[test]
fn render_markdown_file_to_html_reads_real_prd_file() {
let prd_path = std::path::Path::new("docs/geracao-mais-robusta-e-organizada-redesenhar-pipeline-de-geracao-de-artefatos-do-sd-7baa90d82a5a/02-prd.md");
if !prd_path.exists() {
return;
}
let html = render_markdown_file_to_html(prd_path, "prd", "geracao-mais-robusta")
.expect("render_markdown_file_to_html não deve falhar para PRD existente");
assert!(
html.contains("<div class=\"mermaid-diagram\">"),
"PRD real deve gerar elemento mermaid-diagram"
);
assert!(
html.contains("<pre class=\"mermaid-fallback\">"),
"PRD real deve gerar elemento mermaid-fallback"
);
assert!(html.contains("toc-list"), "deve ter TOC");
assert!(
html.contains("<!doctype html>") || html.contains("<!DOCTYPE html>"),
"deve ter DOCTYPE"
);
}
#[test]
fn heading_id_converts_accented_text() {
let id = heading_id("Rastreabilidade e Contexto");
assert!(!id.contains(' '), "id não deve conter espaços");
assert!(
id.starts_with('r') || id.contains('-'),
"id deve ser minúsculo com hífens"
);
}
#[test]
fn inline_md_bold_and_italic() {
let out = inline_md("Texto **negrito** e *itálico*.");
assert!(
out.contains("<strong>negrito</strong>"),
"deve gerar <strong>"
);
assert!(out.contains("<em>itálico</em>"), "deve gerar <em>");
}
#[test]
fn inline_md_code_span() {
let out = inline_md("Execute `cargo build`.");
assert!(
out.contains("<code>cargo build</code>"),
"deve gerar <code>"
);
}
#[test]
fn escape_html_escapes_all_special_chars() {
let input = r#"<div class="test">&'value'</div>"#;
let out = escape_html(input);
assert!(out.contains("<"), "deve escapar < como <");
assert!(out.contains(">"), "deve escapar > como >");
assert!(out.contains("""), "deve escapar \" como "");
assert!(out.contains("&"), "deve escapar & como &");
assert!(out.contains("'"), "deve escapar ' como '");
let stripped = out
.replace("<", "")
.replace(">", "")
.replace(""", "")
.replace("&", "")
.replace("'", "");
assert!(
!stripped.contains('<'),
"nenhum < literal deve restar após stripping"
);
assert!(
!stripped.contains('>'),
"nenhum > literal deve restar após stripping"
);
assert!(
!stripped.contains('"'),
"nenhum \" literal deve restar após stripping"
);
}
#[test]
fn ca_06_planning_html_compiler_builds_deterministic_fingerprint_from_revision_sha_and_generator_version(
) {
let tmp = tempfile::tempdir().unwrap();
let store = tmp.path();
write_planning_stage(store, "02-prd.md", "# PRD\n\nConteúdo PRD.");
write_planning_stage(
store,
"03-techspec.md",
"# Tech Spec\n\nConteúdo Tech Spec.",
);
write_planning_stage(store, "04-tasks.md", "# Tasks\n\nConteúdo Tasks.");
write_planning_stage(
store,
"05-refinement.md",
"# Refinement\n\nConteúdo Refinement.",
);
write_planning_traceability(
store,
&[
("prd", "02-prd.md", "approved"),
("techspec", "03-techspec.md", "approved"),
("tasks", "04-tasks.md", "recorded"),
("refinement", "05-refinement.md", "approved"),
],
);
let first = PlanningHtmlCompiler::compile(store).unwrap();
let second = PlanningHtmlCompiler::compile(store).unwrap();
assert_eq!(first.fingerprint.sha256, second.fingerprint.sha256);
assert_eq!(
first.fingerprint.generator_version,
PLANNING_HTML_GENERATOR_VERSION
);
assert!(first.html.contains("05-planning.html"));
assert!(first.html.contains(&first.fingerprint.sha256));
assert_eq!(first.sources.len(), 4);
}
#[test]
fn ca_guard_planning_html_check_detects_source_drift_before_execution() {
let tmp = tempfile::tempdir().unwrap();
let store = tmp.path();
write_planning_stage(store, "02-prd.md", "# PRD\n\nConteúdo PRD.");
write_planning_stage(
store,
"03-techspec.md",
"# Tech Spec\n\nConteúdo Tech Spec.",
);
write_planning_stage(store, "04-tasks.md", "# Tasks\n\nConteúdo Tasks.");
write_planning_stage(
store,
"05-refinement.md",
"# Refinement\n\nConteúdo Refinement.",
);
write_planning_traceability(
store,
&[
("prd", "02-prd.md", "approved"),
("techspec", "03-techspec.md", "approved"),
("tasks", "04-tasks.md", "recorded"),
("refinement", "05-refinement.md", "recorded"),
],
);
let output = PlanningHtmlCompiler::compile(store).unwrap();
std::fs::write(store.join("05-planning.html"), &output.html).unwrap();
write_planning_derived_artifact(store, &output);
std::fs::write(
store.join("04-tasks.md"),
"# Tasks\n\nConteúdo alterado manualmente.",
)
.unwrap();
let status = PlanningHtmlCompiler::check(store).unwrap();
assert!(matches!(
status,
PlanningHtmlCheck::Stale {
reason: PlanningHtmlStaleReason::SourceHashMismatch { stage, .. }
} if stage == "tasks"
));
}
#[test]
fn ca_07_planning_html_compiler_accepts_skipped_refinement() {
let tmp = tempfile::tempdir().unwrap();
let store = tmp.path();
write_planning_stage(store, "02-prd.md", "# PRD\n\nConteúdo PRD.");
write_planning_stage(
store,
"03-techspec.md",
"# Tech Spec\n\nConteúdo Tech Spec.",
);
write_planning_stage(store, "04-tasks.md", "# Tasks\n\nConteúdo Tasks.");
write_planning_traceability(
store,
&[
("prd", "02-prd.md", "approved"),
("techspec", "03-techspec.md", "approved"),
("tasks", "04-tasks.md", "approved"),
("refinement", "05-refinement.md", "skipped"),
],
);
let output = PlanningHtmlCompiler::compile(store).unwrap();
assert_eq!(output.sources.len(), 4);
let refinement = output
.sources
.iter()
.find(|source| source.stage == "refinement")
.unwrap();
assert_eq!(refinement.state, "skipped");
assert!(!refinement.required);
assert_eq!(refinement.sha256, "");
assert!(output.html.contains("Refinement dispensado"));
}
#[test]
fn ca_03_ca_08_ca_09_ca_10_planning_html_contains_accessible_modes_print_mobile_reduced_motion_and_offline_styles(
) {
let tmp = tempfile::tempdir().unwrap();
let store = tmp.path();
write_planning_stage(store, "02-prd.md", "# PRD\n\nConteúdo PRD.");
write_planning_stage(
store,
"03-techspec.md",
"# Tech Spec\n\nConteúdo Tech Spec.",
);
write_planning_stage(store, "04-tasks.md", "# Tasks\n\nConteúdo Tasks.");
write_planning_stage(
store,
"05-refinement.md",
"# Refinement\n\nConteúdo Refinement.",
);
write_planning_traceability(
store,
&[
("prd", "02-prd.md", "approved"),
("techspec", "03-techspec.md", "approved"),
("tasks", "04-tasks.md", "recorded"),
("refinement", "05-refinement.md", "approved"),
],
);
let html = PlanningHtmlCompiler::compile(store).unwrap().html;
assert!(html.contains("Visão executiva"));
assert!(html.contains("Documentos"));
assert!(html.contains("aria-label=\"Modos de leitura\""));
assert!(html.contains("href=\"#modo-executivo\""));
assert!(html.contains("href=\"#modo-documentos\""));
assert!(html.contains(":focus-visible"));
assert!(html.contains("@media print"));
assert!(html.contains("@media (max-width: 820px)"));
assert!(html.contains("min-width: 0"));
assert!(html.contains("prefers-reduced-motion"));
assert!(html.contains("--brand-copper: #f0a36e"));
assert!(html.contains("--accent: var(--brand-copper)"));
assert!(html.contains("--success: #64c878"));
assert!(html.contains("background-size: 42px 42px"));
assert!(html.contains("Pipeline de fontes"));
assert!(html.contains("class=\"readiness-stage\""));
assert!(!html.contains("<script src"));
assert!(!html.contains("src=\"http://"));
assert!(!html.contains("src=\"https://"));
assert!(!html.contains("href=\"http://"));
assert!(!html.contains("href=\"https://"));
assert!(!html.contains("https://cdn."));
assert!(!html.contains("mermaid.min.js"));
assert!(html.contains("function renderSequence"));
assert!(html.contains("data-diagram-variant"));
assert!(html.contains("shell.dataset.codeBlock"));
assert!(html.contains("code-copy-button"));
assert!(html.contains("Copiar trecho de código"));
}
#[test]
fn ca_security_planning_html_redacts_secrets_and_blocks_unsafe_links() {
let tmp = tempfile::tempdir().unwrap();
let store = tmp.path();
write_planning_stage(
store,
"02-prd.md",
"# PRD\n\nBearer secret-token\n\napi_key=\"sk-test-secret-value\"\n\n\"token\": \"ghp_0123456789012345678901234567890123\"\n\nxoxb-123456789012-abcdefabcdef\n\n-----BEGIN RSA PRIVATE KEY-----\nMIIEsecretline\n-----END RSA PRIVATE KEY-----\n\n[unsafe](javascript:alert(1))\n\n[safe](docs/spec.md?api_key=abc123)",
);
write_planning_stage(
store,
"03-techspec.md",
"# Tech Spec\n\nhttps://user:pass@example.com/path?token=abc",
);
write_planning_stage(store, "04-tasks.md", "# Tasks\n\nConteúdo Tasks.");
write_planning_traceability(
store,
&[
("prd", "02-prd.md", "approved"),
("techspec", "03-techspec.md", "approved"),
("tasks", "04-tasks.md", "approved"),
("refinement", "05-refinement.md", "skipped"),
],
);
let html = PlanningHtmlCompiler::compile(store).unwrap().html;
assert!(!html.contains("secret-token"));
assert!(!html.contains("abc123"));
assert!(!html.contains("user:pass"));
assert!(!html.contains("sk-test-secret-value"));
assert!(!html.contains("ghp_0123456789012345678901234567890123"));
assert!(!html.contains("xoxb-123456789012-abcdefabcdef"));
assert!(!html.contains("MIIEsecretline"));
assert!(!html.contains("javascript:alert"));
assert!(html.contains("Bearer [REDACTED]"));
assert!(html.contains("api_key=[REDACTED]"));
assert!(html.contains("Link inseguro bloqueado"));
}
#[test]
fn ca_security_sanitize_link_url_preserves_safe_relative_and_rejects_unsafe_schemes() {
assert_eq!(
sanitize_link_url("docs/spec.md#sec").as_deref(),
Some("docs/spec.md#sec")
);
assert_eq!(
sanitize_link_url("https://example.com/path?token=abc").as_deref(),
Some("https://example.com/path?token=[REDACTED]")
);
assert_eq!(sanitize_link_url("javascript:alert(1)"), None);
assert_eq!(sanitize_link_url(" data:text/html,evil"), None);
assert_eq!(sanitize_link_url("vbscript:msgbox(1)"), None);
assert_eq!(sanitize_link_url("https://example.com/\u{0008}x"), None);
}
#[test]
fn ca_02_ca_04_ca_05_ca_06_ca_07_planning_html_is_single_derived_file_with_content_provenance_and_skipped_refinement(
) {
let tmp = tempfile::tempdir().unwrap();
let store = tmp.path();
write_planning_stage(
store,
"02-prd.md",
"# PRD\n\n## Objetivos\n\n- Entregar leitura executiva.\n\n## Não objetivos\n\n- Não publicar remotamente.\n\n## Riscos\n\n- Drift das fontes.\n\n## Perguntas abertas\n\n- Quem aprova o merge?",
);
write_planning_stage(
store,
"03-techspec.md",
"# Tech Spec\n\n## Arquitetura\n\nContrato HTML.\n\n## Decisões técnicas\n\n- Compiler derivado e determinístico.",
);
write_planning_stage(
store,
"04-tasks.md",
"# Tasks\n\n## Backlog\n\n- T-01.\n\n## Dependências\n\n- T-02 depende de T-01.",
);
write_planning_traceability(
store,
&[
("prd", "02-prd.md", "approved"),
("techspec", "03-techspec.md", "approved"),
("tasks", "04-tasks.md", "approved"),
("refinement", "05-refinement.md", "skipped"),
],
);
let output = PlanningHtmlCompiler::compile(store).unwrap();
std::fs::write(store.join(PLANNING_HTML_FILENAME), &output.html).unwrap();
write_planning_derived_artifact(store, &output);
let planning_files = std::fs::read_dir(store)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.filter(|name| name.ends_with("planning.html"))
.collect::<Vec<_>>();
assert_eq!(planning_files, vec![PLANNING_HTML_FILENAME.to_string()]);
assert!(output.html.contains("Visão executiva"));
assert!(output.html.contains("Prontidão"));
assert!(output.html.contains("Fontes"));
assert!(output.html.contains("Fingerprint"));
assert!(output.html.contains("Escopo e fora de escopo"));
assert!(output.html.contains("Decisões ativas"));
assert!(output.html.contains("Riscos"));
assert!(output.html.contains("Tarefas e dependências"));
assert!(output.html.contains("DoR, bloqueios e impedimentos"));
assert!(output.html.contains("Perguntas abertas"));
assert!(output.html.contains("Estado dos checkpoints"));
assert_eq!(output.html.matches(">Riscos</h3>").count(), 1);
assert_eq!(output.html.matches(">Perguntas abertas</h3>").count(), 1);
assert!(output.html.contains("Documentos"));
assert!(output
.html
.contains("Conteúdo derivado dos Markdown canônicos"));
assert!(output.html.contains("Proveniência"));
assert!(output.html.contains("02-prd.md"));
assert!(output.html.contains("03-techspec.md"));
assert!(output.html.contains("04-tasks.md"));
assert!(output.html.contains(&format!(
"Gerador: PlanningHtmlCompiler v{}",
PLANNING_HTML_GENERATOR_VERSION
)));
assert!(output.html.contains("Gerado em"));
assert!(output.html.contains("Slug"));
assert!(output.html.contains("Refinement dispensado"));
assert!(output.html.contains("Não aplicável"));
}
#[test]
fn ca_readiness_rejects_recorded_prd_or_techspec() {
let tmp = tempfile::tempdir().unwrap();
let store = tmp.path();
write_complete_planning_store(store, "approved");
let traceability = std::fs::read_to_string(store.join("traceability-map.yaml"))
.unwrap()
.replacen(" state: approved", " state: recorded", 1);
std::fs::write(store.join("traceability-map.yaml"), traceability).unwrap();
let error = PlanningHtmlCompiler::compile(store)
.unwrap_err()
.to_string();
assert!(error.contains("artifacts.prd.state"), "{error}");
assert!(error.contains("approved"), "{error}");
}
#[test]
fn ca_security_planning_sources_cannot_escape_artifact_store() {
let tmp = tempfile::tempdir().unwrap();
let store = tmp.path().join("docs/feat");
std::fs::create_dir_all(&store).unwrap();
write_complete_planning_store(&store, "approved");
let outside = tmp.path().join("secret.md");
std::fs::write(&outside, "# Secret\n\nsk-outside-secret").unwrap();
let outside_sha = planning_test_sha256(&std::fs::read(&outside).unwrap());
let traceability = std::fs::read_to_string(store.join("traceability-map.yaml"))
.unwrap()
.replace("file: 02-prd.md", "file: ../../secret.md")
.replacen(
&format!(
"sha256: \"{}\"",
planning_test_sha256(&std::fs::read(store.join("02-prd.md")).unwrap())
),
&format!("sha256: \"{outside_sha}\""),
1,
);
std::fs::write(store.join("traceability-map.yaml"), traceability).unwrap();
let error = PlanningHtmlCompiler::compile(&store)
.unwrap_err()
.to_string();
assert!(error.contains("02-prd.md"), "{error}");
assert!(error.contains("path"), "{error}");
}
#[test]
fn ca_fingerprint_revision_changes_make_planning_html_stale() {
let tmp = tempfile::tempdir().unwrap();
let store = tmp.path();
write_complete_planning_store(store, "approved");
let output = PlanningHtmlCompiler::compile(store).unwrap();
std::fs::write(store.join(PLANNING_HTML_FILENAME), &output.html).unwrap();
write_planning_derived_artifact(store, &output);
let mut traceability =
std::fs::read_to_string(store.join("traceability-map.yaml")).unwrap();
traceability = traceability.replacen(" revision: 1", " revision: 2", 1);
std::fs::write(store.join("traceability-map.yaml"), traceability).unwrap();
let status = PlanningHtmlCompiler::check(store).unwrap();
assert!(matches!(
status,
PlanningHtmlCheck::Stale {
reason: PlanningHtmlStaleReason::FingerprintMismatch { .. }
}
));
}
#[test]
fn ca_fingerprint_generator_version_metadata_mismatch_blocks_planning_html() {
let tmp = tempfile::tempdir().unwrap();
let store = tmp.path();
write_complete_planning_store(store, "approved");
let output = PlanningHtmlCompiler::compile(store).unwrap();
std::fs::write(store.join(PLANNING_HTML_FILENAME), &output.html).unwrap();
write_planning_derived_artifact(store, &output);
let traceability = std::fs::read_to_string(store.join("traceability-map.yaml"))
.unwrap()
.replace(
&format!(" generator_version: {}", PLANNING_HTML_GENERATOR_VERSION),
" generator_version: 999",
);
std::fs::write(store.join("traceability-map.yaml"), traceability).unwrap();
let status = PlanningHtmlCompiler::check(store).unwrap();
assert!(matches!(
status,
PlanningHtmlCheck::Stale {
reason: PlanningHtmlStaleReason::MetadataIncomplete { field }
} if field == "generator_version"
));
}
#[test]
fn ca_fingerprint_tampered_html_hash_blocks_planning_html() {
let tmp = tempfile::tempdir().unwrap();
let store = tmp.path();
write_complete_planning_store(store, "approved");
let output = PlanningHtmlCompiler::compile(store).unwrap();
std::fs::write(store.join(PLANNING_HTML_FILENAME), &output.html).unwrap();
write_planning_derived_artifact(store, &output);
std::fs::write(
store.join(PLANNING_HTML_FILENAME),
format!("{}\n<!-- adulterado -->", output.html),
)
.unwrap();
let status = PlanningHtmlCompiler::check(store).unwrap();
assert!(matches!(
status,
PlanningHtmlCheck::Stale {
reason: PlanningHtmlStaleReason::HtmlHashMismatch { .. }
}
));
}
#[test]
fn ca_browser_smoke_fixture_writes_real_planning_html_under_target() {
let store = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("sdd-browser-smoke")
.join("planning-html");
if store.exists() {
std::fs::remove_dir_all(&store).unwrap();
}
std::fs::create_dir_all(&store).unwrap();
write_showcase_planning_store(&store);
let output = PlanningHtmlCompiler::compile(&store).unwrap();
std::fs::write(store.join(PLANNING_HTML_FILENAME), &output.html).unwrap();
write_planning_derived_artifact(&store, &output);
assert_eq!(
PlanningHtmlCompiler::check(&store).unwrap(),
PlanningHtmlCheck::Current
);
assert!(store.join(PLANNING_HTML_FILENAME).is_file());
assert!(output.html.contains("Visão executiva"));
assert!(output.html.contains("Documentos"));
assert!(output.html.contains("Central de Exceções Logísticas"));
assert!(output
.html
.contains("Reduzir em 35% o tempo médio de resolução"));
assert!(output.html.contains("GET /v1/logistics/exceptions"));
assert!(output.html.contains("T-06 — Observabilidade e rollout"));
assert!(output.html.contains("Definition of Ready"));
assert!(output.html.contains("data-mode-link=\"modo-executivo\""));
assert!(output.html.contains("class=\"artifact-console\""));
assert!(output
.html
.contains(".artifact-console {\n position: sticky;\n top: 60px;"));
assert!(output.html.contains(
".provenance {\n margin: 0;\n border: 0;\n border-radius: 0;\n background: transparent;"
));
assert!(output.html.contains("data-artifact-target=\"stage-prd\""));
assert!(output.html.contains("data-artifact-target=\"stage-idea\""));
assert!(output
.html
.contains("data-artifact-target=\"stage-adr-01\""));
assert!(output.html.contains("data-living-check="));
assert!(output.html.contains("data-task-check"));
let delivery = DeliveryHtmlCompiler::compile(&store).unwrap();
std::fs::write(store.join(DELIVERY_HTML_FILENAME), &delivery.html).unwrap();
assert!(delivery.html.contains("Registro vivo da entrega"));
assert!(delivery.html.contains("Linha do tempo"));
assert!(delivery.html.contains("Ainda não materializado"));
assert!(store.join(DELIVERY_HTML_FILENAME).is_file());
const COMPLETE_DELIVERY_FILENAME: &str = "10-delivery-complete.html";
let complete_store = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("sdd-browser-smoke")
.join("complete-delivery");
if complete_store.exists() {
std::fs::remove_dir_all(&complete_store).unwrap();
}
std::fs::create_dir_all(&complete_store).unwrap();
write_complete_showcase_delivery_store(&complete_store);
let complete_delivery = DeliveryHtmlCompiler::compile(&complete_store).unwrap();
std::fs::write(
complete_store.join(COMPLETE_DELIVERY_FILENAME),
&complete_delivery.html,
)
.unwrap();
assert!(complete_delivery.html.contains("Ciclo completo"));
assert!(complete_delivery.html.contains("Project Discovery"));
assert!(complete_delivery.html.contains("Risk Classification"));
assert!(complete_delivery.html.contains("QA — Central"));
assert!(complete_delivery.html.contains("Memory — Central"));
assert!(complete_delivery
.html
.contains("data-excalidraw-path=\"assets/central-logistica-architecture.excalidraw\""));
assert!(!complete_delivery.html.contains("Ainda não materializado"));
assert!(complete_store.join(COMPLETE_DELIVERY_FILENAME).is_file());
let project_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let discovery_store = project_root
.join("target")
.join("sdd-browser-smoke")
.join("discovery-html");
std::fs::create_dir_all(&discovery_store).unwrap();
let mut report = crate::domain::discovery::discover_project(project_root);
report.project_root = "/workspace/sdd-layer".to_string();
let provider = dummy_provider();
let context = dummy_ctx(&provider, project_root);
let discovery = DiscoveryHtmlGenerator::new(
&report,
"sdd-layer — Project Discovery",
"Inventário técnico para orientar a geração SDD",
"recorded",
)
.generate_html(&context)
.unwrap();
std::fs::write(
discovery_store.join("00-project-discovery-high-level.html"),
&discovery,
)
.unwrap();
assert!(discovery.contains("Mapa de capacidades"));
assert!(discovery.contains("Arquitetura Inferida"));
assert!(output.html.contains("data-artifact-panel"));
assert!(output.html.contains("href=\"#stage-techspec\""));
assert!(output.html.contains("data-task-graph"));
assert!(output.html.contains("Grafo de dependências"));
assert!(output.html.contains("Mermaid · ${parsed.type}"));
assert!(output.html.contains("data-model-graph"));
assert!(output.html.contains("api-reference"));
assert!(output.html.contains("network-edge-flow"));
assert!(output.html.contains("Arraste o canvas"));
assert!(output.html.contains("diagram-fullscreen-dialog"));
assert!(output.html.contains("installFullscreenControl"));
assert!(output.html.contains("data-architecture-overview"));
assert!(output.html.contains("toggleOrientation"));
assert!(output.html.contains("needsExternalRoute"));
assert!(output.html.contains("edge.directed !== false"));
assert!(output.html.contains("Integração de dados"));
assert!(output.html.contains("Projeção analítica"));
assert!(output
.html
.contains("grid-template-columns: minmax(0, 1fr);\n gap: 26px;"));
assert!(output.html.contains("sdd-living-state:v1"));
assert!(output.html.contains("data-task-check"));
assert!(output
.html
.contains("Referência interativa dos contratos REST"));
assert!(output.html.contains("className = \"data-table-wrap\""));
assert!(output.html.contains("--brand-copper: #f0a36e"));
assert!(output.html.contains("src=\"data:image/webp;base64,UklGR"));
assert!(!output.html.contains("{{ source.state_label }}"));
assert!(output.html.contains(">Aprovado<"));
assert!(!output.html.contains("<script src"));
assert!(!output.html.contains("src=\"http://"));
assert!(!output.html.contains("src=\"https://"));
assert!(!output.html.contains("href=\"http://"));
assert!(!output.html.contains("href=\"https://"));
}
#[test]
fn planning_html_includes_idea_and_sorted_adrs_when_available() {
let tmp = tempfile::tempdir().unwrap();
let store = tmp.path();
write_complete_planning_store(store, "approved");
write_planning_stage(
store,
"01-idea.md",
"# Idea\n\n## Proposta de valor\n\nAcompanhar a entrega inteira em uma superfície viva.",
);
std::fs::create_dir_all(store.join("adrs")).unwrap();
write_planning_stage(
store,
"adrs/adr-02.md",
"# ADR-02\n\n## Status\n\nAceito.\n\n## Decisão\n\nGerar um compilado final.",
);
write_planning_stage(
store,
"adrs/adr-01.md",
"# ADR-01\n\n## Status\n\nAceito.\n\n## Decisão\n\nMarkdown continua canônico.",
);
let mut traceability =
std::fs::read_to_string(store.join("traceability-map.yaml")).unwrap();
let idea_sha = planning_test_sha256(&std::fs::read(store.join("01-idea.md")).unwrap());
traceability = traceability.replacen(
"artifacts:\n",
&format!(
"artifacts:\n idea:\n file: 01-idea.md\n state: recorded\n revision: 1\n sha256: \"{idea_sha}\"\n"
),
1,
);
std::fs::write(store.join("traceability-map.yaml"), traceability).unwrap();
let output = PlanningHtmlCompiler::compile(store).unwrap();
let stages = output
.sources
.iter()
.map(|source| source.stage.as_str())
.collect::<Vec<_>>();
assert_eq!(
&stages[..3],
&["idea", "adr-01", "adr-02"],
"Idea deve anteceder ADRs ordenados"
);
assert!(output.html.contains("Ideia"));
assert!(output.html.contains("ADR-01"));
assert!(output.html.contains("ADR-02"));
}
#[test]
fn planning_html_embeds_safe_excalidraw_companion_for_offline_rendering() {
let tmp = tempfile::tempdir().unwrap();
let store = tmp.path();
write_complete_planning_store(store, "approved");
std::fs::create_dir_all(store.join("assets")).unwrap();
write_planning_stage(
store,
"assets/architecture.excalidraw",
r##"{"type":"excalidraw","elements":[{"id":"api","type":"rectangle","x":20,"y":20,"width":180,"height":80,"strokeColor":"#1f1a17","backgroundColor":"#fde4d2"}],"appState":{"viewBackgroundColor":"#fbf8f2"}}"##,
);
write_planning_stage(
store,
"02-prd.md",
"# PRD\n\n## Arquitetura\n\n[Abrir canvas](assets/architecture.excalidraw)\n",
);
write_planning_traceability(
store,
&[
("prd", "02-prd.md", "approved"),
("techspec", "03-techspec.md", "approved"),
("tasks", "04-tasks.md", "approved"),
("refinement", "05-refinement.md", "approved"),
],
);
let output = PlanningHtmlCompiler::compile(store).unwrap();
assert!(output
.html
.contains("data-excalidraw-path=\"assets/architecture.excalidraw\""));
assert!(output.html.contains("Excalidraw · Canvas renderizado"));
assert!(output
.html
.contains("svg.classList.add(\"excalidraw-svg\")"));
assert!(output
.html
.contains("Redimensionar navegação dos endpoints"));
assert!(!output.html.contains("<script src"));
}
#[test]
fn delivery_html_compiles_a_living_post_execution_record() {
let tmp = tempfile::tempdir().unwrap();
let store = tmp.path();
write_complete_planning_store(store, "approved");
write_planning_stage(
store,
"06-execution.md",
"# Execução\n\n## Resumo do implementado\n\n- T-01 concluída.\n\n## Testes\n\n- cargo test",
);
write_planning_stage(
store,
"07-review.md",
"# Review\n\n## Veredito\n\nAprovado com ressalvas.",
);
let mut traceability =
std::fs::read_to_string(store.join("traceability-map.yaml")).unwrap();
let execution_sha =
planning_test_sha256(&std::fs::read(store.join("06-execution.md")).unwrap());
let review_sha = planning_test_sha256(&std::fs::read(store.join("07-review.md")).unwrap());
traceability.push_str(&format!(
" execution:\n file: 06-execution.md\n state: recorded\n revision: 1\n sha256: \"{execution_sha}\"\n review:\n file: 07-review.md\n state: recorded\n revision: 1\n sha256: \"{review_sha}\"\n qa:\n file: 08-qa.md\n state: pending\n memory:\n file: 09-memory.md\n state: pending\n"
));
std::fs::write(store.join("traceability-map.yaml"), traceability).unwrap();
let output = DeliveryHtmlCompiler::compile(store).unwrap();
assert!(output.html.contains(DELIVERY_HTML_FILENAME));
assert!(output.html.contains("Registro vivo da entrega"));
assert!(output.html.contains("Linha do tempo"));
assert!(output.html.contains("Execução"));
assert!(output.html.contains("Review"));
assert!(output.html.contains("QA"));
assert!(output.html.contains("Ainda não materializado"));
assert!(output.html.contains("sdd-living-state:v1"));
}
fn write_planning_stage(store: &std::path::Path, filename: &str, content: &str) {
std::fs::write(store.join(filename), content).unwrap();
}
fn write_complete_planning_store(store: &std::path::Path, refinement_state: &str) {
write_planning_stage(
store,
"02-prd.md",
"# PRD\n\n## Objetivos\n\n- Entregar uma leitura executiva clara antes da execução.\n\n## Não objetivos\n\n- Substituir os documentos Markdown canônicos.\n\n## Riscos\n\n- Drift entre fontes e HTML derivado.\n\n## Perguntas abertas\n\n- O checkpoint técnico está pronto para aprovação?",
);
write_planning_stage(
store,
"03-techspec.md",
"# Tech Spec\n\n## Arquitetura\n\nCompiler determinístico sobre o artifact store local.\n\n## Decisões técnicas\n\n- HTML autocontido com fingerprint verificável.\n\n## Riscos\n\n- Template visual divergir do contrato de conteúdo.",
);
write_planning_stage(
store,
"04-tasks.md",
"# Tasks\n\n## Backlog\n\n- T-01 — Gerar o compilado pré-execução.\n- T-02 — Validar responsividade e print.\n\n## Dependências\n\n- T-02 depende do compiler e do template final.",
);
let refinement = if refinement_state == "skipped" || refinement_state == "optional" {
refinement_state
} else {
write_planning_stage(
store,
"05-refinement.md",
"# Refinement\n\n## Definition of Ready\n\n- Fontes aprovadas e fingerprint atual.\n\n## Pontos de observação\n\n- Smoke visual obrigatório antes de Execution.",
);
"approved"
};
write_planning_traceability(
store,
&[
("prd", "02-prd.md", "approved"),
("techspec", "03-techspec.md", "approved"),
("tasks", "04-tasks.md", "approved"),
("refinement", "05-refinement.md", refinement),
],
);
}
fn write_showcase_planning_store(store: &std::path::Path) {
std::fs::create_dir_all(store.join("assets")).unwrap();
write_planning_stage(
store,
"assets/central-logistica-architecture.excalidraw",
r##"{
"type": "excalidraw",
"version": 2,
"elements": [
{"id":"events","type":"rectangle","x":20,"y":120,"width":220,"height":100,"strokeColor":"#1f1a17","backgroundColor":"#fde4d2","strokeWidth":2,"roundness":{"type":3}},
{"id":"cases","type":"rectangle","x":340,"y":120,"width":220,"height":100,"strokeColor":"#1f1a17","backgroundColor":"#eef7eb","strokeWidth":2,"roundness":{"type":3}},
{"id":"workspace","type":"rectangle","x":660,"y":120,"width":240,"height":100,"strokeColor":"#1f1a17","backgroundColor":"#fffaf5","strokeWidth":2,"roundness":{"type":3}},
{"id":"flow-1","type":"arrow","x":240,"y":170,"width":100,"height":0,"points":[[0,0],[100,0]],"strokeColor":"#eba06e","backgroundColor":"transparent","strokeWidth":2,"endArrowhead":"arrow"},
{"id":"flow-2","type":"arrow","x":560,"y":170,"width":100,"height":0,"points":[[0,0],[100,0]],"strokeColor":"#eba06e","backgroundColor":"transparent","strokeWidth":2,"endArrowhead":"arrow"}
],
"appState": {"viewBackgroundColor":"#fbf8f2"},
"files": {}
}"##,
);
write_planning_stage(
store,
"01-idea.md",
r#"# Idea — Central de Exceções Logísticas
## Problema
Operações e atendimento investigam a mesma entrega em sistemas diferentes, sem uma prioridade compartilhada, um responsável explícito ou uma trilha única de decisão.
## Proposta de valor
Transformar sinais dispersos de transportadoras em casos acionáveis, com severidade, SLA, responsável e próximo passo visíveis no mesmo lugar.
## MVP
- Consolidar eventos em casos idempotentes.
- Priorizar uma fila operacional por risco e aging.
- Exibir dossiê, timeline e ações autorizadas.
- Medir resolução sem sacrificar reabertura e qualidade.
## Rastreabilidade
- Origem: Project Discovery e classificação de risco.
- Próximo artefato: ADR-01.
"#,
);
std::fs::create_dir_all(store.join("adrs")).unwrap();
write_planning_stage(
store,
"adrs/adr-01.md",
r#"# ADR-01 — Fonte canônica e acompanhamento local
## Status
Aceito.
## Decisão
Markdown e `traceability-map.yaml` continuam canônicos. Marcações no HTML são um acompanhamento local persistido no navegador e não aprovam checkpoints.
## Rastreabilidade
- Origem: Idea registrada.
- Próximo artefato: PRD.
"#,
);
write_planning_stage(
store,
"adrs/adr-02.md",
r#"# ADR-02 — Entrega orientada a casos e outbox
## Status
Aceito.
## Decisão
Persistir casos e timeline em uma transação, publicar alterações pela outbox e atualizar a experiência operacional por SSE.
## Diagrama editável
[Abrir canvas Excalidraw](assets/central-logistica-architecture.excalidraw)
## Rastreabilidade
- Origem: PRD aprovado.
- Próximo artefato: Tech Spec.
"#,
);
write_planning_stage(
store,
"02-prd.md",
r#"# PRD — Central de Exceções Logísticas
## Resumo executivo
A Central de Exceções Logísticas consolida atrasos, falhas de coleta, devoluções e divergências de rastreamento em uma única fila operacional. O objetivo é trocar a investigação manual entre transportadoras, pedidos e canais de atendimento por um fluxo orientado a prioridade, responsável e próximo passo.
## Contexto
Hoje, operações e atendimento descobrem incidentes por canais diferentes. A mesma entrega pode gerar contato duplicado, ações conflitantes e ausência de dono. O produto precisa transformar sinais dispersos em casos acionáveis sem substituir os sistemas transacionais existentes.
## Objetivos
- Reduzir em 35% o tempo médio de resolução de exceções nos primeiros 90 dias.
- Dar visibilidade diária sobre volume, aging, severidade e responsável.
- Garantir que toda exceção crítica tenha dono e próxima ação em até 15 minutos.
- Preservar uma trilha auditável das decisões operacionais.
## Não objetivos
- Substituir o rastreamento oficial das transportadoras.
- Automatizar reembolso sem aprovação humana.
- Criar um novo CRM ou canal de atendimento.
- Reprocessar o histórico anterior aos últimos 90 dias no MVP.
## Personas
- **Analista de Operações:** prioriza a fila, investiga contexto e executa ações corretivas.
- **Líder de Operações:** acompanha capacidade, aging, SLAs e gargalos por transportadora.
- **Atendimento:** consulta o estado do caso sem duplicar ações.
- **Auditoria:** reconstrói quem decidiu, quando e com quais evidências.
## Jornada principal
1. Um evento de rastreamento viola uma regra de exceção.
2. A plataforma cria ou atualiza um caso idempotente por envio e tipo.
3. O caso recebe severidade, SLA e rota de responsabilidade.
4. O analista consulta evidências e escolhe uma ação recomendada.
5. A ação é registrada e, quando necessário, aguarda aprovação.
6. O caso é resolvido ou reaberto por novo evento relevante.
## Escopo
- Fila única com filtros por severidade, transportadora, aging, responsável e status.
- Dossiê do caso com linha do tempo, pedido, envio, contatos e evidências.
- Atribuição manual e automática por regra.
- Ações: solicitar atualização, abrir tratativa, aprovar reembolso e encerrar.
- SLAs configuráveis e alertas de risco de violação.
- Métricas operacionais e exportação CSV com os filtros atuais.
## Fora de escopo
- Machine learning para previsão de atraso.
- Chat em tempo real dentro do caso.
- Aplicativo móvel nativo.
- Alteração direta do status oficial da transportadora.
## Requisitos funcionais
- **RF-01:** consolidar eventos elegíveis em casos idempotentes.
- **RF-02:** ordenar a fila por severidade, risco de SLA e idade.
- **RF-03:** permitir atribuição com histórico de responsável.
- **RF-04:** exibir uma linha do tempo cronológica e redigida.
- **RF-05:** exigir justificativa em ações financeiras.
- **RF-06:** impedir encerramento enquanto houver ação obrigatória pendente.
- **RF-07:** registrar toda mudança de estado em auditoria append-only.
- **RF-08:** refletir novos eventos relevantes sem recarregar a página inteira.
## Regras de negócio
- Uma exceção crítica vence em 2 horas; alta em 8 horas; média em 24 horas.
- Reembolso acima de R$ 300 exige aprovação de liderança.
- Um envio só pode ter um caso aberto por tipo de exceção.
- Reabertura preserva o histórico e incrementa a versão do caso.
- Dados pessoais não aparecem em exportações operacionais.
## Estados
`novo → em_triagem → em_tratativa → aguardando_aprovacao → resolvido`
Transições adicionais: `em_tratativa → bloqueado`, `resolvido → reaberto` e `bloqueado → em_tratativa`.
```mermaid
stateDiagram-v2
[*] --> novo
novo --> em_triagem: caso assumido
em_triagem --> em_tratativa: ação iniciada
em_tratativa --> aguardando_aprovacao: ação financeira
aguardando_aprovacao --> resolvido: aprovação concluída
em_tratativa --> bloqueado: dependência externa
bloqueado --> em_tratativa: dependência resolvida
resolvido --> reaberto: novo evento relevante
resolvido --> [*]
```
## Critérios de aceite
### CA-01 — Priorização
Dado que existam casos críticos e médios, quando a fila for carregada, então casos críticos em risco de SLA aparecem primeiro.
### CA-02 — Idempotência
Dado um evento já processado, quando ele for reenviado com o mesmo identificador, então nenhum caso ou item de timeline duplicado é criado.
### CA-03 — Aprovação financeira
Dado um reembolso acima de R$ 300, quando o analista solicitar a ação, então o caso passa para `aguardando_aprovacao` e não é encerrado automaticamente.
### CA-04 — Auditoria
Dado qualquer mudança de status, responsável ou ação, quando ela for concluída, então ator, timestamp, origem e justificativa ficam disponíveis na timeline.
## Métricas
- Tempo médio e p90 de resolução.
- Percentual de casos dentro do SLA.
- Aging por transportadora e severidade.
- Taxa de reabertura em 7 dias.
- Casos resolvidos por analista/dia, sempre acompanhado de qualidade.
## Riscos
- Eventos inconsistentes entre transportadoras gerarem classificação incorreta.
- Priorização agressiva criar starvation de casos médios.
- Ações financeiras sem segregação adequada de função.
- Volume de atualizações causar pressão no banco e no canal em tempo real.
## Dependências
- Catálogo de transportadoras e mapeamento de eventos.
- Identidade corporativa com papéis `operations_agent` e `operations_lead`.
- Serviço de pedidos para contexto mínimo do envio.
- Política financeira para aprovação e estorno.
## Perguntas abertas
- Qual equipe assume casos sem transportadora identificada?
- O SLA pausa quando a tratativa depende de retorno externo?
- Quais motivos de encerramento serão obrigatórios no piloto?
## Rastreabilidade
Origem: oportunidade validada com Operações e Atendimento. Próximo artefato: Tech Spec.
"#,
);
write_planning_stage(
store,
"03-techspec.md",
r#"# Tech Spec — Central de Exceções Logísticas
## Visão técnica
A solução adiciona um módulo de exceções ao monólito existente, com ingestão assíncrona, projeção de leitura otimizada para a fila e atualizações incrementais para o frontend. O desenho mantém pedidos e rastreamento como fontes externas, enquanto o caso de exceção é a fonte de verdade operacional.
## Arquitetura
```mermaid
flowchart LR
events[Eventos de rastreamento] --> normalizer[Normalizador]
normalizer --> rules[Regras de exceção]
rules --> queue[Fila de comandos]
queue --> cases[Serviço de casos]
cases --> database[Banco relacional]
cases --> outbox[Outbox]
database --> api[API e atualizações SSE]
outbox --> api
api --> workspace[Central operacional web]
```
## Decisões técnicas
- Usar uma tabela de casos e uma timeline append-only, evitando event sourcing completo no MVP.
- Processar eventos com chave idempotente `carrier:event_id`.
- Publicar mudanças por outbox transacional antes do stream SSE.
- Manter filtros e paginação no servidor com cursor estável.
- Calcular severidade e SLA no backend para evitar divergência entre clientes.
- Implementar o módulo atrás da flag `logistics_exceptions_v1`.
## Fluxo de decisão
```mermaid
flowchart TD
start([Evento normalizado]) --> duplicate{Chave idempotente já existe?}
duplicate -->|Sim| ignore([Ignorar sem duplicar])
duplicate -->|Não| classify[Classificar severidade e SLA]
classify --> financial{Ação financeira acima do limite?}
financial -->|Sim| approval[Aguardar aprovação]
financial -->|Não| resolve[Executar ação]
approval --> audit[(Timeline append-only)]
resolve --> audit
```
## Componentes
- `ExceptionEventNormalizer`: traduz payloads de transportadoras para o contrato interno.
- `ExceptionRuleEngine`: classifica tipo, severidade e prazo.
- `ExceptionCaseService`: aplica transições e invariantes.
- `ExceptionRepository`: persiste caso, timeline e cursor da fila.
- `ExceptionStreamPublisher`: entrega atualizações SSE após confirmação da outbox.
- `ExceptionsWorkspace`: compõe fila, dossiê e ações no frontend.
## Sequência de atualização
```mermaid
sequenceDiagram
participant Carrier as Transportadora
participant API as Ingestão
participant Case as Serviço de casos
participant DB as Banco relacional
participant UI as Central operacional
Carrier->>API: publica evento de rastreamento
API->>Case: normaliza e classifica
Case->>DB: persiste caso e outbox
DB-->>UI: entrega atualização por SSE
```
## Integração de dados
```mermaid
flowchart LR
carrier[Eventos externos] --> staging[(Buffer de ingestão)]
staging --> service[Serviço de casos]
service --> store[(Banco operacional)]
store -.-> analytics[(Projeção analítica)]
```
## Modelo de dados
### `logistics_exception_cases`
| Campo | Tipo | Regra |
|---|---|---|
| `id` | UUID | identificador público |
| `shipment_id` | UUID | referência indexada |
| `exception_type` | TEXT | parte da unicidade do caso aberto |
| `severity` | TEXT | `critical`, `high`, `medium` |
| `status` | TEXT | máquina de estados validada |
| `owner_id` | UUID? | usuário responsável |
| `sla_due_at` | TIMESTAMP | calculado no ingresso |
| `version` | BIGINT | optimistic locking |
Índice parcial único: `(shipment_id, exception_type) WHERE status NOT IN ('resolvido')`.
### `logistics_exception_timeline`
Registro append-only com `case_id`, `sequence`, `event_type`, `actor_id`, `source`, `payload_redacted` e `created_at`.
## Contratos de API
### `GET /v1/logistics/exceptions`
Filtros: `status`, `severity`, `carrier_id`, `owner_id`, `sla`, `cursor` e `limit`.
Resposta:
```json
{
"items": [
{
"id": "exc_01",
"severity": "critical",
"status": "em_triagem",
"sla_due_at": "2026-08-01T14:00:00Z",
"version": 3
}
],
"next_cursor": "opaque"
}
```
### `GET /v1/logistics/exceptions/{id}`
Retorna dossiê, timeline, ações disponíveis e permissões calculadas.
### `POST /v1/logistics/exceptions/{id}/actions`
```json
{
"action": "request_refund",
"reason": "shipment_lost",
"amount_cents": 45900,
"expected_version": 3
}
```
Erros relevantes: `409 version_conflict`, `422 invalid_transition`, `403 approval_required` e `429 action_rate_limited`.
## Fluxo técnico
1. O consumidor valida schema e deduplica o evento.
2. O normalizador produz um evento interno redigido.
3. O motor de regras decide se cria, atualiza ou ignora um caso.
4. A transação persiste caso, timeline e outbox.
5. O publisher confirma a outbox e emite atualização SSE.
6. O cliente atualiza somente o caso afetado e preserva filtros.
## Segurança e privacidade
- Autorização por papel e ação, nunca apenas por visibilidade da tela.
- Ações financeiras exigem step-up e segregação entre solicitante e aprovador.
- Payload bruto da transportadora não é exposto no frontend.
- Logs usam IDs técnicos; nome, telefone, endereço e documento são redigidos.
- Exportação CSV respeita os mesmos filtros e permissões da API.
## Observabilidade
- Métricas: eventos recebidos, deduplicados, casos criados, violações de SLA e lag da outbox.
- Traces correlacionam `event_id`, `shipment_id` e `case_id`.
- Alertas para lag acima de 2 minutos, taxa de erro acima de 2% e backlog crescente por 15 minutos.
- Audit log separado das métricas de produto.
## Performance
- Meta p95 da fila: 400 ms com 50 itens.
- Meta p95 do dossiê: 600 ms.
- Índices cobrem status, severidade, SLA, responsável e cursor.
- SSE limita conexões por usuário e envia apenas diffs de casos autorizados.
## Estratégia de testes
- Unidade: regras, SLA, máquina de estados e redaction.
- Contrato: normalizadores por transportadora e API.
- Integração: transação caso + timeline + outbox.
- Concorrência: optimistic locking e evento duplicado.
- E2E: priorização, atribuição, aprovação e reabertura.
- Carga: 100 eventos/s e 500 operadores conectados no cenário de pico.
## Rollout e reversão
- Habilitar para equipe interna, depois para 10%, 50% e 100% das operações.
- Comparar métricas e falsos positivos a cada etapa.
- Reversão desliga criação e SSE pela flag, preservando casos já materializados para consulta.
## Riscos
- O cursor da fila perder estabilidade sob atualizações intensas.
- Regras divergirem da interpretação operacional.
- SSE degradar em proxies com buffering.
- Uma migração de índice bloquear escrita em tabela volumosa.
## Rastreabilidade
Origem: PRD aprovado. Próximo artefato: Tasks.
"#,
);
write_planning_stage(
store,
"04-tasks.md",
r#"# Tasks — Central de Exceções Logísticas
## Backlog
### T-01 — Contratos e persistência
Criar enums, tabelas, índices, timeline append-only e migração online. Cobrir unicidade do caso aberto e optimistic locking.
### T-02 — Normalização e idempotência
Implementar contrato interno, normalizadores iniciais e deduplicação por `carrier:event_id`.
### T-03 — Regras, severidade e SLA
Materializar regras configuráveis, cálculo de prazo e testes de fronteira para critical/high/medium.
### T-04 — API de fila e dossiê
Entregar filtros, cursor estável, ações disponíveis, redaction e autorização por papel.
### T-05 — Workspace operacional
Construir fila, filtros persistentes, dossiê lateral, timeline e ações com estados loading, vazio, erro, conflito e sucesso.
### T-06 — Observabilidade e rollout
Adicionar outbox, SSE, métricas, traces, alertas, flag e roteiro progressivo de habilitação.
### T-07 — Aprovação financeira
Implementar step-up, segregação solicitante/aprovador, justificativa obrigatória e trilha de auditoria.
### T-08 — Qualidade e evidências
Executar testes de contrato, integração, E2E, acessibilidade, responsividade e carga; consolidar evidências para Review.
## Ordem sugerida
1. T-01 → T-02 → T-03 estabelecem a fatia vertical de criação do caso.
2. T-04 e T-05 podem avançar em paralelo após o contrato estabilizar.
3. T-06 conecta atualizações e governança de rollout.
4. T-07 depende da máquina de estados e da API de ações.
5. T-08 fecha critérios e evidências.
## Dependências
- T-02 depende de T-01.
- T-03 depende do contrato interno de T-02.
- T-04 depende de T-01 e T-03.
- T-05 depende do contrato de T-04.
- T-06 depende de T-01 e T-04.
- T-07 depende de T-03 e T-04.
- T-08 depende de T-01 a T-07.
## Critérios de aceite
- Dado um evento duplicado, quando processado, então caso e timeline permanecem únicos.
- Dado conflito de versão, quando uma ação for enviada, então a API retorna `409` sem sobrescrever a decisão concorrente.
- Dado usuário sem papel de liderança, quando tentar aprovar reembolso, então a API retorna `403`.
- Dado viewport de 375 px, quando a central for utilizada, então fila, dossiê e ações não geram overflow horizontal.
- Dado rollout desabilitado, quando eventos chegarem, então o fluxo legado permanece operacional.
## Definition of Done
- Migrações reversíveis e revisadas.
- Contratos documentados e versionados.
- Testes unitários, integração, E2E e carga verdes.
- Métricas, alertas e dashboard disponíveis antes do piloto.
- Flag com owner, expiração e plano de remoção.
- Evidência visual desktop, tablet, mobile e teclado.
## Prompts Agent
Cada tarefa deve ser executada isoladamente, lendo PRD, Tech Spec, esta lista e o Refinement. O executor deve preservar os contratos, provar o critério Gherkin correspondente, registrar arquivos alterados e não habilitar rollout ou ação financeira sem checkpoint humano.
## Rastreabilidade
Origem: PRD e Tech Spec aprovados. Próximo artefato: Refinement.
"#,
);
write_planning_stage(
store,
"05-refinement.md",
r#"# Refinement — Central de Exceções Logísticas
## Resumo
O backlog está pronto para execução em três fatias: fundação transacional, experiência operacional e governança de rollout. A primeira entrega demonstrável deve receber um evento, criar um caso crítico idempotente e exibi-lo na fila com SLA.
## Solução proposta
- Sprint 1: T-01, T-02 e T-03 — caso criado e classificado.
- Sprint 2: T-04 e T-05 — fila e dossiê operacionais.
- Sprint 3: T-06, T-07 e T-08 — tempo real, aprovação, rollout e evidências.
## Definition of Ready
- PRD e Tech Spec aprovados.
- Contratos de evento e API revisados.
- Donos de Operações, Segurança e Finanças identificados.
- Massa sintética disponível para casos crítico, alto, médio, duplicado e reaberto.
- Flag `logistics_exceptions_v1` criada desligada por padrão.
- Métricas de baseline coletadas antes do piloto.
## Bloqueios
- Nenhum bloqueio técnico impeditivo.
- A política final de pausa de SLA por dependência externa permanece decisão de produto antes do rollout de 50%.
## Pontos de observação
- A migração do índice parcial deve usar estratégia online e janela monitorada.
- A lista precisa preservar posição e seleção ao receber eventos SSE.
- Conflito de versão deve orientar recarregar o caso sem perder o texto da justificativa.
- Métrica de produtividade nunca deve ser usada sem taxa de reabertura e qualidade.
## Checklist
- [ ] Persistência e idempotência.
- [ ] Regras e máquina de estados.
- [ ] API autorizada e redigida.
- [ ] Fila e dossiê acessíveis.
- [ ] Outbox, SSE e observabilidade.
- [ ] Aprovação financeira segregada.
- [ ] Testes, evidências e rollout.
## Subtasks e estimativas
| Subtask | Estimativa |
|---|---:|
| Migração, índices e rollback | 6.0h |
| Contrato interno e deduplicação | 5.0h |
| Regras, SLA e estados | 7.0h |
| API de fila, dossiê e ações | 10.0h |
| UI operacional e responsiva | 14.0h |
| Outbox, SSE, métricas e alertas | 9.0h |
| Aprovação financeira | 6.0h |
| E2E, carga e evidências | 8.0h |
## Flags, Remote Configs e Configurações
- `logistics_exceptions_v1`: habilita a central por equipe.
- `logistics_exceptions_sse`: habilita atualizações incrementais.
- `refund_approval_threshold_cents`: padrão `30000`.
- Toda flag precisa de owner, motivo, data de revisão e condição de remoção.
## Definition of Done
- Critérios CA-01 a CA-04 comprovados.
- Sem achados altos de segurança, dados ou concorrência.
- P95 dentro das metas com cenário de pico.
- Review visual aprovado em 1280, 768 e 375 px.
- Runbook de ativação, monitoramento e reversão revisado.
## Rastreabilidade
Origem: Tasks. Próximo passo: gerar `05-planning.html` e iniciar Execution somente após o guard `Current`.
"#,
);
write_planning_stage(
store,
"06-execution.md",
r#"# Execution — Central de Exceções Logísticas
## Resumo do implementado
- T-01 a T-04 concluídas com persistência, idempotência, regras, SLA e API.
- T-05 em validação visual com teclado, leitor de tela e viewport móvel.
## Evidências
- Migração aplicada em ambiente efêmero.
- Contratos e cenários de concorrência exercitados.
- Smoke da fila e do dossiê registrado.
## Checklist
- [x] Persistência e idempotência.
- [x] Regras e máquina de estados.
- [x] API autorizada e redigida.
- [ ] Fila e dossiê acessíveis.
## Rastreabilidade
- Origem: Tasks e Refinement aprovados.
- Próximo artefato: Review.
"#,
);
write_planning_stage(
store,
"07-review.md",
r#"# Review — Central de Exceções Logísticas
## Veredito
Aprovado com ressalvas. O núcleo transacional está correto; o rollout aguarda a evidência de acessibilidade e o cenário de recuperação do SSE.
## Achados
- Nenhum achado crítico ou alto.
- Um achado médio de foco após atualização incremental permanece acompanhado.
## Rastreabilidade
- Origem: Execution gravada.
- Próximo artefato: QA.
"#,
);
write_named_planning_traceability(
store,
"Central de Exceções Logísticas",
"central-de-excecoes-logisticas",
&[
("idea", "01-idea.md", "recorded"),
("prd", "02-prd.md", "approved"),
("techspec", "03-techspec.md", "approved"),
("tasks", "04-tasks.md", "approved"),
("refinement", "05-refinement.md", "approved"),
("execution", "06-execution.md", "recorded"),
("review", "07-review.md", "recorded"),
],
);
let mut traceability =
std::fs::read_to_string(store.join("traceability-map.yaml")).unwrap();
traceability.push_str(
" qa:\n file: 08-qa.md\n state: pending\n memory:\n file: 09-memory.md\n state: pending\n",
);
std::fs::write(store.join("traceability-map.yaml"), traceability).unwrap();
}
fn write_complete_showcase_delivery_store(store: &std::path::Path) {
write_showcase_planning_store(store);
write_planning_stage(
store,
"00-project-discovery.md",
r#"# Project Discovery — Central de Exceções Logísticas
## Contexto do projeto
O produto combina um monólito transacional, ingestão assíncrona de eventos de rastreamento e uma aplicação web operacional. Os módulos existentes de pedidos, identidade e auditoria devem ser reutilizados.
## Stack e comandos
- Backend transacional com banco relacional e outbox.
- Frontend web responsivo com atualização incremental por SSE.
- Testes unitários, contratos, integração, acessibilidade e carga como gates locais.
## Arquitetura inferida
```mermaid
flowchart LR
carriers[Transportadoras] --> ingestion[Ingestão]
ingestion --> monolith[Monólito operacional]
monolith --> database[Banco relacional]
monolith --> web[Workspace web]
```
## Riscos observados
- Contratos heterogêneos entre transportadoras.
- Mudanças concorrentes no mesmo caso.
- Ações financeiras exigem segregação de função.
## Rastreabilidade
- Origem: inventário técnico local.
- Próximo artefato: Risk Classification.
"#,
);
write_planning_stage(
store,
"00-risk-classification.md",
r#"# Risk Classification
## Classificação
Risco alto: persistência, concorrência, autorização financeira, dados operacionais e atualização em tempo real atravessam backend, frontend e operação.
## Estratégia de controle
- Refinement obrigatório.
- Review multidimensional.
- QA com contratos, carga, acessibilidade e recuperação do stream.
- Rollout gradual por flag e reversão documentada.
## Rastreabilidade
- Origem: Project Discovery.
- Próximo artefato: Idea.
"#,
);
write_planning_stage(
store,
"08-qa.md",
r#"# QA — Central de Exceções Logísticas
## Veredito
Aprovado. Os critérios funcionais, técnicos e operacionais foram exercitados com evidências reproduzíveis.
## Matriz executada
| Cenário | Evidência | Resultado |
|---|---|---|
| Evento duplicado | Reenvio com a mesma chave idempotente | Aprovado |
| Conflito de versão | Duas ações concorrentes no mesmo caso | Aprovado |
| Priorização por SLA | Massa crítica, alta e média | Aprovado |
| Aprovação financeira | Reembolso acima do limite | Aprovado |
| Recuperação SSE | Queda e reconexão com cursor | Aprovado |
| Acessibilidade | Teclado, foco e leitor de tela | Aprovado |
| Responsividade | 375, 768 e 1280 px | Aprovado |
## Evidências
- Contratos de API e eventos validados.
- P95 de leitura dentro da meta na massa de pico.
- Nenhuma violação crítica ou alta de segurança.
- Fallback por polling confirmado durante indisponibilidade do SSE.
## Defeitos
Nenhum defeito impeditivo. Um ajuste cosmético de foco foi corrigido e revalidado antes do veredito.
## Rastreabilidade
- Origem: Review aprovado.
- Próximo artefato: Memory.
"#,
);
write_planning_stage(
store,
"09-memory.md",
r#"# Memory — Central de Exceções Logísticas
## Resultado final
A entrega consolidou eventos heterogêneos em casos idempotentes, introduziu uma fila operacional priorizada, dossiê auditável, ações autorizadas e atualizações incrementais. O rollout está apto a avançar por coorte monitorada.
## Decisões preservadas
- Markdown e traceability continuam canônicos; HTML é uma superfície viva derivada.
- Caso e timeline são persistidos em uma transação.
- Mudanças são publicadas por outbox antes do SSE.
- Severidade, SLA e permissões são calculados no backend.
- Reembolso acima do limite mantém aprovação humana segregada.
## Aprendizados
- Produtividade isolada mascara reabertura; acompanhar qualidade e aging em conjunto.
- Reconexão precisa preservar posição, seleção e rascunho da justificativa.
- A matriz de contratos das transportadoras deve evoluir como artefato versionado.
## Operação
- Flag inicial em 10%, seguida de 25%, 50% e 100%.
- Observar erro de ingestão, lag da outbox, conexões SSE, p95 e reabertura.
- Reverter a flag e drenar consumidores se qualquer guardrail ultrapassar o limite.
## Estado
Ciclo completo: Idea, ADRs, PRD, Tech Spec, Tasks, Refinement, Execution, Review, QA e Memory materializados.
## Rastreabilidade
- Origem: QA aprovado e evidências finais.
- Estado: ciclo completo e preservado no registro vivo.
"#,
);
write_named_planning_traceability(
store,
"Central de Exceções Logísticas — Ciclo completo",
"central-de-excecoes-logisticas-ciclo-completo",
&[
("project-discovery", "00-project-discovery.md", "recorded"),
("risk", "00-risk-classification.md", "recorded"),
("idea", "01-idea.md", "recorded"),
("prd", "02-prd.md", "approved"),
("techspec", "03-techspec.md", "approved"),
("tasks", "04-tasks.md", "approved"),
("refinement", "05-refinement.md", "approved"),
("execution", "06-execution.md", "recorded"),
("review", "07-review.md", "approved"),
("qa", "08-qa.md", "approved"),
("memory", "09-memory.md", "recorded"),
],
);
}
fn write_planning_traceability(store: &std::path::Path, stages: &[(&str, &str, &str)]) {
write_named_planning_traceability(store, "Teste", "teste", stages);
}
fn write_named_planning_traceability(
store: &std::path::Path,
name: &str,
slug: &str,
stages: &[(&str, &str, &str)],
) {
let mut text = format!("orchestration:\n name: {name}\n slug: {slug}\nartifacts:\n");
for (stage, filename, state) in stages {
let sha256 = if *state == "skipped" || *state == "optional" {
String::new()
} else {
planning_test_sha256(&std::fs::read(store.join(filename)).unwrap())
};
text.push_str(&format!(
" {stage}:\n file: {filename}\n state: {state}\n revision: 1\n sha256: \"{sha256}\"\n"
));
}
std::fs::write(store.join("traceability-map.yaml"), text).unwrap();
}
fn write_planning_derived_artifact(store: &std::path::Path, output: &PlanningHtmlOutput) {
let mut text = std::fs::read_to_string(store.join("traceability-map.yaml")).unwrap();
text.push_str("derived_artifacts:\n");
text.push_str(" planning_html:\n");
text.push_str(" file: 05-planning.html\n");
text.push_str(" canonical: false\n");
text.push_str(" state: current\n");
text.push_str(" generator: PlanningHtmlCompiler\n");
text.push_str(&format!(
" generator_version: {}\n",
output.fingerprint.generator_version
));
text.push_str(&format!(
" fingerprint: \"{}\"\n",
output.fingerprint.sha256
));
text.push_str(&format!(" sha256: \"{}\"\n", output.sha256));
text.push_str(&format!(" generated_at: \"{}\"\n", output.generated_at));
text.push_str(" sources:\n");
for source in &output.sources {
text.push_str(&format!(
" - stage: {}\n path: {}\n state: {}\n revision: {}\n sha256: \"{}\"\n",
source.stage,
source.path,
source.state,
source
.revision
.map(|revision| revision.to_string())
.unwrap_or_else(|| "null".to_string()),
source.sha256
));
}
std::fs::write(store.join("traceability-map.yaml"), text).unwrap();
}
fn planning_test_sha256(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
}