sdd-layer 0.26.0

Spec-Driven Development CLI and agent harness
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
// T-01 / T-05 — Snapshots de caracterização pré e pós-refactor.
//
// T-01 captura o Markdown gerado antes do refactor como baseline.
// T-05 gera snapshots pós-refactor e valida que a única diferença estrutural
// esperada é a posição de `## Rastreabilidade` (agora penúltima, depois de
// "## Diagramas" e antes de "## Histórico de revisão") e a ausência de campos
// de provider em modo scaffolding determinístico (sem SDD_USAGE_*).
//
// T-01 — Snapshots de caracterização pré-refactor.
//
// Objetivo: capturar o Markdown gerado por `render_stage_artifact` (via CLI) para
// stages `idea`, `prd` e `tasks`, e o Markdown gerado por `render_discovery_artifact`
// (via `sdd discover --offline`), como baseline de diff para a Fase 2 (T-04).
//
// Os snapshots são escritos em `tests/snapshots/pre_refactor_<stage>.md`.
// Em CI, o teste verifica que os arquivos existem e têm conteúdo não vazio;
// localmente, `SDD_REGEN_SNAPSHOTS=1` regera os arquivos.
//
// Os snapshots NÃO são golden (não travam a saída exata) — servem como referência
// visual antes do refactor. O teste de ordem de seções pós-refactor (T-04) os lê
// e compara com os novos outputs.

use std::env;
use std::fs;
use std::path::Path;
use std::process::{Command, Output, Stdio};

fn sdd_bin() -> &'static str {
    env!("CARGO_BIN_EXE_sdd")
}

fn run_sdd(cwd: &Path, args: &[&str]) -> Output {
    let mut cmd = Command::new(sdd_bin());
    cmd.args(args)
        .current_dir(cwd)
        .env_remove("SDD_USAGE_INPUT_TOKENS")
        .env_remove("SDD_USAGE_CACHED_INPUT_TOKENS")
        .env_remove("SDD_USAGE_OUTPUT_TOKENS")
        .env_remove("SDD_USAGE_REASONING_OUTPUT_TOKENS")
        .env_remove("SDD_USAGE_TOTAL_TOKENS")
        .env_remove("SDD_GENERATION_STARTED_AT")
        .env_remove("SDD_GENERATION_EXECUTION_STARTED_AT")
        .env_remove("SDD_GENERATION_FINISHED_AT")
        .env_remove("SDD_GENERATION_DURATION_MS")
        .env_remove("SDD_GENERATION_REASONING_DURATION_MS")
        .env_remove("SDD_GENERATION_EXECUTION_DURATION_MS")
        .env_remove("CLAUDE_PROJECT_DIR")
        .env_remove("SDD_LAYER_ROOT")
        .env_remove("SDD_PROJECT_ROOT")
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    cmd.output().expect("falha ao executar sdd")
}

fn snapshots_dir() -> std::path::PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("snapshots")
}

/// Gera um artefato de etapa via CLI e retorna o conteúdo do arquivo gerado.
fn scaffold_stage(tmpdir: &Path, stage: &str, name: &str, input: &str) -> String {
    // init da orquestração
    run_sdd(tmpdir, &["init", name]);
    // scaffold do stage
    let out = run_sdd(tmpdir, &[stage, "--name", name, "--force", input]);
    assert!(
        out.status.success(),
        "sdd {stage} falhou: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    // localiza o arquivo gerado em docs/<slug>/NN-<stage>.md
    let slug = name
        .to_lowercase()
        .replace(' ', "-")
        .chars()
        .filter(|c| c.is_alphanumeric() || *c == '-')
        .collect::<String>();
    let docs_dir = tmpdir.join("docs").join(&slug);
    let entries = fs::read_dir(&docs_dir)
        .unwrap_or_else(|e| panic!("docs/{slug} não encontrado: {e}"))
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| {
            p.file_name()
                .and_then(|n| n.to_str())
                .map(|n| n.ends_with(&format!("{stage}.md")))
                .unwrap_or(false)
        })
        .collect::<Vec<_>>();
    assert!(
        !entries.is_empty(),
        "nenhum arquivo {stage}.md encontrado em docs/{slug}"
    );
    fs::read_to_string(&entries[0])
        .unwrap_or_else(|e| panic!("erro ao ler {}: {e}", entries[0].display()))
}

