archdrift 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
archdrift/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """
2
+ archdrift — Contrato de arquitetura para agentes de código.
3
+
4
+ API pública mínima: quem quiser usar como biblioteca (em vez de CLI)
5
+ importa daqui. Tudo o mais em cli.py é considerado interno.
6
+ """
7
+
8
+ from archdrift.cli import (
9
+ main,
10
+ cmd_generate,
11
+ cmd_check,
12
+ scan,
13
+ detect_stack,
14
+ )
15
+
16
+ __version__ = "0.1.0"
17
+ __all__ = ["main", "cmd_generate", "cmd_check", "scan", "detect_stack"]
archdrift/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Permite executar como módulo: python -m archdrift generate ."""
2
+
3
+ from archdrift.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
archdrift/cli.py ADDED
@@ -0,0 +1,650 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ archdrift.py — Contrato de arquitetura para agentes de código.
4
+
5
+ Gera e mantém um ARCHITECTURE.md que é ao mesmo tempo:
6
+ 1. MAPA -> árvore curada + stack detectada (regenerado pelo script)
7
+ 2. CONTRATO -> tabela de responsabilidades editada por HUMANOS
8
+ e validada em CI (drift detection)
9
+
10
+ COMANDOS:
11
+ python archdrift.py generate <path> # gera/atualiza preservando curadoria
12
+ python archdrift.py check <path> # valida drift; exit 1 se divergiu (CI)
13
+ python archdrift.py <path> # atalho para generate
14
+
15
+ COMO FUNCIONA O MERGE:
16
+ O ARCHITECTURE.md tem seções GERENCIADAS, delimitadas por marcadores:
17
+
18
+ <!-- map:begin <nome> -->
19
+ ...conteúdo regenerado pelo script...
20
+ <!-- map:end <nome> -->
21
+
22
+ Tudo FORA dos marcadores é seu: notas, ADRs, diagramas, o que quiser.
23
+ O script nunca toca nesse texto.
24
+
25
+ A tabela de responsabilidades é caso especial: fica dentro de marcadores,
26
+ mas o MERGE preserva as descrições que você editou à mão. Prioridade:
27
+ descrição humana > heurística por nome (ROLE_HINTS) > "(preencher)"
28
+
29
+ COMO FUNCIONA O CHECK (para CI):
30
+ Falha (exit 1) se qualquer uma destas condições for verdadeira:
31
+ a) a estrutura em disco mudou desde a última geração (hash divergente)
32
+ b) existe pasta relevante em disco SEM responsabilidade declarada
33
+ c) existe pasta documentada que NÃO existe mais em disco
34
+ Ou seja: o time é obrigado a manter o contrato vivo — exatamente o que
35
+ agentes de código precisam para confiar no arquivo.
36
+
37
+ FILOSOFIA:
38
+ Tools como Repomix/CodeGraph são DESCRITIVOS: leem o código e dizem o que
39
+ existe. Este script é NORMATIVO: captura a INTENÇÃO do arquiteto e cobra
40
+ que o repo continue obedecendo. O agente lê a intenção; o CI valida.
41
+ """
42
+
43
+ import sys
44
+ import json
45
+ import re
46
+ import hashlib
47
+ import argparse
48
+ from pathlib import Path
49
+ from collections import Counter
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # CONFIGURAÇÃO
53
+ # ---------------------------------------------------------------------------
54
+
55
+ # Pastas que NÃO interessam ao agente (ruído). Ajuste à sua stack.
56
+ IGNORE_DIRS = {
57
+ ".git", "node_modules", "__pycache__", ".venv", "venv", "env",
58
+ "dist", "build", ".next", ".nuxt", "target", "vendor",
59
+ ".idea", ".vscode", "coverage", ".pytest_cache", ".mypy_cache",
60
+ "bin", "obj", ".terraform", "tmp", ".turbo", ".cache",
61
+ }
62
+
63
+ # Arquivos ignorados na listagem (não mudam a arquitetura).
64
+ # Inclui as próprias saídas do script, para não poluir execuções seguintes.
65
+ IGNORE_FILES = {".DS_Store", "Thumbs.db", "desktop.ini",
66
+ "ARCHITECTURE.md", "structure.json"}
67
+
68
+ # Dotfiles/dotdirs que SÃO relevantes para o agente entender o projeto.
69
+ DOTFILE_WHITELIST = {
70
+ ".github", ".gitlab-ci.yml", ".env.example", ".env.sample",
71
+ ".gitignore", ".dockerignore", ".editorconfig", ".nvmrc",
72
+ ".eslintrc.json", ".eslintrc.js", ".prettierrc", ".babelrc",
73
+ }
74
+
75
+ # Profundidade máxima da ÁRVORE exibida. Além disso vira ruído.
76
+ MAX_DEPTH = 4
77
+
78
+ # Profundidade máxima do CONTRATO: pastas até este nível PRECISAM ter
79
+ # responsabilidade declarada (o check cobra). Mais fundo que isso é detalhe
80
+ # de implementação e não entra na tabela.
81
+ DOC_DEPTH = 2
82
+
83
+ # Máximo de arquivos de CÓDIGO listados individualmente por diretório.
84
+ MAX_CODE_FILES_PER_DIR = 30
85
+
86
+ # Placeholder para responsabilidade pendente. O check falha enquanto existir.
87
+ PENDING = "_(preencher)_"
88
+
89
+ # Extensões consideradas CÓDIGO/CONFIG -> listadas individualmente na árvore.
90
+ CODE_EXTS = {
91
+ ".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
92
+ ".go", ".rs", ".java", ".kt", ".rb", ".php", ".cs", ".swift",
93
+ ".c", ".h", ".cpp", ".hpp", ".sql", ".sh", ".ps1",
94
+ ".vue", ".svelte", ".astro",
95
+ ".yml", ".yaml", ".toml", ".ini", ".env", ".tf",
96
+ ".html", ".css", ".scss", ".sass", ".graphql", ".proto", ".prisma",
97
+ ".md", ".mdx",
98
+ }
99
+
100
+ # Arquivos SEM extensão que importam (não são colapsados em grupo).
101
+ IMPORTANT_BARE_NAMES = {
102
+ "Dockerfile", "Makefile", "Procfile", "Rakefile", "Gemfile",
103
+ "LICENSE", "CODEOWNERS",
104
+ }
105
+
106
+ # JSONs de config que importam; os demais .json são tratados como dados.
107
+ IMPORTANT_JSON_NAMES = {
108
+ "package.json", "tsconfig.json", "composer.json", "deno.json",
109
+ "jsconfig.json", "lerna.json", "nx.json", "turbo.json",
110
+ "vercel.json", "firebase.json", "app.json", "manifest.json",
111
+ }
112
+
113
+ # Marcadores de stack: arquivo na raiz -> (stack, descrição).
114
+ STACK_MARKERS = {
115
+ "package.json": ("Node.js", "manifesto de dependências npm"),
116
+ "tsconfig.json": ("TypeScript", "configuração do compilador TS"),
117
+ "pyproject.toml": ("Python", "manifesto do projeto Python"),
118
+ "requirements.txt": ("Python", "dependências pip"),
119
+ "Pipfile": ("Python", "dependências pipenv"),
120
+ "go.mod": ("Go", "módulo Go"),
121
+ "Cargo.toml": ("Rust", "manifesto Cargo"),
122
+ "pom.xml": ("Java (Maven)", "build Maven"),
123
+ "build.gradle": ("Java/Kotlin (Gradle)", "build Gradle"),
124
+ "build.gradle.kts": ("Kotlin (Gradle)", "build Gradle KTS"),
125
+ "composer.json": ("PHP", "dependências Composer"),
126
+ "Gemfile": ("Ruby", "dependências Bundler"),
127
+ "mix.exs": ("Elixir", "projeto Mix"),
128
+ "Dockerfile": ("Docker", "imagem de container"),
129
+ "docker-compose.yml": ("Docker Compose","orquestração local"),
130
+ "docker-compose.yaml": ("Docker Compose","orquestração local"),
131
+ "serverless.yml": ("Serverless", "config Serverless Framework"),
132
+ "next.config.js": ("Next.js", "config Next.js"),
133
+ "next.config.mjs": ("Next.js", "config Next.js"),
134
+ "next.config.ts": ("Next.js", "config Next.js"),
135
+ "nuxt.config.ts": ("Nuxt", "config Nuxt"),
136
+ "vite.config.ts": ("Vite", "bundler Vite"),
137
+ "vite.config.js": ("Vite", "bundler Vite"),
138
+ "angular.json": ("Angular", "workspace Angular"),
139
+ "manage.py": ("Django", "entrypoint Django"),
140
+ "artisan": ("Laravel", "CLI Laravel"),
141
+ "main.tf": ("Terraform", "infraestrutura como código"),
142
+ }
143
+
144
+ # Heurísticas de responsabilidade por nome de pasta.
145
+ # São apenas o CHUTE INICIAL: o merge preserva o que o humano escrever.
146
+ ROLE_HINTS = {
147
+ "src": "Código-fonte principal da aplicação",
148
+ "app": "Ponto de entrada / camada de aplicação",
149
+ "api": "Endpoints e rotas expostas",
150
+ "routes": "Definição de rotas HTTP",
151
+ "controllers": "Camada de controle (recebe request, orquestra)",
152
+ "services": "Regras de negócio / lógica de domínio",
153
+ "models": "Modelos de dados / entidades",
154
+ "entities": "Entidades de domínio",
155
+ "repositories": "Acesso a dados (persistência)",
156
+ "db": "Configuração e migrações de banco",
157
+ "database": "Configuração e migrações de banco",
158
+ "migrations": "Scripts de migração de schema",
159
+ "components": "Componentes de UI reutilizáveis",
160
+ "pages": "Páginas / telas da aplicação",
161
+ "views": "Camada de apresentação",
162
+ "hooks": "React hooks / lógica reutilizável de UI",
163
+ "utils": "Funções utilitárias genéricas",
164
+ "helpers": "Funções auxiliares",
165
+ "lib": "Bibliotecas internas / wrappers",
166
+ "config": "Configuração da aplicação",
167
+ "middleware": "Middlewares (auth, logging, etc.)",
168
+ "middlewares": "Middlewares (auth, logging, etc.)",
169
+ "tests": "Testes automatizados",
170
+ "test": "Testes automatizados",
171
+ "__tests__": "Testes automatizados",
172
+ "e2e": "Testes end-to-end",
173
+ "public": "Assets estáticos servidos diretamente",
174
+ "assets": "Imagens, fontes e mídia",
175
+ "static": "Arquivos estáticos",
176
+ "styles": "Folhas de estilo / temas",
177
+ "types": "Definições de tipos (TS) / interfaces",
178
+ "interfaces": "Contratos / interfaces",
179
+ "schemas": "Schemas de validação",
180
+ "dto": "Data Transfer Objects",
181
+ "docs": "Documentação do projeto",
182
+ "scripts": "Scripts de automação / build",
183
+ "store": "Estado global (Redux/Zustand/etc.)",
184
+ "context": "Contextos de estado (React Context)",
185
+ "guards": "Proteção de rotas / autorização",
186
+ "workers": "Jobs assíncronos / background workers",
187
+ "jobs": "Jobs agendados / filas",
188
+ "events": "Eventos de domínio / pub-sub",
189
+ "adapters": "Adaptadores para serviços externos",
190
+ "infra": "Infraestrutura (deploy, IaC)",
191
+ "shared": "Código compartilhado entre módulos",
192
+ "core": "Núcleo do domínio / kernel da aplicação",
193
+ }
194
+
195
+ # Marcadores das seções gerenciadas dentro do ARCHITECTURE.md.
196
+ MARK_BEGIN = "<!-- map:begin {name} -->"
197
+ MARK_END = "<!-- map:end {name} -->"
198
+ MARK_HASH = "<!-- map:hash {value} -->"
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # CLASSIFICAÇÃO
203
+ # ---------------------------------------------------------------------------
204
+
205
+ def infer_role(dirname: str) -> str:
206
+ """
207
+ Responsabilidade inferida pelo nome da pasta.
208
+ Matching exato primeiro; depois parcial por palavra
209
+ (ex: 'user-services' -> hint de 'services').
210
+ """
211
+ name = dirname.lower()
212
+ if name in ROLE_HINTS:
213
+ return ROLE_HINTS[name]
214
+ parts = set(name.replace("-", " ").replace("_", " ").replace(".", " ").split())
215
+ for hint, role in ROLE_HINTS.items():
216
+ if hint in parts:
217
+ return role
218
+ return ""
219
+
220
+
221
+ def is_code_file(path: Path) -> bool:
222
+ """Código/config (listado um a um) vs. dado/asset (colapsado em grupo)."""
223
+ if path.name in IMPORTANT_BARE_NAMES:
224
+ return True
225
+ if path.name in DOTFILE_WHITELIST:
226
+ return True
227
+ if path.suffix.lower() == ".json":
228
+ return path.name in IMPORTANT_JSON_NAMES
229
+ return path.suffix.lower() in CODE_EXTS
230
+
231
+
232
+ # ---------------------------------------------------------------------------
233
+ # DETECÇÃO DE STACK
234
+ # ---------------------------------------------------------------------------
235
+
236
+ def detect_stack(root: Path):
237
+ """Procura arquivos marcadores na raiz. Retorna [(stack, arquivo, desc)]."""
238
+ found = []
239
+ for marker, (stack, desc) in STACK_MARKERS.items():
240
+ if (root / marker).exists():
241
+ found.append((stack, marker, desc))
242
+
243
+ workflows = root / ".github" / "workflows"
244
+ if workflows.is_dir():
245
+ n = sum(1 for f in workflows.iterdir() if f.suffix in (".yml", ".yaml"))
246
+ if n:
247
+ found.append(("GitHub Actions",
248
+ f".github/workflows ({n} workflow(s))",
249
+ "pipelines de CI/CD"))
250
+ return found
251
+
252
+
253
+ # ---------------------------------------------------------------------------
254
+ # VARREDURA DO FILESYSTEM
255
+ # ---------------------------------------------------------------------------
256
+
257
+ def scan(root: Path):
258
+ """
259
+ Percorre o projeto e retorna (nodes, edges, tree_lines).
260
+ IDs dos nós = caminho relativo à raiz ('.' para a raiz).
261
+ Cada nó de diretório carrega 'depth' para o contrato saber
262
+ quais pastas exigem documentação (depth <= DOC_DEPTH).
263
+ """
264
+ nodes, edges, tree_lines = [], [], []
265
+ root = root.resolve()
266
+ visited = set() # (dev, inode) -> proteção contra loop de symlink
267
+
268
+ nodes.append({"id": ".", "name": root.name, "type": "dir",
269
+ "role": "Raiz do projeto", "depth": 0})
270
+
271
+ def keep(entry: Path) -> bool:
272
+ """Filtro de ruído."""
273
+ name = entry.name
274
+ if entry.is_dir() and name in IGNORE_DIRS:
275
+ return False
276
+ if entry.is_file() and name in IGNORE_FILES:
277
+ return False
278
+ if name.startswith(".") and name not in DOTFILE_WHITELIST:
279
+ return False
280
+ return True
281
+
282
+ def walk(current: Path, parent_id: str, depth: int, prefix: str):
283
+ if depth > MAX_DEPTH:
284
+ return
285
+ try:
286
+ st = current.resolve().stat()
287
+ key = (st.st_dev, st.st_ino)
288
+ except OSError:
289
+ return
290
+ if key in visited:
291
+ return
292
+ visited.add(key)
293
+
294
+ try:
295
+ entries = sorted((e for e in current.iterdir() if keep(e)),
296
+ key=lambda p: (p.is_file(), p.name.lower()))
297
+ except PermissionError:
298
+ return
299
+
300
+ dirs = [e for e in entries if e.is_dir()]
301
+ files = [e for e in entries if e.is_file()]
302
+
303
+ # Truncamento por tipo: código listado, dados colapsados por extensão
304
+ code_files = [f for f in files if is_code_file(f)]
305
+ data_files = [f for f in files if not is_code_file(f)]
306
+ shown_code = code_files[:MAX_CODE_FILES_PER_DIR]
307
+ hidden_code = len(code_files) - len(shown_code)
308
+ data_summary = Counter(
309
+ (f.suffix.lower() or "(sem extensão)") for f in data_files
310
+ ).most_common()
311
+
312
+ display = ([("dir", d) for d in dirs]
313
+ + [("file", f) for f in shown_code]
314
+ + ([("more", hidden_code)] if hidden_code else [])
315
+ + [("summary", s) for s in data_summary])
316
+
317
+ for i, (kind, item) in enumerate(display):
318
+ last = (i == len(display) - 1)
319
+ connector = "└── " if last else "├── "
320
+ child_prefix = prefix + (" " if last else "│ ")
321
+
322
+ if kind == "dir":
323
+ node_id = item.name if parent_id == "." else f"{parent_id}/{item.name}"
324
+ role = infer_role(item.name)
325
+ comment = f" # {role}" if role else ""
326
+ tree_lines.append(f"{prefix}{connector}{item.name}/{comment}")
327
+ nodes.append({"id": node_id, "name": item.name, "type": "dir",
328
+ "role": role, "depth": depth})
329
+ edges.append({"from": parent_id, "to": node_id, "rel": "contains"})
330
+ walk(item, node_id, depth + 1, child_prefix)
331
+
332
+ elif kind == "file":
333
+ node_id = item.name if parent_id == "." else f"{parent_id}/{item.name}"
334
+ tree_lines.append(f"{prefix}{connector}{item.name}")
335
+ nodes.append({"id": node_id, "name": item.name, "type": "file",
336
+ "role": "", "depth": depth})
337
+ edges.append({"from": parent_id, "to": node_id, "rel": "contains"})
338
+
339
+ elif kind == "more":
340
+ tree_lines.append(f"{prefix}{connector}… (+{item} arquivos de código)")
341
+
342
+ elif kind == "summary":
343
+ ext, count = item
344
+ tree_lines.append(f"{prefix}{connector}({count} arquivo(s) {ext})")
345
+ node_id = f"*{ext}" if parent_id == "." else f"{parent_id}/*{ext}"
346
+ nodes.append({"id": node_id, "name": f"*{ext}", "type": "file_group",
347
+ "role": "", "depth": depth, "count": count})
348
+ edges.append({"from": parent_id, "to": node_id, "rel": "contains"})
349
+
350
+ tree_lines.append(f"{root.name}/")
351
+ walk(root, ".", 1, "")
352
+ return nodes, edges, tree_lines
353
+
354
+
355
+ def contract_dirs(nodes):
356
+ """Pastas que participam do CONTRATO (exigem responsabilidade declarada)."""
357
+ return [n for n in nodes
358
+ if n["type"] == "dir" and n["id"] != "." and n["depth"] <= DOC_DEPTH]
359
+
360
+
361
+ def structure_hash(tree_lines, stack_info) -> str:
362
+ """
363
+ Hash da estrutura atual. Gravado no MD na geração; o check compara
364
+ com o recalculado para saber se o mapa está desatualizado.
365
+ """
366
+ payload = "\n".join(tree_lines) + "|" + json.dumps(sorted(stack_info))
367
+ return hashlib.sha1(payload.encode("utf-8")).hexdigest()[:12]
368
+
369
+
370
+ # ---------------------------------------------------------------------------
371
+ # PARSE DO ARCHITECTURE.md EXISTENTE (para merge e check)
372
+ # ---------------------------------------------------------------------------
373
+
374
+ def parse_existing(md_text: str):
375
+ """
376
+ Extrai do MD existente:
377
+ - roles: {pasta -> responsabilidade} (a curadoria humana)
378
+ - hash: hash gravado na última geração (ou None)
379
+ Só linhas de tabela no formato `| `pasta` | descrição |` contam.
380
+ """
381
+ roles = {}
382
+ # Linhas de tabela cujo 1º campo é um caminho entre crases
383
+ for m in re.finditer(r"^\|\s*`([^`]+)`\s*\|\s*(.*?)\s*\|\s*$",
384
+ md_text, re.MULTILINE):
385
+ path, desc = m.group(1), m.group(2)
386
+ # Ignora a linha de cabeçalho/separador acidentalmente casada
387
+ if set(desc) <= {"-", " "}:
388
+ continue
389
+ roles[path] = desc
390
+
391
+ hm = re.search(r"<!-- map:hash (\w+) -->", md_text)
392
+ return roles, (hm.group(1) if hm else None)
393
+
394
+
395
+ def merge_roles(scanned_dirs, existing_roles):
396
+ """
397
+ O coração do merge: decide a responsabilidade final de cada pasta.
398
+ 1. Se o humano escreveu algo (e não é o placeholder) -> preserva.
399
+ 2. Senão, se há heurística por nome -> usa.
400
+ 3. Senão -> placeholder pendente (o check vai cobrar).
401
+ Retorna ({pasta -> desc}, [pastas documentadas que sumiram do disco]).
402
+ """
403
+ merged = {}
404
+ scanned_ids = {n["id"] for n in scanned_dirs}
405
+
406
+ for n in scanned_dirs:
407
+ human = existing_roles.get(n["id"], "")
408
+ if human and human != PENDING:
409
+ merged[n["id"]] = human # curadoria humana vence
410
+ elif n["role"]:
411
+ merged[n["id"]] = n["role"] # heurística
412
+ else:
413
+ merged[n["id"]] = PENDING # pendência explícita
414
+
415
+ # Pastas documentadas antes, mas que não existem mais (drift)
416
+ orphans = [p for p in existing_roles
417
+ if p not in scanned_ids and p != "."]
418
+ return merged, orphans
419
+
420
+
421
+ # ---------------------------------------------------------------------------
422
+ # GERAÇÃO DO MARKDOWN (com seções gerenciadas)
423
+ # ---------------------------------------------------------------------------
424
+
425
+ def render_section(name: str, body_lines) -> str:
426
+ """Envelopa um bloco de conteúdo nos marcadores de seção gerenciada."""
427
+ return "\n".join([MARK_BEGIN.format(name=name),
428
+ *body_lines,
429
+ MARK_END.format(name=name)])
430
+
431
+
432
+ def render_stack(stack_info) -> str:
433
+ lines = ["## Stack detectada", ""]
434
+ if stack_info:
435
+ lines += ["| Tecnologia | Evidência | O que é |",
436
+ "|------------|-----------|---------|"]
437
+ lines += [f"| {s} | `{m}` | {d} |" for s, m, d in stack_info]
438
+ else:
439
+ lines.append("_Nenhum marcador de stack encontrado na raiz._")
440
+ return render_section("stack", lines)
441
+
442
+
443
+ def render_tree(tree_lines, hash_value: str) -> str:
444
+ lines = ["## Estrutura de pastas", "",
445
+ MARK_HASH.format(value=hash_value),
446
+ "```", *tree_lines, "```"]
447
+ return render_section("tree", lines)
448
+
449
+
450
+ def render_contract(merged_roles) -> str:
451
+ lines = [
452
+ "## Responsabilidades das pastas",
453
+ "",
454
+ "<!-- Edite as descrições abaixo: elas são PRESERVADAS entre gerações. -->",
455
+ f"<!-- Linhas com {PENDING} são pendências: `check` falha até você preenchê-las. -->",
456
+ "",
457
+ "| Pasta | Responsabilidade |",
458
+ "|-------|------------------|",
459
+ ]
460
+ lines += [f"| `{p}` | {d} |" for p, d in sorted(merged_roles.items())]
461
+ return render_section("contract", lines)
462
+
463
+
464
+ def build_new_markdown(root, stack_info, tree_lines, merged_roles, hash_value) -> str:
465
+ """Template completo — usado só quando o ARCHITECTURE.md ainda não existe."""
466
+ return "\n".join([
467
+ f"# Arquitetura — {root.name}",
468
+ "",
469
+ "> Contrato de arquitetura mantido por `archdrift.py`.",
470
+ "> Seções entre `<!-- map:begin/end -->` são regeneradas; o resto é seu.",
471
+ "",
472
+ render_stack(stack_info),
473
+ "",
474
+ render_tree(tree_lines, hash_value),
475
+ "",
476
+ render_contract(merged_roles),
477
+ "",
478
+ "## Notas",
479
+ "",
480
+ "_Espaço livre: decisões de arquitetura, convenções, ADRs. "
481
+ "O script nunca toca nesta seção._",
482
+ "",
483
+ ])
484
+
485
+
486
+ def replace_section(md_text: str, name: str, new_block: str) -> str:
487
+ """
488
+ Substitui o conteúdo de uma seção gerenciada existente.
489
+ Se os marcadores não existirem (ex: MD antigo/da v1), o bloco é
490
+ anexado ao final — o usuário reorganiza a posição uma vez, se quiser.
491
+ """
492
+ begin = re.escape(MARK_BEGIN.format(name=name))
493
+ end = re.escape(MARK_END.format(name=name))
494
+ pattern = re.compile(begin + r".*?" + end, re.DOTALL)
495
+ if pattern.search(md_text):
496
+ # \g<0> não se aplica: substituímos o bloco inteiro pelo novo
497
+ return pattern.sub(lambda _: new_block, md_text)
498
+ return md_text.rstrip() + "\n\n" + new_block + "\n"
499
+
500
+
501
+ # ---------------------------------------------------------------------------
502
+ # COMANDOS
503
+ # ---------------------------------------------------------------------------
504
+
505
+ def cmd_generate(root: Path) -> int:
506
+ """Gera ou atualiza ARCHITECTURE.md + structure.json preservando curadoria."""
507
+ stack_info = detect_stack(root)
508
+ nodes, edges, tree_lines = scan(root)
509
+ cdirs = contract_dirs(nodes)
510
+ h = structure_hash(tree_lines, stack_info)
511
+
512
+ md_path = root / "ARCHITECTURE.md"
513
+ existing_roles, _ = ({}, None)
514
+ if md_path.exists():
515
+ existing_roles, _ = parse_existing(md_path.read_text(encoding="utf-8"))
516
+
517
+ merged, orphans = merge_roles(cdirs, existing_roles)
518
+
519
+ if md_path.exists():
520
+ # MERGE: só as seções gerenciadas mudam; o texto humano fica intacto
521
+ md = md_path.read_text(encoding="utf-8")
522
+ md = replace_section(md, "stack", render_stack(stack_info))
523
+ md = replace_section(md, "tree", render_tree(tree_lines, h))
524
+ md = replace_section(md, "contract", render_contract(merged))
525
+ else:
526
+ md = build_new_markdown(root, stack_info, tree_lines, merged, h)
527
+
528
+ md_path.write_text(md, encoding="utf-8")
529
+
530
+ # Grafo JSON com as responsabilidades FINAIS (pós-merge) aplicadas aos nós
531
+ for n in nodes:
532
+ if n["id"] in merged:
533
+ n["role"] = merged[n["id"]]
534
+ graph = {
535
+ "root": str(root),
536
+ "generated_by": "archdrift.py",
537
+ "hash": h,
538
+ "stack": [{"stack": s, "marker": m, "desc": d} for s, m, d in stack_info],
539
+ "nodes": nodes,
540
+ "edges": edges,
541
+ }
542
+ (root / "structure.json").write_text(
543
+ json.dumps(graph, indent=2, ensure_ascii=False), encoding="utf-8")
544
+
545
+ # Resumo no terminal
546
+ pending = [p for p, d in merged.items() if d == PENDING]
547
+ print(f"✓ {md_path} atualizado (hash {h})")
548
+ print(f"✓ {root / 'structure.json'} gerado ({len(edges)} arestas)")
549
+ if stack_info:
550
+ print(f"✓ Stack: {', '.join(sorted({s for s, _, _ in stack_info}))}")
551
+ if pending:
552
+ print(f"⚠ {len(pending)} pasta(s) sem responsabilidade declarada:")
553
+ for p in pending:
554
+ print(f" - {p}")
555
+ print(" Edite a tabela no ARCHITECTURE.md. (`check` falha até resolver.)")
556
+ if orphans:
557
+ print(f"⚠ {len(orphans)} pasta(s) documentada(s) que não existem mais "
558
+ f"(removidas da tabela): {', '.join(orphans)}")
559
+ return 0
560
+
561
+
562
+ def cmd_check(root: Path) -> int:
563
+ """
564
+ Valida o contrato. Pensado para CI:
565
+ python archdrift.py check . # exit 1 em caso de drift
566
+ """
567
+ md_path = root / "ARCHITECTURE.md"
568
+ if not md_path.exists():
569
+ print("✗ ARCHITECTURE.md não existe. Rode `generate` primeiro.")
570
+ return 1
571
+
572
+ stack_info = detect_stack(root)
573
+ nodes, _, tree_lines = scan(root)
574
+ cdirs = contract_dirs(nodes)
575
+ current_hash = structure_hash(tree_lines, stack_info)
576
+
577
+ roles, saved_hash = parse_existing(md_path.read_text(encoding="utf-8"))
578
+ scanned_ids = {n["id"] for n in cdirs}
579
+
580
+ problems = []
581
+
582
+ # (a) Estrutura mudou e ninguém regenerou o mapa
583
+ if saved_hash is None:
584
+ problems.append("Mapa sem hash (formato antigo?). Rode `generate` para migrar.")
585
+ elif saved_hash != current_hash:
586
+ problems.append(
587
+ f"Estrutura em disco mudou desde a última geração "
588
+ f"(hash {saved_hash} -> {current_hash}). Rode `generate`.")
589
+
590
+ # (b) Pastas do contrato sem responsabilidade declarada
591
+ for n in cdirs:
592
+ desc = roles.get(n["id"], "")
593
+ if not desc or desc == PENDING:
594
+ problems.append(f"Pasta sem responsabilidade declarada: `{n['id']}`")
595
+
596
+ # (c) Pastas documentadas que sumiram do disco
597
+ for p in roles:
598
+ if p != "." and p not in scanned_ids:
599
+ problems.append(f"Pasta documentada mas inexistente em disco: `{p}`")
600
+
601
+ if problems:
602
+ print(f"✗ Drift de arquitetura detectado ({len(problems)} problema(s)):\n")
603
+ for p in problems:
604
+ print(f" - {p}")
605
+ print("\nCorrija editando o ARCHITECTURE.md ou rode `generate` e revise o diff.")
606
+ return 1
607
+
608
+ print(f"✓ Arquitetura em conformidade (hash {current_hash}, "
609
+ f"{len(cdirs)} pastas documentadas).")
610
+ return 0
611
+
612
+
613
+ # ---------------------------------------------------------------------------
614
+ # CLI
615
+ # ---------------------------------------------------------------------------
616
+
617
+ def main():
618
+ parser = argparse.ArgumentParser(
619
+ description="Contrato de arquitetura para agentes de código.")
620
+ sub = parser.add_subparsers(dest="command")
621
+
622
+ p_gen = sub.add_parser("generate", help="gera/atualiza preservando curadoria")
623
+ p_gen.add_argument("path", nargs="?", default=".")
624
+
625
+ p_chk = sub.add_parser("check", help="valida drift (exit 1 se divergiu)")
626
+ p_chk.add_argument("path", nargs="?", default=".")
627
+
628
+ # Retrocompatibilidade: `python archdrift.py <path>` == generate
629
+ argv = sys.argv[1:]
630
+ if argv and argv[0] not in ("generate", "check", "-h", "--help"):
631
+ argv = ["generate", *argv]
632
+
633
+ args = parser.parse_args(argv)
634
+ if not args.command:
635
+ parser.print_help()
636
+ sys.exit(1)
637
+
638
+ root = Path(args.path)
639
+ if not root.is_dir():
640
+ print(f"Caminho inválido ou não é diretório: {root}")
641
+ sys.exit(1)
642
+
643
+ if args.command == "generate":
644
+ sys.exit(cmd_generate(root.resolve()))
645
+ else:
646
+ sys.exit(cmd_check(root.resolve()))
647
+
648
+
649
+ if __name__ == "__main__":
650
+ main()
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: archdrift
3
+ Version: 0.1.0
4
+ Summary: Architecture contract for AI coding agents: a curated, human-edited ARCHITECTURE.md with drift detection in CI.
5
+ Project-URL: Homepage, https://github.com/EvertDeveloper/archdrift
6
+ Project-URL: Issues, https://github.com/EvertDeveloper/archdrift/issues
7
+ Author: Everton Godoi
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: agents-md,ai-agents,architecture,claude-code,context-engineering,cursor,documentation,drift-detection
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Documentation
16
+ Classifier: Topic :: Software Development :: Quality Assurance
17
+ Requires-Python: >=3.9
18
+ Provides-Extra: dev
19
+ Requires-Dist: build; extra == 'dev'
20
+ Requires-Dist: pytest>=7.0; extra == 'dev'
21
+ Requires-Dist: twine; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # archdrift
25
+
26
+ **Architecture contract for AI coding agents.** A curated `ARCHITECTURE.md` that agents read, humans edit, and CI enforces — so your architecture docs never rot.
27
+
28
+ [![CI](https://github.com/SEU_USUARIO/archdrift/actions/workflows/ci.yml/badge.svg)](https://github.com/SEU_USUARIO/archdrift/actions)
29
+ [![PyPI](https://img.shields.io/pypi/v/archdrift)](https://pypi.org/project/archdrift/)
30
+ ![Python](https://img.shields.io/badge/python-3.9%2B-blue)
31
+ ![Zero deps](https://img.shields.io/badge/dependencies-0-brightgreen)
32
+
33
+ ---
34
+
35
+ ## The problem
36
+
37
+ Tools like Repomix and CodeGraph are **descriptive**: they read your code and tell agents what exists. But no tool captures what your architecture is *supposed* to be — the intent behind it. So agents guess, `ARCHITECTURE.md` files rot within weeks, and nobody notices until an agent (or a new dev) builds on top of stale assumptions.
38
+
39
+ ## The idea
40
+
41
+ `archdrift` makes your `ARCHITECTURE.md` **normative**:
42
+
43
+ 1. **Generated map** — folder tree, detected stack, responsibilities table. Curated for token efficiency: code files listed, assets collapsed.
44
+ 2. **Human curation that survives** — edit any description in the responsibilities table, add ADRs and notes anywhere outside the managed sections. Regeneration **never** destroys your edits.
45
+ 3. **Drift detection in CI** — `archdrift check` exits 1 when reality diverges from the documented contract. The doc can't rot, because rot breaks the build.
46
+
47
+ ```
48
+ ┌─────────────┐ generate ┌─────────────────┐ read ┌────────────┐
49
+ │ Filesystem │ ────────────▶ │ ARCHITECTURE.md │ ────────▶ │ AI agent │
50
+ └─────────────┘ │ (you curate) │ └────────────┘
51
+ ▲ └─────────────────┘
52
+ │ │ check (CI)
53
+ └───────── drift? exit 1 ◀──────┘
54
+ ```
55
+
56
+ ## Quick start
57
+
58
+ ```bash
59
+ uvx archdrift . # or: pipx run archdrift .
60
+ ```
61
+
62
+ This generates `ARCHITECTURE.md` (the contract) and `structure.json` (the graph, for tooling). Folders without a known role get marked `_(preencher)_` — fill them in, they're preserved forever.
63
+
64
+ Then enforce it in CI:
65
+
66
+ ```yaml
67
+ # .github/workflows/ci.yml
68
+ - name: Architecture drift check
69
+ run: uvx archdrift check .
70
+ ```
71
+
72
+ Now every PR that adds an undocumented folder, deletes a documented one, or changes structure without regenerating the map **fails the build** until someone updates the contract.
73
+
74
+ ## How merging works
75
+
76
+ Managed sections are delimited by markers:
77
+
78
+ ```markdown
79
+ <!-- map:begin tree -->
80
+ ...regenerated by the tool...
81
+ <!-- map:end tree -->
82
+
83
+ ## ADR-001 ← yours, never touched
84
+
85
+ <!-- map:begin contract -->
86
+ | Folder | Responsibility |
87
+ | `src/models` | Prisma entities — NEVER import from controllers | ← your edit, preserved
88
+ <!-- map:end contract -->
89
+ ```
90
+
91
+ Priority for each folder's description: **your edit** > name-based heuristic > pending placeholder.
92
+
93
+ ## Why not just use a repo packer / knowledge graph?
94
+
95
+ Use both. Repomix, CodeGraph, aider's repo-map are excellent at telling agents *what the code is*. `archdrift` covers the piece they can't infer: *what the architecture is meant to be* — and keeps that promise honest via CI. It's `dependency-cruiser` energy, rethought for the agent era, in a single zero-dependency Python file.
96
+
97
+ ## Roadmap
98
+
99
+ - [x] Managed sections + merge (human curation survives regeneration)
100
+ - [x] `check` mode — drift detection with meaningful exit codes for CI
101
+ - [x] Stack detection, token-efficient tree (type-aware truncation)
102
+ - [ ] `AGENTS.md` integration (pointer section into the emerging convention)
103
+ - [ ] Declarative rules (`src/controllers must-not-import src/repositories`)
104
+ - [ ] MCP server mode
105
+ - [ ] Semantic graph: declared dependencies vs. real imports — the diff is the product
106
+
107
+ ## Development
108
+
109
+ ```bash
110
+ pip install -e ".[dev]"
111
+ pytest -v
112
+ ```
113
+
114
+ MIT licensed. Issues and PRs welcome.
@@ -0,0 +1,8 @@
1
+ archdrift/__init__.py,sha256=nsNQ-gNwMTbCJXQE5hxuAZsi8xB8L3GNNXT077Y8bEQ,401
2
+ archdrift/__main__.py,sha256=0-ZTHY4-siqvXfUQB4gqCctmTfQRRuMp7uGG6nmpoSk,139
3
+ archdrift/cli.py,sha256=wtN4M7eHuqrUpt8Awtje7l4vJZKq_XQLK5yQNtYMlzU,26459
4
+ archdrift-0.1.0.dist-info/METADATA,sha256=JaVW_jTiA9yznZvyZS2SmZ_sDPs1DMkIaJAbKH0bMGk,5179
5
+ archdrift-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
6
+ archdrift-0.1.0.dist-info/entry_points.txt,sha256=KOT9EKPWUJt-hgvseVUHKgetqeAy_AZpvRsLcJoIYk8,49
7
+ archdrift-0.1.0.dist-info/licenses/LICENSE,sha256=wk-NfONivtJfZ1aaVCIbkAEC3scE2gJRRnPus141BqA,1065
8
+ archdrift-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ archdrift = archdrift.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SEU_NOME
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.