Add context packs impact analysis and message validation

This commit is contained in:
2026-05-07 10:17:24 +08:00
parent f2d0a83705
commit 0c5254eb1b
18 changed files with 780 additions and 13 deletions

View File

@@ -116,6 +116,8 @@ func aiUsage(gspVersion, scan string) string {
- Use `+"`gsp trace <id>`"+` to inspect relations.
- Use `+"`gsp flatten <id>`"+` before implementation or task splitting.
- Use `+"`gsp pack <id>`"+` when a compact AI context is needed.
- Use `+"`gsp impact <id>`"+` before changing shared GSP units.
- Use `+"`gsp message validate <file>`"+` for agent communication messages.
- Use `+"`gsp stage-check --stage <stage>`"+` before stage handoff.
`, gspVersion, scan)

View File

@@ -0,0 +1,113 @@
package gsp
import (
"fmt"
"sort"
"strings"
)
func (p PackResult) Markdown() string {
var builder strings.Builder
builder.WriteString("# GSP Context Pack\n\n")
builder.WriteString(fmt.Sprintf("- Entry: `%s`\n", p.Entry))
if p.Intent != "" {
builder.WriteString(fmt.Sprintf("- Intent: `%s`\n", p.Intent))
}
if p.Stage != "" {
builder.WriteString(fmt.Sprintf("- Stage: `%s`\n", p.Stage))
}
builder.WriteString(fmt.Sprintf("- Units: `%d`\n", p.Summary.UnitCount))
if p.Summary.MinResolution != "" {
builder.WriteString(fmt.Sprintf("- Resolution: `%s` to `%s`\n", p.Summary.MinResolution, p.Summary.MaxResolution))
}
if p.Budget > 0 {
builder.WriteString(fmt.Sprintf("- Budget: `%d`\n", p.Budget))
builder.WriteString(fmt.Sprintf("- Approx chars: `%d`\n", p.ApproxChars))
builder.WriteString(fmt.Sprintf("- Truncated: `%v`\n", p.Truncated))
}
builder.WriteString("\n## Units\n\n")
for _, unit := range p.Units {
builder.WriteString(fmt.Sprintf("### %s\n\n", unit.DisplayTitle()))
builder.WriteString(fmt.Sprintf("- id: `%s`\n", unit.ID))
if unit.Type != "" {
builder.WriteString(fmt.Sprintf("- type: `%s`\n", unit.Type))
}
if unit.Resolution != "" {
builder.WriteString(fmt.Sprintf("- resolution: `%s`\n", unit.Resolution))
}
if unit.Refines != "" {
builder.WriteString(fmt.Sprintf("- refines: `%s`\n", unit.Refines))
}
if len(unit.With) > 0 {
var with []string
for _, rel := range unit.With {
with = append(with, "`"+rel.ID+"`")
}
sort.Strings(with)
builder.WriteString("- with: " + strings.Join(with, ", ") + "\n")
}
if unit.Context != "" {
builder.WriteString("\n")
builder.WriteString(unit.Context)
builder.WriteString("\n")
}
builder.WriteString("\n")
}
if len(p.Warnings) > 0 {
builder.WriteString("## Warnings\n\n")
for _, warning := range p.Warnings {
builder.WriteString(fmt.Sprintf("- `%s`: %s\n", warning.Code, warning.Message))
}
}
return builder.String()
}
func (r ImpactResult) Markdown() string {
var builder strings.Builder
builder.WriteString("# GSP Impact\n\n")
builder.WriteString(fmt.Sprintf("- Entry: `%s`\n", r.Entry))
builder.WriteString(fmt.Sprintf("- Direct affected: `%d`\n", r.Summary.DirectCount))
builder.WriteString(fmt.Sprintf("- Indirect affected: `%d`\n", r.Summary.IndirectCount))
builder.WriteString(fmt.Sprintf("- Max depth: `%d`\n", r.Summary.MaxDepth))
writeImpactSection(&builder, "Direct", r.Direct)
writeImpactSection(&builder, "Indirect", r.Indirect)
if len(r.Warnings) > 0 {
builder.WriteString("## Warnings\n\n")
for _, warning := range r.Warnings {
builder.WriteString(fmt.Sprintf("- `%s`: %s\n", warning.Code, warning.Message))
}
}
return builder.String()
}
func writeImpactSection(builder *strings.Builder, title string, entries []ImpactEntry) {
builder.WriteString("\n## " + title + "\n\n")
if len(entries) == 0 {
builder.WriteString("None.\n")
return
}
for _, entry := range entries {
label := entry.Title
if label == "" {
label = entry.ID
}
builder.WriteString(fmt.Sprintf("- `%s` %s", entry.ID, label))
if entry.Kind != "" || entry.Via != "" {
builder.WriteString(fmt.Sprintf(" via `%s` `%s`", entry.Kind, entry.Via))
}
if entry.Type != "" {
builder.WriteString(fmt.Sprintf(" type `%s`", entry.Type))
}
if entry.Resolution != "" {
builder.WriteString(fmt.Sprintf(" resolution `%s`", entry.Resolution))
}
builder.WriteString("\n")
}
}
func (u Unit) DisplayTitle() string {
if strings.TrimSpace(u.Title) != "" {
return u.Title
}
return u.ID
}

View File

@@ -96,6 +96,33 @@ func (g Graph) Markdown() string {
return builder.String()
}
func (p *Project) ImpactGraph(impact ImpactResult) Graph {
nodeMap := map[string]GraphNode{}
if unit, ok := p.ByID[impact.Entry]; ok {
nodeMap[impact.Entry] = GraphNode{ID: unit.ID, Title: unit.Title, Type: unit.Type, Resolution: unit.Resolution, File: unit.File}
} else {
nodeMap[impact.Entry] = GraphNode{ID: impact.Entry, Missing: true}
}
for _, entry := range append(append([]ImpactEntry{}, impact.Direct...), impact.Indirect...) {
nodeMap[entry.ID] = GraphNode{ID: entry.ID, Title: entry.Title, Type: entry.Type, Resolution: entry.Resolution, File: entry.File}
if _, ok := nodeMap[entry.Via]; !ok && entry.Via != "" {
if unit, exists := p.ByID[entry.Via]; exists {
nodeMap[entry.Via] = GraphNode{ID: unit.ID, Title: unit.Title, Type: unit.Type, Resolution: unit.Resolution, File: unit.File}
} else {
nodeMap[entry.Via] = GraphNode{ID: entry.Via, Missing: true}
}
}
}
nodes := make([]GraphNode, 0, len(nodeMap))
for _, node := range nodeMap {
nodes = append(nodes, node)
}
sort.Slice(nodes, func(i, j int) bool {
return nodes[i].ID < nodes[j].ID
})
return Graph{Nodes: nodes, Edges: impact.Edges}
}
func (g Graph) Canvas() ([]byte, error) {
canvas := canvasDocument{
Nodes: make([]canvasNode, 0, len(g.Nodes)),

View File

@@ -0,0 +1,99 @@
package gsp
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
const GSPMessageVersion = "0.1"
var allowedMessageIntents = map[string]bool{
"design": true,
"implement": true,
"review": true,
"test": true,
"acceptance": true,
"handoff": true,
"inspect": true,
}
type Message struct {
MessageVersion string `json:"gspMessageVersion,omitempty" yaml:"gspMessageVersion"`
ID string `json:"id,omitempty" yaml:"id"`
From string `json:"from,omitempty" yaml:"from"`
To string `json:"to,omitempty" yaml:"to"`
Intent string `json:"intent,omitempty" yaml:"intent"`
Entry string `json:"entry,omitempty" yaml:"entry"`
Stage string `json:"stage,omitempty" yaml:"stage"`
Requires []string `json:"requires,omitempty" yaml:"requires"`
ContextPack ContextPack `json:"contextPack,omitempty" yaml:"contextPack"`
File string `json:"file,omitempty" yaml:"-"`
}
type ContextPack struct {
Mode string `json:"mode,omitempty" yaml:"mode"`
Depth int `json:"depth,omitempty" yaml:"depth"`
Budget int `json:"budget,omitempty" yaml:"budget"`
}
func ReadMessage(root, file string) (Message, error) {
path := file
if !filepath.IsAbs(path) {
path = filepath.Join(root, file)
}
data, err := os.ReadFile(path)
if err != nil {
return Message{}, err
}
var message Message
if err := yaml.Unmarshal(data, &message); err != nil {
return Message{}, err
}
message.File = relPath(root, path)
return message, nil
}
func (p *Project) ValidateMessage(message Message) Report {
report := Report{OK: true}
if message.MessageVersion == "" {
report.addError("missing_message_version", message.ID, message.File, "message requires gspMessageVersion")
} else if message.MessageVersion != GSPMessageVersion {
report.addError("unsupported_message_version", message.ID, message.File, fmt.Sprintf("GSP message version %q is not supported", message.MessageVersion))
}
if message.ID == "" {
report.addError("missing_id", "", message.File, "message requires id")
}
if message.From == "" {
report.addError("missing_from", message.ID, message.File, "message requires from")
}
if message.To == "" {
report.addError("missing_to", message.ID, message.File, "message requires to")
}
if message.Intent == "" {
report.addError("missing_intent", message.ID, message.File, "message requires intent")
} else if !allowedMessageIntents[message.Intent] {
report.addError("invalid_intent", message.ID, message.File, fmt.Sprintf("intent %q is not allowed", message.Intent))
}
if message.Entry == "" {
report.addError("missing_entry", message.ID, message.File, "message requires entry")
} else if _, ok := p.ByID[message.Entry]; !ok {
report.addError("missing_entry_gsp", message.Entry, message.File, fmt.Sprintf("entry references missing GSP %q", message.Entry))
}
if message.Stage != "" {
if _, ok := p.Manifest.minResolution(message.Stage); !ok {
report.addError("unknown_stage", message.ID, message.File, fmt.Sprintf("unknown stage %q", message.Stage))
}
}
for _, id := range message.Requires {
if _, ok := p.ByID[id]; !ok {
report.addError("missing_required_gsp", id, message.File, fmt.Sprintf("requires references missing GSP %q", id))
}
}
if message.ContextPack.Mode != "" && !allowedMessageIntents[message.ContextPack.Mode] {
report.addError("invalid_context_pack_mode", message.ID, message.File, fmt.Sprintf("contextPack mode %q is not allowed", message.ContextPack.Mode))
}
return report
}

View File

@@ -131,14 +131,53 @@ type TraceResult struct {
type PackResult struct {
Entry string `json:"entry"`
Intent string `json:"intent,omitempty"`
Stage string `json:"stage,omitempty"`
Depth int `json:"depth"`
Budget int `json:"budget,omitempty"`
Truncated bool `json:"truncated"`
Units []*Unit `json:"units"`
Summary Summary `json:"summary"`
ApproxChars int `json:"approxChars"`
Warnings []Issue `json:"warnings,omitempty"`
}
type Summary struct {
UnitCount int `json:"unitCount"`
MinResolution string `json:"minResolution,omitempty"`
MaxResolution string `json:"maxResolution,omitempty"`
TypeCounts map[string]int `json:"typeCounts,omitempty"`
MissingCount int `json:"missingCount,omitempty"`
}
type ImpactResult struct {
Entry string `json:"entry"`
Depth int `json:"depth"`
Summary ImpactSummary `json:"summary"`
Direct []ImpactEntry `json:"direct,omitempty"`
Indirect []ImpactEntry `json:"indirect,omitempty"`
Edges []GraphEdge `json:"edges,omitempty"`
Warnings []Issue `json:"warnings,omitempty"`
}
type ImpactSummary struct {
DirectCount int `json:"directCount"`
IndirectCount int `json:"indirectCount"`
TotalCount int `json:"totalCount"`
MaxDepth int `json:"maxDepth"`
}
type ImpactEntry struct {
ID string `json:"id"`
Title string `json:"title,omitempty"`
Type string `json:"type,omitempty"`
Resolution string `json:"resolution,omitempty"`
File string `json:"file,omitempty"`
Depth int `json:"depth"`
Via string `json:"via,omitempty"`
Kind string `json:"kind,omitempty"`
}
type Graph struct {
Nodes []GraphNode `json:"nodes"`
Edges []GraphEdge `json:"edges"`

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"sort"
"strings"
)
func (p *Project) Validate(stage string) Report {
@@ -165,6 +166,10 @@ func (p *Project) Flatten(id string, depth int, filter Filter) FlattenResult {
}
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
@@ -181,15 +186,131 @@ func (p *Project) Pack(id string, depth, budget int, filter Filter) PackResult {
}
return PackResult{
Entry: id,
Intent: intent,
Stage: stage,
Depth: depth,
Budget: budget,
Truncated: truncated,
Units: units,
Summary: summarizeUnits(units, len(flattened.Warnings)),
ApproxChars: approx,
Warnings: flattened.Warnings,
}
}
func summarizeUnits(units []*Unit, missingCount int) Summary {
summary := Summary{
UnitCount: len(units),
TypeCounts: map[string]int{},
MissingCount: missingCount,
}
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

View File

@@ -91,6 +91,25 @@ context: Base lottery page.
if !foundTitle {
t.Fatalf("expected canvas node text to include title, got %+v", canvas.Nodes)
}
pack := project.PackFor("page.lottery.main", "implement", "implement", -1, 0, Filter{})
if pack.Summary.UnitCount != 4 {
t.Fatalf("pack unit count = %d", pack.Summary.UnitCount)
}
if !strings.Contains(pack.Markdown(), "GSP Context Pack") {
t.Fatal("expected pack markdown")
}
impact := project.Impact("feedback.positive", -1)
if impact.Summary.DirectCount != 1 {
t.Fatalf("impact direct count = %d", impact.Summary.DirectCount)
}
if impact.Direct[0].ID != "page.lottery.main" {
t.Fatalf("impact direct = %+v", impact.Direct)
}
if !strings.Contains(impact.Markdown(), "GSP Impact") {
t.Fatal("expected impact markdown")
}
}
func TestValidateMissingReference(t *testing.T) {
@@ -263,6 +282,40 @@ func TestInitAIUsageRequiresManifest(t *testing.T) {
}
}
func TestValidateMessage(t *testing.T) {
root := t.TempDir()
if _, err := InitProject(root, InitOptions{
Entry: "page.sample.main",
}); err != nil {
t.Fatal(err)
}
writeTestFile(t, root, "message.gspmsg", `gspMessageVersion: 0.1
id: msg.sample
from: planner
to: implementer
intent: implement
entry: page.sample.main
stage: implement
requires:
- page.sample.main
contextPack:
mode: implement
depth: -1
`)
project, err := LoadProject(root)
if err != nil {
t.Fatal(err)
}
message, err := ReadMessage(project.Root, "message.gspmsg")
if err != nil {
t.Fatal(err)
}
report := project.ValidateMessage(message)
if !report.OK {
t.Fatalf("expected valid message, got %+v", report.Errors)
}
}
func writeTestFile(t *testing.T, root, name, content string) {
t.Helper()
path := filepath.Join(root, name)