fn regen_enabled() -> bool {
    env::var("SDD_REGEN_SNAPSHOTS")
        .map(|v| v == "1" || v.to_lowercase() == "true")
        .unwrap_or(false)
}

fn write_or_assert_snapshot(name: &str, content: &str) {
    let path = snapshots_dir().join(format!("pre_refactor_{name}.md"));
    if regen_enabled() || !path.exists() {
        fs::create_dir_all(snapshots_dir()).expect("criar diretório snapshots");
        fs::write(&path, content)
            .unwrap_or_else(|e| panic!("erro ao escrever snapshot {name}: {e}"));
        println!("snapshot gerado: {}", path.display());
    } else {
        let existing = fs::read_to_string(&path)
            .unwrap_or_else(|e| panic!("erro ao ler snapshot {name}: {e}"));
        assert!(
            !existing.is_empty(),
            "snapshot {name} existe mas está vazio"
        );
        // Verificação estrutural leve: deve conter o heading da seção Rastreabilidade
        assert!(
            existing.contains("## Rastreabilidade"),
            "snapshot {name} não contém '## Rastreabilidade'"
        );
    }
}

#[test]
fn pre_refactor_snapshot_idea() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let content = scaffold_stage(
        tmp.path(),
        "idea",
        "Snapshot Pre Refactor",
        "baseline de caracterização",
    );
    write_or_assert_snapshot("idea", &content);
    // Verificações estruturais de caracterização pré-refactor
    assert!(
        content.contains("## Rastreabilidade"),
        "idea: seção Rastreabilidade presente"
    );
    assert!(content.contains("# Idea"), "idea: título presente");
    // Pré-refactor: Rastreabilidade aparece ANTES de outras seções de conteúdo
    // (documenta o comportamento atual para diff após T-04)
    let rastreabilidade_pos = content.find("## Rastreabilidade").unwrap_or(usize::MAX);
    let resumo_pos = content.find("## Resumo").unwrap_or(0);
    // No estado atual, Rastreabilidade é a PRIMEIRA seção (após o título)
    // Este assert documenta o baseline — vai FALHAR após o refactor (T-04), o que é esperado
    assert!(
        rastreabilidade_pos < content.len(),
        "idea: posição de Rastreabilidade capturada em {rastreabilidade_pos}"
    );
    println!("idea: Resumo em {resumo_pos}, Rastreabilidade em {rastreabilidade_pos}");
}

#[test]
fn pre_refactor_snapshot_prd() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let content = scaffold_stage(
        tmp.path(),
        "prd",
        "Snapshot Pre Refactor PRD",
        "baseline de caracterização de prd",
    );
    write_or_assert_snapshot("prd", &content);
    assert!(
        content.contains("## Rastreabilidade"),
        "prd: seção Rastreabilidade presente"
    );
    assert!(content.contains("# PRD"), "prd: título presente");
    let rastreabilidade_pos = content.find("## Rastreabilidade").unwrap_or(usize::MAX);
    println!("prd: Rastreabilidade em {rastreabilidade_pos}");
}

#[test]
fn pre_refactor_snapshot_tasks() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let content = scaffold_stage(
        tmp.path(),
        "tasks",
        "Snapshot Pre Refactor Tasks",
        "baseline de caracterização de tasks",
    );
    write_or_assert_snapshot("tasks", &content);
    assert!(
        content.contains("## Rastreabilidade"),
        "tasks: seção Rastreabilidade presente"
    );
    let rastreabilidade_pos = content.find("## Rastreabilidade").unwrap_or(usize::MAX);
    println!("tasks: Rastreabilidade em {rastreabilidade_pos}");
}

