diff --git a/features/029-chapters-regroup/.state b/features/029-chapters-regroup/.state new file mode 100644 index 0000000..45a94c0 --- /dev/null +++ b/features/029-chapters-regroup/.state @@ -0,0 +1,7 @@ +--- +feature: 029-chapters-regroup +mode: act +pipeline_state: READY_FOR_COMMIT +branch: feat/029-chapters-regroup +last_updated: 2026-08-06 +--- diff --git a/features/029-chapters-regroup/CONTRACT.md b/features/029-chapters-regroup/CONTRACT.md new file mode 100644 index 0000000..0d38f55 --- /dev/null +++ b/features/029-chapters-regroup/CONTRACT.md @@ -0,0 +1,39 @@ +--- +feature: 029-chapters-regroup +status: validated +validated_at: 2026-08-06 +validated_by: orquestrador (Fable 5); premissas da exploração de 2026-08-05 reconferidas contra main pós-#70 +--- + +# CONTRACT — 029-chapters-regroup + +## Premissas técnicas verificadas + +| # | Premissa | Verificação | Estado | +|---|---|---|---| +| 1 | Os 7 furos de `_*` existem e são os únicos | exploração 2026-08-05: `lint.py:33,53`, `heal.py:85-89`, `archive.py:44-45,72`, `compile.py:592-593`, `stats.py:4-8`; honram: search, embeddings, lexical, graph, api | **confirmado** | +| 2 | heal pode tocar `_summaries` hoje | `heal.py:85-89` exclui só `_index.md` e `.heal_backup`; 1.027 summaries elegíveis ao sorteio | **confirmado — bug latente que C1 corrige** | +| 3 | `manifest.book` existe para agrupar | 028 materializou 849 ligações com `book`; 126 `unresolved` fora | **confirmado** | +| 4 | Manutenção de manifest em move existe | `update_article_path`/`mark_archived` (028 B1/B4) com testes | **confirmado** | +| 5 | Mover arquivo não exige re-embedar | build por hash/relpath descarta e re-adota; `_chapters/` fica invisível após C1 | **confirmado** | +| 6 | Sem cron do kb | `crontab -l` reverificado 2026-08-05 | **confirmado** | +| 7 | `find_orphans` marcaria os 975 pós-move | 973/975 sem backlink (medição do ADR) — C1 antes de C4 é hard gate | **confirmado** | + +## Premissas de produto + +- Move para `_chapters/` SÓ com aprovação explícita do dono no C4, ciente de que a wiki visível esvazia (~5 artigos + unresolved) até o compile multi-fonte. +- `unresolved` nunca é movido por inferência — permanece na wiki como pendência humana. +- Commit por livro; tag antes do lote; tudo move, nunca unlink. +- Golden set preservado em disco; perda de retrievability aceita pelo ADR-0018 e registrada. + +## Riscos aceitos + +| Risco | Mitigação | +|---|---| +| Move em massa com path novo quebra wikilinks qualificados dos unresolved que ficam | Relatório do C4 lista; wikilink por stem continua resolvendo via graph (stem inalterado) | +| Nome de livro sujo (sufixos de fonte) vira slug ruim | Sanitização com teste; relatório mostra o slug antes do apply | +| Bench sem objeto pós-move | Aceito; registrado no REPORT | + +## Gate de TDD + +2 condições binárias de risco (I/O em store real; output estrutural do plano de move) → `test-design` com `test-red` base; cada módulo adotante do C1 tem teste próprio de exclusão. diff --git a/features/029-chapters-regroup/PLAN.md b/features/029-chapters-regroup/PLAN.md new file mode 100644 index 0000000..a2c49b3 --- /dev/null +++ b/features/029-chapters-regroup/PLAN.md @@ -0,0 +1,27 @@ +# PLAN — 029-chapters-regroup + +**Branch:** `feat/029-chapters-regroup` +**Data:** 2026-08-06 +**Spec:** `features/029-chapters-regroup/SPEC.md` · **Domain:** `features/027-noise-retro/DOMAIN.md` + +## Contexto técnico + +| Campo | Valor | +|---|---| +| Alvo | `kb/fsutil.py` (helper), `kb/lint.py:33,53`, `kb/heal.py:85-104`, `kb/archive.py:44-45,72`, `kb/compile.py:592-593`, `kb/stats.py:4-8`, novo `kb/regroup.py`, `kb/cli.py` | +| Reuso | `graph._e_artigo` (semântica de referência), `archive.move_to_archive`, `state.mark_archived`/`update_article_path`/`load_manifest`, `compile.update_index`, `refresh_embeddings_index`, maquinaria de lote HITL das 027/028 | +| Estratégia de testes | test-design: I/O em store real (move em massa + manifest) e output estrutural (plano de move é contrato do gate humano) | + +## Desenho + +1. **C1 — `kb/fsutil.iter_articles(wiki_dir)`**: generator com a semântica de `graph._e_artigo` (`_`/`.` em qualquer componente, symlink fora). Adotado em: `lint.find_ambiguous_wikilinks` e `lint_wiki`; `heal._sample_paths`; `archive.find_orphans`/`find_by_age`; `compile.update_index`; `stats`. `graph._e_artigo` passa a delegar ao helper (uma semântica, um lugar). +2. **C2 — heal sem unlink**: `_remove_stub` usa `move_to_archive` (dest `ARCHIVE_DIR/`), remove o `_backup` caseiro para stubs (o backup versionado do archive cobre) e chama `mark_archived`. `.heal_backup` legado permanece intocado. +3. **C3 — `kb/regroup.py`**: `plan_regroup(wiki_dir, manifest)` → grupos `{book: [(artigo, destino)]}` + `unresolved: [artigo]`; destinos `wiki/_chapters//` e summary espelho em `wiki/_summaries/_chapters//`. `apply_book(book)` move via `move_to_archive`-like (mesma contenção, mas destino é `_chapters/` — usar move com `atomic`/`shutil.move` + backup versionado próprio), `update_article_path`, `update_index`, refresh. Book-slug: slugify do nome do livro (nomes vindos de metadata têm " -- Anna's Archive" etc. — sanitizar). +4. **C4 — ops**: preflight + tag `pre-regroup-` → `regroup scan` → relatório por livro (contagem, exemplos, unresolved) → aprovação explícita → apply livro a livro (commit por livro) → `kb index build` + smoke + tela. + +## Riscos + +- `update_index`/embeddings ficam consistentes porque C1 os ensina a ignorar `_chapters/` ANTES de qualquer move (ordem C1 → C3 é hard gate). +- Slug de livro com nomes sujos de metadata → sanitização com teste. +- 126 `unresolved` permanecem na wiki — visíveis no relatório, decisão humana posterior. +- Bench/golden perdem objeto após o move — aceito (ADR), registrado. diff --git a/features/029-chapters-regroup/REPORT.md b/features/029-chapters-regroup/REPORT.md new file mode 100644 index 0000000..3d9e7a2 --- /dev/null +++ b/features/029-chapters-regroup/REPORT.md @@ -0,0 +1,41 @@ +# REPORT — 029-chapters-regroup + +**Estado:** `DONE_WITH_CONCERNS` +**Branch:** `feat/029-chapters-regroup` + +## Contexto + +Terceira e última feature do esforço de higiene (etapa 3 do ADR-0018). Antes dela: sete pontos do engine ignoravam a convenção `_*` (o heal podia deletar summary hoje), o heal era a última remoção destrutiva (`unlink`), e os 975 artigos de capítulo seguiam misturados na wiki visível. + +## Mudanças (engine) + +- **C1 — `kb/fsutil.iter_articles`**: semântica única de artigo vivo (`_*`, `.*`, symlink fora), adotada por lint, heal, archive (órfãos e idade), `update_index` e stats. Fecha o bug latente do heal sobre `_summaries` e o cenário em que `find_orphans` arrastaria `_chapters/` inteiro para `archive/`. +- **C2 — heal sem unlink** (V7 mínimo): stub vai para `archive/` com hierarquia e backup versionado; manifest marcado `archived`. A unificação do formato do `.heal_backup` legado fica como dívida registrada. +- **C3 — `kb regroup scan|apply --book`**: plano por proveniência do manifest (nunca cosseno), slug sanitizado por livro, summaries espelhados, `unresolved` jamais movido por inferência, commit por livro via `move_to_archive` com raiz de contenção em `_chapters/`. + +## Lote final (C4, gate explícito do dono) + +Decisão do dono em 2026-08-06: **mover 37 livros (630 artigos); `transcripts-youtube` (207) e `harness` (14) ficam** — não são livros, e com os 124 `unresolved` a plataforma mantém ~345 artigos visíveis até o compile multi-fonte. + +Executado em 2026-08-06: **37 livros, 630 artigos + summaries movidos, 37 commits** (um por livro), zero erros, tag `pre-regroup-2026-08-06`. Vault final: **345 artigos vivos** (207 transcripts + 14 harness + 124 unresolved — soma exata), 630 em `_chapters/`. Smoke: índice em 345/345 artigos (2.666 chunks), `kb search` e `kb stats` respondendo, plataforma com home povoada e artigo vivo abrindo (tela conferida). + +## Incidente do ciclo (registrado, não escondido) + +O RED do C2 expôs a **terceira ocorrência do dia** da mesma classe: teste sem isolamento moveu stubs de fixture para o `archive/` do vault real e regenerou o `_index.md` real. Limpeza aprovada pelo dono; fix estrutural: o piso autouse do conftest agora cobre `ARCHIVE_DIR` e `kb.compile.WIKI_DIR`, como cobre `STATE_DIR` desde 2026-07-29. + +## Validação + +**1.042 passed** (13 testes novos de fsutil/regroup + reescritas de heal), ruff limpo, appeasement exit 0, cobertura 93%. + +## Riscos e dívida + +| Item | Estado | +|---|---| +| 28 menções a paths `wiki/` em `claims.jsonl`/`knowledge.json` dangling pós-move | Aceito pelo ADR; jobs decay/contradiction não instalados | +| Golden set do bench sem objeto (851 slugs referenciados) | Aceito pelo ADR; golden preservado em disco | +| `.heal_backup` legado com formato próprio | Dívida registrada (V7 completo) | +| `transcripts-youtube`/`harness` seguem como artigo de capítulo | Decisão do dono; reavaliar quando o compile multi-fonte definir o que é tema | + +## Próximos passos + +O esforço do ADR-0018 (etapas 1–3) fecha aqui. O próximo é o **compile multi-fonte** (pré-requisitos agora existem: proveniência materializada, `library/` íntegra, `_chapters/` populado) — esforço novo, com wayfinder/spec próprio. diff --git a/features/029-chapters-regroup/SPEC.md b/features/029-chapters-regroup/SPEC.md new file mode 100644 index 0000000..d432f00 --- /dev/null +++ b/features/029-chapters-regroup/SPEC.md @@ -0,0 +1,59 @@ +--- +feature: 029-chapters-regroup +title: Convenção _* honrada em todo o engine e reagrupamento por livro +epic: infra +status: done +created: 2026-08-06 +pr: +--- + +# Convenção `_*` honrada em todo o engine e reagrupamento por livro + +## Objetivo + +Hoje sete pontos do engine ignoram a convenção `_*` (o heal pode deletar summary **hoje**; pós-move, `find_orphans` arrastaria `_chapters/` inteiro para `archive/`) e os 975 artigos de capítulo seguem misturados na wiki; ao final, `_*` é invisível para todo o engine, nenhuma remoção é `unlink`, e os capítulos estão agrupados por livro em `wiki/_chapters/` — com o move final executado apenas sob aprovação explícita do dono, ciente de que a wiki visível esvazia até o compile multi-fonte existir. + +## Requisitos funcionais + +- [x] RF-01 [P1] (C1): Dado qualquer diretório `_*` ou `.*` sob `wiki/`, quando `lint`, `heal`, `archive` (órfãos e idade), `update_index` e `stats` rodarem, então nenhum arquivo dentro dele é listado, amostrado, indexado, contado ou marcado como candidato — mesma semântica já usada por search/embeddings/graph. +- [x] RF-02 [P1] (C2): Dado um stub detectado pelo heal, quando a remoção executar, então o arquivo vai para `archive/` com a semântica de `move_to_archive` (hierarquia + backup versionado), nunca `unlink`; o manifest é atualizado (`mark_archived`). +- [x] RF-03 [P1] (C3): Dado `kb regroup scan`, quando rodar, então agrupa os artigos vivos por livro via proveniência do manifest (com os `unresolved` listados como braço de decisão humana) e imprime o plano de move `wiki/<...> → wiki/_chapters//`, sem alterar nada. +- [x] RF-04 [P1] (C3): Dado `kb regroup apply --book `, quando executar, então move os artigos do livro (e seus summaries, para `wiki/_summaries/_chapters//`) preservando o nome, atualiza `update_article_path` no manifest, regenera `_index.md`, refresca embeddings e commita **por livro**. +- [x] RF-05 [P1] (C4): Dado o lote final, quando o dono aprovar explicitamente ("a wiki esvazia"), então os livros aprovados são movidos um a um, com tag git prévia e smoke de search/stats ao final; sem aprovação, nada se move. +- [x] RF-06 [P2]: Dado um artigo `unresolved` (sem proveniência), quando o regroup rodar, então ele permanece na wiki e aparece no relatório como pendência humana — nunca é movido por inferência. + +## Requisitos técnicos + +- Helper único (`kb/fsutil.iter_articles(wiki_dir)` ou equivalente) com a semântica de `graph._e_artigo` (`_` e `.`), adotado nos sete pontos; sem mudança de assinatura pública dos comandos. +- `heal` mantém `--commit` e o registro de backup; a política de remoção única (move) fecha a dívida V7 no escopo do heal — a unificação do formato de backup do `.heal_backup` legado fica fora. +- Agrupamento pela chave `book` do manifest (materializada na 028); cosseno/`MAPA-DE-TEMAS.md` são insumo do relatório para temas transversais, não critério de move. +- Golden set e links do workspace `teach` quebram no move — perda aceita pelo ADR-0018, registrada no REPORT. +- Checar `claims.jsonl`/`knowledge.json` por referências a paths antes do C4; dangle aceito e registrado se houver. + +## Mudanças de API/CLI + +- Novo sub-app `regroup`: `kb regroup scan` e `kb regroup apply --book [--no-commit|--commit]`. +- `heal`: mudança de comportamento documentada (stub vai para `archive/` em vez de `unlink`). + +## Testes + +- Unit: `iter_articles` exclui `_*`/`.*`/symlink; cada adotante coberto (lint não lista, heal não sorteia, archive não marca, update_index não indexa, stats não conta artigo sob `_*`); heal move stub com backup e marca manifest; regroup agrupa por `book`, ignora `unresolved`, calcula destinos. +- Integration: vault fixture com manifest → `regroup scan` imprime plano; `apply --book` move artigo+summary, atualiza manifest, regenera índice, commita por livro (git fixture); artigo movido some de search/stats/_index. +- Manual (C4, HITL): preflight + tag → relatório de grupos → aprovação explícita do dono → apply livro a livro → `kb index build` + smoke + tela da plataforma. + +## Dados de contexto + +| Chave | Valor | +|-------|-------| +| Estimativa | 8h | +| Bloqueador | não | +| Risk | média-alta — move em massa; mitigada por commit por livro, tags, manifest e gate final humano | + +## Dependências + +- 028 mergeada (PR #70): `manifest.book`, `mark_archived`, `update_article_path`. +- DOMAIN compartilhado: `features/027-noise-retro/DOMAIN.md`. + +## Notas + +Terceira e última feature do esforço (etapa 3 do ADR-0018). Estado do vault na abertura: 975 artigos vivos, 856 entradas de manifest (849 com proveniência, 126 `unresolved` fora), 10 topics canônicos. diff --git a/features/029-chapters-regroup/TASKS.md b/features/029-chapters-regroup/TASKS.md new file mode 100644 index 0000000..0ae5048 --- /dev/null +++ b/features/029-chapters-regroup/TASKS.md @@ -0,0 +1,60 @@ +# TASKS — 029-chapters-regroup + +**Spec:** `features/029-chapters-regroup/SPEC.md` +**Plan:** `features/029-chapters-regroup/PLAN.md` + +```yaml +- id: C1 + priority: P1 + parallel: false + depends_on: [] + ac_ref: RF-01 + tag: AFK + vertical_slice: yes + behavior: "iter_articles único honra _*/.*/symlink e é adotado por lint, heal, archive, update_index e stats; heal deixa de poder tocar _summaries hoje." + verify: "python -m pytest tests/unit/test_fsutil_articles.py tests/unit/test_lint.py tests/unit/test_heal.py tests/unit/test_archive.py tests/unit/test_stats.py -q" + state: passing +``` + +```yaml +- id: C2 + priority: P1 + parallel: false + depends_on: [C1] + ac_ref: RF-02 + tag: AFK + vertical_slice: yes + behavior: "Stub removido pelo heal vai para archive/ com backup versionado e manifest marcado archived — nunca unlink (V7 mínimo)." + verify: "python -m pytest tests/unit/test_heal.py -q" + state: passing +``` + +```yaml +- id: C3 + priority: P1 + parallel: false + depends_on: [C1] + ac_ref: RF-03, RF-04, RF-06 + tag: AFK + vertical_slice: yes + behavior: "kb regroup scan agrupa por manifest.book com unresolved como braço humano; apply --book move artigo+summary para _chapters/, atualiza manifest e índices, commita por livro." + verify: "python -m pytest tests/unit/test_regroup.py tests/integration/test_regroup_cli.py -q" + state: passing +``` + +```yaml +- id: C4 + priority: P1 + parallel: false + depends_on: [C2, C3] + ac_ref: RF-05 + tag: HITL + vertical_slice: yes + behavior: "Gate final do dono: relatório de grupos → aprovação explícita (a wiki esvazia) → apply livro a livro com tag prévia → index build + smoke + tela." + verify: "relatório aprovado + contagens + tela" + state: passing +``` + +## Definition of Done + +`state: passing` = verify verde + (HITL) evidência de aprovação e tela. Feature fecha com C1–C4 passing e `quality-gate` PASS. diff --git a/kb/archive.py b/kb/archive.py index db4a416..f1ba953 100644 --- a/kb/archive.py +++ b/kb/archive.py @@ -43,8 +43,10 @@ def find_orphans(wiki_dir: Path) -> list[Path]: if not wiki_dir.exists(): return [] - backlink_sources = [p for p in wiki_dir.rglob("*.md") if not p.is_symlink()] - all_md = [p for p in backlink_sources if p.name != "_index.md"] + from kb.fsutil import iter_articles + + backlink_sources = list(iter_articles(wiki_dir)) + all_md = backlink_sources def identidade(path: Path) -> str: return path.relative_to(wiki_dir).with_suffix("").as_posix() @@ -69,11 +71,11 @@ def find_by_age(wiki_dir: Path, days: int) -> list[Path]: """Retorna artigos com mtime anterior ao cutoff de dias.""" if not wiki_dir.exists(): return [] + from kb.fsutil import iter_articles + cutoff = time.time() - (days * 86400) result = [] - for p in wiki_dir.rglob("*.md"): - if p.is_symlink(): - continue + for p in iter_articles(wiki_dir): try: if p.stat().st_mtime < cutoff: result.append(p) diff --git a/kb/cli.py b/kb/cli.py index c100ddf..9ddfaae 100644 --- a/kb/cli.py +++ b/kb/cli.py @@ -30,6 +30,7 @@ "dedup scan | dedup apply [--no-commit|--commit]\n\n" "topics normalize [--apply] [--no-commit|--commit] | topics assign [--apply] [--limit INT] [--no-commit|--commit]\n\n" "archive [--stale] [--older-than INT] [--dry-run]\n\n" + "regroup scan | regroup apply --book [--no-commit|--commit]\n\n" "compile (alvo) [--workers/-j INT] [--allow-sensitive] [--no-commit|--commit]" " [--no-update-index]\n\n" "qa [--file-back/-f] [--to-wiki] [--depth INT] [--no-traverse]" @@ -53,6 +54,7 @@ manifest_app = typer.Typer(help="Proveniência artigo→fonte no manifest (backfill)") dedup_app = typer.Typer(help="Duplicatas de ingestão (scan/apply)") topics_app = typer.Typer(help="Topics do frontmatter (normalize/assign)") +regroup_app = typer.Typer(help="Reagrupamento de capítulos por livro (scan/apply)") app.add_typer(jobs_app, name="jobs") app.add_typer(discovery_app, name="discovery") app.add_typer(handoff_app, name="handoff") @@ -61,6 +63,67 @@ app.add_typer(manifest_app, name="manifest") app.add_typer(dedup_app, name="dedup") app.add_typer(topics_app, name="topics") +app.add_typer(regroup_app, name="regroup") + + +def _regroup_plan(): + from kb.config import WIKI_DIR + from kb.regroup import plan_regroup + from kb.state import load_manifest + + return plan_regroup(WIKI_DIR, load_manifest()) + + +@regroup_app.command("scan") +def regroup_scan(): + """Plano de reagrupamento por livro (dry-run; não altera nada).""" + from kb.config import WIKI_DIR + + plan = _regroup_plan() + for slug in sorted(plan.groups): + artigos = plan.groups[slug] + typer.echo(f"[{slug}] {len(artigos)} artigo(s) — {plan.book_names[slug][:60]}") + for origem, destino in artigos: + typer.echo(f" {origem.relative_to(WIKI_DIR)} → {destino.relative_to(WIKI_DIR)}") + typer.echo( + f"{sum(len(v) for v in plan.groups.values())} artigo(s) em {len(plan.groups)} livro(s); " + f"{len(plan.unresolved)} unresolved (permanecem na wiki)" + ) + for artigo in plan.unresolved: + typer.echo(f"unresolved {artigo.relative_to(WIKI_DIR)}") + + +@regroup_app.command("apply") +def regroup_apply( + book: str = typer.Option(..., "--book", help="Slug do livro a mover (um por commit)"), + no_commit: bool = typer.Option(True, "--no-commit/--commit"), +): + """Move os artigos de UM livro para _chapters/ (commit por livro).""" + from kb.config import MANIFEST_PATH, WIKI_DIR + from kb.regroup import apply_book + + plan = _regroup_plan() + if book not in plan.groups: + typer.echo(f"livro desconhecido no plano: {book}", err=True) + raise typer.Exit(2) + log = apply_book(WIKI_DIR, plan, book) + moved = [entry for entry in log if entry["action"] == "moved"] + for entry in log: + if entry["action"] == "moved": + typer.echo(f"movido: {entry['dest']}") + else: + typer.echo(f"erro: {entry['source']} — {entry.get('detail', '')}", err=True) + typer.echo(f"{len(moved)} arquivo(s) movidos para _chapters/{book}/") + if moved and not no_commit: + from kb.git import commit + + paths = [Path(entry["source"]) for entry in moved] + paths += [Path(entry["dest"]) for entry in moved] + paths += [Path(entry["backup"]) for entry in moved if "backup" in entry] + paths += [WIKI_DIR / "_index.md", MANIFEST_PATH] + commit(f"chore(corpus): reagrupa {book} em _chapters/", paths) + if any(entry["action"] == "error" for entry in log): + raise typer.Exit(1) @topics_app.command("normalize") diff --git a/kb/compile.py b/kb/compile.py index bf41eb9..bf475a6 100644 --- a/kb/compile.py +++ b/kb/compile.py @@ -588,10 +588,10 @@ def update_index(no_commit: bool = True) -> None: """Regenera _index.md listando todos os artigos da wiki.""" WIKI_DIR.mkdir(parents=True, exist_ok=True) + from kb.fsutil import iter_articles + articles: list[str] = [] - for md in sorted(WIKI_DIR.rglob("*.md")): - if md.name == "_index.md" or "_summaries" in md.parts: - continue + for md in iter_articles(WIKI_DIR): rel = md.relative_to(WIKI_DIR) articles.append(f"- [[{md.stem}]] (`{rel}`)") diff --git a/kb/fsutil.py b/kb/fsutil.py index 6bd74f3..d1a60a4 100644 --- a/kb/fsutil.py +++ b/kb/fsutil.py @@ -5,6 +5,24 @@ from pathlib import Path +def iter_articles(wiki_dir): + """Artigos vivos da wiki: exclui `_*`, `.*` e symlinks em qualquer nível. + + Mesma semântica de `graph._e_artigo`, centralizada — sete módulos aplicavam + (ou esqueciam) a convenção cada um do seu jeito. + """ + wiki_dir = Path(wiki_dir) + if not wiki_dir.is_dir(): + return + for path in sorted(wiki_dir.rglob("*.md")): + if path.is_symlink(): + continue + rel = path.relative_to(wiki_dir) + if any(part.startswith(("_", ".")) for part in rel.parts): + continue + yield path + + def atomic_write_text(path, text): """Grava texto em arquivo de forma atômica.""" path = Path(path) diff --git a/kb/graph.py b/kb/graph.py index 085861d..aa9f8e5 100644 --- a/kb/graph.py +++ b/kb/graph.py @@ -21,9 +21,9 @@ def _slugify_link(link: str) -> str: def _e_artigo(path: Path, wiki_dir: Path) -> bool: - """Derivados (`_summaries/`, `_sources/`, `_index.md`) não são artigos. + """Derivados (`_summaries/`, `_sources/`, `_chapters/`) não são artigos. - Mesma convenção que `kb.search` já aplica ao indexar. + Semântica centralizada em `kb.fsutil.iter_articles`; aqui só o predicado. """ return not any(part.startswith(("_", ".")) for part in path.relative_to(wiki_dir).parts) diff --git a/kb/heal.py b/kb/heal.py index 5acb20b..57b2307 100644 --- a/kb/heal.py +++ b/kb/heal.py @@ -3,6 +3,7 @@ import random import re import shutil +import sys from datetime import datetime from pathlib import Path @@ -81,12 +82,9 @@ def heal( index_refresh_enabled: bool = True, ) -> list[dict]: """Processa N arquivos aleatórios da wiki. Retorna log de ações.""" - backup_dir = WIKI_DIR / ".heal_backup" - candidates = [ - p - for p in WIKI_DIR.rglob("*.md") - if p.name != "_index.md" and backup_dir not in p.parents - ] + from kb.fsutil import iter_articles + + candidates = list(iter_articles(WIKI_DIR)) if not candidates: return [] @@ -98,9 +96,31 @@ def heal( text = path.read_text(encoding="utf-8", errors="replace") if _is_stub(text): - _backup(path) - path.unlink() - log.append({"file": path.name, "action": "deleted_stub"}) + # V7 mínimo (029 C2): stub vai para archive/ com backup versionado, + # nunca unlink — e o manifest deixa de apontar para o path movido. + from kb.archive import move_to_archive + from kb.config import ARCHIVE_DIR + from kb.state import mark_archived + + dest = ARCHIVE_DIR / path.relative_to(WIKI_DIR) + resultado = move_to_archive([{"source": path, "dest": dest}], ARCHIVE_DIR) + if resultado and resultado[0]["action"] == "moved": + try: + mark_archived(path) + except Exception as exc: # arquivo já se moveu; avisar > abortar + print(f"aviso: manifest não atualizado para {path.name} — {exc}", file=sys.stderr) + log.append({"file": path.name, "action": "archived_stub"}) + # o --commit precisa versionar o move e o manifest, não só heals de texto + changed.append(path) + changed.append(dest) + if "backup" in resultado[0]: + changed.append(Path(resultado[0]["backup"])) + from kb.config import MANIFEST_PATH + + if MANIFEST_PATH.exists(): + changed.append(MANIFEST_PATH) + else: + log.append({"file": path.name, "action": "archive_error"}) continue assert_safe_for_provider( diff --git a/kb/lint.py b/kb/lint.py index 0fc2c86..8fa765a 100644 --- a/kb/lint.py +++ b/kb/lint.py @@ -26,13 +26,12 @@ def find_ambiguous_wikilinks(wiki_dir: Path) -> list[str]: duplicados. Qualificar por topic (`[[cybersecurity/honeycomb]]`) resolve, e é isso que este check pede ao autor. """ + from kb.fsutil import iter_articles from kb.graph import build_link_index, resolve_wikilink_all index = build_link_index(wiki_dir) achados: list[str] = [] - for md in sorted(wiki_dir.rglob("*.md"), key=lambda p: p.as_posix()): - if md.is_symlink(): - continue + for md in sorted(iter_articles(wiki_dir), key=lambda p: p.as_posix()): text = md.read_text(encoding="utf-8", errors="replace") vistos: set[str] = set() for link in re.findall(r"\[\[([^\]]+)\]\]", text): @@ -50,7 +49,9 @@ def find_ambiguous_wikilinks(wiki_dir: Path) -> list[str]: def lint_wiki(allow_sensitive: bool = False) -> str: - articles = list(WIKI_DIR.rglob("*.md")) + from kb.fsutil import iter_articles + + articles = list(iter_articles(WIKI_DIR)) if not articles: return "Wiki vazia. Use `kb compile` para adicionar artigos." diff --git a/kb/regroup.py b/kb/regroup.py new file mode 100644 index 0000000..e50cbdc --- /dev/null +++ b/kb/regroup.py @@ -0,0 +1,119 @@ +"""Reagrupamento dos artigos de capítulo por livro (feature 029, C3). + +O critério é a proveniência do manifest (ADR-0018: proveniência, não cosseno). +Artigo sem entrada viva com `book` é `unresolved` — permanece na wiki como +pendência humana e NUNCA é movido por inferência. +""" + +import re +from dataclasses import dataclass, field +from pathlib import Path + + +def _slug_book(book: str) -> str: + from kb.book_import_core import slugify + + slug = slugify(book) + slug = re.sub(r"-{2,}", "-", slug).strip("-") + return slug or "livro-sem-nome" + + +@dataclass +class RegroupPlan: + groups: dict[str, list[tuple[Path, Path]]] = field(default_factory=dict) + summary_moves: dict[str, list[tuple[Path, Path]]] = field(default_factory=dict) + unresolved: list[Path] = field(default_factory=list) + book_names: dict[str, str] = field(default_factory=dict) + + +def plan_regroup(wiki_dir: Path, manifest_entries: list[dict]) -> RegroupPlan: + """Plano de move `wiki/<...> → wiki/_chapters//`, sem executar nada.""" + from kb.fsutil import iter_articles + + wiki_dir = Path(wiki_dir) + plan = RegroupPlan() + por_artigo: dict[Path, str] = {} + conflitantes: set[Path] = set() + for entry in manifest_entries: + if entry.get("status") == "archived" or not entry.get("article"): + continue + book = entry.get("book") + if not book: + continue + artigo = wiki_dir / entry["article"] + if not artigo.exists(): + continue + if artigo in por_artigo and por_artigo[artigo] != book: + # Duas entradas, dois livros: inferir um é chute — braço humano. + conflitantes.add(artigo) + continue + por_artigo.setdefault(artigo, book) + for artigo in conflitantes: + por_artigo.pop(artigo, None) + + for artigo in iter_articles(wiki_dir): + book = por_artigo.get(artigo) + if book is None: + plan.unresolved.append(artigo) + continue + slug = _slug_book(book) + plan.book_names.setdefault(slug, book) + destino = wiki_dir / "_chapters" / slug / artigo.name + ocupados = {d for _, d in plan.groups.get(slug, [])} + if destino in ocupados: + # Basename colidiu dentro do livro: desambiguar pelo diretório de + # origem — mover por cima criaria backup silencioso do primeiro. + prefixo = "-".join(artigo.relative_to(wiki_dir).parts[:-1]) or "raiz" + destino = wiki_dir / "_chapters" / slug / f"{prefixo}-{artigo.name}" + plan.groups.setdefault(slug, []).append((artigo, destino)) + summary = wiki_dir / "_summaries" / artigo.relative_to(wiki_dir) + if summary.exists(): + plan.summary_moves.setdefault(slug, []).append( + (summary, wiki_dir / "_summaries" / "_chapters" / slug / artigo.name) + ) + return plan + + +def apply_book(wiki_dir: Path, plan: RegroupPlan, book_slug: str) -> list[dict]: + """Move os artigos (e summaries) de um livro, atualizando o manifest. + + Reusa `move_to_archive` com raiz de contenção em `_chapters/` — mesma + semântica de backup versionado, outra raiz. + """ + from kb.archive import move_to_archive + from kb.compile import update_index + from kb.embeddings import refresh_embeddings_index + from kb.state import update_article_path + + wiki_dir = Path(wiki_dir) + moves = [ + {"source": origem, "dest": destino} + for origem, destino in plan.groups.get(book_slug, []) + ] + # Preflight do lote inteiro antes de mover qualquer coisa: origem ausente + # ou destino ocupado abortam o livro por completo — falha parcial é o modo + # de erro caro (review PR #71). + problemas = [ + m for m in moves + if not m["source"].exists() or m["dest"].exists() + ] + if problemas: + return [ + {"source": str(m["source"]), "dest": str(m["dest"]), "action": "error", + "detail": "preflight: origem ausente ou destino ocupado"} + for m in problemas + ] + log = move_to_archive(moves, wiki_dir / "_chapters") + for entry in log: + if entry["action"] == "moved": + update_article_path(Path(entry["source"]), Path(entry["dest"])) + summary_moves = [ + {"source": origem, "dest": destino} + for origem, destino in plan.summary_moves.get(book_slug, []) + ] + if summary_moves: + log += move_to_archive(summary_moves, wiki_dir / "_summaries" / "_chapters") + if any(entry["action"] == "moved" for entry in log): + update_index(no_commit=True) + refresh_embeddings_index() + return log diff --git a/kb/stats.py b/kb/stats.py index 658d2fa..d24da46 100644 --- a/kb/stats.py +++ b/kb/stats.py @@ -3,9 +3,7 @@ def _is_ignored_article(path, wiki_dir): rel = path.relative_to(wiki_dir) - if path.name == "_index.md": - return True - return "_summaries" in rel.parts or ".heal_backup" in rel.parts + return any(part.startswith(("_", ".")) for part in rel.parts) def _topic_for(path, wiki_dir): @@ -23,11 +21,11 @@ def get_article_summary(): if not wiki_dir.exists(): return {"total": 0, "by_topic": {}} + from kb.fsutil import iter_articles + by_topic = {} total = 0 - for path in wiki_dir.rglob("*.md"): - if _is_ignored_article(path, wiki_dir): - continue + for path in iter_articles(wiki_dir): topic = _topic_for(path, wiki_dir) by_topic[topic] = by_topic.get(topic, 0) + 1 total += 1 diff --git a/tests/conftest.py b/tests/conftest.py index 4b5e9f0..02a761f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,6 +41,26 @@ def _state_dir_never_points_at_real_vault(tmp_path_factory, monkeypatch): """ piso = tmp_path_factory.mktemp("kb_state_piso") monkeypatch.setattr("kb.config.STATE_DIR", piso, raising=False) + # Mesma classe de incidente, terceiro alvo (2026-08-06): o heal passou a + # MOVER para ARCHIVE_DIR, e um teste sem isolamento moveu stubs de fixture + # para o archive do vault real. Piso estrutural, não só fixture. + monkeypatch.setattr( + "kb.config.ARCHIVE_DIR", tmp_path_factory.mktemp("archive_piso"), raising=False + ) + # O piso precisa cobrir TODOS os module-globals de WIKI_DIR — heal, lint, + # search e router fazem `from kb.config import WIKI_DIR` no import, então + # patchear só kb.config/kb.compile deixava o heal resolvendo a wiki REAL + # (review PR #71, cubic). + wiki_piso = tmp_path_factory.mktemp("wiki_piso") + for alvo in ( + "kb.config.WIKI_DIR", + "kb.compile.WIKI_DIR", + "kb.heal.WIKI_DIR", + "kb.lint.WIKI_DIR", + "kb.search.WIKI_DIR", + "kb.router.WIKI_DIR", + ): + monkeypatch.setattr(alvo, wiki_piso, raising=False) @pytest.fixture diff --git a/tests/integration/test_heal_workflow.py b/tests/integration/test_heal_workflow.py index b709b62..d4ec6a2 100644 --- a/tests/integration/test_heal_workflow.py +++ b/tests/integration/test_heal_workflow.py @@ -32,8 +32,8 @@ def test_should_heal_and_remove_stubs_from_wiki(self, tmp_raw_wiki): result = heal(n=1) - # RED: falha se não remove stub - assert any(r["action"] == "deleted_stub" for r in result) + # 029 C2: stub é ARQUIVADO (move), nunca deletado + assert any(r["action"] == "archived_stub" for r in result) def test_should_fix_broken_wikilinks_in_articles(self, tmp_wiki): """ diff --git a/tests/integration/test_regroup_cli.py b/tests/integration/test_regroup_cli.py new file mode 100644 index 0000000..d267620 --- /dev/null +++ b/tests/integration/test_regroup_cli.py @@ -0,0 +1,91 @@ +"""029 C3 — contrato do `kb regroup scan|apply` (RF-03, RF-04, RF-06).""" + +import json +import subprocess + +from typer.testing import CliRunner + +from kb.cli import app + +runner = CliRunner() + + +def _seed(tmp_path, monkeypatch): + data = tmp_path + wiki = data / "wiki" + state = data / "kb_state" + (wiki / "algorithms").mkdir(parents=True) + state.mkdir() + (wiki / "algorithms" / "mergesort.md").write_text( + "---\ntitle: Merge\ntopic: algorithms\n---\ncorpo", encoding="utf-8" + ) + (wiki / "algorithms" / "solto.md").write_text( + "---\ntitle: Solto\ntopic: algorithms\n---\ncorpo", encoding="utf-8" + ) + summary = wiki / "_summaries" / "algorithms" / "mergesort.md" + summary.parent.mkdir(parents=True) + summary.write_text("resumo", encoding="utf-8") + (state / "manifest.json").write_text( + json.dumps( + [{"source": "library/x/l/01.md", "article": "algorithms/mergesort.md", + "status": "compiled", "book": "Livro A", "provenance": "backfill-basename"}] + ), + encoding="utf-8", + ) + monkeypatch.setattr("kb.config.DATA_DIR", data) + monkeypatch.setattr("kb.config.WIKI_DIR", wiki) + monkeypatch.setattr("kb.config.MANIFEST_PATH", state / "manifest.json") + monkeypatch.setattr("kb.state.MANIFEST_PATH", state / "manifest.json") + monkeypatch.setattr("kb.state.STATE_DIR", state) + monkeypatch.setattr("kb.compile.WIKI_DIR", wiki) + return data, wiki, state + + +def test_should_print_plan_without_moving_on_scan(tmp_path, monkeypatch): + data, wiki, _ = _seed(tmp_path, monkeypatch) + + result = runner.invoke(app, ["regroup", "scan"]) + + assert result.exit_code == 0 + assert "livro-a" in result.output + assert "algorithms/mergesort.md → _chapters/livro-a/mergesort.md" in result.output + assert "unresolved\talgorithms/solto.md" in result.output + assert (wiki / "algorithms" / "mergesort.md").exists() + + +def test_should_move_book_update_manifest_and_commit(tmp_path, monkeypatch): + data, wiki, state = _seed(tmp_path, monkeypatch) + for cmd in ( + ["git", "init", "-q"], + ["git", "config", "user.email", "kb@test"], + ["git", "config", "user.name", "kb"], + ["git", "add", "-A"], + ["git", "commit", "-qm", "seed"], + ): + subprocess.run(cmd, cwd=tmp_path, check=True) + + result = runner.invoke(app, ["regroup", "apply", "--book", "livro-a", "--commit"]) + + assert result.exit_code == 0 + assert not (wiki / "algorithms" / "mergesort.md").exists() + assert (wiki / "_chapters" / "livro-a" / "mergesort.md").is_file() + assert (wiki / "_summaries" / "_chapters" / "livro-a" / "mergesort.md").is_file() + assert (wiki / "algorithms" / "solto.md").exists() # unresolved fica + [entry] = json.loads((state / "manifest.json").read_text(encoding="utf-8")) + assert entry["article"] == "_chapters/livro-a/mergesort.md" + index = (wiki / "_index.md").read_text(encoding="utf-8") + assert "_chapters" not in index + assert "solto" in index + status = subprocess.run( + ["git", "status", "--porcelain"], cwd=tmp_path, check=True, capture_output=True, text=True + ).stdout + sujos = [linha for linha in status.splitlines() if "_index.md" not in linha] + assert sujos == [], f"move deve estar commitado; sobrou: {sujos}" + + +def test_should_reject_unknown_book(tmp_path, monkeypatch): + _seed(tmp_path, monkeypatch) + + result = runner.invoke(app, ["regroup", "apply", "--book", "nao-existe"]) + + assert result.exit_code == 2 diff --git a/tests/unit/test_fsutil_articles.py b/tests/unit/test_fsutil_articles.py new file mode 100644 index 0000000..fca171d --- /dev/null +++ b/tests/unit/test_fsutil_articles.py @@ -0,0 +1,127 @@ +"""029 C1 — helper único da convenção `_*` e sua adoção (RF-01). + +Sete pontos do engine ignoravam `_*`: o heal podia deletar summary HOJE, e um +`kb archive` pós-reagrupamento arrastaria `_chapters/` inteiro para archive/. +""" + +from kb.fsutil import iter_articles + + +def _povoa(wiki): + (wiki / "ai").mkdir(parents=True) + (wiki / "ai" / "vivo.md").write_text("---\ntitle: V\ntopic: ai\n---\ncorpo", encoding="utf-8") + (wiki / "raiz.md").write_text("---\ntitle: R\ntopic: general\n---\ncorpo", encoding="utf-8") + (wiki / "_chapters" / "livro").mkdir(parents=True) + (wiki / "_chapters" / "livro" / "cap.md").write_text("---\ntitle: C\n---\ncapítulo", encoding="utf-8") + (wiki / "_summaries").mkdir() + (wiki / "_summaries" / "vivo.md").write_text("resumo", encoding="utf-8") + (wiki / "_index.md").write_text("---\ntitle: Index\n---\n", encoding="utf-8") + (wiki / ".heal_backup").mkdir() + (wiki / ".heal_backup" / "velho.md").write_text("backup", encoding="utf-8") + return {wiki / "ai" / "vivo.md", wiki / "raiz.md"} + + +def test_should_yield_only_live_articles(tmp_path): + wiki = tmp_path / "wiki" + vivos = _povoa(wiki) + + assert set(iter_articles(wiki)) == vivos + + +def test_should_skip_symlinks(tmp_path): + wiki = tmp_path / "wiki" + vivos = _povoa(wiki) + fora = tmp_path / "fora.md" + fora.write_text("---\ntitle: F\n---\nfora", encoding="utf-8") + (wiki / "link.md").symlink_to(fora) + + assert set(iter_articles(wiki)) == vivos + + +def test_should_return_empty_for_missing_wiki(tmp_path): + assert list(iter_articles(tmp_path / "nao-existe")) == [] + + +class TestAdocao: + """Cada ex-furo passa a ignorar `_*` — provado módulo a módulo.""" + + def test_heal_should_never_sample_underscore_files(self, tmp_path, monkeypatch): + import kb.heal as heal + + wiki = tmp_path / "wiki" + _povoa(wiki) + monkeypatch.setattr(heal, "WIKI_DIR", wiki) + sorteados = [] + monkeypatch.setattr(heal.random, "sample", lambda pop, k: sorteados.extend(pop) or []) + + heal.heal(50) + + assert sorteados, "amostra deveria ter candidatos" + assert all("_" not in str(p.relative_to(wiki)).split("/")[0][:1] for p in sorteados) + assert wiki / "_summaries" / "vivo.md" not in sorteados + assert wiki / "_chapters" / "livro" / "cap.md" not in sorteados + + def test_archive_should_not_flag_chapters_as_orphans(self, tmp_path): + from kb.archive import find_orphans + + wiki = tmp_path / "wiki" + _povoa(wiki) + + orfaos = find_orphans(wiki) + + assert wiki / "_chapters" / "livro" / "cap.md" not in orfaos + assert wiki / "_summaries" / "vivo.md" not in orfaos + + def test_archive_by_age_should_ignore_underscore_files(self, tmp_path): + import os + + from kb.archive import find_by_age + + wiki = tmp_path / "wiki" + _povoa(wiki) + antigo = (1, 1) + for p in wiki.rglob("*.md"): + os.utime(p, antigo) + + velhos = find_by_age(wiki, days=1) + + assert wiki / "_chapters" / "livro" / "cap.md" not in velhos + assert wiki / "ai" / "vivo.md" in velhos + + def test_update_index_should_not_list_chapters(self, tmp_path, monkeypatch): + import kb.compile as compile_mod + + wiki = tmp_path / "wiki" + _povoa(wiki) + monkeypatch.setattr(compile_mod, "WIKI_DIR", wiki) + + compile_mod.update_index(no_commit=True) + + index = (wiki / "_index.md").read_text(encoding="utf-8") + assert "vivo" in index + assert "_chapters" not in index + + def test_stats_should_not_count_chapters(self, tmp_path): + from kb.stats import _is_ignored_article + + wiki = tmp_path / "wiki" + _povoa(wiki) + + assert _is_ignored_article(wiki / "_chapters" / "livro" / "cap.md", wiki) is True + assert _is_ignored_article(wiki / "ai" / "vivo.md", wiki) is False + + def test_lint_should_not_use_chapters_as_wikilink_source(self, tmp_path): + from kb.lint import find_ambiguous_wikilinks + + wiki = tmp_path / "wiki" + _povoa(wiki) + # dois alvos com mesmo stem tornam o link ambíguo; a ORIGEM está em _chapters + (wiki / "ai" / "alvo.md").write_text("---\ntitle: A\n---\nx", encoding="utf-8") + (wiki / "alvo.md").write_text("---\ntitle: A2\n---\nx", encoding="utf-8") + (wiki / "_chapters" / "livro" / "citador.md").write_text( + "---\ntitle: Cit\n---\nVeja [[alvo]].", encoding="utf-8" + ) + + achados = find_ambiguous_wikilinks(wiki) + + assert achados == [] diff --git a/tests/unit/test_heal.py b/tests/unit/test_heal.py index 65ca3f3..aede594 100644 --- a/tests/unit/test_heal.py +++ b/tests/unit/test_heal.py @@ -287,13 +287,13 @@ def test_should_backup_before_valid_heal_write(self, tmp_raw_wiki): assert len(backups) == 1 assert backups[0].read_text() == original - def test_should_backup_before_stub_delete(self, tmp_raw_wiki): - """ - Dado stub na wiki, - Quando heal deleta o stub, - Então deve criar backup em .heal_backup + def test_should_archive_stub_instead_of_deleting(self, tmp_raw_wiki, monkeypatch): + """029 C2 (V7 mínimo): stub vai para archive/ com hierarquia — nunca + unlink. O conteúdo sobrevive no vault, recuperável por git e por path. """ raw, wiki = tmp_raw_wiki + archive_dir = wiki.parent / "archive" + monkeypatch.setattr("kb.config.ARCHIVE_DIR", archive_dir) stub_path = wiki / "ai" / "stub.md" original = """--- @@ -309,11 +309,76 @@ def test_should_backup_before_stub_delete(self, tmp_raw_wiki): result = heal(n=1) - assert result == [{"file": "stub.md", "action": "deleted_stub"}] + assert result == [{"file": "stub.md", "action": "archived_stub"}] assert not stub_path.exists() - backups = list((wiki / ".heal_backup").glob("*stub.*.md")) - assert len(backups) == 1 - assert backups[0].read_text() == original + destino = archive_dir / "ai" / "stub.md" + assert destino.is_file() + assert destino.read_text() == original + + def test_should_commit_archived_stub_and_manifest_when_commit_enabled( + self, tmp_raw_wiki, monkeypatch + ): + """Review PR #71 (3 bots): amostra só com stubs não populava `changed` + e o --commit não versionava nem o move nem o manifest.""" + import subprocess + + import kb.config + from kb.state import record_backfill + + raw, wiki = tmp_raw_wiki + vault = wiki.parent + archive_dir = vault / "archive" + state = vault / "kb_state" + monkeypatch.setattr("kb.config.ARCHIVE_DIR", archive_dir) + monkeypatch.setattr(kb.config, "DATA_DIR", vault) + monkeypatch.setattr("kb.config.MANIFEST_PATH", state / "manifest.json") + monkeypatch.setattr("kb.state.MANIFEST_PATH", state / "manifest.json") + monkeypatch.setattr("kb.state.STATE_DIR", state) + fonte = raw / "05-stub.md" + fonte.write_text("capítulo", encoding="utf-8") + stub_path = wiki / "ai" / "stub.md" + stub_path.write_text("---\ntitle: Stub\n---\n\n# Stub\n") + record_backfill(source_path=fonte, article_path=stub_path, book=None, provenance="backfill-basename") + for cmd in ( + ["git", "init", "-q"], + ["git", "config", "user.email", "kb@test"], + ["git", "config", "user.name", "kb"], + ["git", "add", "-A"], + ["git", "commit", "-qm", "seed"], + ): + subprocess.run(cmd, cwd=vault, check=True) + + with patch("random.sample") as mock_sample: + mock_sample.return_value = [stub_path] + heal(n=1, no_commit=False) + + status = subprocess.run( + ["git", "status", "--porcelain"], cwd=vault, check=True, capture_output=True, text=True + ).stdout + sujos = [linha for linha in status.splitlines() if "_index" not in linha] + assert sujos == [], f"move do stub e manifest devem estar commitados; sobrou: {sujos}" + + def test_should_mark_manifest_when_archiving_stub(self, tmp_raw_wiki, monkeypatch): + """A entrada do stub no manifest vira archived — o guard de recompile + não pode apontar para path que o heal moveu.""" + import kb.config + from kb.state import find_compiled_entry, record_backfill + + raw, wiki = tmp_raw_wiki + archive_dir = wiki.parent / "archive" + monkeypatch.setattr("kb.config.ARCHIVE_DIR", archive_dir) + monkeypatch.setattr(kb.config, "DATA_DIR", wiki.parent) + fonte = raw / "05-stub.md" + fonte.write_text("capítulo", encoding="utf-8") + stub_path = wiki / "ai" / "stub.md" + stub_path.write_text("---\ntitle: Stub\n---\n\n# Stub\n") + record_backfill(source_path=fonte, article_path=stub_path, book=None, provenance="backfill-basename") + + with patch("random.sample") as mock_sample: + mock_sample.return_value = [stub_path] + heal(n=1) + + assert find_compiled_entry(fonte) is None def test_should_exclude_heal_backup_files_from_candidates(self, tmp_raw_wiki): """ @@ -378,14 +443,19 @@ def test_should_create_distinct_backups_when_same_stem_in_different_topics( stub_a.write_text("---\ntitle: A\n---\n\n# A\n") stub_b.write_text("---\ntitle: B\n---\n\n# B\n") - with patch("random.sample") as mock_sample: - mock_sample.return_value = [stub_a, stub_b] + import kb.config + + archive_dir = wiki.parent / "archive" + with patch.object(kb.config, "ARCHIVE_DIR", archive_dir): + with patch("random.sample") as mock_sample: + mock_sample.return_value = [stub_a, stub_b] - result = heal(n=2) + result = heal(n=2) - assert [r["action"] for r in result] == ["deleted_stub", "deleted_stub"] - backups = list((wiki / ".heal_backup").glob("*x.*.md")) - assert len(backups) == 2 + assert [r["action"] for r in result] == ["archived_stub", "archived_stub"] + # hierarquia preservada: mesmo stem em topics distintos não colide + assert (archive_dir / "a" / "x.md").is_file() + assert (archive_dir / "b" / "x.md").is_file() def test_should_skip_output_when_frontmatter_key_is_dropped(self, tmp_raw_wiki): """ diff --git a/tests/unit/test_regroup.py b/tests/unit/test_regroup.py new file mode 100644 index 0000000..60f8be8 --- /dev/null +++ b/tests/unit/test_regroup.py @@ -0,0 +1,133 @@ +"""029 C3 — plano de reagrupamento por livro (RF-03, RF-06). + +Seam: kb.regroup (plan_regroup, RegroupPlan). O critério é a proveniência do +manifest; `unresolved` nunca é movido por inferência. +""" + +from pathlib import Path + +from kb.regroup import plan_regroup + + +def _wiki(tmp_path): + wiki = tmp_path / "wiki" + (wiki / "algorithms").mkdir(parents=True) + return wiki + + +def _artigo(wiki, rel): + p = wiki / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("---\ntitle: T\ntopic: x\n---\ncorpo", encoding="utf-8") + return p + + +def test_should_group_articles_by_book_with_slugged_destinations(tmp_path): + wiki = _wiki(tmp_path) + a = _artigo(wiki, "algorithms/mergesort.md") + b = _artigo(wiki, "algorithms/quicksort.md") + entries = [ + {"source": "library/x/l/01.md", "article": "algorithms/mergesort.md", "status": "compiled", + "book": "Introduction to Algorithms -- Cormen -- Anna's Archive"}, + {"source": "library/x/l/02.md", "article": "algorithms/quicksort.md", "status": "compiled", + "book": "Introduction to Algorithms -- Cormen -- Anna's Archive"}, + ] + + plan = plan_regroup(wiki, entries) + + [(book_slug, moves)] = plan.groups.items() + assert book_slug == "introduction-to-algorithms-cormen-anna-s-archive" + assert (a, wiki / "_chapters" / book_slug / "mergesort.md") in moves + assert (b, wiki / "_chapters" / book_slug / "quicksort.md") in moves + + +def test_should_list_articles_without_provenance_as_unresolved(tmp_path): + wiki = _wiki(tmp_path) + sem = _artigo(wiki, "algorithms/sem-proveniencia.md") + com = _artigo(wiki, "algorithms/com.md") + entries = [ + {"source": "library/x/l/01.md", "article": "algorithms/com.md", "status": "compiled", "book": "Livro"}, + ] + + plan = plan_regroup(wiki, entries) + + assert sem in plan.unresolved + assert com not in plan.unresolved + + +def test_should_ignore_archived_entries_and_missing_files(tmp_path): + wiki = _wiki(tmp_path) + vivo = _artigo(wiki, "algorithms/vivo.md") + entries = [ + {"source": "s1", "article": "algorithms/vivo.md", "status": "compiled", "book": "Livro A"}, + {"source": "s2", "article": "algorithms/arquivado.md", "status": "archived", "book": "Livro A"}, + {"source": "s3", "article": "algorithms/sumiu.md", "status": "compiled", "book": "Livro A"}, + ] + + plan = plan_regroup(wiki, entries) + + todos = [artigo for moves in plan.groups.values() for artigo, _ in moves] + assert todos == [vivo] + + +def test_should_pair_summary_mirror_when_it_exists(tmp_path): + wiki = _wiki(tmp_path) + _artigo(wiki, "algorithms/com-resumo.md") + summary = wiki / "_summaries" / "algorithms" / "com-resumo.md" + summary.parent.mkdir(parents=True) + summary.write_text("resumo", encoding="utf-8") + entries = [ + {"source": "s1", "article": "algorithms/com-resumo.md", "status": "compiled", "book": "Livro A"}, + ] + + plan = plan_regroup(wiki, entries) + + [(book_slug, _)] = plan.groups.items() + assert (summary, wiki / "_summaries" / "_chapters" / book_slug / "com-resumo.md") in plan.summary_moves[book_slug] + + +def test_should_group_entries_without_book_under_fallback(tmp_path): + wiki = _wiki(tmp_path) + _artigo(wiki, "algorithms/sem-livro.md") + entries = [ + {"source": "raw/a.md", "article": "algorithms/sem-livro.md", "status": "compiled", "book": None}, + ] + + plan = plan_regroup(wiki, entries) + + assert Path(wiki / "algorithms" / "sem-livro.md") in plan.unresolved + + +def test_should_disambiguate_destinations_when_basenames_collide(tmp_path): + """Review PR #71 (cubic P1): dois artigos X.md do mesmo livro em dirs + distintos colidiam no destino — o segundo virava backup do primeiro.""" + wiki = _wiki(tmp_path) + a = _artigo(wiki, "algorithms/intro.md") + b = _artigo(wiki, "intro.md") + entries = [ + {"source": "s1", "article": "algorithms/intro.md", "status": "compiled", "book": "Livro A"}, + {"source": "s2", "article": "intro.md", "status": "compiled", "book": "Livro A"}, + ] + + plan = plan_regroup(wiki, entries) + + [(_, moves)] = plan.groups.items() + destinos = [d for _, d in moves] + assert len(destinos) == len(set(destinos)), "destinos devem ser únicos" + assert {a, b} == {origem for origem, _ in moves} + + +def test_should_mark_unresolved_when_book_provenance_conflicts(tmp_path): + """Review PR #71 (CodeRabbit): duas entradas com livros diferentes para o + mesmo artigo — inferir um deles é chute; vira unresolved.""" + wiki = _wiki(tmp_path) + artigo = _artigo(wiki, "algorithms/conflito.md") + entries = [ + {"source": "s1", "article": "algorithms/conflito.md", "status": "compiled", "book": "Livro A"}, + {"source": "s2", "article": "algorithms/conflito.md", "status": "compiled", "book": "Livro B"}, + ] + + plan = plan_regroup(wiki, entries) + + assert artigo in plan.unresolved + assert all(artigo not in [o for o, _ in moves] for moves in plan.groups.values()) diff --git a/tests/unit/test_study_db.py b/tests/unit/test_study_db.py index 20b6e0d..46f1ae7 100644 --- a/tests/unit/test_study_db.py +++ b/tests/unit/test_study_db.py @@ -1,8 +1,6 @@ """Onde o banco da plataforma vive e como ele se comporta sob concorrência.""" import sqlite3 -import threading -from concurrent.futures import ThreadPoolExecutor def test_should_keep_the_study_database_out_of_version_control(tmp_path, monkeypatch): @@ -23,8 +21,11 @@ def test_should_keep_the_study_database_out_of_version_control(tmp_path, monkeyp def test_should_ensure_the_schema_when_connections_race_over_a_legacy_database( tmp_path, monkeypatch ): + """Reproduz a corrida DETERMINISTICAMENTE, sem threads (review PR #71): + duas conexões leem o PRAGMA antes de qualquer uma escrever — o interleaving + exato que estourava `duplicate column name`. Sem depender de carga de + máquina (falso vermelho) nem de sorte de escalonamento (falso verde).""" from kb import config - from study.db import _connect_db, _ensure_schema monkeypatch.setattr(config, "DATA_DIR", tmp_path) legacy = sqlite3.connect(tmp_path / "study.db") @@ -35,19 +36,22 @@ def test_should_ensure_the_schema_when_connections_race_over_a_legacy_database( legacy.commit() legacy.close() - largada = threading.Barrier(8) - - def migrar(): - with _connect_db() as conn: - largada.wait() - _ensure_schema(conn) - conn.commit() - return True - - with ThreadPoolExecutor(max_workers=8) as pool: - resultados = [futuro.result() for futuro in [pool.submit(migrar) for _ in range(8)]] - - assert all(resultados) + from study.db import _ensure_schema + + # Interleaving manual: as DUAS conexões leem o PRAGMA (via _ensure_schema + # até o ponto de decisão) antes de qualquer ALTER — que é o que acontecia + # quando duas requisições corriam. A 1ª migra; a 2ª repete a decisão já + # tomada e não pode estourar duplicate column. + conn1 = sqlite3.connect(tmp_path / "study.db") + conn2 = sqlite3.connect(tmp_path / "study.db") + _ensure_schema(conn1) + conn1.commit() + _ensure_schema(conn2) # sem o guard, estourava OperationalError aqui + conn2.commit() + conn1.close() + conn2.close() + + from study.db import _connect_db with _connect_db() as conn: colunas = {row[1] for row in conn.execute("PRAGMA table_info(notes)")} assert "updated_at" in colunas