Agrega RUNBOOK.md y runbooks/ (fuera de content/, para no interferir con la publicación de Zola), con reglas de nombres/etiquetas/aislamiento de celdas mutantes verificadas por clusterlog validate. Instalación pinneada a v3.17.4 con checksum verificado (scripts/runme-install.sh), un job de CI separado (.gitea/workflows/ci.yml) que sólo corre celdas ci-safe, y la decisión documentada en docs/adr/0004-runme-sidecar.md y docs/RUNME.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
497 lines
19 KiB
Go
497 lines
19 KiB
Go
package clusterlog
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type ValidationIssue struct {
|
|
Level string `json:"level"`
|
|
Code string `json:"code"`
|
|
Path string `json:"path,omitempty"`
|
|
Message string `json:"message"`
|
|
Details any `json:"details,omitempty"`
|
|
}
|
|
|
|
type ValidationReport struct {
|
|
Valid bool `json:"valid"`
|
|
Errors []ValidationIssue `json:"errors"`
|
|
Warnings []ValidationIssue `json:"warnings"`
|
|
Checks map[string]int `json:"checks"`
|
|
ZolaChecked bool `json:"zola_checked"`
|
|
}
|
|
|
|
type pageRecord struct {
|
|
Section string
|
|
Meta PageMeta
|
|
ID string
|
|
}
|
|
|
|
func (r *ValidationReport) add(level, code, path, message string, details any) {
|
|
issue := ValidationIssue{Level: level, Code: code, Path: filepath.ToSlash(path), Message: message, Details: details}
|
|
if level == "warning" {
|
|
r.Warnings = append(r.Warnings, issue)
|
|
} else {
|
|
r.Errors = append(r.Errors, issue)
|
|
}
|
|
}
|
|
|
|
func valueStrings(value any) []string {
|
|
switch v := value.(type) {
|
|
case []string:
|
|
return v
|
|
case []any:
|
|
out := make([]string, 0, len(v))
|
|
for _, item := range v {
|
|
out = append(out, fmt.Sprint(item))
|
|
}
|
|
return out
|
|
case string:
|
|
if v != "" {
|
|
return []string{v}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func fingerprintBelongs(registry AdminRegistry, adminID, fingerprint string) bool {
|
|
admin, ok := adminByID(registry, adminID)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, key := range admin.SSHKeys {
|
|
if key.Fingerprint == fingerprint {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validTaskStatus(status string) bool {
|
|
switch status {
|
|
case "backlog", "todo", "in_progress", "blocked", "done", "cancelled":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validProjectStatus(status string) bool {
|
|
switch status {
|
|
case "planned", "active", "blocked", "done", "completed", "cancelled", "archived":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (a *App) runValidate(root string, args []string) error {
|
|
fs := newFlagSet("validate")
|
|
skipZola := fs.Bool("skip-zola", false, "omite zola check")
|
|
requireZola := fs.Bool("require-zola", false, "falla si Zola no está instalado")
|
|
if err := fs.Parse(args); err != nil {
|
|
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
|
}
|
|
report := ValidationReport{Checks: map[string]int{}, Errors: []ValidationIssue{}, Warnings: []ValidationIssue{}}
|
|
|
|
registry, err := loadAdmins(root)
|
|
if err != nil {
|
|
report.add("error", "admins_invalid", filepath.Join(root, "data", "admins.json"), err.Error(), nil)
|
|
} else {
|
|
a.validateAdmins(root, registry, &report)
|
|
}
|
|
|
|
pages := a.validatePages(root, registry, &report)
|
|
a.validateEvents(root, registry, pages, &report)
|
|
a.validateRunbooks(root, &report)
|
|
|
|
if len(report.Errors) == 0 {
|
|
if _, _, _, err := a.Sync(root, true); err != nil {
|
|
report.add("error", "generated_state_stale", filepath.Join(root, "data", "generated", "site_state.json"), err.Error(), nil)
|
|
} else {
|
|
report.Checks["generated_state"] = 1
|
|
}
|
|
}
|
|
|
|
if !*skipZola {
|
|
zolaPath, lookupErr := exec.LookPath("zola")
|
|
if lookupErr != nil {
|
|
if *requireZola {
|
|
report.add("error", "zola_missing", "", "Zola no está instalado o no está en PATH", nil)
|
|
} else {
|
|
report.add("warning", "zola_missing", "", "Zola no está instalado; se omitió la validación de plantillas y enlaces", nil)
|
|
}
|
|
} else {
|
|
report.ZolaChecked = true
|
|
cmd := exec.Command(zolaPath, "check")
|
|
cmd.Dir = root
|
|
var output bytes.Buffer
|
|
cmd.Stdout = &output
|
|
cmd.Stderr = &output
|
|
if err := cmd.Run(); err != nil {
|
|
report.add("error", "zola_check_failed", filepath.Join(root, "zola.toml"), "zola check falló", strings.TrimSpace(output.String()))
|
|
} else {
|
|
report.Checks["zola"] = 1
|
|
}
|
|
}
|
|
}
|
|
|
|
report.Valid = len(report.Errors) == 0
|
|
if !report.Valid {
|
|
return NewError(ExitValidation, "validation_failed", "la validación del proyecto encontró errores", report)
|
|
}
|
|
warnings := make([]string, 0, len(report.Warnings))
|
|
for _, issue := range report.Warnings {
|
|
warnings = append(warnings, issue.Message)
|
|
}
|
|
return a.printResult("validate", report, warnings...)
|
|
}
|
|
|
|
func (a *App) validateAdmins(root string, registry AdminRegistry, report *ValidationReport) {
|
|
if registry.ReviewPolicy.Default != "all-active" && registry.ReviewPolicy.Default != "all-active-except-author" {
|
|
report.add("error", "invalid_default_review_policy", filepath.Join(root, "data", "admins.json"), "review_policy.default no es válido", registry.ReviewPolicy.Default)
|
|
}
|
|
ids := map[string]string{}
|
|
emails := map[string]string{}
|
|
fingerprints := map[string]string{}
|
|
ownerCount := 0
|
|
for _, admin := range registry.Admins {
|
|
report.Checks["admins"]++
|
|
idKey := normalizeID(admin.ID)
|
|
if admin.ID == "" || slugify(admin.ID) != admin.ID {
|
|
report.add("error", "invalid_admin_id", filepath.Join(root, "data", "admins.json"), "el ID del administrador debe ser un slug estable en minúsculas", admin.ID)
|
|
}
|
|
if prior, ok := ids[idKey]; ok {
|
|
report.add("error", "duplicate_admin_id", filepath.Join(root, "data", "admins.json"), "ID de administrador duplicado", []string{prior, admin.ID})
|
|
}
|
|
ids[idKey] = admin.ID
|
|
emailKey := strings.ToLower(strings.TrimSpace(admin.Email))
|
|
if emailKey == "" || !strings.Contains(emailKey, "@") {
|
|
report.add("error", "invalid_admin_email", filepath.Join(root, "data", "admins.json"), "correo de administrador inválido", admin.ID)
|
|
}
|
|
if prior, ok := emails[emailKey]; ok {
|
|
report.add("error", "duplicate_admin_email", filepath.Join(root, "data", "admins.json"), "correo de administrador duplicado", []string{prior, admin.ID})
|
|
}
|
|
emails[emailKey] = admin.ID
|
|
if hasRole(admin, "owner") && admin.Active {
|
|
ownerCount++
|
|
}
|
|
if admin.Active && len(admin.SSHKeys) == 0 {
|
|
report.add("error", "active_admin_without_key", filepath.Join(root, "data", "admins.json"), "un administrador activo debe tener al menos una llave SSH", admin.ID)
|
|
}
|
|
for _, key := range admin.SSHKeys {
|
|
report.Checks["ssh_keys"]++
|
|
_, computed, err := parsePublicKeyLine(key.PublicKey)
|
|
if err != nil {
|
|
report.add("error", "invalid_public_key", filepath.Join(root, "data", "admins.json"), err.Error(), admin.ID)
|
|
continue
|
|
}
|
|
if key.Fingerprint != computed {
|
|
report.add("error", "fingerprint_mismatch", filepath.Join(root, "data", "admins.json"), "la huella almacenada no corresponde a la llave pública", map[string]any{"admin_id": admin.ID, "stored": key.Fingerprint, "computed": computed})
|
|
}
|
|
if prior, ok := fingerprints[key.Fingerprint]; ok {
|
|
report.add("error", "duplicate_ssh_key", filepath.Join(root, "data", "admins.json"), "una llave SSH está asignada a más de un administrador", []string{prior, admin.ID})
|
|
}
|
|
fingerprints[key.Fingerprint] = admin.ID
|
|
}
|
|
}
|
|
if len(registry.Admins) > 0 && ownerCount == 0 {
|
|
report.add("error", "missing_owner", filepath.Join(root, "data", "admins.json"), "debe existir al menos un administrador activo con rol owner", nil)
|
|
}
|
|
|
|
allowedPath := filepath.Join(root, "config", "allowed_signers")
|
|
allowedData, err := os.ReadFile(allowedPath)
|
|
if err != nil {
|
|
report.add("error", "allowed_signers_missing", allowedPath, "no se pudo leer el archivo de firmantes autorizados", err.Error())
|
|
return
|
|
}
|
|
allowed := string(allowedData)
|
|
for _, admin := range registry.Admins {
|
|
if !admin.Active {
|
|
continue
|
|
}
|
|
for _, key := range admin.SSHKeys {
|
|
fields := strings.Fields(key.PublicKey)
|
|
if len(fields) < 2 {
|
|
continue
|
|
}
|
|
expected := fmt.Sprintf("%s namespaces=\"git\" %s %s", admin.Email, fields[0], fields[1])
|
|
if !strings.Contains(allowed, expected) {
|
|
report.add("error", "allowed_signer_missing", allowedPath, "falta una llave activa en allowed_signers", map[string]any{"admin_id": admin.ID, "fingerprint": key.Fingerprint})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *App) validatePages(root string, registry AdminRegistry, report *ValidationReport) []pageRecord {
|
|
sections := []string{"documentacion", "manuales", "memorias", "proyectos", "tareas"}
|
|
pages := []pageRecord{}
|
|
ids := []string{}
|
|
for _, section := range sections {
|
|
files, err := listMarkdownFiles(root, section)
|
|
if err != nil {
|
|
report.add("error", "content_walk_failed", filepath.Join(root, "content", section), err.Error(), nil)
|
|
continue
|
|
}
|
|
for _, path := range files {
|
|
report.Checks["content_pages"]++
|
|
data, readErr := os.ReadFile(path)
|
|
if readErr != nil {
|
|
report.add("error", "content_read_failed", path, readErr.Error(), nil)
|
|
continue
|
|
}
|
|
if findings := scanSecrets(string(data)); len(findings) > 0 {
|
|
report.add("error", "possible_secret", path, "el contenido parece incluir secretos", findings)
|
|
}
|
|
meta, parseErr := parseMarkdown(path)
|
|
if parseErr != nil {
|
|
report.add("error", "front_matter_invalid", path, parseErr.Error(), nil)
|
|
continue
|
|
}
|
|
id := normalizeID(meta.ExtraString("id"))
|
|
if id == "" {
|
|
report.add("error", "missing_page_id", path, "la página no tiene extra.id", nil)
|
|
}
|
|
ids = append(ids, id)
|
|
pages = append(pages, pageRecord{Section: section, Meta: meta, ID: id})
|
|
if strings.TrimSpace(meta.String("title")) == "" {
|
|
report.add("error", "missing_title", path, "la página no tiene título", nil)
|
|
}
|
|
authors := valueStrings(meta.Root["authors"])
|
|
for _, author := range authors {
|
|
if _, ok := adminByID(registry, author); !ok && author != "sistema" {
|
|
report.add("error", "unknown_author", path, "la página referencia un autor no registrado", author)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, duplicate := range pageIDDuplicates(ids) {
|
|
report.add("error", "duplicate_page_id", filepath.Join(root, "content"), "hay IDs de contenido duplicados", duplicate)
|
|
}
|
|
|
|
projectIDs := map[string]bool{}
|
|
for _, page := range pages {
|
|
if page.Section == "proyectos" {
|
|
projectIDs[page.ID] = true
|
|
}
|
|
}
|
|
for _, page := range pages {
|
|
path := page.Meta.Path
|
|
switch page.Section {
|
|
case "memorias":
|
|
if !strings.HasPrefix(page.ID, "MEM-") {
|
|
report.add("error", "invalid_memory_id", path, "el ID de memoria debe iniciar con MEM-", page.ID)
|
|
}
|
|
authorID := page.Meta.ExtraString("author_id")
|
|
if _, ok := adminByID(registry, authorID); !ok && authorID != "sistema" {
|
|
report.add("error", "unknown_memory_author", path, "author_id no corresponde a un administrador", authorID)
|
|
}
|
|
fingerprint := page.Meta.ExtraString("author_fingerprint")
|
|
if authorID != "sistema" && !fingerprintBelongs(registry, authorID, fingerprint) {
|
|
report.add("error", "memory_fingerprint_mismatch", path, "author_fingerprint no pertenece al autor", map[string]any{"author_id": authorID, "fingerprint": fingerprint})
|
|
}
|
|
policy := page.Meta.ExtraString("review_policy")
|
|
if policy != "" && policy != "all-active" && policy != "all-active-except-author" {
|
|
report.add("error", "invalid_review_policy", path, "review_policy no es válido", policy)
|
|
}
|
|
if page.Meta.ExtraBool("reviewer_snapshot") {
|
|
seenReviewers := map[string]bool{}
|
|
for _, reviewer := range page.Meta.ExtraStrings("reviewers") {
|
|
if seenReviewers[reviewer] {
|
|
report.add("error", "duplicate_reviewer", path, "un revisor aparece más de una vez", reviewer)
|
|
}
|
|
seenReviewers[reviewer] = true
|
|
if _, ok := adminByID(registry, reviewer); !ok {
|
|
report.add("error", "unknown_reviewer", path, "el conjunto capturado referencia un administrador inexistente", reviewer)
|
|
}
|
|
}
|
|
}
|
|
case "tareas":
|
|
if !strings.HasPrefix(page.ID, "TSK-") {
|
|
report.add("error", "invalid_task_id", path, "el ID de tarea debe iniciar con TSK-", page.ID)
|
|
}
|
|
priority := page.Meta.ExtraString("priority")
|
|
if priority != "low" && priority != "medium" && priority != "high" && priority != "urgent" {
|
|
report.add("error", "invalid_task_priority", path, "priority no es válida", priority)
|
|
}
|
|
status := firstNonEmpty(page.Meta.ExtraString("status"), "todo")
|
|
if !validTaskStatus(status) {
|
|
report.add("error", "invalid_task_status", path, "status no es válido", status)
|
|
}
|
|
if due := page.Meta.ExtraString("due"); validateDate(due) != nil {
|
|
report.add("error", "invalid_due", path, "due debe usar YYYY-MM-DD", due)
|
|
}
|
|
projectID := normalizeID(page.Meta.ExtraString("project_id"))
|
|
if projectID != "" && !projectIDs[projectID] {
|
|
report.add("error", "unknown_project", path, "project_id no existe", projectID)
|
|
}
|
|
for _, assignee := range page.Meta.ExtraStrings("assignees") {
|
|
admin, ok := adminByID(registry, assignee)
|
|
if !ok || !admin.Active {
|
|
report.add("error", "invalid_assignee", path, "assignee no existe o está inactivo", assignee)
|
|
}
|
|
}
|
|
case "proyectos":
|
|
if !strings.HasPrefix(page.ID, "PRJ-") {
|
|
report.add("error", "invalid_project_id", path, "el ID de proyecto debe iniciar con PRJ-", page.ID)
|
|
}
|
|
status := firstNonEmpty(page.Meta.ExtraString("status"), "planned")
|
|
if !validProjectStatus(status) {
|
|
report.add("error", "invalid_project_status", path, "status no es válido", status)
|
|
}
|
|
if target := page.Meta.ExtraString("target_date"); validateDate(target) != nil {
|
|
report.add("error", "invalid_target_date", path, "target_date debe usar YYYY-MM-DD", target)
|
|
}
|
|
for _, owner := range page.Meta.ExtraStrings("owners") {
|
|
admin, ok := adminByID(registry, owner)
|
|
if !ok || !admin.Active {
|
|
report.add("error", "invalid_owner", path, "owner no existe o está inactivo", owner)
|
|
}
|
|
}
|
|
case "documentacion":
|
|
if !strings.HasPrefix(page.ID, "DOC-") {
|
|
report.add("error", "invalid_document_id", path, "el ID de documentación debe iniciar con DOC-", page.ID)
|
|
}
|
|
case "manuales":
|
|
if !strings.HasPrefix(page.ID, "RUN-") {
|
|
report.add("error", "invalid_manual_id", path, "el ID de manual debe iniciar con RUN-", page.ID)
|
|
}
|
|
}
|
|
}
|
|
return pages
|
|
}
|
|
|
|
func (a *App) validateEvents(root string, registry AdminRegistry, pages []pageRecord, report *ValidationReport) {
|
|
memories := map[string]pageRecord{}
|
|
tasks := map[string]pageRecord{}
|
|
for _, page := range pages {
|
|
if page.Section == "memorias" {
|
|
memories[page.ID] = page
|
|
}
|
|
if page.Section == "tareas" {
|
|
tasks[page.ID] = page
|
|
}
|
|
}
|
|
reviews, err := loadReviewLog(root)
|
|
if err != nil {
|
|
report.add("error", "review_log_invalid", filepath.Join(root, "data", reviewEventsDirName), err.Error(), nil)
|
|
} else {
|
|
seen := map[string]bool{}
|
|
for _, event := range reviews.Events {
|
|
report.Checks["review_events"]++
|
|
if seen[event.ID] {
|
|
report.add("error", "duplicate_review_event", filepath.Join(root, "data", reviewEventsDirName), "ID de evento de revisión duplicado", event.ID)
|
|
}
|
|
seen[event.ID] = true
|
|
if _, ok := memories[normalizeID(event.EntryID)]; !ok {
|
|
report.add("warning", "orphan_review_event", filepath.Join(root, "data", reviewEventsDirName), "el evento apunta a una memoria que ya no está en el árbol actual", event.EntryID)
|
|
}
|
|
if _, ok := adminByID(registry, event.AdminID); !ok {
|
|
report.add("error", "unknown_review_admin", filepath.Join(root, "data", reviewEventsDirName), "admin_id de revisión no existe", event.AdminID)
|
|
}
|
|
if !fingerprintBelongs(registry, event.AdminID, event.Fingerprint) {
|
|
report.add("error", "review_fingerprint_mismatch", filepath.Join(root, "data", reviewEventsDirName), "la huella del evento no pertenece al administrador", event.ID)
|
|
}
|
|
if _, parseErr := time.Parse(time.RFC3339, event.ReviewedAt); parseErr != nil {
|
|
report.add("error", "invalid_review_time", filepath.Join(root, "data", reviewEventsDirName), "reviewed_at no es RFC3339", event.ID)
|
|
}
|
|
if len(event.ContentSHA256) != 64 {
|
|
report.add("error", "invalid_review_hash", filepath.Join(root, "data", reviewEventsDirName), "content_sha256 no tiene longitud SHA-256", event.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
log, err := loadTaskLog(root)
|
|
if err != nil {
|
|
report.add("error", "task_log_invalid", filepath.Join(root, "data", taskEventsDirName), err.Error(), nil)
|
|
return
|
|
}
|
|
seen := map[string]bool{}
|
|
activeTimer := map[string]TaskEvent{}
|
|
totalMinutes := map[string]int{}
|
|
for _, event := range log.Events {
|
|
report.Checks["task_events"]++
|
|
if seen[event.ID] {
|
|
report.add("error", "duplicate_task_event", filepath.Join(root, "data", taskEventsDirName), "ID de evento de tarea duplicado", event.ID)
|
|
}
|
|
seen[event.ID] = true
|
|
if _, ok := tasks[normalizeID(event.TaskID)]; !ok {
|
|
report.add("error", "unknown_task_event_target", filepath.Join(root, "data", taskEventsDirName), "el evento apunta a una tarea inexistente", event.TaskID)
|
|
}
|
|
if _, ok := adminByID(registry, event.AdminID); !ok {
|
|
report.add("error", "unknown_task_admin", filepath.Join(root, "data", taskEventsDirName), "admin_id de evento no existe", event.AdminID)
|
|
}
|
|
if !fingerprintBelongs(registry, event.AdminID, event.Fingerprint) {
|
|
report.add("error", "task_fingerprint_mismatch", filepath.Join(root, "data", taskEventsDirName), "la huella del evento no pertenece al administrador", event.ID)
|
|
}
|
|
if _, parseErr := time.Parse(time.RFC3339, event.At); parseErr != nil {
|
|
report.add("error", "invalid_task_event_time", filepath.Join(root, "data", taskEventsDirName), "at no es RFC3339", event.ID)
|
|
}
|
|
switch event.Type {
|
|
case "start":
|
|
if active, ok := activeTimer[event.AdminID]; ok {
|
|
code := "duplicate_task_start"
|
|
message := "el administrador inició dos veces la misma tarea sin detenerla"
|
|
if normalizeID(active.TaskID) != normalizeID(event.TaskID) {
|
|
code = "concurrent_task_timers"
|
|
message = "el administrador tiene más de un temporizador activo"
|
|
}
|
|
report.add("error", code, filepath.Join(root, "data", taskEventsDirName), message, map[string]any{
|
|
"admin_id": event.AdminID, "active_event": active.ID, "new_event": event.ID,
|
|
})
|
|
}
|
|
activeTimer[event.AdminID] = event
|
|
case "stop":
|
|
active, ok := activeTimer[event.AdminID]
|
|
if !ok || normalizeID(active.TaskID) != normalizeID(event.TaskID) {
|
|
report.add("error", "task_stop_without_start", filepath.Join(root, "data", taskEventsDirName), "el evento stop no corresponde a un temporizador activo", event.ID)
|
|
} else {
|
|
delete(activeTimer, event.AdminID)
|
|
}
|
|
if event.Minutes <= 0 {
|
|
report.add("error", "invalid_stop_minutes", filepath.Join(root, "data", taskEventsDirName), "un evento stop debe registrar minutos positivos", event.ID)
|
|
}
|
|
totalMinutes[normalizeID(event.TaskID)] += event.Minutes
|
|
case "adjust":
|
|
totalMinutes[normalizeID(event.TaskID)] += event.Minutes
|
|
if event.Minutes == 0 {
|
|
report.add("error", "invalid_adjust_minutes", filepath.Join(root, "data", taskEventsDirName), "un ajuste no puede ser cero", event.ID)
|
|
}
|
|
case "status":
|
|
if !validTaskStatus(event.Status) {
|
|
report.add("error", "invalid_status_event", filepath.Join(root, "data", taskEventsDirName), "status no es válido", event.Status)
|
|
}
|
|
default:
|
|
report.add("error", "invalid_task_event_type", filepath.Join(root, "data", taskEventsDirName), "tipo de evento desconocido", event.Type)
|
|
}
|
|
}
|
|
|
|
for taskID, minutes := range totalMinutes {
|
|
if minutes < 0 {
|
|
report.add("error", "negative_task_time", filepath.Join(root, "data", taskEventsDirName), "los eventos dejan el tiempo acumulado por debajo de cero", map[string]any{
|
|
"task_id": taskID, "actual_minutes": minutes,
|
|
})
|
|
}
|
|
}
|
|
|
|
// La lectura estable de errores facilita comparaciones en CI y por agentes.
|
|
sort.Slice(report.Errors, func(i, j int) bool {
|
|
if report.Errors[i].Path == report.Errors[j].Path {
|
|
return report.Errors[i].Code < report.Errors[j].Code
|
|
}
|
|
return report.Errors[i].Path < report.Errors[j].Path
|
|
})
|
|
sort.Slice(report.Warnings, func(i, j int) bool {
|
|
if report.Warnings[i].Path == report.Warnings[j].Path {
|
|
return report.Warnings[i].Code < report.Warnings[j].Code
|
|
}
|
|
return report.Warnings[i].Path < report.Warnings[j].Path
|
|
})
|
|
}
|