Add GSP links field and link inspection

This commit is contained in:
2026-05-07 10:41:38 +08:00
parent 0c5254eb1b
commit 27e71d8c51
18 changed files with 448 additions and 17 deletions

View File

@@ -106,6 +106,7 @@ func aiUsage(gspVersion, scan string) string {
- Preserve `+"`id`"+`; do not rename it unless explicitly requested.
- `+"`id`"+` is the unique identity of a GSP unit.
- `+"`title`"+` is display text; use `+"`id`"+` when `+"`title`"+` is missing.
- `+"`links`"+` associates a GSP with paths, folders, URLs, or external addresses.
- Use only fields valid for the declared GSP version.
- `+"`with`"+` means related design context.
- `+"`refines`"+` means single-source refinement.
@@ -116,6 +117,7 @@ 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 links <id>`"+` to inspect associated files, folders, URLs, or addresses.
- 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.

View File

@@ -17,6 +17,9 @@ func (p PackResult) Markdown() string {
builder.WriteString(fmt.Sprintf("- Stage: `%s`\n", p.Stage))
}
builder.WriteString(fmt.Sprintf("- Units: `%d`\n", p.Summary.UnitCount))
if p.Summary.LinkCount > 0 {
builder.WriteString(fmt.Sprintf("- Links: `%d`\n", p.Summary.LinkCount))
}
if p.Summary.MinResolution != "" {
builder.WriteString(fmt.Sprintf("- Resolution: `%s` to `%s`\n", p.Summary.MinResolution, p.Summary.MaxResolution))
}
@@ -53,6 +56,16 @@ func (p PackResult) Markdown() string {
}
builder.WriteString("\n")
}
if len(p.Links) > 0 {
builder.WriteString("## Links\n\n")
for _, link := range p.Links {
builder.WriteString(fmt.Sprintf("- `%s` %s\n", link.Owner, link.Path))
builder.WriteString(fmt.Sprintf(" - role: `%s`\n", link.Role))
builder.WriteString(fmt.Sprintf(" - kind: `%s`\n", link.Kind))
builder.WriteString(fmt.Sprintf(" - status: `%s`\n", link.Status))
}
builder.WriteString("\n")
}
if len(p.Warnings) > 0 {
builder.WriteString("## Warnings\n\n")
for _, warning := range p.Warnings {

View File

@@ -0,0 +1,143 @@
package gsp
import (
"fmt"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
)
var allowedLinkRoles = map[string]bool{
"reference": true,
"source": true,
"binding": true,
"output": true,
"evidence": true,
}
func (p *Project) Links(id string, depth int) LinkResult {
flattened := p.Flatten(id, depth, Filter{})
links := p.resolveLinks(flattened.Units)
summary := summarizeLinks(links)
return LinkResult{
Entry: id,
Depth: depth,
Links: links,
Summary: summary,
Warnings: flattened.Warnings,
}
}
func (p *Project) resolveLinks(units []*Unit) []ResolvedLink {
var result []ResolvedLink
for _, unit := range units {
for _, link := range unit.Links {
result = append(result, p.resolveLink(unit, link))
}
}
sort.Slice(result, func(i, j int) bool {
if result[i].Owner == result[j].Owner {
return result[i].Path < result[j].Path
}
return result[i].Owner < result[j].Owner
})
return result
}
func (p *Project) resolveLink(unit *Unit, link Link) ResolvedLink {
role := strings.TrimSpace(link.Role)
if role == "" {
role = "reference"
}
resolved := ResolvedLink{
Owner: unit.ID,
Title: unit.Title,
Path: strings.TrimSpace(link.Path),
Role: role,
Context: strings.TrimSpace(link.Context),
File: unit.File,
}
resolved.Kind, resolved.Status, resolved.Exists = p.classifyLink(resolved.Path)
return resolved
}
func (p *Project) classifyLink(path string) (kind, status string, exists bool) {
if path == "" {
return "unknown", "invalid", false
}
if isURL(path) {
parsed, err := url.ParseRequestURI(path)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "url", "invalid", false
}
return "url", "unchecked", false
}
checkPath := path
if !filepath.IsAbs(checkPath) {
checkPath = filepath.Join(p.Root, filepath.FromSlash(checkPath))
}
info, err := os.Stat(checkPath)
if err != nil {
if os.IsNotExist(err) {
return "missing", "missing", false
}
return "unknown", "invalid", false
}
if info.IsDir() {
return "folder", "ok", true
}
return "file", "ok", true
}
func isURL(value string) bool {
lower := strings.ToLower(value)
return strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://")
}
func summarizeLinks(links []ResolvedLink) LinkSummary {
summary := LinkSummary{
Total: len(links),
ByKind: map[string]int{},
ByRole: map[string]int{},
ByState: map[string]int{},
}
for _, link := range links {
summary.ByKind[link.Kind]++
summary.ByRole[link.Role]++
summary.ByState[link.Status]++
}
if len(summary.ByKind) == 0 {
summary.ByKind = nil
}
if len(summary.ByRole) == 0 {
summary.ByRole = nil
}
if len(summary.ByState) == 0 {
summary.ByState = nil
}
return summary
}
func (r LinkResult) Markdown() string {
var builder strings.Builder
builder.WriteString("# GSP Links\n\n")
builder.WriteString(fmt.Sprintf("- Entry: `%s`\n", r.Entry))
builder.WriteString(fmt.Sprintf("- Links: `%d`\n", r.Summary.Total))
if len(r.Links) == 0 {
builder.WriteString("\nNo links.\n")
return builder.String()
}
builder.WriteString("\n## Links\n\n")
for _, link := range r.Links {
builder.WriteString(fmt.Sprintf("- `%s` %s\n", link.Owner, link.Path))
builder.WriteString(fmt.Sprintf(" - role: `%s`\n", link.Role))
builder.WriteString(fmt.Sprintf(" - kind: `%s`\n", link.Kind))
builder.WriteString(fmt.Sprintf(" - status: `%s`\n", link.Status))
if link.Context != "" {
builder.WriteString(fmt.Sprintf(" - context: %s\n", link.Context))
}
}
return builder.String()
}

View File

@@ -24,6 +24,7 @@ type Unit struct {
With Relations `json:"with,omitempty" yaml:"with"`
Refines string `json:"refines,omitempty" yaml:"refines"`
Type string `json:"type,omitempty" yaml:"type"`
Links Links `json:"links,omitempty" yaml:"links"`
File string `json:"file,omitempty" yaml:"-"`
}
@@ -67,6 +68,55 @@ func (r *Relations) UnmarshalYAML(value *yaml.Node) error {
return nil
}
type Link struct {
Path string `json:"path" yaml:"path"`
Role string `json:"role,omitempty" yaml:"role"`
Context string `json:"context,omitempty" yaml:"context"`
}
type Links []Link
func (l *Links) UnmarshalYAML(value *yaml.Node) error {
if value.Kind == 0 || value.Tag == "!!null" {
*l = nil
return nil
}
switch value.Kind {
case yaml.ScalarNode:
if value.Value == "" {
return fmt.Errorf("links item cannot be empty")
}
*l = Links{{Path: value.Value}}
return nil
case yaml.SequenceNode:
out := make([]Link, 0, len(value.Content))
for _, item := range value.Content {
switch item.Kind {
case yaml.ScalarNode:
if item.Value == "" {
return fmt.Errorf("links item cannot be empty")
}
out = append(out, Link{Path: item.Value})
case yaml.MappingNode:
var link Link
if err := item.Decode(&link); err != nil {
return err
}
if link.Path == "" {
return fmt.Errorf("links object item requires path")
}
out = append(out, link)
default:
return fmt.Errorf("links item must be a string or object")
}
}
*l = out
return nil
default:
return fmt.Errorf("links must be a string or list")
}
}
type Issue struct {
Level string `json:"level"`
Code string `json:"code"`
@@ -112,6 +162,7 @@ type IndexEntry struct {
Resolution string `json:"resolution,omitempty"`
With []string `json:"with,omitempty"`
Refines string `json:"refines,omitempty"`
Links []string `json:"links,omitempty"`
}
type FlattenResult struct {
@@ -130,16 +181,17 @@ 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"`
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"`
Links []ResolvedLink `json:"links,omitempty"`
Summary Summary `json:"summary"`
ApproxChars int `json:"approxChars"`
Warnings []Issue `json:"warnings,omitempty"`
}
type Summary struct {
@@ -148,6 +200,34 @@ type Summary struct {
MaxResolution string `json:"maxResolution,omitempty"`
TypeCounts map[string]int `json:"typeCounts,omitempty"`
MissingCount int `json:"missingCount,omitempty"`
LinkCount int `json:"linkCount,omitempty"`
}
type LinkResult struct {
Entry string `json:"entry"`
Depth int `json:"depth"`
Links []ResolvedLink `json:"links,omitempty"`
Summary LinkSummary `json:"summary"`
Warnings []Issue `json:"warnings,omitempty"`
}
type LinkSummary struct {
Total int `json:"total"`
ByKind map[string]int `json:"byKind,omitempty"`
ByRole map[string]int `json:"byRole,omitempty"`
ByState map[string]int `json:"byState,omitempty"`
}
type ResolvedLink struct {
Owner string `json:"owner"`
Title string `json:"title,omitempty"`
Path string `json:"path"`
Role string `json:"role"`
Kind string `json:"kind"`
Status string `json:"status"`
Exists bool `json:"exists,omitempty"`
Context string `json:"context,omitempty"`
File string `json:"file,omitempty"`
}
type ImpactResult struct {

View File

@@ -3,6 +3,7 @@ package gsp
import (
"encoding/json"
"fmt"
"path/filepath"
"sort"
"strings"
)
@@ -41,6 +42,22 @@ func (p *Project) Validate(stage string) Report {
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))
@@ -80,7 +97,12 @@ func (p *Project) Index() []IndexEntry {
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,
@@ -89,6 +111,7 @@ func (p *Project) Index() []IndexEntry {
Resolution: unit.Resolution,
With: with,
Refines: unit.Refines,
Links: links,
})
}
sort.Slice(entries, func(i, j int) bool {
@@ -184,6 +207,7 @@ func (p *Project) PackFor(id, intent, stage string, depth, budget int, filter Fi
units = candidate
approx = len(data)
}
links := p.resolveLinks(units)
return PackResult{
Entry: id,
Intent: intent,
@@ -192,17 +216,19 @@ func (p *Project) PackFor(id, intent, stage string, depth, budget int, filter Fi
Budget: budget,
Truncated: truncated,
Units: units,
Summary: summarizeUnits(units, len(flattened.Warnings)),
Links: links,
Summary: summarizeUnits(units, len(flattened.Warnings), len(links)),
ApproxChars: approx,
Warnings: flattened.Warnings,
}
}
func summarizeUnits(units []*Unit, missingCount int) Summary {
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

View File

@@ -19,6 +19,10 @@ title: Lottery Page
type: page
resolution: L3
context: Lottery page.
links:
- assets/ui
- path: https://example.com/lottery
role: reference
with:
- id: ui.button.primary
context: Main action.
@@ -29,6 +33,7 @@ refines: page.lottery.base
type: ui
resolution: L3
context: Primary button.
links: assets/ui/button.txt
`)
writeTestFile(t, design, "feedback.gsp", `id: feedback.positive
type: feedback
@@ -39,6 +44,11 @@ context: Positive feedback.
resolution: L2
context: Base lottery page.
`)
assets := filepath.Join(root, "assets", "ui")
if err := os.MkdirAll(assets, 0755); err != nil {
t.Fatal(err)
}
writeTestFile(t, assets, "button.txt", `button reference`)
project, err := LoadProject(root)
if err != nil {
@@ -99,6 +109,20 @@ context: Base lottery page.
if !strings.Contains(pack.Markdown(), "GSP Context Pack") {
t.Fatal("expected pack markdown")
}
if pack.Summary.LinkCount != 3 {
t.Fatalf("pack link count = %d", pack.Summary.LinkCount)
}
links := project.Links("page.lottery.main", -1)
if links.Summary.Total != 3 {
t.Fatalf("links total = %d", links.Summary.Total)
}
if links.Summary.ByKind["folder"] != 1 || links.Summary.ByKind["file"] != 1 || links.Summary.ByKind["url"] != 1 {
t.Fatalf("links kind summary = %+v", links.Summary.ByKind)
}
if !strings.Contains(links.Markdown(), "GSP Links") {
t.Fatal("expected links markdown")
}
impact := project.Impact("feedback.positive", -1)
if impact.Summary.DirectCount != 1 {