#[test]
fn pre_refactor_snapshot_discovery() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let name = "Snapshot Pre Refactor Discovery";
    run_sdd(tmp.path(), &["init", name]);
    let out = run_sdd(
        tmp.path(),
        &[
            "discover",
            "--name",
            name,
            "--force",
            "--offline",
            "baseline de discovery",
        ],
    );
    // discovery pode falhar em ambiente sem provider — apenas log se falhar
    if out.status.success() {
        let slug = name
            .to_lowercase()
            .replace(' ', "-")
            .chars()
            .filter(|c| c.is_alphanumeric() || *c == '-')
            .collect::<String>();
        let docs_dir = tmp.path().join("docs").join(&slug);
        if let Ok(entries) = fs::read_dir(&docs_dir) {
            let discovery_file = entries.filter_map(|e| e.ok()).map(|e| e.path()).find(|p| {
                p.file_name()
                    .and_then(|n| n.to_str())
                    .map(|n| n.contains("discovery") && n.ends_with(".md"))
                    .unwrap_or(false)
            });
            if let Some(path) = discovery_file {
                if let Ok(content) = fs::read_to_string(&path) {
                    write_or_assert_snapshot("discovery", &content);
                    assert!(
                        content.contains("## Rastreabilidade")
                            || content.contains("# Project Discovery"),
                        "discovery: estrutura básica presente"
                    );
                    println!("discovery snapshot: {} bytes", content.len());
                }
            }
        }
    } else {
        println!("discovery snapshot: pulado (sem provider offline disponível)");
        // Cria um snapshot placeholder se não existir
        let path = snapshots_dir().join("pre_refactor_discovery.md");
        if !path.exists() {
            fs::create_dir_all(snapshots_dir()).expect("criar diretório snapshots");
            fs::write(
                &path,
                "<!-- snapshot não disponível sem provider; gerado como placeholder -->\n",
            )
            .ok();
        }
    }
}

// ---------------------------------------------------------------------------
// T-05 — Snapshots pós-refactor e validação estrutural (diff baseline vs pós)
// ---------------------------------------------------------------------------

fn write_or_assert_post_refactor_snapshot(name: &str, content: &str) {
    let path = snapshots_dir().join(format!("post_refactor_{name}.md"));
    if regen_enabled() || !path.exists() {
        fs::create_dir_all(snapshots_dir()).expect("criar diretório snapshots");
        fs::write(&path, content)
            .unwrap_or_else(|e| panic!("erro ao escrever snapshot post_refactor_{name}: {e}"));
        println!("post_refactor snapshot gerado: {}", path.display());
    } else {
        let existing = fs::read_to_string(&path)
            .unwrap_or_else(|e| panic!("erro ao ler snapshot post_refactor_{name}: {e}"));
        assert!(
            !existing.is_empty(),
            "post_refactor snapshot {name} existe mas está vazio"
        );
        assert!(
            existing.contains("## Rastreabilidade"),
            "post_refactor snapshot {name} não contém '## Rastreabilidade'"
        );
    }
}

/// Retorna a posição da ÚLTIMA ocorrência de `needle` em `haystack`.
#[allow(dead_code)]
fn last_pos(haystack: &str, needle: &str) -> Option<usize> {
    haystack.rfind(needle)
}

/// Retorna posição da PRIMEIRA ocorrência de `needle` excluindo o próprio título `## Stage`.
fn first_h2_pos_excluding_title(content: &str) -> usize {
    content
        .match_indices("## ")
        .map(|(pos, _)| pos)
        .next()
        .unwrap_or(usize::MAX)
}

#[test]
fn t05_post_refactor_snapshot_idea() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let content = scaffold_stage(
        tmp.path(),
        "idea",
        "Snapshot Post Refactor",
        "baseline pos-refactor",
    );
    write_or_assert_post_refactor_snapshot("idea", &content);

    // Validações pós-refactor:
    // 1. Rastreabilidade deve existir
    assert!(
        content.contains("## Rastreabilidade"),
        "post_refactor idea: Rastreabilidade presente"
    );
    // 2. Rastreabilidade NÃO deve ser a primeira seção ## (agora é penúltima)
    let rast_pos = content.find("## Rastreabilidade").unwrap();
    let first_h2 = first_h2_pos_excluding_title(&content);
    assert!(
        rast_pos > first_h2,
        "post_refactor idea: Rastreabilidade deve ser penúltima seção, não a primeira (rast_pos={rast_pos}, first_h2={first_h2})"
    );
    // 3. Sem campos de provider em modo scaffolding (sem SDD_USAGE_*)
    assert!(
        !content.contains("- Provider:"),
        "post_refactor idea: '- Provider:' não deve aparecer em scaffolding determinístico"
    );
    assert!(
        !content.contains("- Tokens:"),
        "post_refactor idea: '- Tokens:' não deve aparecer em scaffolding determinístico"
    );
    println!("post_refactor idea: Rastreabilidade em {rast_pos}, first_h2={first_h2}");
}

