specleap-framework 2.0.0
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/.agents/backend.md +419 -0
- package/.agents/frontend.md +577 -0
- package/.agents/producto.md +516 -0
- package/.commands/adoptar.md +323 -0
- package/.commands/ayuda.md +142 -0
- package/.commands/crear-tickets.md +55 -0
- package/.commands/documentar.md +285 -0
- package/.commands/explicar.md +234 -0
- package/.commands/implementar.md +383 -0
- package/.commands/inicio.md +824 -0
- package/.commands/nuevo/README.md +292 -0
- package/.commands/nuevo/questions-base.yaml +320 -0
- package/.commands/nuevo/responses-example.yaml +53 -0
- package/.commands/planificar.md +253 -0
- package/.commands/refinar.md +306 -0
- package/LICENSE +21 -0
- package/README.md +603 -0
- package/SETUP.md +351 -0
- package/install.sh +152 -0
- package/package.json +60 -0
- package/proyectos/_template/.gitkeep +1 -0
- package/proyectos/_template/ANEXOS.md +21 -0
- package/proyectos/_template/CONTRATO.md +26 -0
- package/proyectos/_template/context/.gitkeep +1 -0
- package/rules/development-rules.md +113 -0
- package/rules/environment-protection.md +97 -0
- package/rules/git-workflow.md +142 -0
- package/rules/session-protocol.md +121 -0
- package/scripts/README.md +129 -0
- package/scripts/analyze-project.sh +826 -0
- package/scripts/create-asana-tasks.sh +133 -0
- package/scripts/detect-project-type.sh +141 -0
- package/scripts/estimate-effort.sh +290 -0
- package/scripts/generate-asana-structure.sh +262 -0
- package/scripts/generate-contract.sh +360 -0
- package/scripts/generate-contrato.sh +555 -0
- package/scripts/install-git-hooks.sh +141 -0
- package/scripts/install-skills.sh +130 -0
- package/scripts/lib/asana-utils.sh +191 -0
- package/scripts/lib/jira-project-utils.sh +222 -0
- package/scripts/lib/questions.json +831 -0
- package/scripts/lib/render-contrato.py +195 -0
- package/scripts/lib/validate.sh +325 -0
- package/scripts/parse-contrato.sh +190 -0
- package/scripts/setup-mcp.sh +654 -0
- package/scripts/test-cuestionario.sh +428 -0
- package/setup.sh +458 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ArchSpec — Renderizador de CONTRATO.md
|
|
4
|
+
Convierte respuestas planas (project.name) a estructura anidada y renderiza con Jinja2
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from jinja2 import Environment, FileSystemLoader
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
def flatten_to_nested(flat_dict):
|
|
14
|
+
"""
|
|
15
|
+
Convierte dict plano {'project.name': 'foo'} a anidado {'project': {'name': 'foo'}}
|
|
16
|
+
"""
|
|
17
|
+
nested = {}
|
|
18
|
+
|
|
19
|
+
for key, value in flat_dict.items():
|
|
20
|
+
parts = key.split('.')
|
|
21
|
+
current = nested
|
|
22
|
+
|
|
23
|
+
for i, part in enumerate(parts):
|
|
24
|
+
if i == len(parts) - 1:
|
|
25
|
+
# Último nivel, asignar valor
|
|
26
|
+
current[part] = value
|
|
27
|
+
else:
|
|
28
|
+
# Crear nivel intermedio si no existe
|
|
29
|
+
if part not in current:
|
|
30
|
+
current[part] = {}
|
|
31
|
+
current = current[part]
|
|
32
|
+
|
|
33
|
+
return nested
|
|
34
|
+
|
|
35
|
+
def convert_string_to_type(value, expected_type):
|
|
36
|
+
"""
|
|
37
|
+
Convierte strings a su tipo apropiado (bool, int, array)
|
|
38
|
+
"""
|
|
39
|
+
if expected_type == 'boolean':
|
|
40
|
+
if isinstance(value, bool):
|
|
41
|
+
return value
|
|
42
|
+
return value.lower() in ['true', '1', 'yes', 's', 'si']
|
|
43
|
+
|
|
44
|
+
if expected_type == 'number':
|
|
45
|
+
try:
|
|
46
|
+
return int(value)
|
|
47
|
+
except:
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
if expected_type == 'array':
|
|
51
|
+
if isinstance(value, list):
|
|
52
|
+
return value
|
|
53
|
+
# Si es string vacío, devolver array vacío
|
|
54
|
+
if value == "":
|
|
55
|
+
return []
|
|
56
|
+
# Si es JSON array string
|
|
57
|
+
if isinstance(value, str) and value.startswith('['):
|
|
58
|
+
try:
|
|
59
|
+
return json.loads(value)
|
|
60
|
+
except:
|
|
61
|
+
return []
|
|
62
|
+
return [value]
|
|
63
|
+
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
def normalize_answers(answers_flat, questions_meta):
|
|
67
|
+
"""
|
|
68
|
+
Normaliza respuestas según metadata de preguntas (tipos, defaults)
|
|
69
|
+
"""
|
|
70
|
+
normalized = {}
|
|
71
|
+
|
|
72
|
+
for q in questions_meta:
|
|
73
|
+
q_id = q['id']
|
|
74
|
+
q_type = q['type']
|
|
75
|
+
q_default = q.get('default', None)
|
|
76
|
+
|
|
77
|
+
# Obtener valor o usar default
|
|
78
|
+
value = answers_flat.get(q_id, q_default)
|
|
79
|
+
|
|
80
|
+
# Convertir a tipo apropiado
|
|
81
|
+
if value is not None:
|
|
82
|
+
value = convert_string_to_type(value, q_type)
|
|
83
|
+
|
|
84
|
+
normalized[q_id] = value
|
|
85
|
+
|
|
86
|
+
return normalized
|
|
87
|
+
|
|
88
|
+
def add_computed_fields(nested_dict):
|
|
89
|
+
"""
|
|
90
|
+
Agrega campos computados (fecha actual, contadores, etc.)
|
|
91
|
+
"""
|
|
92
|
+
# Agregar fecha actual si no existe
|
|
93
|
+
if 'project' in nested_dict:
|
|
94
|
+
if not nested_dict['project'].get('created_at'):
|
|
95
|
+
nested_dict['project']['created_at'] = datetime.now().strftime('%Y-%m-%d')
|
|
96
|
+
|
|
97
|
+
if not nested_dict['project'].get('status'):
|
|
98
|
+
nested_dict['project']['status'] = 'draft'
|
|
99
|
+
|
|
100
|
+
if not nested_dict['project'].get('version'):
|
|
101
|
+
nested_dict['project']['version'] = '1.0'
|
|
102
|
+
|
|
103
|
+
# Contar features
|
|
104
|
+
if 'features' in nested_dict and 'core' in nested_dict['features']:
|
|
105
|
+
core_count = len(nested_dict['features']['core']) if nested_dict['features']['core'] else 0
|
|
106
|
+
nested_dict['_computed'] = {'core_count': core_count}
|
|
107
|
+
|
|
108
|
+
return nested_dict
|
|
109
|
+
|
|
110
|
+
def main():
|
|
111
|
+
if len(sys.argv) < 4:
|
|
112
|
+
print("Uso: render-contrato.py <answers.json> <questions.json> <template.md> [output.md]")
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
|
|
115
|
+
answers_file = sys.argv[1]
|
|
116
|
+
questions_file = sys.argv[2]
|
|
117
|
+
template_file = sys.argv[3]
|
|
118
|
+
output_file = sys.argv[4] if len(sys.argv) > 4 else None
|
|
119
|
+
|
|
120
|
+
# Leer archivos
|
|
121
|
+
with open(answers_file, 'r') as f:
|
|
122
|
+
answers_flat = json.load(f)
|
|
123
|
+
|
|
124
|
+
with open(questions_file, 'r') as f:
|
|
125
|
+
questions_data = json.load(f)
|
|
126
|
+
questions_meta = questions_data['questions']
|
|
127
|
+
|
|
128
|
+
# Normalizar respuestas según tipos
|
|
129
|
+
answers_normalized = normalize_answers(answers_flat, questions_meta)
|
|
130
|
+
|
|
131
|
+
# Convertir plano a anidado
|
|
132
|
+
answers_nested = flatten_to_nested(answers_normalized)
|
|
133
|
+
|
|
134
|
+
# Agregar campos computados
|
|
135
|
+
answers_nested = add_computed_fields(answers_nested)
|
|
136
|
+
|
|
137
|
+
# Configurar Jinja2
|
|
138
|
+
template_dir = '/'.join(template_file.split('/')[:-1])
|
|
139
|
+
template_name = template_file.split('/')[-1]
|
|
140
|
+
|
|
141
|
+
env = Environment(
|
|
142
|
+
loader=FileSystemLoader(template_dir),
|
|
143
|
+
trim_blocks=True,
|
|
144
|
+
lstrip_blocks=True,
|
|
145
|
+
keep_trailing_newline=True
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Leer template (separar YAML frontmatter del contenido Jinja2)
|
|
149
|
+
with open(template_file, 'r') as f:
|
|
150
|
+
template_content = f.read()
|
|
151
|
+
|
|
152
|
+
# Convertir placeholders tipo {VAR} a sintaxis Jinja2 {{ var }} para templates legacy
|
|
153
|
+
# Solo si no es el template principal (que ya usa Jinja2)
|
|
154
|
+
if 'LEGACY' in template_file or '{PROJECT_NAME}' in template_content:
|
|
155
|
+
import re
|
|
156
|
+
# Convertir {VAR_NAME} -> {{ var_name }}
|
|
157
|
+
def convert_placeholder(match):
|
|
158
|
+
var_name = match.group(1).lower().replace('_', '.')
|
|
159
|
+
return '{{ ' + var_name + ' }}'
|
|
160
|
+
template_content = re.sub(r'\{([A-Z_]+)\}', convert_placeholder, template_content)
|
|
161
|
+
|
|
162
|
+
# Detectar frontmatter YAML
|
|
163
|
+
if template_content.startswith('---'):
|
|
164
|
+
parts = template_content.split('---', 2)
|
|
165
|
+
if len(parts) >= 3:
|
|
166
|
+
yaml_front = parts[1]
|
|
167
|
+
jinja_content = parts[2]
|
|
168
|
+
else:
|
|
169
|
+
yaml_front = ""
|
|
170
|
+
jinja_content = template_content
|
|
171
|
+
else:
|
|
172
|
+
yaml_front = ""
|
|
173
|
+
jinja_content = template_content
|
|
174
|
+
|
|
175
|
+
# Renderizar YAML frontmatter
|
|
176
|
+
yaml_template = env.from_string(yaml_front)
|
|
177
|
+
yaml_rendered = yaml_template.render(**answers_nested)
|
|
178
|
+
|
|
179
|
+
# Renderizar contenido Jinja2
|
|
180
|
+
content_template = env.from_string(jinja_content)
|
|
181
|
+
content_rendered = content_template.render(**answers_nested)
|
|
182
|
+
|
|
183
|
+
# Combinar
|
|
184
|
+
final_output = f"---\n{yaml_rendered}---{content_rendered}"
|
|
185
|
+
|
|
186
|
+
# Escribir output
|
|
187
|
+
if output_file:
|
|
188
|
+
with open(output_file, 'w') as f:
|
|
189
|
+
f.write(final_output)
|
|
190
|
+
print(f"✅ CONTRATO.md generado: {output_file}")
|
|
191
|
+
else:
|
|
192
|
+
print(final_output)
|
|
193
|
+
|
|
194
|
+
if __name__ == '__main__':
|
|
195
|
+
main()
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
# SpecLeap — Funciones de validación avanzadas
|
|
4
|
+
# Fase 2.2
|
|
5
|
+
|
|
6
|
+
# ============================================================================
|
|
7
|
+
# VALIDACIONES POR TIPO
|
|
8
|
+
# ============================================================================
|
|
9
|
+
|
|
10
|
+
validate_string_advanced() {
|
|
11
|
+
local value="$1"
|
|
12
|
+
local pattern="${2:-}"
|
|
13
|
+
local min_length="${3:-0}"
|
|
14
|
+
local max_length="${4:-999999}"
|
|
15
|
+
|
|
16
|
+
local length=${#value}
|
|
17
|
+
|
|
18
|
+
# Validar longitud mínima
|
|
19
|
+
if [[ $length -lt $min_length ]]; then
|
|
20
|
+
echo "ERROR:Debe tener al menos $min_length caracteres (actualmente: $length)"
|
|
21
|
+
return 1
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
# Validar longitud máxima
|
|
25
|
+
if [[ $length -gt $max_length ]]; then
|
|
26
|
+
echo "ERROR:Debe tener máximo $max_length caracteres (actualmente: $length)"
|
|
27
|
+
return 1
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
# Validar patrón regex
|
|
31
|
+
if [[ -n "$pattern" ]] && ! [[ "$value" =~ $pattern ]]; then
|
|
32
|
+
case "$pattern" in
|
|
33
|
+
'^[a-z0-9-]+$')
|
|
34
|
+
echo "ERROR:Solo se permiten letras minúsculas, números y guiones (sin espacios ni mayúsculas)"
|
|
35
|
+
;;
|
|
36
|
+
'^#[0-9A-Fa-f]{6}$')
|
|
37
|
+
echo "ERROR:Debe ser un código hexadecimal válido (ejemplo: #3B82F6)"
|
|
38
|
+
;;
|
|
39
|
+
*)
|
|
40
|
+
echo "ERROR:Formato inválido"
|
|
41
|
+
;;
|
|
42
|
+
esac
|
|
43
|
+
return 1
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
return 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
validate_select_strict() {
|
|
50
|
+
local value="$1"
|
|
51
|
+
shift
|
|
52
|
+
local options=("$@")
|
|
53
|
+
|
|
54
|
+
# Normalizar a minúsculas para comparación
|
|
55
|
+
local value_lower="${value,,}"
|
|
56
|
+
|
|
57
|
+
for opt in "${options[@]}"; do
|
|
58
|
+
if [[ "${opt,,}" == "$value_lower" ]]; then
|
|
59
|
+
# Devolver el valor normalizado (minúsculas)
|
|
60
|
+
echo "$opt"
|
|
61
|
+
return 0
|
|
62
|
+
fi
|
|
63
|
+
done
|
|
64
|
+
|
|
65
|
+
echo "ERROR:Opción inválida. Opciones válidas: ${options[*]}"
|
|
66
|
+
return 1
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
validate_multiselect() {
|
|
70
|
+
local value="$1"
|
|
71
|
+
shift
|
|
72
|
+
local valid_options=("$@")
|
|
73
|
+
|
|
74
|
+
# Si está vacío, es válido (si no es required)
|
|
75
|
+
if [[ -z "$value" ]]; then
|
|
76
|
+
return 0
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
# Separar por comas
|
|
80
|
+
IFS=',' read -ra selected <<< "$value"
|
|
81
|
+
|
|
82
|
+
for item in "${selected[@]}"; do
|
|
83
|
+
# Trim espacios
|
|
84
|
+
item="$(echo "$item" | xargs)"
|
|
85
|
+
item_lower="${item,,}"
|
|
86
|
+
|
|
87
|
+
local found=false
|
|
88
|
+
for opt in "${valid_options[@]}"; do
|
|
89
|
+
if [[ "${opt,,}" == "$item_lower" ]]; then
|
|
90
|
+
found=true
|
|
91
|
+
break
|
|
92
|
+
fi
|
|
93
|
+
done
|
|
94
|
+
|
|
95
|
+
if [[ "$found" == false ]]; then
|
|
96
|
+
echo "ERROR:Opción inválida '$item'. Opciones válidas: ${valid_options[*]}"
|
|
97
|
+
return 1
|
|
98
|
+
fi
|
|
99
|
+
done
|
|
100
|
+
|
|
101
|
+
return 0
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
validate_boolean_strict() {
|
|
105
|
+
local value="$1"
|
|
106
|
+
|
|
107
|
+
case "${value,,}" in
|
|
108
|
+
true|s|si|yes|y|1)
|
|
109
|
+
echo "true"
|
|
110
|
+
return 0
|
|
111
|
+
;;
|
|
112
|
+
false|n|no|0)
|
|
113
|
+
echo "false"
|
|
114
|
+
return 0
|
|
115
|
+
;;
|
|
116
|
+
*)
|
|
117
|
+
echo "ERROR:Respuesta inválida. Usa: s/n, true/false, yes/no, 1/0"
|
|
118
|
+
return 1
|
|
119
|
+
;;
|
|
120
|
+
esac
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
validate_number_range() {
|
|
124
|
+
local value="$1"
|
|
125
|
+
local min="${2:-0}"
|
|
126
|
+
local max="${3:-999999999}"
|
|
127
|
+
|
|
128
|
+
# Validar que sea número
|
|
129
|
+
if ! [[ "$value" =~ ^[0-9]+$ ]]; then
|
|
130
|
+
echo "ERROR:Debe ser un número entero"
|
|
131
|
+
return 1
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
# Validar rango
|
|
135
|
+
if [[ $value -lt $min ]]; then
|
|
136
|
+
echo "ERROR:Debe ser al menos $min"
|
|
137
|
+
return 1
|
|
138
|
+
fi
|
|
139
|
+
|
|
140
|
+
if [[ $value -gt $max ]]; then
|
|
141
|
+
echo "ERROR:Debe ser máximo $max"
|
|
142
|
+
return 1
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
return 0
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
validate_array_items() {
|
|
149
|
+
local value="$1"
|
|
150
|
+
local min_items="${2:-0}"
|
|
151
|
+
local max_items="${3:-999}"
|
|
152
|
+
local separator="${4:-,}"
|
|
153
|
+
|
|
154
|
+
# Contar items
|
|
155
|
+
if [[ -z "$value" ]]; then
|
|
156
|
+
local count=0
|
|
157
|
+
else
|
|
158
|
+
IFS="$separator" read -ra items <<< "$value"
|
|
159
|
+
local count=${#items[@]}
|
|
160
|
+
fi
|
|
161
|
+
|
|
162
|
+
if [[ $count -lt $min_items ]]; then
|
|
163
|
+
echo "ERROR:Debes proporcionar al menos $min_items elemento(s)"
|
|
164
|
+
return 1
|
|
165
|
+
fi
|
|
166
|
+
|
|
167
|
+
if [[ $count -gt $max_items ]]; then
|
|
168
|
+
echo "ERROR:Puedes proporcionar máximo $max_items elemento(s)"
|
|
169
|
+
return 1
|
|
170
|
+
fi
|
|
171
|
+
|
|
172
|
+
return 0
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
# ============================================================================
|
|
176
|
+
# CONDICIONES Y DEPENDENCIAS
|
|
177
|
+
# ============================================================================
|
|
178
|
+
|
|
179
|
+
should_skip_question() {
|
|
180
|
+
local skip_if_json="$1"
|
|
181
|
+
local answers_file="$2"
|
|
182
|
+
|
|
183
|
+
# Si no hay skip_if, no skipear
|
|
184
|
+
if [[ "$skip_if_json" == "null" ]] || [[ -z "$skip_if_json" ]]; then
|
|
185
|
+
return 1
|
|
186
|
+
fi
|
|
187
|
+
|
|
188
|
+
# Extraer campo y valor esperado
|
|
189
|
+
local skip_field=$(echo "$skip_if_json" | jq -r 'keys[0]')
|
|
190
|
+
local skip_value=$(echo "$skip_if_json" | jq -r '.[]')
|
|
191
|
+
|
|
192
|
+
# Obtener valor actual del campo
|
|
193
|
+
local current_value=$(jq -r ".\"$skip_field\" // \"\"" "$answers_file")
|
|
194
|
+
|
|
195
|
+
# Si el valor actual coincide con el valor de skip, saltamos
|
|
196
|
+
if [[ "$current_value" == "$skip_value" ]]; then
|
|
197
|
+
return 0 # Sí, skipear
|
|
198
|
+
fi
|
|
199
|
+
|
|
200
|
+
return 1 # No skipear
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
get_auto_suggest() {
|
|
204
|
+
local auto_suggest_json="$1"
|
|
205
|
+
local dependent_field="$2"
|
|
206
|
+
local answers_file="$3"
|
|
207
|
+
|
|
208
|
+
# Si no hay auto_suggest, devolver vacío
|
|
209
|
+
if [[ "$auto_suggest_json" == "null" ]] || [[ -z "$auto_suggest_json" ]]; then
|
|
210
|
+
echo ""
|
|
211
|
+
return
|
|
212
|
+
fi
|
|
213
|
+
|
|
214
|
+
# Obtener valor del campo del que depende
|
|
215
|
+
local dep_value=$(jq -r ".\"$dependent_field\" // \"\"" "$answers_file")
|
|
216
|
+
|
|
217
|
+
# Obtener sugerencia basada en ese valor
|
|
218
|
+
local suggestion=$(echo "$auto_suggest_json" | jq -r ".\"$dep_value\" // \"\"")
|
|
219
|
+
|
|
220
|
+
echo "$suggestion"
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
# ============================================================================
|
|
224
|
+
# VALIDADOR PRINCIPAL
|
|
225
|
+
# ============================================================================
|
|
226
|
+
|
|
227
|
+
validate_answer() {
|
|
228
|
+
local value="$1"
|
|
229
|
+
local question_json="$2"
|
|
230
|
+
local answers_file="$3"
|
|
231
|
+
|
|
232
|
+
local q_type=$(echo "$question_json" | jq -r '.type')
|
|
233
|
+
local q_required=$(echo "$question_json" | jq -r '.required')
|
|
234
|
+
local q_validation=$(echo "$question_json" | jq -r '.validation // {}')
|
|
235
|
+
local q_options=$(echo "$question_json" | jq -r '.options // [] | join(" ")')
|
|
236
|
+
|
|
237
|
+
# Si es vacío y no es requerido, OK
|
|
238
|
+
if [[ -z "$value" ]] && [[ "$q_required" == "false" ]]; then
|
|
239
|
+
echo "OK"
|
|
240
|
+
return 0
|
|
241
|
+
fi
|
|
242
|
+
|
|
243
|
+
# Si es vacío y es requerido, ERROR
|
|
244
|
+
if [[ -z "$value" ]] && [[ "$q_required" == "true" ]]; then
|
|
245
|
+
echo "ERROR:Esta pregunta es obligatoria"
|
|
246
|
+
return 1
|
|
247
|
+
fi
|
|
248
|
+
|
|
249
|
+
# Validar según tipo
|
|
250
|
+
case "$q_type" in
|
|
251
|
+
string)
|
|
252
|
+
local pattern=$(echo "$q_validation" | jq -r '.pattern // ""')
|
|
253
|
+
local min_length=$(echo "$q_validation" | jq -r '.min_length // 0')
|
|
254
|
+
local max_length=$(echo "$q_validation" | jq -r '.max_length // 999999')
|
|
255
|
+
|
|
256
|
+
if ! validate_string_advanced "$value" "$pattern" "$min_length" "$max_length"; then
|
|
257
|
+
return 1
|
|
258
|
+
fi
|
|
259
|
+
echo "OK"
|
|
260
|
+
;;
|
|
261
|
+
|
|
262
|
+
text)
|
|
263
|
+
local max_length=$(echo "$q_validation" | jq -r '.max_length // 999999')
|
|
264
|
+
|
|
265
|
+
if ! validate_string_advanced "$value" "" "0" "$max_length"; then
|
|
266
|
+
return 1
|
|
267
|
+
fi
|
|
268
|
+
echo "OK"
|
|
269
|
+
;;
|
|
270
|
+
|
|
271
|
+
select)
|
|
272
|
+
local opts_array=($(echo "$q_options"))
|
|
273
|
+
local result
|
|
274
|
+
if ! result=$(validate_select_strict "$value" "${opts_array[@]}"); then
|
|
275
|
+
echo "$result"
|
|
276
|
+
return 1
|
|
277
|
+
fi
|
|
278
|
+
echo "OK:$result"
|
|
279
|
+
;;
|
|
280
|
+
|
|
281
|
+
multiselect)
|
|
282
|
+
local opts_array=($(echo "$q_options"))
|
|
283
|
+
if ! validate_multiselect "$value" "${opts_array[@]}"; then
|
|
284
|
+
return 1
|
|
285
|
+
fi
|
|
286
|
+
echo "OK"
|
|
287
|
+
;;
|
|
288
|
+
|
|
289
|
+
boolean)
|
|
290
|
+
local result
|
|
291
|
+
if ! result=$(validate_boolean_strict "$value"); then
|
|
292
|
+
echo "$result"
|
|
293
|
+
return 1
|
|
294
|
+
fi
|
|
295
|
+
echo "OK:$result"
|
|
296
|
+
;;
|
|
297
|
+
|
|
298
|
+
number)
|
|
299
|
+
local min=$(echo "$q_validation" | jq -r '.min // 0')
|
|
300
|
+
local max=$(echo "$q_validation" | jq -r '.max // 999999999')
|
|
301
|
+
|
|
302
|
+
if ! validate_number_range "$value" "$min" "$max"; then
|
|
303
|
+
return 1
|
|
304
|
+
fi
|
|
305
|
+
echo "OK"
|
|
306
|
+
;;
|
|
307
|
+
|
|
308
|
+
array)
|
|
309
|
+
local min_items=$(echo "$q_validation" | jq -r '.min_items // 0')
|
|
310
|
+
local max_items=$(echo "$q_validation" | jq -r '.max_items // 999')
|
|
311
|
+
local separator=$(echo "$q_validation" | jq -r '.separator // ","')
|
|
312
|
+
|
|
313
|
+
if ! validate_array_items "$value" "$min_items" "$max_items" "$separator"; then
|
|
314
|
+
return 1
|
|
315
|
+
fi
|
|
316
|
+
echo "OK"
|
|
317
|
+
;;
|
|
318
|
+
|
|
319
|
+
*)
|
|
320
|
+
echo "OK"
|
|
321
|
+
;;
|
|
322
|
+
esac
|
|
323
|
+
|
|
324
|
+
return 0
|
|
325
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
# Cargar sistema i18n
|
|
4
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
5
|
+
source "$SCRIPT_DIR/../.specleap/i18n.sh"
|
|
6
|
+
|
|
7
|
+
# SpecLeap — Parser CONTRATO.md → JSON
|
|
8
|
+
# Extrae YAML frontmatter y lo convierte a JSON para procesamiento
|
|
9
|
+
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
|
|
12
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
13
|
+
|
|
14
|
+
# Colores
|
|
15
|
+
RED='\033[0;31m'
|
|
16
|
+
GREEN='\033[0;32m'
|
|
17
|
+
CYAN='\033[0;36m'
|
|
18
|
+
RESET='\033[0m'
|
|
19
|
+
|
|
20
|
+
print_error() {
|
|
21
|
+
echo -e "${RED}$(t "scripts.error"): $1${RESET}" >&2
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
print_success() {
|
|
25
|
+
echo -e "${GREEN}$(t "scripts.success") $1${RESET}" >&2
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
print_info() {
|
|
29
|
+
echo -e "${CYAN}$(t "scripts.info") $1${RESET}" >&2
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# ============================================================================
|
|
33
|
+
# PARSER
|
|
34
|
+
# ============================================================================
|
|
35
|
+
|
|
36
|
+
parse_contrato() {
|
|
37
|
+
local contrato_file="$1"
|
|
38
|
+
local output_file="${2:-}"
|
|
39
|
+
|
|
40
|
+
if [[ ! -f "$contrato_file" ]]; then
|
|
41
|
+
print_error "Archivo no encontrado: $contrato_file"
|
|
42
|
+
return 1
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
# Verificar que yq esté instalado
|
|
46
|
+
if ! command -v yq &> /dev/null; then
|
|
47
|
+
print_error "yq no está instalado. Instala con: brew install yq"
|
|
48
|
+
return 1
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
print_info "Parseando $contrato_file..."
|
|
52
|
+
|
|
53
|
+
# Extraer solo el YAML frontmatter (entre --- ---)
|
|
54
|
+
# Usa awk para capturar solo el primer bloque entre --- (líneas 2 hasta el segundo ---)
|
|
55
|
+
local json_output
|
|
56
|
+
if json_output=$(awk '/^---$/{if(++n==2) exit; next} n==1' "$contrato_file" | yq eval -o=json '.' - 2>/dev/null); then
|
|
57
|
+
if [[ -n "$output_file" ]]; then
|
|
58
|
+
echo "$json_output" > "$output_file"
|
|
59
|
+
print_success "JSON exportado a: $output_file"
|
|
60
|
+
else
|
|
61
|
+
echo "$json_output"
|
|
62
|
+
fi
|
|
63
|
+
return 0
|
|
64
|
+
else
|
|
65
|
+
print_error "Fallo al parsear YAML frontmatter"
|
|
66
|
+
return 1
|
|
67
|
+
fi
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# ============================================================================
|
|
71
|
+
# VALIDACIÓN
|
|
72
|
+
# ============================================================================
|
|
73
|
+
|
|
74
|
+
validate_contrato() {
|
|
75
|
+
local contrato_file="$1"
|
|
76
|
+
|
|
77
|
+
print_info "Validando estructura CONTRATO.md..."
|
|
78
|
+
|
|
79
|
+
# Parsear a JSON
|
|
80
|
+
local json
|
|
81
|
+
if ! json=$(parse_contrato "$contrato_file"); then
|
|
82
|
+
return 1
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
# Validar campos obligatorios
|
|
86
|
+
local required_fields=(
|
|
87
|
+
".project.name"
|
|
88
|
+
".project.display_name"
|
|
89
|
+
".identity.objective"
|
|
90
|
+
".identity.problem_solved"
|
|
91
|
+
".stack.backend.framework"
|
|
92
|
+
".features.core"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
local errors=0
|
|
96
|
+
|
|
97
|
+
for field in "${required_fields[@]}"; do
|
|
98
|
+
local value=$(echo "$json" | jq -r "$field // empty")
|
|
99
|
+
if [[ -z "$value" ]] || [[ "$value" == "null" ]]; then
|
|
100
|
+
print_error "Campo obligatorio faltante: $field"
|
|
101
|
+
errors=$((errors + 1))
|
|
102
|
+
fi
|
|
103
|
+
done
|
|
104
|
+
|
|
105
|
+
if [[ $errors -eq 0 ]]; then
|
|
106
|
+
print_success "CONTRATO válido"
|
|
107
|
+
return 0
|
|
108
|
+
else
|
|
109
|
+
print_error "CONTRATO inválido: $errors campos faltantes"
|
|
110
|
+
return 1
|
|
111
|
+
fi
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# ============================================================================
|
|
115
|
+
# EXTRACCIÓN DE CAMPOS ESPECÍFICOS
|
|
116
|
+
# ============================================================================
|
|
117
|
+
|
|
118
|
+
get_project_name() {
|
|
119
|
+
local contrato_file="$1"
|
|
120
|
+
parse_contrato "$contrato_file" | jq -r '.project.name'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
get_core_features() {
|
|
124
|
+
local contrato_file="$1"
|
|
125
|
+
parse_contrato "$contrato_file" | jq -r '.features.core[]'
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
get_backend_framework() {
|
|
129
|
+
local contrato_file="$1"
|
|
130
|
+
parse_contrato "$contrato_file" | jq -r '.stack.backend.framework'
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
get_frontend_framework() {
|
|
134
|
+
local contrato_file="$1"
|
|
135
|
+
parse_contrato "$contrato_file" | jq -r '.stack.frontend.framework'
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# ============================================================================
|
|
139
|
+
# MAIN
|
|
140
|
+
# ============================================================================
|
|
141
|
+
|
|
142
|
+
main() {
|
|
143
|
+
local command="${1:-parse}"
|
|
144
|
+
local contrato_file="${2:-}"
|
|
145
|
+
local output_file="${3:-}"
|
|
146
|
+
|
|
147
|
+
case "$command" in
|
|
148
|
+
parse)
|
|
149
|
+
if [[ -z "$contrato_file" ]]; then
|
|
150
|
+
echo "Uso: $0 parse <CONTRATO.md> [output.json]"
|
|
151
|
+
exit 1
|
|
152
|
+
fi
|
|
153
|
+
parse_contrato "$contrato_file" "$output_file"
|
|
154
|
+
;;
|
|
155
|
+
validate)
|
|
156
|
+
if [[ -z "$contrato_file" ]]; then
|
|
157
|
+
echo "Uso: $0 validate <CONTRATO.md>"
|
|
158
|
+
exit 1
|
|
159
|
+
fi
|
|
160
|
+
validate_contrato "$contrato_file"
|
|
161
|
+
;;
|
|
162
|
+
get-name)
|
|
163
|
+
if [[ -z "$contrato_file" ]]; then
|
|
164
|
+
echo "Uso: $0 get-name <CONTRATO.md>"
|
|
165
|
+
exit 1
|
|
166
|
+
fi
|
|
167
|
+
get_project_name "$contrato_file"
|
|
168
|
+
;;
|
|
169
|
+
get-features)
|
|
170
|
+
if [[ -z "$contrato_file" ]]; then
|
|
171
|
+
echo "Uso: $0 get-features <CONTRATO.md>"
|
|
172
|
+
exit 1
|
|
173
|
+
fi
|
|
174
|
+
get_core_features "$contrato_file"
|
|
175
|
+
;;
|
|
176
|
+
*)
|
|
177
|
+
echo "Comandos disponibles:"
|
|
178
|
+
echo " parse <CONTRATO.md> [output.json] — Parsear YAML → JSON"
|
|
179
|
+
echo " validate <CONTRATO.md> — Validar estructura"
|
|
180
|
+
echo " get-name <CONTRATO.md> — Obtener nombre proyecto"
|
|
181
|
+
echo " get-features <CONTRATO.md> — Listar features core"
|
|
182
|
+
exit 1
|
|
183
|
+
;;
|
|
184
|
+
esac
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
# Ejecutar si se llama directamente
|
|
188
|
+
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
189
|
+
main "$@"
|
|
190
|
+
fi
|