package gsp import ( "encoding/json" "fmt" "path/filepath" "sort" "strings" ) func (p *Project) Validate(stage string) Report { report := Report{OK: true} for _, issue := range p.LoadIssues { switch issue.Level { case "error": report.Errors = append(report.Errors, issue) report.OK = false case "warning": report.Warnings = append(report.Warnings, issue) default: report.Notices = append(report.Notices, issue) } } if p.Manifest != nil { if p.Manifest.GSPVersion == "" { report.addWarning("missing_gsp_version", "", p.Manifest.File, "manifest has no gspVersion") } else if !SupportsGSPVersion(p.Manifest.GSPVersion) { report.addError("unsupported_gsp_version", "", p.Manifest.File, fmt.Sprintf("GSP version %q is not supported by this toolkit", p.Manifest.GSPVersion)) } } p.validateFieldRegistry(&report) for _, unit := range p.Units { if unit.ID == "" { report.addError("missing_id", "", unit.File, "GSP requires id") } if unit.Resolution != "" { if _, ok := resolutionValue(unit.Resolution); !ok { report.addError("invalid_resolution", unit.ID, unit.File, fmt.Sprintf("resolution %q is not allowed", unit.Resolution)) } } for _, rel := range unit.With { if _, ok := p.ByID[rel.ID]; !ok { report.addError("missing_with", unit.ID, unit.File, fmt.Sprintf("with references missing GSP %q", rel.ID)) } } for _, link := range unit.Links { role := strings.TrimSpace(link.Role) if role != "" && !allowedLinkRoles[role] { report.addError("invalid_link_role", unit.ID, unit.File, fmt.Sprintf("link role %q is not allowed", role)) } resolved := p.resolveLink(unit, link) switch resolved.Status { case "missing": report.addWarning("missing_link", unit.ID, unit.File, fmt.Sprintf("link %q does not exist", resolved.Path)) case "invalid": report.addError("invalid_link", unit.ID, unit.File, fmt.Sprintf("link %q is invalid", resolved.Path)) } if filepath.IsAbs(resolved.Path) { report.addWarning("absolute_link_path", unit.ID, unit.File, fmt.Sprintf("link %q is absolute and reduces portability", resolved.Path)) } } if unit.Refines != "" { if _, ok := p.ByID[unit.Refines]; !ok { report.addError("missing_refines", unit.ID, unit.File, fmt.Sprintf("refines references missing GSP %q", unit.Refines)) } } if unit.Context == "" { report.addNotice("placeholder", unit.ID, unit.File, "GSP has no context and is treated as placeholder") } p.validateCustomFields(&report, unit) } for id, units := range p.Duplicates { files := make([]string, 0, len(units)) seen := map[string]bool{} for _, unit := range units { if !seen[unit.File] { files = append(files, unit.File) seen[unit.File] = true } } sort.Strings(files) report.addError("duplicate_id", id, "", fmt.Sprintf("id %q is defined more than once: %v", id, files)) } if stage != "" { stageReport := p.StageCheck(stage) report.Errors = append(report.Errors, stageReport.Errors...) report.Warnings = append(report.Warnings, stageReport.Warnings...) if len(stageReport.Errors) > 0 { report.OK = false } } return report } func (p *Project) Index() []IndexEntry { entries := make([]IndexEntry, 0, len(p.Units)) for _, unit := range p.Units { with := make([]string, 0, len(unit.With)) for _, rel := range unit.With { with = append(with, rel.ID) } links := make([]string, 0, len(unit.Links)) for _, link := range unit.Links { links = append(links, link.Path) } sort.Strings(with) sort.Strings(links) entries = append(entries, IndexEntry{ ID: unit.ID, Title: unit.Title, File: unit.File, Type: unit.Type, Resolution: unit.Resolution, With: with, Refines: unit.Refines, Links: links, }) } sort.Slice(entries, func(i, j int) bool { return entries[i].ID < entries[j].ID }) return entries } var defaultStageRules = map[string]string{ "design": "L0", "integrate": "L2", "implement": "L3", "bind": "L4", "release": "L5", } func (p *Project) StageCheck(stage string) Report { report := Report{OK: true} required, ok := p.Manifest.minResolution(stage) if !ok { report.addError("unknown_stage", "", "", fmt.Sprintf("unknown stage %q", stage)) return report } minRank, _ := resolutionValue(required) for _, unit := range p.Units { rank, ok := resolutionValue(unit.Resolution) if !ok { report.addError("invalid_resolution", unit.ID, unit.File, fmt.Sprintf("resolution %q is not allowed", unit.Resolution)) continue } if rank < minRank { report.addError("low_resolution", unit.ID, unit.File, fmt.Sprintf("resolution %s is below %s for stage %s", displayResolution(unit.Resolution), required, stage)) } } if len(report.Errors) == 0 { report.OK = true } return report } func displayResolution(value string) string { if value == "" { return "L0" } return value } func (p *Project) Trace(id string, depth int, filter Filter) TraceResult { flatten := p.Flatten(id, depth, filter) graph := p.graphForUnits(flatten.Units) return TraceResult{ Entry: id, Depth: depth, Nodes: flatten.Units, Edges: graph.Edges, Warnings: flatten.Warnings, } } func (p *Project) Flatten(id string, depth int, filter Filter) FlattenResult { walker := &walker{ project: p, depth: depth, filter: filter, seen: map[string]bool{}, stack: map[string]bool{}, } walker.visit(id, 0) return FlattenResult{ Entry: id, Depth: depth, Units: walker.units, Warnings: walker.warnings, } } func (p *Project) Pack(id string, depth, budget int, filter Filter) PackResult { return p.PackFor(id, "", "", depth, budget, filter) } func (p *Project) PackFor(id, intent, stage string, depth, budget int, filter Filter) PackResult { flattened := p.Flatten(id, depth, filter) units := make([]*Unit, 0, len(flattened.Units)) approx := 0 truncated := false for _, unit := range flattened.Units { candidate := append(units, unit) data, _ := json.Marshal(candidate) if budget > 0 && len(data) > budget && len(units) > 0 { truncated = true break } units = candidate approx = len(data) } links := p.resolveLinks(units) return PackResult{ Entry: id, Intent: intent, Stage: stage, Depth: depth, Budget: budget, Truncated: truncated, Units: units, Links: links, Summary: summarizeUnits(units, len(flattened.Warnings), len(links)), ApproxChars: approx, Warnings: flattened.Warnings, } } func summarizeUnits(units []*Unit, missingCount, linkCount int) Summary { summary := Summary{ UnitCount: len(units), TypeCounts: map[string]int{}, MissingCount: missingCount, LinkCount: linkCount, } min := 99 max := -1 for _, unit := range units { if unit.Type != "" { summary.TypeCounts[unit.Type]++ } rank, ok := resolutionValue(unit.Resolution) if !ok { continue } if rank < min { min = rank summary.MinResolution = displayResolution(unit.Resolution) } if rank > max { max = rank summary.MaxResolution = displayResolution(unit.Resolution) } } if len(summary.TypeCounts) == 0 { summary.TypeCounts = nil } return summary } func (p *Project) Impact(id string, depth int) ImpactResult { result := ImpactResult{Entry: id, Depth: depth} if _, ok := p.ByID[id]; !ok { result.Warnings = append(result.Warnings, Issue{Level: "warning", Code: "missing", ID: id, Message: fmt.Sprintf("missing GSP %q", id)}) return result } reverse := map[string][]ImpactEntry{} for _, unit := range p.Units { if unit.Refines != "" { reverse[unit.Refines] = append(reverse[unit.Refines], impactEntry(unit, 0, unit.Refines, "refines")) } for _, rel := range unit.With { reverse[rel.ID] = append(reverse[rel.ID], impactEntry(unit, 0, rel.ID, "with")) } } for key := range reverse { sort.Slice(reverse[key], func(i, j int) bool { return reverse[key][i].ID < reverse[key][j].ID }) } seen := map[string]bool{id: true} queue := []ImpactEntry{{ID: id, Depth: 0}} for len(queue) > 0 { current := queue[0] queue = queue[1:] if depth >= 0 && current.Depth >= depth { continue } for _, affected := range reverse[current.ID] { if seen[affected.ID] { continue } seen[affected.ID] = true affected.Depth = current.Depth + 1 affected.Via = current.ID if affected.Depth == 1 { result.Direct = append(result.Direct, affected) } else { result.Indirect = append(result.Indirect, affected) } result.Edges = append(result.Edges, GraphEdge{From: affected.ID, To: current.ID, Kind: affected.Kind}) queue = append(queue, affected) if affected.Depth > result.Summary.MaxDepth { result.Summary.MaxDepth = affected.Depth } } } sortImpactEntries(result.Direct) sortImpactEntries(result.Indirect) sort.Slice(result.Edges, func(i, j int) bool { return strings.Join([]string{result.Edges[i].From, result.Edges[i].To, result.Edges[i].Kind}, "\x00") < strings.Join([]string{result.Edges[j].From, result.Edges[j].To, result.Edges[j].Kind}, "\x00") }) result.Summary.DirectCount = len(result.Direct) result.Summary.IndirectCount = len(result.Indirect) result.Summary.TotalCount = len(result.Direct) + len(result.Indirect) return result } func impactEntry(unit *Unit, depth int, via, kind string) ImpactEntry { return ImpactEntry{ ID: unit.ID, Title: unit.Title, Type: unit.Type, Resolution: unit.Resolution, File: unit.File, Depth: depth, Via: via, Kind: kind, } } func sortImpactEntries(entries []ImpactEntry) { sort.Slice(entries, func(i, j int) bool { if entries[i].Depth == entries[j].Depth { return entries[i].ID < entries[j].ID } return entries[i].Depth < entries[j].Depth }) } type walker struct { project *Project depth int filter Filter seen map[string]bool stack map[string]bool units []*Unit warnings []Issue } func (w *walker) visit(id string, currentDepth int) { if w.depth >= 0 && currentDepth > w.depth { return } if w.stack[id] { w.warnings = append(w.warnings, Issue{Level: "warning", Code: "cycle", ID: id, Message: fmt.Sprintf("cycle detected at %q", id)}) return } unit, ok := w.project.ByID[id] if !ok { w.warnings = append(w.warnings, Issue{Level: "warning", Code: "missing", ID: id, Message: fmt.Sprintf("missing GSP %q", id)}) return } if w.seen[id] { return } w.seen[id] = true if currentDepth == 0 || w.filter.Allows(unit) { w.units = append(w.units, unit) } w.stack[id] = true if unit.Refines != "" { w.visit(unit.Refines, currentDepth+1) } withIDs := make([]string, 0, len(unit.With)) for _, rel := range unit.With { withIDs = append(withIDs, rel.ID) } sort.Strings(withIDs) for _, relID := range withIDs { w.visit(relID, currentDepth+1) } delete(w.stack, id) }