Files
GSP/toolkit/internal/gsp/project.go

386 lines
11 KiB
Go
Raw Normal View History

2026-05-06 18:40:37 +08:00
package gsp
import (
"encoding/json"
"fmt"
"path/filepath"
2026-05-06 18:40:37 +08:00
"sort"
"strings"
2026-05-06 18:40:37 +08:00
)
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)
}
2026-05-06 18:40:37 +08:00
}
2026-05-06 19:40:55 +08:00
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))
}
}
2026-05-07 11:04:11 +08:00
p.validateFieldRegistry(&report)
2026-05-06 18:40:37 +08:00
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))
}
}
2026-05-06 18:40:37 +08:00
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")
}
2026-05-07 11:04:11 +08:00
p.validateCustomFields(&report, unit)
2026-05-06 18:40:37 +08:00
}
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)
}
2026-05-06 18:40:37 +08:00
sort.Strings(with)
sort.Strings(links)
2026-05-06 18:40:37 +08:00
entries = append(entries, IndexEntry{
ID: unit.ID,
2026-05-06 20:00:50 +08:00
Title: unit.Title,
2026-05-06 18:40:37 +08:00
File: unit.File,
Type: unit.Type,
Resolution: unit.Resolution,
With: with,
Refines: unit.Refines,
Links: links,
2026-05-06 18:40:37 +08:00
})
}
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",
}
2026-05-06 18:40:37 +08:00
func (p *Project) StageCheck(stage string) Report {
report := Report{OK: true}
required, ok := p.Manifest.minResolution(stage)
2026-05-06 18:40:37 +08:00
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 {
2026-05-06 18:40:37 +08:00
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)
2026-05-06 18:40:37 +08:00
return PackResult{
Entry: id,
Intent: intent,
Stage: stage,
2026-05-06 18:40:37 +08:00
Depth: depth,
Budget: budget,
Truncated: truncated,
Units: units,
Links: links,
Summary: summarizeUnits(units, len(flattened.Warnings), len(links)),
2026-05-06 18:40:37 +08:00
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
})
}
2026-05-06 18:40:37 +08:00
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)
}