@cristiancorreau/forge 2.9.5 → 2.9.7

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.
Files changed (50) hide show
  1. package/assets/adapters/claude-code/commands/new-feature.md +10 -5
  2. package/assets/adapters/claude-code/commands/plan.md +61 -36
  3. package/assets/adapters/claude-code/commands/session-start.md +1 -1
  4. package/assets/adapters/claude-code/commands/ship.md +6 -4
  5. package/assets/adapters/claude-code/commands/work.md +8 -6
  6. package/assets/core/skills/README.md +2 -2
  7. package/assets/core/skills/aitmpl-search/SKILL.md +7 -19
  8. package/assets/core/skills/local2prod/SKILL.md +1 -1
  9. package/assets/core/skills/new-feature/SKILL.md +1 -1
  10. package/assets/core/skills/phase-kickoff/SKILL.md +2 -0
  11. package/assets/core/skills/spec/SKILL.md +2 -0
  12. package/assets/core/skills/wiki-ingest/SKILL.md +7 -7
  13. package/assets/core/skills/wiki-lint/SKILL.md +4 -4
  14. package/assets/core/skills/wiki-query/SKILL.md +3 -3
  15. package/dist/commands/doctor.d.ts.map +1 -1
  16. package/dist/commands/doctor.js +2 -1
  17. package/dist/commands/doctor.js.map +1 -1
  18. package/dist/commands/init.js +1 -1
  19. package/dist/lib/paths.d.ts +1 -2
  20. package/dist/lib/paths.d.ts.map +1 -1
  21. package/dist/lib/paths.js +12 -16
  22. package/dist/lib/paths.js.map +1 -1
  23. package/dist/version.d.ts +1 -1
  24. package/dist/version.js +1 -1
  25. package/package.json +2 -2
  26. package/assets/adapters/claude-code/generate-claude-md.py +0 -304
  27. package/assets/adapters/codex/generate-codex-config.py +0 -269
  28. package/assets/adapters/kiro/generate-steering.py +0 -367
  29. package/assets/adapters/opencode/generate-agents-md.py +0 -262
  30. package/assets/core/hooks/pre-bash-check.py +0 -202
  31. package/assets/core/hooks/pre-edit-check.py +0 -317
  32. package/assets/forge.py +0 -1265
  33. package/assets/requirements.txt +0 -2
  34. package/assets/scripts/aitmpl-search.py +0 -808
  35. package/assets/scripts/forge-add-opportunities.py +0 -92
  36. package/assets/scripts/forge-audit.py +0 -1061
  37. package/assets/scripts/forge-generate-all.py +0 -283
  38. package/assets/scripts/forge-init.py +0 -900
  39. package/assets/scripts/forge-migrate-project-yaml.py +0 -397
  40. package/assets/scripts/forge-scaffold-profile.py +0 -181
  41. package/assets/scripts/forge-teardown.py +0 -193
  42. package/assets/scripts/forge-validate-project-yaml.py +0 -457
  43. package/assets/scripts/forge-wizard.py +0 -1003
  44. package/assets/scripts/setup-codex.sh +0 -229
  45. package/assets/scripts/team-install.sh +0 -147
  46. package/assets/scripts/token-stats.py +0 -201
  47. package/dist/lib/python.d.ts +0 -4
  48. package/dist/lib/python.d.ts.map +0 -1
  49. package/dist/lib/python.js +0 -46
  50. 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()