@cristiancorreau/forge 2.9.6 → 2.9.8
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.
- package/README.md +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +4 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/generate.js +1 -1
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/init.js +2 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/lib/generators/codex.d.ts.map +1 -1
- package/dist/lib/generators/codex.js +10 -8
- package/dist/lib/generators/codex.js.map +1 -1
- package/dist/lib/generators/kiro.d.ts +1 -1
- package/dist/lib/generators/kiro.d.ts.map +1 -1
- package/dist/lib/generators/kiro.js +15 -16
- package/dist/lib/generators/kiro.js.map +1 -1
- package/dist/lib/generators/opencode.d.ts.map +1 -1
- package/dist/lib/generators/opencode.js +13 -12
- package/dist/lib/generators/opencode.js.map +1 -1
- package/dist/lib/paths.d.ts +1 -2
- package/dist/lib/paths.d.ts.map +1 -1
- package/dist/lib/paths.js +12 -16
- package/dist/lib/paths.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/assets/adapters/claude-code/generate-claude-md.py +0 -304
- package/assets/adapters/codex/generate-codex-config.py +0 -269
- package/assets/adapters/codex/hooks/codex.yaml.tpl +0 -43
- package/assets/adapters/codex/hooks/forge-codex-finish.sh +0 -158
- package/assets/adapters/codex/hooks/forge-codex-start.sh +0 -186
- package/assets/adapters/kiro/generate-steering.py +0 -367
- package/assets/adapters/opencode/generate-agents-md.py +0 -262
- package/assets/core/hooks/pre-bash-check.py +0 -202
- package/assets/core/hooks/pre-edit-check.py +0 -317
- package/assets/forge.py +0 -1265
- package/assets/requirements.txt +0 -2
- package/assets/scripts/aitmpl-search.py +0 -808
- package/assets/scripts/forge-add-opportunities.py +0 -92
- package/assets/scripts/forge-audit.py +0 -1061
- package/assets/scripts/forge-generate-all.py +0 -283
- package/assets/scripts/forge-init.py +0 -900
- package/assets/scripts/forge-migrate-project-yaml.py +0 -397
- package/assets/scripts/forge-scaffold-profile.py +0 -181
- package/assets/scripts/forge-teardown.py +0 -193
- package/assets/scripts/forge-validate-project-yaml.py +0 -457
- package/assets/scripts/forge-wizard.py +0 -1003
- package/assets/scripts/setup-codex.sh +0 -229
- package/assets/scripts/team-install.sh +0 -147
- package/assets/scripts/token-stats.py +0 -201
- package/dist/lib/python.d.ts +0 -4
- package/dist/lib/python.d.ts.map +0 -1
- package/dist/lib/python.js +0 -46
- package/dist/lib/python.js.map +0 -1
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
forge-teardown.py — Elimina la configuración de forge de un proyecto.
|
|
4
|
-
|
|
5
|
-
Usage:
|
|
6
|
-
python3 .agentic/scripts/forge-teardown.py # preview (dry-run)
|
|
7
|
-
python3 .agentic/scripts/forge-teardown.py --confirm # ejecutar
|
|
8
|
-
|
|
9
|
-
Qué elimina:
|
|
10
|
-
- .claude/agents/ instalados por forge (solo los que coinciden con el roster declarado)
|
|
11
|
-
- .claude/commands/ instalados por forge (wiki-ingest.md, wiki-query.md, wiki-lint.md)
|
|
12
|
-
- AGENTS.md (generado por forge-init.py)
|
|
13
|
-
- Entrada del submodule .agentic en .gitmodules y .git/config (si aplica)
|
|
14
|
-
|
|
15
|
-
Qué NO elimina:
|
|
16
|
-
- project.yaml (fuente de verdad del proyecto — puede reutilizarse)
|
|
17
|
-
- CLAUDE.md (puede haberse customizado manualmente)
|
|
18
|
-
- .claude/agents/ creados manualmente (Tier 3 — no están en forge)
|
|
19
|
-
- docs/wiki/ ni cualquier otro contenido generado por el equipo
|
|
20
|
-
- El hook pre-commit (instalado por el usuario manualmente — desactivar con: git config --unset core.hooksPath)
|
|
21
|
-
|
|
22
|
-
Requiere: pyyaml
|
|
23
|
-
"""
|
|
24
|
-
import subprocess
|
|
25
|
-
import sys
|
|
26
|
-
import shutil
|
|
27
|
-
from pathlib import Path
|
|
28
|
-
|
|
29
|
-
try:
|
|
30
|
-
import yaml
|
|
31
|
-
except ImportError:
|
|
32
|
-
print("ERROR: pyyaml requerido. pip install pyyaml", file=sys.stderr)
|
|
33
|
-
sys.exit(1)
|
|
34
|
-
|
|
35
|
-
DRY_RUN = "--confirm" not in sys.argv
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def find_project_root() -> Path:
|
|
39
|
-
here = Path.cwd()
|
|
40
|
-
for p in [here] + list(here.parents):
|
|
41
|
-
if (p / "project.yaml").exists():
|
|
42
|
-
return p
|
|
43
|
-
raise FileNotFoundError("No se encontró project.yaml")
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def find_forge_dir() -> Path:
|
|
47
|
-
root = find_project_root()
|
|
48
|
-
for candidate in [root / ".agentic", root / "forge", Path(__file__).parent.parent]:
|
|
49
|
-
if (candidate / "core").exists():
|
|
50
|
-
return candidate
|
|
51
|
-
raise FileNotFoundError("No se encontró el directorio forge con core/")
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def load_project(root: Path) -> dict:
|
|
55
|
-
with open(root / "project.yaml") as f:
|
|
56
|
-
return yaml.safe_load(f)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def get_forge_agent_names(forge: Path, config: dict) -> set[str]:
|
|
60
|
-
"""Retorna los nombres de agentes que forge podría haber instalado."""
|
|
61
|
-
agents_cfg = config.get("agents", {})
|
|
62
|
-
profiles = agents_cfg.get("profiles", [])
|
|
63
|
-
|
|
64
|
-
names: set[str] = set()
|
|
65
|
-
# Core agents
|
|
66
|
-
for name in (agents_cfg.get("active", []) + agents_cfg.get("compliance", [])):
|
|
67
|
-
if (forge / "core" / "agents" / f"{name}.md").exists():
|
|
68
|
-
names.add(name)
|
|
69
|
-
# Profile agents
|
|
70
|
-
for profile in profiles:
|
|
71
|
-
profile_dir = forge / "profiles" / profile / "agents"
|
|
72
|
-
if profile_dir.exists():
|
|
73
|
-
for f in profile_dir.glob("*.md"):
|
|
74
|
-
names.add(f.stem)
|
|
75
|
-
return names
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def action(label: str, path: Path, is_dir: bool = False):
|
|
79
|
-
rel = path
|
|
80
|
-
try:
|
|
81
|
-
rel = path.relative_to(Path.cwd())
|
|
82
|
-
except ValueError:
|
|
83
|
-
pass
|
|
84
|
-
|
|
85
|
-
if DRY_RUN:
|
|
86
|
-
kind = "DIR " if is_dir else "FILE"
|
|
87
|
-
print(f" [DRY] {label} {kind} {rel}")
|
|
88
|
-
return
|
|
89
|
-
|
|
90
|
-
if is_dir and path.exists():
|
|
91
|
-
shutil.rmtree(path)
|
|
92
|
-
print(f" [DEL] DIR {rel}")
|
|
93
|
-
elif path.exists():
|
|
94
|
-
path.unlink()
|
|
95
|
-
print(f" [DEL] FILE {rel}")
|
|
96
|
-
else:
|
|
97
|
-
print(f" [SKIP] no existe: {rel}")
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def _remove_submodule(dry_run: bool) -> bool:
|
|
101
|
-
"""Remueve .agentic como git submodule. Retorna True si éxito."""
|
|
102
|
-
cmds = [
|
|
103
|
-
["git", "rm", "--cached", ".agentic"],
|
|
104
|
-
["git", "config", "--remove-section", "submodule..agentic"],
|
|
105
|
-
]
|
|
106
|
-
dirs_to_remove = [Path(".agentic"), Path(".git/modules/.agentic")]
|
|
107
|
-
|
|
108
|
-
if dry_run:
|
|
109
|
-
print(" [DRY] ejecutaría:")
|
|
110
|
-
for cmd in cmds:
|
|
111
|
-
print(f" $ {' '.join(cmd)}")
|
|
112
|
-
for d in dirs_to_remove:
|
|
113
|
-
print(f" $ rm -rf {d}")
|
|
114
|
-
return True
|
|
115
|
-
|
|
116
|
-
success = True
|
|
117
|
-
for cmd in cmds:
|
|
118
|
-
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
119
|
-
if result.returncode != 0:
|
|
120
|
-
# Puede fallar si ya fue removido — no es error fatal
|
|
121
|
-
pass
|
|
122
|
-
|
|
123
|
-
for d in dirs_to_remove:
|
|
124
|
-
if d.exists():
|
|
125
|
-
shutil.rmtree(d, ignore_errors=True)
|
|
126
|
-
|
|
127
|
-
print(" [OK] Submodule .agentic removido")
|
|
128
|
-
return success
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def main():
|
|
132
|
-
try:
|
|
133
|
-
root = find_project_root()
|
|
134
|
-
forge = find_forge_dir()
|
|
135
|
-
config = load_project(root)
|
|
136
|
-
except FileNotFoundError as e:
|
|
137
|
-
print(f"ERROR: {e}", file=sys.stderr)
|
|
138
|
-
sys.exit(1)
|
|
139
|
-
|
|
140
|
-
if DRY_RUN:
|
|
141
|
-
print("=== forge-teardown (DRY RUN — pasar --confirm para ejecutar) ===\n")
|
|
142
|
-
else:
|
|
143
|
-
print("=== forge-teardown ===\n")
|
|
144
|
-
|
|
145
|
-
forge_agents = get_forge_agent_names(forge, config)
|
|
146
|
-
agents_dir = root / ".claude" / "agents"
|
|
147
|
-
|
|
148
|
-
print("Agentes instalados por forge:")
|
|
149
|
-
for name in sorted(forge_agents):
|
|
150
|
-
agent_path = agents_dir / f"{name}.md"
|
|
151
|
-
action("eliminar", agent_path)
|
|
152
|
-
|
|
153
|
-
print("\nSlash commands de forge:")
|
|
154
|
-
forge_commands = ["wiki-ingest.md", "wiki-query.md", "wiki-lint.md"]
|
|
155
|
-
for cmd in forge_commands:
|
|
156
|
-
action("eliminar", root / ".claude" / "commands" / cmd)
|
|
157
|
-
|
|
158
|
-
print("\nArchivos generados por forge:")
|
|
159
|
-
action("eliminar", root / "AGENTS.md")
|
|
160
|
-
|
|
161
|
-
# Verificar si .claude/agents/ quedó vacío
|
|
162
|
-
if not DRY_RUN and agents_dir.exists():
|
|
163
|
-
remaining = list(agents_dir.glob("*.md"))
|
|
164
|
-
if not remaining:
|
|
165
|
-
action("eliminar directorio vacío", agents_dir, is_dir=True)
|
|
166
|
-
else:
|
|
167
|
-
print(f"\n [INFO] .claude/agents/ retiene {len(remaining)} agente(s) no instalados por forge:")
|
|
168
|
-
for f in sorted(remaining):
|
|
169
|
-
print(f" {f.name}")
|
|
170
|
-
|
|
171
|
-
# Submodule
|
|
172
|
-
gitmodules = root / ".gitmodules"
|
|
173
|
-
if gitmodules.exists():
|
|
174
|
-
content = gitmodules.read_text()
|
|
175
|
-
if ".agentic" in content or "forge" in content:
|
|
176
|
-
print("\nSubmodule forge detectado:")
|
|
177
|
-
_remove_submodule(DRY_RUN)
|
|
178
|
-
|
|
179
|
-
print("\nHook pre-commit:")
|
|
180
|
-
print(" [INFO] Si instalaste el hook, desactivarlo con:")
|
|
181
|
-
print(" git config --unset core.hooksPath")
|
|
182
|
-
print(" rm .githooks/pre-commit # si existe")
|
|
183
|
-
|
|
184
|
-
print()
|
|
185
|
-
if DRY_RUN:
|
|
186
|
-
print("Nada fue eliminado. Pasar --confirm para ejecutar.")
|
|
187
|
-
else:
|
|
188
|
-
print("Teardown completado.")
|
|
189
|
-
print("Revisar CLAUDE.md — puede tener referencias a forge que quieras actualizar.")
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if __name__ == "__main__":
|
|
193
|
-
main()
|
|
@@ -1,457 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
forge-validate-project-yaml.py — Valida un project.yaml contra el schema v2 de Forge.
|
|
4
|
-
|
|
5
|
-
Usage:
|
|
6
|
-
python3 .agentic/scripts/forge-validate-project-yaml.py
|
|
7
|
-
python3 .agentic/scripts/forge-validate-project-yaml.py --json
|
|
8
|
-
|
|
9
|
-
Busca project.yaml desde el directorio actual hacia arriba.
|
|
10
|
-
Valida contra core/schemas/project.schema.json del directorio de Forge.
|
|
11
|
-
|
|
12
|
-
Exit codes:
|
|
13
|
-
0 — válido (puede haber warnings)
|
|
14
|
-
1 — inválido (hay errores)
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
import sys
|
|
18
|
-
import os
|
|
19
|
-
import json
|
|
20
|
-
import argparse
|
|
21
|
-
import re
|
|
22
|
-
from pathlib import Path
|
|
23
|
-
from typing import Optional, Tuple, List
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
# ---------------------------------------------------------------------------
|
|
27
|
-
# Utilidades de búsqueda de archivos
|
|
28
|
-
# ---------------------------------------------------------------------------
|
|
29
|
-
|
|
30
|
-
def find_project_yaml(start: Path) -> Optional[Path]:
|
|
31
|
-
"""Busca project.yaml desde start hacia la raíz del sistema de archivos."""
|
|
32
|
-
current = start.resolve()
|
|
33
|
-
while True:
|
|
34
|
-
candidate = current / "project.yaml"
|
|
35
|
-
if candidate.exists():
|
|
36
|
-
return candidate
|
|
37
|
-
parent = current.parent
|
|
38
|
-
if parent == current:
|
|
39
|
-
return None
|
|
40
|
-
current = parent
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def find_forge_root() -> Optional[Path]:
|
|
44
|
-
"""
|
|
45
|
-
Determina el directorio raíz de Forge buscando core/schemas/project.schema.json.
|
|
46
|
-
Primero busca relativo al script; luego relativo al CWD.
|
|
47
|
-
"""
|
|
48
|
-
script_dir = Path(__file__).resolve().parent
|
|
49
|
-
# Estructura esperada: forge/scripts/forge-validate-project-yaml.py
|
|
50
|
-
# forge/core/schemas/project.schema.json
|
|
51
|
-
candidate = script_dir.parent / "core" / "schemas" / "project.schema.json"
|
|
52
|
-
if candidate.exists():
|
|
53
|
-
return candidate.parent.parent.parent
|
|
54
|
-
|
|
55
|
-
# Fallback: busca desde CWD hacia arriba
|
|
56
|
-
current = Path.cwd().resolve()
|
|
57
|
-
while True:
|
|
58
|
-
candidate = current / "core" / "schemas" / "project.schema.json"
|
|
59
|
-
if candidate.exists():
|
|
60
|
-
return current
|
|
61
|
-
parent = current.parent
|
|
62
|
-
if parent == current:
|
|
63
|
-
return None
|
|
64
|
-
current = parent
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
# ---------------------------------------------------------------------------
|
|
68
|
-
# Carga YAML con fallback a stdlib si PyYAML no está disponible
|
|
69
|
-
# ---------------------------------------------------------------------------
|
|
70
|
-
|
|
71
|
-
def load_yaml(path: Path) -> dict:
|
|
72
|
-
try:
|
|
73
|
-
import yaml
|
|
74
|
-
with open(path, "r", encoding="utf-8") as f:
|
|
75
|
-
data = yaml.safe_load(f)
|
|
76
|
-
return data if data is not None else {}
|
|
77
|
-
except ImportError:
|
|
78
|
-
# Fallback mínimo: no podemos parsear YAML complejo sin pyyaml
|
|
79
|
-
raise RuntimeError(
|
|
80
|
-
"pyyaml no está instalado. Instalarlo con: pip install pyyaml"
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
# ---------------------------------------------------------------------------
|
|
85
|
-
# Validación manual (sin jsonschema)
|
|
86
|
-
# ---------------------------------------------------------------------------
|
|
87
|
-
|
|
88
|
-
VALID_MODES = {"startup", "standard", "enterprise"}
|
|
89
|
-
VALID_STATUSES = {"active", "paused", "maintenance", "archived"}
|
|
90
|
-
VALID_LANGUAGES = {"typescript", "python", "ruby", "go", "php", "mixed"}
|
|
91
|
-
VALID_PROVIDERS = {"vercel", "railway", "fly", "aws", "github-actions", "custom"}
|
|
92
|
-
VALID_BACKENDS = {"hono", "fastapi", "rails", "express", "laravel", "nestjs", "django", "go-gin"}
|
|
93
|
-
VALID_FRONTENDS = {"nextjs", "nuxt", "remix", "rails-views", "astro", "sveltekit"}
|
|
94
|
-
VALID_DATABASES = {"postgresql", "mysql", "sqlite"}
|
|
95
|
-
VALID_ORMS = {"drizzle", "prisma", "sequelize", "typeorm", "sqlalchemy", "active-record"}
|
|
96
|
-
VALID_PKG_MANAGERS = {"npm", "pnpm", "yarn", "bun", "pip", "poetry", "bundler"}
|
|
97
|
-
VALID_MONOREPOS = {"turborepo", "nx", "lerna"}
|
|
98
|
-
VALID_TESTING = {"vitest", "jest", "pytest", "rspec", "phpunit", "playwright", "cypress"}
|
|
99
|
-
VALID_COMPLIANCE = {"gdpr", "lgpd", "ley-21719", "ccpa"}
|
|
100
|
-
VALID_PROFILES = {
|
|
101
|
-
"hono-drizzle", "nextjs-admin", "astro", "expo", "playwright-crawler",
|
|
102
|
-
"fastapi", "express", "rails", "nestjs", "django", "vuenuxt", "go-gin", "sveltekit"
|
|
103
|
-
}
|
|
104
|
-
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
105
|
-
URL_RE = re.compile(r"^https?://")
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def _check_enum(value, valid_set, field_path: str, errors: List[str], nullable: bool = True) -> None:
|
|
109
|
-
if value is None:
|
|
110
|
-
return
|
|
111
|
-
if value not in valid_set:
|
|
112
|
-
errors.append(f"{field_path}: valor '{value}' no válido. Opciones: {sorted(valid_set)}")
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def _check_bool(value, field_path: str, errors: List[str]) -> None:
|
|
116
|
-
if value is not None and not isinstance(value, bool):
|
|
117
|
-
errors.append(f"{field_path}: debe ser boolean (true/false), recibido: {type(value).__name__}")
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def _check_list_of_strings(value, field_path: str, errors: List[str]) -> None:
|
|
121
|
-
if value is None:
|
|
122
|
-
return
|
|
123
|
-
if not isinstance(value, list):
|
|
124
|
-
errors.append(f"{field_path}: debe ser una lista")
|
|
125
|
-
return
|
|
126
|
-
for i, item in enumerate(value):
|
|
127
|
-
if not isinstance(item, str):
|
|
128
|
-
errors.append(f"{field_path}[{i}]: debe ser string, recibido: {type(item).__name__}")
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def validate_manually(data: dict) -> Tuple[List[str], List[str]]:
|
|
132
|
-
"""
|
|
133
|
-
Validación manual básica para cuando jsonschema no está disponible.
|
|
134
|
-
Retorna (errors, warnings).
|
|
135
|
-
"""
|
|
136
|
-
errors: list[str] = []
|
|
137
|
-
warnings: list[str] = []
|
|
138
|
-
|
|
139
|
-
if not isinstance(data, dict):
|
|
140
|
-
errors.append("El archivo debe ser un objeto YAML/mapa en el nivel raíz")
|
|
141
|
-
return errors, warnings
|
|
142
|
-
|
|
143
|
-
# -----------------------------------------------------------------------
|
|
144
|
-
# project (required)
|
|
145
|
-
# -----------------------------------------------------------------------
|
|
146
|
-
project = data.get("project")
|
|
147
|
-
if project is None:
|
|
148
|
-
errors.append("project: sección requerida no encontrada")
|
|
149
|
-
elif not isinstance(project, dict):
|
|
150
|
-
errors.append("project: debe ser un objeto")
|
|
151
|
-
else:
|
|
152
|
-
if not project.get("name"):
|
|
153
|
-
errors.append("project.name: campo requerido vacío o ausente")
|
|
154
|
-
elif not isinstance(project["name"], str):
|
|
155
|
-
errors.append("project.name: debe ser string")
|
|
156
|
-
|
|
157
|
-
mode = project.get("mode")
|
|
158
|
-
if not mode:
|
|
159
|
-
errors.append("project.mode: campo requerido ausente. Valores: startup | standard | enterprise")
|
|
160
|
-
else:
|
|
161
|
-
_check_enum(mode, VALID_MODES, "project.mode", errors)
|
|
162
|
-
|
|
163
|
-
if project.get("slug") is not None:
|
|
164
|
-
slug = project["slug"]
|
|
165
|
-
if not isinstance(slug, str):
|
|
166
|
-
errors.append("project.slug: debe ser string")
|
|
167
|
-
elif not SLUG_RE.match(slug):
|
|
168
|
-
errors.append(f"project.slug: '{slug}' debe ser lowercase sin espacios (ej: mi-proyecto)")
|
|
169
|
-
|
|
170
|
-
if project.get("language") is not None:
|
|
171
|
-
_check_enum(project["language"], VALID_LANGUAGES, "project.language", errors)
|
|
172
|
-
|
|
173
|
-
if project.get("status") is not None:
|
|
174
|
-
_check_enum(project["status"], VALID_STATUSES, "project.status", errors)
|
|
175
|
-
|
|
176
|
-
# -----------------------------------------------------------------------
|
|
177
|
-
# stack
|
|
178
|
-
# -----------------------------------------------------------------------
|
|
179
|
-
stack = data.get("stack")
|
|
180
|
-
if stack is not None and isinstance(stack, dict):
|
|
181
|
-
if stack.get("backend") is not None:
|
|
182
|
-
_check_enum(stack["backend"], VALID_BACKENDS, "stack.backend", errors)
|
|
183
|
-
if stack.get("frontend") is not None:
|
|
184
|
-
_check_enum(stack["frontend"], VALID_FRONTENDS, "stack.frontend", errors)
|
|
185
|
-
if stack.get("database") is not None:
|
|
186
|
-
_check_enum(stack["database"], VALID_DATABASES, "stack.database", errors)
|
|
187
|
-
if stack.get("orm") is not None:
|
|
188
|
-
_check_enum(stack["orm"], VALID_ORMS, "stack.orm", errors)
|
|
189
|
-
if stack.get("package_manager") is not None:
|
|
190
|
-
_check_enum(stack["package_manager"], VALID_PKG_MANAGERS, "stack.package_manager", errors)
|
|
191
|
-
if stack.get("monorepo") is not None:
|
|
192
|
-
_check_enum(stack["monorepo"], VALID_MONOREPOS, "stack.monorepo", errors)
|
|
193
|
-
if stack.get("testing") is not None:
|
|
194
|
-
testing = stack["testing"]
|
|
195
|
-
if not isinstance(testing, list):
|
|
196
|
-
errors.append("stack.testing: debe ser una lista")
|
|
197
|
-
else:
|
|
198
|
-
for fw in testing:
|
|
199
|
-
_check_enum(fw, VALID_TESTING, "stack.testing[]", errors)
|
|
200
|
-
|
|
201
|
-
# -----------------------------------------------------------------------
|
|
202
|
-
# agents
|
|
203
|
-
# -----------------------------------------------------------------------
|
|
204
|
-
agents = data.get("agents")
|
|
205
|
-
if agents is not None and isinstance(agents, dict):
|
|
206
|
-
_check_list_of_strings(agents.get("active"), "agents.active", errors)
|
|
207
|
-
_check_list_of_strings(agents.get("compliance"), "agents.compliance", errors)
|
|
208
|
-
|
|
209
|
-
by_role = agents.get("by_role")
|
|
210
|
-
if by_role is not None:
|
|
211
|
-
if not isinstance(by_role, dict):
|
|
212
|
-
errors.append("agents.by_role: debe ser un objeto (mapeo rol → modelo)")
|
|
213
|
-
else:
|
|
214
|
-
for role, model in by_role.items():
|
|
215
|
-
if model is not None and not isinstance(model, str):
|
|
216
|
-
errors.append(f"agents.by_role.{role}: debe ser string (nombre del modelo) o null")
|
|
217
|
-
|
|
218
|
-
profiles = agents.get("profiles")
|
|
219
|
-
if profiles is not None:
|
|
220
|
-
if not isinstance(profiles, list):
|
|
221
|
-
errors.append("agents.profiles: debe ser una lista")
|
|
222
|
-
else:
|
|
223
|
-
for p in profiles:
|
|
224
|
-
_check_enum(p, VALID_PROFILES, "agents.profiles[]", errors)
|
|
225
|
-
|
|
226
|
-
# -----------------------------------------------------------------------
|
|
227
|
-
# deploy (nuevo en v2)
|
|
228
|
-
# -----------------------------------------------------------------------
|
|
229
|
-
deploy = data.get("deploy")
|
|
230
|
-
if deploy is not None and isinstance(deploy, dict):
|
|
231
|
-
if deploy.get("provider") is not None:
|
|
232
|
-
_check_enum(deploy["provider"], VALID_PROVIDERS, "deploy.provider", errors)
|
|
233
|
-
|
|
234
|
-
prod_url = deploy.get("production_url")
|
|
235
|
-
if prod_url is not None and not isinstance(prod_url, str):
|
|
236
|
-
errors.append("deploy.production_url: debe ser string (URL)")
|
|
237
|
-
elif isinstance(prod_url, str) and not URL_RE.match(prod_url):
|
|
238
|
-
errors.append(f"deploy.production_url: '{prod_url}' no parece una URL válida (debe empezar con http:// o https://)")
|
|
239
|
-
|
|
240
|
-
smoke_tests = deploy.get("smoke_tests")
|
|
241
|
-
if smoke_tests is not None:
|
|
242
|
-
if not isinstance(smoke_tests, list):
|
|
243
|
-
errors.append("deploy.smoke_tests: debe ser una lista")
|
|
244
|
-
else:
|
|
245
|
-
for i, test in enumerate(smoke_tests):
|
|
246
|
-
if not isinstance(test, dict):
|
|
247
|
-
errors.append(f"deploy.smoke_tests[{i}]: debe ser un objeto")
|
|
248
|
-
continue
|
|
249
|
-
if not test.get("url"):
|
|
250
|
-
errors.append(f"deploy.smoke_tests[{i}].url: campo requerido")
|
|
251
|
-
status = test.get("expect_status")
|
|
252
|
-
if status is not None and (not isinstance(status, int) or status < 100 or status > 599):
|
|
253
|
-
errors.append(f"deploy.smoke_tests[{i}].expect_status: debe ser integer entre 100 y 599")
|
|
254
|
-
if "expect_json" in test and test["expect_json"] is not None:
|
|
255
|
-
if not isinstance(test["expect_json"], dict):
|
|
256
|
-
errors.append(f"deploy.smoke_tests[{i}].expect_json: debe ser un objeto")
|
|
257
|
-
|
|
258
|
-
# -----------------------------------------------------------------------
|
|
259
|
-
# mcp (nuevo en v2)
|
|
260
|
-
# -----------------------------------------------------------------------
|
|
261
|
-
mcp = data.get("mcp")
|
|
262
|
-
if mcp is not None and isinstance(mcp, dict):
|
|
263
|
-
servers = mcp.get("servers")
|
|
264
|
-
if servers is not None:
|
|
265
|
-
if not isinstance(servers, list):
|
|
266
|
-
errors.append("mcp.servers: debe ser una lista")
|
|
267
|
-
else:
|
|
268
|
-
for i, server in enumerate(servers):
|
|
269
|
-
if not isinstance(server, dict):
|
|
270
|
-
errors.append(f"mcp.servers[{i}]: debe ser un objeto")
|
|
271
|
-
continue
|
|
272
|
-
if not server.get("name"):
|
|
273
|
-
errors.append(f"mcp.servers[{i}].name: campo requerido")
|
|
274
|
-
_check_list_of_strings(server.get("auto_approve"), f"mcp.servers[{i}].auto_approve", errors)
|
|
275
|
-
|
|
276
|
-
# -----------------------------------------------------------------------
|
|
277
|
-
# github (nuevo en v2)
|
|
278
|
-
# -----------------------------------------------------------------------
|
|
279
|
-
github = data.get("github")
|
|
280
|
-
if github is not None and isinstance(github, dict):
|
|
281
|
-
gh_project = github.get("project")
|
|
282
|
-
if gh_project is not None and isinstance(gh_project, dict):
|
|
283
|
-
number = gh_project.get("number")
|
|
284
|
-
if number is not None and (not isinstance(number, int) or number < 1):
|
|
285
|
-
errors.append("github.project.number: debe ser un integer positivo")
|
|
286
|
-
|
|
287
|
-
# -----------------------------------------------------------------------
|
|
288
|
-
# rules (nuevo en v2)
|
|
289
|
-
# -----------------------------------------------------------------------
|
|
290
|
-
rules = data.get("rules")
|
|
291
|
-
if rules is not None and isinstance(rules, dict):
|
|
292
|
-
_check_list_of_strings(rules.get("forbidden_in_production"), "rules.forbidden_in_production", errors)
|
|
293
|
-
_check_list_of_strings(rules.get("forbidden_patterns"), "rules.forbidden_patterns", errors)
|
|
294
|
-
_check_bool(rules.get("required_review_before_ship"), "rules.required_review_before_ship", errors)
|
|
295
|
-
_check_bool(rules.get("require_spec_before_implementation"), "rules.require_spec_before_implementation", errors)
|
|
296
|
-
_check_bool(rules.get("conventional_commits"), "rules.conventional_commits", errors)
|
|
297
|
-
|
|
298
|
-
# Validar que los forbidden_patterns son regex válidos
|
|
299
|
-
for i, pattern in enumerate(rules.get("forbidden_patterns") or []):
|
|
300
|
-
try:
|
|
301
|
-
re.compile(pattern)
|
|
302
|
-
except re.error as e:
|
|
303
|
-
errors.append(f"rules.forbidden_patterns[{i}]: regex inválida '{pattern}' — {e}")
|
|
304
|
-
|
|
305
|
-
# -----------------------------------------------------------------------
|
|
306
|
-
# compliance
|
|
307
|
-
# -----------------------------------------------------------------------
|
|
308
|
-
compliance = data.get("compliance")
|
|
309
|
-
if compliance is not None and isinstance(compliance, dict):
|
|
310
|
-
frameworks = compliance.get("frameworks")
|
|
311
|
-
if frameworks is not None:
|
|
312
|
-
if not isinstance(frameworks, list):
|
|
313
|
-
errors.append("compliance.frameworks: debe ser una lista")
|
|
314
|
-
else:
|
|
315
|
-
for fw in frameworks:
|
|
316
|
-
_check_enum(fw, VALID_COMPLIANCE, "compliance.frameworks[]", errors)
|
|
317
|
-
_check_bool(compliance.get("pii_handling"), "compliance.pii_handling", errors)
|
|
318
|
-
_check_bool(compliance.get("audit_logs"), "compliance.audit_logs", errors)
|
|
319
|
-
|
|
320
|
-
# -----------------------------------------------------------------------
|
|
321
|
-
# Warnings — campos de v1 que indican que no se migró
|
|
322
|
-
# -----------------------------------------------------------------------
|
|
323
|
-
if project and isinstance(project, dict) and not project.get("mode"):
|
|
324
|
-
warnings.append("project.mode no definido — se recomienda migrar a v2 con forge-migrate-project-yaml.py")
|
|
325
|
-
|
|
326
|
-
deploy_section = data.get("deploy")
|
|
327
|
-
if deploy_section is None:
|
|
328
|
-
warnings.append("Sección 'deploy' ausente — considera agregar deploy.provider y deploy.smoke_tests (v2)")
|
|
329
|
-
|
|
330
|
-
rules_section = data.get("rules")
|
|
331
|
-
if rules_section is None:
|
|
332
|
-
warnings.append("Sección 'rules' ausente — considera agregar guardrails del proyecto (v2)")
|
|
333
|
-
|
|
334
|
-
github_section = data.get("github")
|
|
335
|
-
if github_section is None:
|
|
336
|
-
warnings.append("Sección 'github' ausente — considera integrar con GitHub Projects (v2)")
|
|
337
|
-
|
|
338
|
-
return errors, warnings
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
# ---------------------------------------------------------------------------
|
|
342
|
-
# Validación con jsonschema (cuando está disponible)
|
|
343
|
-
# ---------------------------------------------------------------------------
|
|
344
|
-
|
|
345
|
-
def validate_with_jsonschema(data: dict, schema: dict) -> Tuple[List[str], List[str]]:
|
|
346
|
-
"""Valida usando jsonschema si está instalado. Retorna (errors, warnings)."""
|
|
347
|
-
try:
|
|
348
|
-
import jsonschema
|
|
349
|
-
validator = jsonschema.Draft7Validator(schema)
|
|
350
|
-
errors = []
|
|
351
|
-
for error in sorted(validator.iter_errors(data), key=lambda e: list(e.path)):
|
|
352
|
-
path = ".".join(str(p) for p in error.path) if error.path else "raíz"
|
|
353
|
-
errors.append(f"{path}: {error.message}")
|
|
354
|
-
|
|
355
|
-
# Warnings adicionales (lógica de negocio no expresable en JSON Schema)
|
|
356
|
-
_, warnings = validate_manually(data)
|
|
357
|
-
return errors, warnings
|
|
358
|
-
except ImportError:
|
|
359
|
-
# jsonschema no disponible — usar validación manual
|
|
360
|
-
return validate_manually(data)
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
# ---------------------------------------------------------------------------
|
|
364
|
-
# Main
|
|
365
|
-
# ---------------------------------------------------------------------------
|
|
366
|
-
|
|
367
|
-
def main():
|
|
368
|
-
parser = argparse.ArgumentParser(
|
|
369
|
-
description="Valida project.yaml contra el schema v2 de Forge"
|
|
370
|
-
)
|
|
371
|
-
parser.add_argument(
|
|
372
|
-
"--json",
|
|
373
|
-
action="store_true",
|
|
374
|
-
help="Emitir resultado como JSON en lugar de texto"
|
|
375
|
-
)
|
|
376
|
-
args = parser.parse_args()
|
|
377
|
-
|
|
378
|
-
# Buscar project.yaml
|
|
379
|
-
project_yaml_path = find_project_yaml(Path.cwd())
|
|
380
|
-
if project_yaml_path is None:
|
|
381
|
-
result = {
|
|
382
|
-
"valid": False,
|
|
383
|
-
"errors": ["No se encontró project.yaml en el directorio actual ni en directorios superiores"],
|
|
384
|
-
"warnings": []
|
|
385
|
-
}
|
|
386
|
-
if args.json:
|
|
387
|
-
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
388
|
-
else:
|
|
389
|
-
print("ERROR: No se encontró project.yaml")
|
|
390
|
-
sys.exit(1)
|
|
391
|
-
|
|
392
|
-
# Buscar schema
|
|
393
|
-
forge_root = find_forge_root()
|
|
394
|
-
schema = None
|
|
395
|
-
if forge_root:
|
|
396
|
-
schema_path = forge_root / "core" / "schemas" / "project.schema.json"
|
|
397
|
-
if schema_path.exists():
|
|
398
|
-
try:
|
|
399
|
-
with open(schema_path, "r", encoding="utf-8") as f:
|
|
400
|
-
schema = json.load(f)
|
|
401
|
-
except Exception as e:
|
|
402
|
-
pass # Fallback a validación manual
|
|
403
|
-
|
|
404
|
-
# Cargar project.yaml
|
|
405
|
-
try:
|
|
406
|
-
data = load_yaml(project_yaml_path)
|
|
407
|
-
except RuntimeError as e:
|
|
408
|
-
result = {"valid": False, "errors": [str(e)], "warnings": []}
|
|
409
|
-
if args.json:
|
|
410
|
-
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
411
|
-
else:
|
|
412
|
-
print(f"ERROR: {e}")
|
|
413
|
-
sys.exit(1)
|
|
414
|
-
except Exception as e:
|
|
415
|
-
result = {
|
|
416
|
-
"valid": False,
|
|
417
|
-
"errors": [f"Error al parsear project.yaml: {e}"],
|
|
418
|
-
"warnings": []
|
|
419
|
-
}
|
|
420
|
-
if args.json:
|
|
421
|
-
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
422
|
-
else:
|
|
423
|
-
print(f"ERROR: {e}")
|
|
424
|
-
sys.exit(1)
|
|
425
|
-
|
|
426
|
-
# Validar
|
|
427
|
-
if schema is not None:
|
|
428
|
-
errors, warnings = validate_with_jsonschema(data, schema)
|
|
429
|
-
else:
|
|
430
|
-
errors, warnings = validate_manually(data)
|
|
431
|
-
|
|
432
|
-
valid = len(errors) == 0
|
|
433
|
-
result = {"valid": valid, "errors": errors, "warnings": warnings}
|
|
434
|
-
|
|
435
|
-
if args.json:
|
|
436
|
-
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
437
|
-
else:
|
|
438
|
-
print(f"Validando: {project_yaml_path}")
|
|
439
|
-
print()
|
|
440
|
-
if valid:
|
|
441
|
-
print("OK — project.yaml es válido")
|
|
442
|
-
else:
|
|
443
|
-
print(f"INVALIDO — {len(errors)} error(s) encontrado(s):")
|
|
444
|
-
for e in errors:
|
|
445
|
-
print(f" [ERROR] {e}")
|
|
446
|
-
|
|
447
|
-
if warnings:
|
|
448
|
-
print()
|
|
449
|
-
print(f"{len(warnings)} advertencia(s):")
|
|
450
|
-
for w in warnings:
|
|
451
|
-
print(f" [WARN] {w}")
|
|
452
|
-
|
|
453
|
-
sys.exit(0 if valid else 1)
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
if __name__ == "__main__":
|
|
457
|
-
main()
|