feat: añadir skills create-tui, init-frontend, init-go-module y utilidades

Nuevas skills para crear TUIs, inicializar frontends React y módulos Go.
Incluye binario parallel-executor y utilidades de soporte.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 02:15:34 +01:00
parent 8055ec216e
commit c36aa18c67
20 changed files with 4276 additions and 0 deletions
+251
View File
@@ -0,0 +1,251 @@
// Package core contiene funciones puras para el parallel executor.
// Sin side effects: parseo de planes, análisis de dependencias, agrupamiento.
package core
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
df "github.com/lucasdataproyects/devfactory/core"
)
// Issue representa una issue parseada del plan de ejecución.
type Issue struct {
Number int
Slug string
Title string
Group int
Deps []int
}
// ExecutionPlan es el plan completo parseado del markdown.
type ExecutionPlan struct {
Groups []IssueGroup
Total int
}
// IssueGroup es un conjunto de issues ejecutables en paralelo.
type IssueGroup struct {
Number int
Name string
Issues []Issue
}
// WorktreeSpec define la especificación para crear un worktree.
type WorktreeSpec struct {
Issue Issue
BranchName string
WorkDir string
}
// ExecutionResult es el resultado de ejecutar una issue.
type ExecutionResult struct {
Issue Issue
Success bool
Duration string
Error string
LogFile string
}
var (
groupHeaderRe = regexp.MustCompile(`^##\s+Grupo\s+(\d+)`)
issueLineRe = regexp.MustCompile(`-\s+Issue\s+#(\d{4})\s*-?\s*(.*)`)
depRe = regexp.MustCompile(`#(\d{4})`)
)
// ParsePlan parsea el contenido markdown de PARALLEL_EXECUTION_ORDER.md.
// Función pura: recibe string, retorna Result[ExecutionPlan].
func ParsePlan(content string) df.Result[ExecutionPlan] {
lines := strings.Split(content, "\n")
if len(lines) == 0 {
return df.Err[ExecutionPlan](errors.New("empty plan"))
}
groups := parsePlanGroups(lines)
if len(groups) == 0 {
return df.Err[ExecutionPlan](errors.New("no groups found in plan"))
}
total := df.Reduce(groups, 0, func(acc int, g IssueGroup) int {
return acc + len(g.Issues)
})
return df.Ok(ExecutionPlan{
Groups: groups,
Total: total,
})
}
// parsePlanGroups extrae los grupos del markdown.
func parsePlanGroups(lines []string) []IssueGroup {
var groups []IssueGroup
var currentGroup *IssueGroup
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if matches := groupHeaderRe.FindStringSubmatch(trimmed); len(matches) > 1 {
if currentGroup != nil {
groups = append(groups, *currentGroup)
}
num, _ := strconv.Atoi(matches[1])
currentGroup = &IssueGroup{
Number: num,
Name: trimmed,
}
continue
}
if currentGroup != nil {
if matches := issueLineRe.FindStringSubmatch(trimmed); len(matches) > 1 {
num, _ := strconv.Atoi(matches[1])
title := strings.TrimSpace(matches[2])
deps := extractDeps(title, num)
issue := Issue{
Number: num,
Slug: issueSlug(num, title),
Title: title,
Group: currentGroup.Number,
Deps: deps,
}
currentGroup.Issues = append(currentGroup.Issues, issue)
}
}
}
if currentGroup != nil && len(currentGroup.Issues) > 0 {
groups = append(groups, *currentGroup)
}
return groups
}
// extractDeps extrae números de issues referenciadas como dependencias.
func extractDeps(text string, selfNum int) []int {
matches := depRe.FindAllStringSubmatch(text, -1)
return df.FilterSlice(
df.MapSlice(matches, func(m []string) int {
n, _ := strconv.Atoi(m[1])
return n
}),
func(n int) bool { return n != selfNum },
)
}
// issueSlug genera el slug de branch para una issue.
func issueSlug(num int, title string) string {
slug := strings.ToLower(title)
slug = regexp.MustCompile(`[^a-z0-9\s-]`).ReplaceAllString(slug, "")
slug = regexp.MustCompile(`\s+`).ReplaceAllString(slug, "-")
slug = regexp.MustCompile(`-{2,}`).ReplaceAllString(slug, "-")
slug = strings.Trim(slug, "-")
words := strings.SplitN(slug, "-", 5)
if len(words) > 4 {
words = words[:4]
}
slug = strings.Join(words, "-")
if slug == "" {
slug = "issue"
}
return formatIssueNum(num) + "-" + slug
}
func formatIssueNum(n int) string {
return fmt.Sprintf("%04d", n)
}
// FilterGroup filtra el plan para ejecutar solo un grupo específico.
func FilterGroup(plan ExecutionPlan, groupNum int) df.Result[ExecutionPlan] {
filtered := df.FilterSlice(plan.Groups, func(g IssueGroup) bool {
return g.Number == groupNum
})
if len(filtered) == 0 {
return df.Err[ExecutionPlan](fmt.Errorf("group not found: %d", groupNum))
}
total := len(filtered[0].Issues)
return df.Ok(ExecutionPlan{Groups: filtered, Total: total})
}
// BuildWorktreeSpecs genera las specs de worktrees para un grupo de issues.
// Función pura: recibe issues y base path, retorna specs.
func BuildWorktreeSpecs(issues []Issue, basePath string) []WorktreeSpec {
return df.MapSlice(issues, func(issue Issue) WorktreeSpec {
branch := "issue/" + issue.Slug
workDir := basePath + "/worktrees/issue-" + formatIssueNum(issue.Number)
return WorktreeSpec{
Issue: issue,
BranchName: branch,
WorkDir: workDir,
}
})
}
// ParseIssueFiles parsea los archivos de issues del directorio dev/issues/.
// Retorna issues con dependencias extraídas del contenido de cada archivo.
func ParseIssueFiles(files map[string]string) []Issue {
var issues []Issue
numRe := regexp.MustCompile(`^(\d{3,4})[a-z]?-(.+)\.md$`)
for filename, content := range files {
matches := numRe.FindStringSubmatch(filename)
if len(matches) < 3 {
continue
}
num, _ := strconv.Atoi(matches[1])
slug := matches[2]
// Extraer título de la primera línea
title := slug
for _, line := range strings.SplitN(content, "\n", 5) {
if strings.HasPrefix(line, "# ") {
title = strings.TrimPrefix(line, "# ")
title = strings.TrimSpace(title)
break
}
}
// Extraer dependencias de "Bloqueada por" o tabla de dependencias
deps := extractAllDeps(content, num)
// Solo incluir si está pendiente
if isIssuePending(content) {
issues = append(issues, Issue{
Number: num,
Slug: slug,
Title: title,
Deps: deps,
})
}
}
return issues
}
func extractAllDeps(content string, selfNum int) []int {
allMatches := depRe.FindAllStringSubmatch(content, -1)
seen := make(map[int]bool)
var deps []int
for _, m := range allMatches {
n, _ := strconv.Atoi(m[1])
if n != selfNum && !seen[n] {
seen[n] = true
deps = append(deps, n)
}
}
return deps
}
func isIssuePending(content string) bool {
lower := strings.ToLower(content)
return strings.Contains(lower, "pendiente") ||
strings.Contains(lower, "🟡") ||
(!strings.Contains(lower, "completado") && !strings.Contains(lower, "✅"))
}
@@ -0,0 +1,89 @@
package core
import (
"testing"
)
func TestParsePlan(t *testing.T) {
plan := `# Plan de Ejecución Paralela
## Grupo 1
- Issue #0003 - testing agent
- Issue #0006 - improve db-reader
## Grupo 2
- Issue #0004 - api client — depende de #0003
`
result := ParsePlan(plan)
if result.IsErr() {
t.Fatalf("ParsePlan failed: %v", result.Error())
}
ep := result.Unwrap()
if len(ep.Groups) != 2 {
t.Errorf("expected 2 groups, got %d", len(ep.Groups))
}
if ep.Total != 3 {
t.Errorf("expected 3 total issues, got %d", ep.Total)
}
if ep.Groups[0].Issues[0].Number != 3 {
t.Errorf("expected issue #0003, got #%04d", ep.Groups[0].Issues[0].Number)
}
if len(ep.Groups[1].Issues[0].Deps) != 1 || ep.Groups[1].Issues[0].Deps[0] != 3 {
t.Errorf("expected issue #0004 to depend on #0003")
}
}
func TestParsePlanEmpty(t *testing.T) {
result := ParsePlan("")
if !result.IsErr() {
t.Error("expected error for empty plan")
}
}
func TestFilterGroup(t *testing.T) {
plan := ExecutionPlan{
Groups: []IssueGroup{
{Number: 1, Issues: []Issue{{Number: 1}}},
{Number: 2, Issues: []Issue{{Number: 2}, {Number: 3}}},
},
Total: 3,
}
result := FilterGroup(plan, 2)
if result.IsErr() {
t.Fatalf("FilterGroup failed: %v", result.Error())
}
filtered := result.Unwrap()
if filtered.Total != 2 {
t.Errorf("expected 2 issues, got %d", filtered.Total)
}
}
func TestFilterGroupNotFound(t *testing.T) {
plan := ExecutionPlan{Groups: []IssueGroup{{Number: 1}}}
result := FilterGroup(plan, 99)
if !result.IsErr() {
t.Error("expected error for non-existent group")
}
}
func TestBuildWorktreeSpecs(t *testing.T) {
issues := []Issue{
{Number: 3, Slug: "0003-testing"},
{Number: 6, Slug: "0006-db-reader"},
}
specs := BuildWorktreeSpecs(issues, "/tmp/project")
if len(specs) != 2 {
t.Fatalf("expected 2 specs, got %d", len(specs))
}
if specs[0].BranchName != "issue/0003-testing" {
t.Errorf("expected branch issue/0003-testing, got %s", specs[0].BranchName)
}
if specs[1].WorkDir != "/tmp/project/worktrees/issue-0006" {
t.Errorf("unexpected workdir: %s", specs[1].WorkDir)
}
}
+217
View File
@@ -0,0 +1,217 @@
package core
import (
"errors"
"fmt"
"sort"
df "github.com/lucasdataproyects/devfactory/core"
)
// TopologicalSort ordena issues por dependencias usando Kahn's algorithm.
// Función pura: retorna Result con los grupos ordenados.
func TopologicalSort(issues []Issue) df.Result[[]IssueGroup] {
if len(issues) == 0 {
return df.Err[[]IssueGroup](errors.New("no issues to sort"))
}
// Construir mapa de issues por número
issueMap := make(map[int]Issue)
for _, issue := range issues {
issueMap[issue.Number] = issue
}
// Calcular in-degree (solo deps que existen en el set)
inDegree := make(map[int]int)
for _, issue := range issues {
if _, exists := inDegree[issue.Number]; !exists {
inDegree[issue.Number] = 0
}
for _, dep := range issue.Deps {
if _, exists := issueMap[dep]; exists {
inDegree[issue.Number]++
}
}
}
// Kahn's algorithm por niveles (cada nivel = un grupo paralelo)
var groups []IssueGroup
resolved := make(map[int]bool)
remaining := len(issues)
groupNum := 1
for remaining > 0 {
// Encontrar issues con in-degree 0
var ready []Issue
for _, issue := range issues {
if !resolved[issue.Number] && inDegree[issue.Number] == 0 {
issue.Group = groupNum
ready = append(ready, issue)
}
}
if len(ready) == 0 {
// Ciclo detectado
var cycleNums []int
for _, issue := range issues {
if !resolved[issue.Number] {
cycleNums = append(cycleNums, issue.Number)
}
}
return df.Err[[]IssueGroup](
fmt.Errorf("circular dependency detected among issues: %v", cycleNums),
)
}
// Ordenar por número dentro del grupo (determinista)
sort.Slice(ready, func(i, j int) bool {
return ready[i].Number < ready[j].Number
})
groups = append(groups, IssueGroup{
Number: groupNum,
Name: fmt.Sprintf("Grupo %d", groupNum),
Issues: ready,
})
// Marcar como resueltas y decrementar in-degree
for _, issue := range ready {
resolved[issue.Number] = true
remaining--
// Decrementar in-degree de dependientes
for _, other := range issues {
if !resolved[other.Number] {
for _, dep := range other.Deps {
if dep == issue.Number {
inDegree[other.Number]--
}
}
}
}
}
groupNum++
}
return df.Ok(groups)
}
// AnalyzeFileConflicts detecta issues que tocan los mismos archivos.
// Recibe un mapa issue_number -> lista de archivos mencionados.
// Retorna pares de issues en conflicto.
func AnalyzeFileConflicts(filesByIssue map[int][]string) []ConflictPair {
var conflicts []ConflictPair
nums := sortedKeys(filesByIssue)
for i := 0; i < len(nums); i++ {
for j := i + 1; j < len(nums); j++ {
shared := intersect(filesByIssue[nums[i]], filesByIssue[nums[j]])
if len(shared) > 0 {
conflicts = append(conflicts, ConflictPair{
IssueA: nums[i],
IssueB: nums[j],
SharedFiles: shared,
})
}
}
}
return conflicts
}
// ConflictPair representa dos issues que comparten archivos.
type ConflictPair struct {
IssueA int
IssueB int
SharedFiles []string
}
// GeneratePlanMarkdown genera el markdown del plan de ejecución.
// Función pura: recibe grupos, retorna string.
func GeneratePlanMarkdown(groups []IssueGroup, conflicts []ConflictPair) string {
var b []byte
b = append(b, "# Plan de Ejecución Paralela\n\n"...)
b = append(b, fmt.Sprintf("Generado automáticamente. Total: %d issues en %d grupos.\n\n",
countIssues(groups), len(groups))...)
if len(conflicts) > 0 {
b = append(b, "## Conflictos Detectados\n\n"...)
for _, c := range conflicts {
b = append(b, fmt.Sprintf("- Issue #%04d y #%04d comparten: %v\n",
c.IssueA, c.IssueB, c.SharedFiles)...)
}
b = append(b, "\n"...)
}
for _, group := range groups {
b = append(b, fmt.Sprintf("## Grupo %d\n\n", group.Number)...)
for _, issue := range group.Issues {
depStr := ""
if len(issue.Deps) > 0 {
depStr = fmt.Sprintf(" — depende de %s", formatDeps(issue.Deps))
}
b = append(b, fmt.Sprintf("- Issue #%04d - %s%s\n", issue.Number, issue.Title, depStr)...)
}
b = append(b, "\n"...)
}
b = append(b, "## Resumen\n\n"...)
b = append(b, "| Métrica | Valor |\n"...)
b = append(b, "|---------|-------|\n"...)
b = append(b, fmt.Sprintf("| Issues totales | %d |\n", countIssues(groups))...)
b = append(b, fmt.Sprintf("| Grupos paralelos | %d |\n", len(groups))...)
if len(groups) > 0 {
sequential := countIssues(groups)
parallel := len(groups)
if sequential > 0 {
saving := ((sequential - parallel) * 100) / sequential
b = append(b, fmt.Sprintf("| Ahorro estimado | %d%% |\n", saving)...)
}
}
return string(b)
}
func countIssues(groups []IssueGroup) int {
return df.Reduce(groups, 0, func(acc int, g IssueGroup) int {
return acc + len(g.Issues)
})
}
func formatDeps(deps []int) string {
strs := df.MapSlice(deps, func(n int) string {
return fmt.Sprintf("#%04d", n)
})
s := ""
for i, str := range strs {
if i > 0 {
s += ", "
}
s += str
}
return s
}
func sortedKeys(m map[int][]string) []int {
keys := make([]int, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Ints(keys)
return keys
}
func intersect(a, b []string) []string {
set := make(map[string]bool)
for _, s := range a {
set[s] = true
}
var result []string
for _, s := range b {
if set[s] {
result = append(result, s)
}
}
return result
}
@@ -0,0 +1,102 @@
package core
import (
"strings"
"testing"
)
func TestTopologicalSort(t *testing.T) {
issues := []Issue{
{Number: 1, Title: "base", Deps: nil},
{Number: 2, Title: "depends on 1", Deps: []int{1}},
{Number: 3, Title: "independent", Deps: nil},
{Number: 4, Title: "depends on 1 and 3", Deps: []int{1, 3}},
}
result := TopologicalSort(issues)
if result.IsErr() {
t.Fatalf("TopologicalSort failed: %v", result.Error())
}
groups := result.Unwrap()
if len(groups) < 2 {
t.Fatalf("expected at least 2 groups, got %d", len(groups))
}
// Group 1 should have issues 1 and 3 (no deps)
g1Nums := make(map[int]bool)
for _, issue := range groups[0].Issues {
g1Nums[issue.Number] = true
}
if !g1Nums[1] || !g1Nums[3] {
t.Errorf("group 1 should contain issues 1 and 3, got %v", groups[0].Issues)
}
}
func TestTopologicalSortCycle(t *testing.T) {
issues := []Issue{
{Number: 1, Title: "a", Deps: []int{2}},
{Number: 2, Title: "b", Deps: []int{1}},
}
result := TopologicalSort(issues)
if !result.IsErr() {
t.Error("expected cycle detection error")
}
if !strings.Contains(result.Error().Error(), "circular") {
t.Errorf("expected circular dependency error, got: %v", result.Error())
}
}
func TestTopologicalSortEmpty(t *testing.T) {
result := TopologicalSort(nil)
if !result.IsErr() {
t.Error("expected error for empty issues")
}
}
func TestAnalyzeFileConflicts(t *testing.T) {
files := map[int][]string{
1: {"core/types.go", "shell/http.go"},
2: {"core/types.go", "app/main.go"},
3: {"app/other.go"},
}
conflicts := AnalyzeFileConflicts(files)
if len(conflicts) != 1 {
t.Fatalf("expected 1 conflict, got %d", len(conflicts))
}
if conflicts[0].IssueA != 1 || conflicts[0].IssueB != 2 {
t.Errorf("expected conflict between 1 and 2, got %d and %d",
conflicts[0].IssueA, conflicts[0].IssueB)
}
if conflicts[0].SharedFiles[0] != "core/types.go" {
t.Errorf("expected shared file core/types.go, got %s", conflicts[0].SharedFiles[0])
}
}
func TestGeneratePlanMarkdown(t *testing.T) {
groups := []IssueGroup{
{Number: 1, Issues: []Issue{
{Number: 1, Title: "base"},
{Number: 3, Title: "other"},
}},
{Number: 2, Issues: []Issue{
{Number: 2, Title: "depends", Deps: []int{1}},
}},
}
md := GeneratePlanMarkdown(groups, nil)
if !strings.Contains(md, "## Grupo 1") {
t.Error("markdown should contain group headers")
}
if !strings.Contains(md, "Issue #0001") {
t.Error("markdown should contain issue references")
}
if !strings.Contains(md, "3 issues") || !strings.Contains(md, "2 grupos") {
// Check summary table
if !strings.Contains(md, "| 3 |") {
t.Error("markdown should contain correct totals")
}
}
}