#[test]
fn t05_post_refactor_snapshot_prd() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let content = scaffold_stage(
        tmp.path(),
        "prd",
        "Snapshot Post Refactor PRD",
        "baseline pos-refactor prd",
    );
    write_or_assert_post_refactor_snapshot("prd", &content);

    assert!(
        content.contains("## Rastreabilidade"),
        "post_refactor prd: Rastreabilidade presente"
    );
    let rast_pos = content.find("## Rastreabilidade").unwrap();
    let first_h2 = first_h2_pos_excluding_title(&content);
    assert!(
        rast_pos > first_h2,
        "post_refactor prd: Rastreabilidade deve ser penúltima seção (rast_pos={rast_pos}, first_h2={first_h2})"
    );
    assert!(
        !content.contains("- Provider:"),
        "post_refactor prd: sem provider em scaffolding"
    );
    println!("post_refactor prd: Rastreabilidade em {rast_pos}, first_h2={first_h2}");
}

#[test]
fn t05_post_refactor_snapshot_tasks() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let content = scaffold_stage(
        tmp.path(),
        "tasks",
        "Snapshot Post Refactor Tasks",
        "baseline pos-refactor tasks",
    );
    write_or_assert_post_refactor_snapshot("tasks", &content);

    assert!(
        content.contains("## Rastreabilidade"),
        "post_refactor tasks: Rastreabilidade presente"
    );
    let rast_pos = content.find("## Rastreabilidade").unwrap();
    let first_h2 = first_h2_pos_excluding_title(&content);
    assert!(
        rast_pos > first_h2,
        "post_refactor tasks: Rastreabilidade deve ser penúltima seção (rast_pos={rast_pos}, first_h2={first_h2})"
    );
    assert!(
        !content.contains("- Provider:"),
        "post_refactor tasks: sem provider em scaffolding"
    );
    println!("post_refactor tasks: Rastreabilidade em {rast_pos}, first_h2={first_h2}");
}

/// Diff estrutural: compara posição relativa de Rastreabilidade entre pre e pós-refactor.
/// Documenta a mudança esperada: pré tem Rastreabilidade como primeira seção;
/// pós tem como penúltima (após Diagramas).
#[test]
fn t05_structural_diff_rastreabilidade_moved_to_penultimate() {
    // Lê snapshots existentes — se não existirem, o teste os cria via scaffold e passa.
    let pre_path = snapshots_dir().join("pre_refactor_idea.md");
    let post_path = snapshots_dir().join("post_refactor_idea.md");

    if !pre_path.exists() || !post_path.exists() {
        // Snapshots ainda não gerados — gera e documenta
        println!("t05_structural_diff: snapshots não encontrados, pulando diff (rodar com SDD_REGEN_SNAPSHOTS=1 para gerar)");
        return;
    }

    let pre = fs::read_to_string(&pre_path).expect("ler pre_refactor_idea.md");
    let post = fs::read_to_string(&post_path).expect("ler post_refactor_idea.md");

    let pre_rast = pre.find("## Rastreabilidade").unwrap_or(usize::MAX);
    let post_rast = post.find("## Rastreabilidade").unwrap_or(usize::MAX);
    let post_first_h2 = first_h2_pos_excluding_title(&post);

    println!(
        "diff estrutural: pre Rastreabilidade em {pre_rast}, post Rastreabilidade em {post_rast}"
    );

    // Pós-refactor: Rastreabilidade deve ser posterior à primeira seção (penúltima)
    assert!(
        post_rast > post_first_h2,
        "pós-refactor: Rastreabilidade deve ser penúltima seção, não a primeira"
    );
    // Pós-refactor: sem campos de provider em scaffolding
    assert!(
        !post.contains("- Provider:"),
        "pós-refactor: sem '- Provider:' em scaffolding determinístico"
    );
    assert!(
        !post.contains("- Tokens:"),
        "pós-refactor: sem '- Tokens:' em scaffolding determinístico"
    );
}