2026-05-06 18:40:37 +08:00
package gsp
import (
"encoding/json"
"fmt"
2026-05-07 10:41:38 +08:00
"path/filepath"
2026-05-06 18:40:37 +08:00
"sort"
2026-05-07 10:17:24 +08:00
"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 {
2026-05-06 19:06:32 +08:00
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 ) )
}
}
2026-05-07 10:41:38 +08:00
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 )
}
2026-05-07 10:41:38 +08:00
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 )
2026-05-07 10:41:38 +08:00
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 ,
2026-05-07 10:41:38 +08:00
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
}
2026-05-06 19:06:32 +08:00
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 }
2026-05-06 19:06:32 +08:00
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 {
2026-05-07 10:17:24 +08:00
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 )
}
2026-05-07 10:41:38 +08:00
links := p . resolveLinks ( units )
2026-05-06 18:40:37 +08:00
return PackResult {
Entry : id ,
2026-05-07 10:17:24 +08:00
Intent : intent ,
Stage : stage ,
2026-05-06 18:40:37 +08:00
Depth : depth ,
Budget : budget ,
Truncated : truncated ,
Units : units ,
2026-05-07 10:41:38 +08:00
Links : links ,
Summary : summarizeUnits ( units , len ( flattened . Warnings ) , len ( links ) ) ,
2026-05-06 18:40:37 +08:00
ApproxChars : approx ,
Warnings : flattened . Warnings ,
}
}
2026-05-07 10:41:38 +08:00
func summarizeUnits ( units [ ] * Unit , missingCount , linkCount int ) Summary {
2026-05-07 10:17:24 +08:00
summary := Summary {
UnitCount : len ( units ) ,
TypeCounts : map [ string ] int { } ,
MissingCount : missingCount ,
2026-05-07 10:41:38 +08:00
LinkCount : linkCount ,
2026-05-07 10:17:24 +08:00
}
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 )
}