diff --git a/README.md b/README.md index 62fdb53..46052fe 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ bin/gsp.exe .\bin\gsp.exe flatten page.lottery.main --root examples\lottery --depth -1 --out .gsp\flattened.json .\bin\gsp.exe pack page.lottery.main --root examples\lottery --depth -1 --budget 12000 --out .gsp\context-pack.json .\bin\gsp.exe pack page.lottery.main --root examples\lottery --for implement --stage implement --format md --out .gsp\context-pack.md +.\bin\gsp.exe links page.lottery.main --root examples\lottery --format md --out .gsp\links.md .\bin\gsp.exe impact feedback.positive --root examples\lottery --format md --out .gsp\impact.md .\bin\gsp.exe impact feedback.positive --root examples\lottery --format canvas --out .gsp\impact.canvas .\bin\gsp.exe message validate messages\implement-page.gspmsg --root examples\lottery --out .gsp\message-report.json @@ -68,6 +69,7 @@ bin/gsp.exe .gsp/flattened.json .gsp/context-pack.json .gsp/context-pack.md +.gsp/links.md .gsp/impact.md .gsp/impact.canvas .gsp/message-report.json @@ -155,6 +157,7 @@ gsp validate gsp index gsp flatten gsp pack --for implement --format md +gsp links --format md gsp impact --format md gsp message validate message.gspmsg gsp graph diff --git a/examples/lottery/README.md b/examples/lottery/README.md index c4fc592..1b5ee9c 100644 --- a/examples/lottery/README.md +++ b/examples/lottery/README.md @@ -25,6 +25,7 @@ page.lottery.main .\bin\gsp.exe validate --root examples\lottery .\bin\gsp.exe flatten page.lottery.main --root examples\lottery --depth -1 .\bin\gsp.exe pack page.lottery.main --root examples\lottery --for implement --format md +.\bin\gsp.exe links page.lottery.main --root examples\lottery --format md .\bin\gsp.exe impact feedback.positive --root examples\lottery --format md .\bin\gsp.exe message validate messages\implement-page.gspmsg --root examples\lottery .\bin\gsp.exe graph page.lottery.main --root examples\lottery --format mermaid diff --git a/examples/lottery/assets/ui/button_primary.txt b/examples/lottery/assets/ui/button_primary.txt new file mode 100644 index 0000000..1bd8823 --- /dev/null +++ b/examples/lottery/assets/ui/button_primary.txt @@ -0,0 +1 @@ +Primary button visual reference placeholder. diff --git a/examples/lottery/design/page.lottery.main.gsp b/examples/lottery/design/page.lottery.main.gsp index 41b3921..6c88013 100644 --- a/examples/lottery/design/page.lottery.main.gsp +++ b/examples/lottery/design/page.lottery.main.gsp @@ -3,6 +3,10 @@ title: 抽奖页面 type: page resolution: L3 context: 抽奖页面,需要表达奖励期待、抽取行为和结果反馈。玩家应快速理解奖池价值、抽奖入口和结果反馈。 +links: + - path: assets/ui + role: reference + - https://example.com/lottery-reference with: - ui.button.reward_primary - feedback.positive diff --git a/examples/lottery/design/ui.button.primary.gsp b/examples/lottery/design/ui.button.primary.gsp index 2969e5f..b45f702 100644 --- a/examples/lottery/design/ui.button.primary.gsp +++ b/examples/lottery/design/ui.button.primary.gsp @@ -3,3 +3,4 @@ title: 通用主按钮 type: ui resolution: L3 context: 通用主按钮,用于明确、正向、优先级最高的玩家操作。 +links: assets/ui/button_primary.txt diff --git a/specs/versions/0.1/README.md b/specs/versions/0.1/README.md index e211923..b1ac627 100644 --- a/specs/versions/0.1/README.md +++ b/specs/versions/0.1/README.md @@ -84,6 +84,7 @@ context: 积极反馈。用于让玩家在操作后获得明确、正向、值 | `with` | 否 | 通用设计语境关系。 | | `refines` | 否 | 单一细化来源。 | | `type` | 否 | 辅助分类。 | +| `links` | 否 | 关联路径、文件夹、URL 或外部地址。 | ## 核心边界 @@ -95,6 +96,7 @@ context: 积极反馈。用于让玩家在操作后获得明确、正向、值 - `with` 是通用设计语境关系。 - `refines` 是单一细化来源。 - `type` 是辅助分类字段。 +- `links` 是外部对象关联字段。 - GSP 不预设抽象和实体的硬边界。 - 置信度、自我纠错、模块信誉和历史归因属于外部模块,不进入 GSP 核心协议。 @@ -124,6 +126,40 @@ title: 抽奖页面 工具在图形、索引和 AI 入口中优先使用 `title` 展示。没有 `title` 时使用 `id`。 +## links + +`links` 表示当前 GSP 关联到的路径、文件夹、URL 或外部地址。 + +```yaml +links: assets/ui/button_primary.png +``` + +```yaml +links: + - assets/ui/button_primary.png + - https://example.com/style-guide +``` + +```yaml +links: + - path: runtime/ui/RewardButton.prefab + role: binding + - path: docs/reward-style.md + role: reference +``` + +对象写法只使用 `path`、`role` 和 `context`。`role` 默认是 `reference`。 + +内置 `role`: + +| role | 含义 | +|---|---| +| `reference` | 参考资料。 | +| `source` | 原始来源或素材来源。 | +| `binding` | 与实现对象绑定。 | +| `output` | 输出物。 | +| `evidence` | 验收、测试或结论依据。 | + ## with `with` 表示当前 GSP 需要与哪些 GSP 一起进入设计语境。 diff --git a/specs/versions/0.1/ai-usage.md b/specs/versions/0.1/ai-usage.md index ebb02f1..2758cf8 100644 --- a/specs/versions/0.1/ai-usage.md +++ b/specs/versions/0.1/ai-usage.md @@ -6,6 +6,7 @@ - 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. @@ -16,6 +17,7 @@ - Use `gsp trace ` to inspect relations. - Use `gsp flatten ` before implementation or task splitting. - Use `gsp pack ` when a compact AI context is needed. +- Use `gsp links ` to inspect associated files, folders, URLs, or addresses. - Use `gsp impact ` before changing shared GSP units. - Use `gsp message validate ` for agent communication messages. - Use `gsp stage-check --stage ` before stage handoff. diff --git a/specs/versions/0.1/commands.md b/specs/versions/0.1/commands.md index 3364070..af44bed 100644 --- a/specs/versions/0.1/commands.md +++ b/specs/versions/0.1/commands.md @@ -139,6 +139,21 @@ md Human-readable impact report. canvas Obsidian JSON Canvas for affected relations. ``` +## links + +List normalized links from one GSP id and its context. + +```bash +gsp links [--root .] [--depth -1] [--format json|md] [--out links.json] +``` + +The tool resolves link kind and status: + +```text +kind: url | file | folder | missing | unknown +status: ok | missing | invalid | unchecked +``` + ## message Validate GSP agent communication messages. diff --git a/specs/versions/0.1/gsp.schema.json b/specs/versions/0.1/gsp.schema.json index 4ebc902..1f86d45 100644 --- a/specs/versions/0.1/gsp.schema.json +++ b/specs/versions/0.1/gsp.schema.json @@ -59,6 +59,44 @@ "type": { "type": "string", "description": "Optional helper category for search, display, compiler hints and context pruning." + }, + "links": { + "description": "External paths, folders, URLs, or addresses associated with this GSP.", + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "object", + "additionalProperties": true, + "required": ["path"], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": ["reference", "source", "binding", "output", "evidence"] + }, + "context": { + "type": "string" + } + } + } + ] + } + } + ] } } } diff --git a/toolkit/README.md b/toolkit/README.md index 155e9ca..18451a4 100644 --- a/toolkit/README.md +++ b/toolkit/README.md @@ -177,6 +177,7 @@ gsp index gsp trace gsp flatten gsp pack +gsp links gsp impact gsp message validate gsp graph @@ -193,6 +194,7 @@ go build -o ../bin/gsp ./cmd/gsp ../bin/gsp validate --root ../examples/lottery ../bin/gsp flatten page.lottery.main --root ../examples/lottery --depth -1 --out ../.gsp/flattened.json ../bin/gsp pack page.lottery.main --root ../examples/lottery --for implement --format md --out ../.gsp/context-pack.md +../bin/gsp links page.lottery.main --root ../examples/lottery --format md --out ../.gsp/links.md ../bin/gsp impact feedback.positive --root ../examples/lottery --format md --out ../.gsp/impact.md ../bin/gsp graph page.lottery.main --root ../examples/lottery --format mermaid --out ../.gsp/graph.mmd ../bin/gsp graph page.lottery.main --root ../examples/lottery --format md --out ../.gsp/graph.md @@ -221,6 +223,7 @@ go build -o ../bin/gsp ./cmd/gsp .gsp/graph.canvas .gsp/context-pack.json .gsp/context-pack.md +.gsp/links.md .gsp/flattened.json .gsp/impact.md ``` diff --git a/toolkit/cmd/gsp/completion.go b/toolkit/cmd/gsp/completion.go index 496e055..538de4a 100644 --- a/toolkit/cmd/gsp/completion.go +++ b/toolkit/cmd/gsp/completion.go @@ -12,6 +12,7 @@ $script:GspSubcommands = @( 'trace', 'flatten', 'pack', + 'links', 'impact', 'message', 'graph', @@ -29,6 +30,7 @@ $script:GspFlags = @{ 'trace' = @('--root', '--depth', '--out') 'flatten' = @('--root', '--depth', '--include-type', '--exclude-type', '--out') 'pack' = @('--root', '--for', '--stage', '--depth', '--budget', '--format', '--include-type', '--exclude-type', '--out') + 'links' = @('--root', '--depth', '--format', '--out') 'impact' = @('--root', '--depth', '--format', '--out') 'message' = @('validate') 'graph' = @('--root', '--depth', '--format', '--out') @@ -80,6 +82,8 @@ Register-ArgumentCompleter -Native -CommandName gsp -ScriptBlock { $formats = @('json', 'md', 'canvas') if ($command -eq 'graph') { $formats = @('json', 'mermaid', 'md', 'canvas') + } elseif ($command -eq 'links') { + $formats = @('json', 'md') } return $formats | Where-Object { $_ -like "$wordToComplete*" } | @@ -125,7 +129,7 @@ Register-ArgumentCompleter -Native -CommandName gsp -ScriptBlock { ForEach-Object { New-GspCompletion $_ } } - if (@('trace', 'flatten', 'pack', 'impact', 'graph') -contains $command) { + if (@('trace', 'flatten', 'pack', 'links', 'impact', 'graph') -contains $command) { return Get-GspIds | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { New-GspCompletion $_ } @@ -144,11 +148,13 @@ _gsp_completion() { cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" cmd="${COMP_WORDS[1]}" - local commands="init ai-init version completion validate index trace flatten pack impact message graph stage-check help" + local commands="init ai-init version completion validate index trace flatten pack links impact message graph stage-check help" case "$prev" in --format) if [[ "$cmd" == "graph" ]]; then COMPREPLY=( $(compgen -W "json mermaid md canvas" -- "$cur") ) + elif [[ "$cmd" == "links" ]]; then + COMPREPLY=( $(compgen -W "json md" -- "$cur") ) else COMPREPLY=( $(compgen -W "json md canvas" -- "$cur") ) fi @@ -171,6 +177,7 @@ _gsp_completion() { trace) COMPREPLY=( $(compgen -W "--root --depth --out" -- "$cur") ) ;; flatten) COMPREPLY=( $(compgen -W "--root --depth --include-type --exclude-type --out" -- "$cur") ) ;; pack) COMPREPLY=( $(compgen -W "--root --for --stage --depth --budget --format --include-type --exclude-type --out" -- "$cur") ) ;; + links) COMPREPLY=( $(compgen -W "--root --depth --format --out" -- "$cur") ) ;; impact) COMPREPLY=( $(compgen -W "--root --depth --format --out" -- "$cur") ) ;; message) COMPREPLY=( $(compgen -W "validate" -- "$cur") ) ;; graph) COMPREPLY=( $(compgen -W "--root --depth --format --out" -- "$cur") ) ;; @@ -180,7 +187,7 @@ _gsp_completion() { return fi case "$cmd" in - trace|flatten|pack|impact|graph) + trace|flatten|pack|links|impact|graph) local ids ids=$(gsp index --root . 2>/dev/null | sed -n 's/.*"id": "\([^"]*\)".*/\1/p') COMPREPLY=( $(compgen -W "$ids" -- "$cur") ) @@ -206,6 +213,7 @@ _gsp() { 'trace' 'flatten' 'pack' + 'links' 'impact' 'message' 'graph' @@ -220,12 +228,13 @@ _gsp "$@" func fishCompletionScript() string { return `# GSP fish completion -complete -c gsp -f -n '__fish_use_subcommand' -a 'init ai-init version completion validate index trace flatten pack impact message graph stage-check help' +complete -c gsp -f -n '__fish_use_subcommand' -a 'init ai-init version completion validate index trace flatten pack links impact message graph stage-check help' complete -c gsp -n '__fish_seen_subcommand_from graph' -l format -a 'json mermaid md canvas' complete -c gsp -n '__fish_seen_subcommand_from pack impact' -l format -a 'json md canvas' +complete -c gsp -n '__fish_seen_subcommand_from links' -l format -a 'json md' complete -c gsp -n '__fish_seen_subcommand_from stage-check' -l stage -a 'design integrate implement bind release' complete -c gsp -n '__fish_seen_subcommand_from ai-init' -l skill -a 'generic codex' complete -c gsp -n '__fish_seen_subcommand_from pack' -l for -a 'design implement review test acceptance handoff inspect' -complete -c gsp -n '__fish_seen_subcommand_from trace flatten pack impact graph' -a '(gsp index --root . 2>/dev/null | string match -r ''"id": "([^"]+)"'' | string replace -r ''.*"id": "([^"]+)".*'' ''$1'')' +complete -c gsp -n '__fish_seen_subcommand_from trace flatten pack links impact graph' -a '(gsp index --root . 2>/dev/null | string match -r ''"id": "([^"]+)"'' | string replace -r ''.*"id": "([^"]+)".*'' ''$1'')' ` } diff --git a/toolkit/cmd/gsp/main.go b/toolkit/cmd/gsp/main.go index d570d34..85fe48a 100644 --- a/toolkit/cmd/gsp/main.go +++ b/toolkit/cmd/gsp/main.go @@ -48,6 +48,8 @@ func run(args []string) error { return runFlatten(args[1:]) case "pack": return runPack(args[1:]) + case "links": + return runLinks(args[1:]) case "impact": return runImpact(args[1:]) case "message": @@ -78,6 +80,7 @@ Usage: gsp trace [--root .] [--depth 3] [--out trace.json] gsp flatten [--root .] [--depth 3] [--include-type a,b] [--exclude-type a,b] [--out flattened.json] gsp pack [--root .] [--for implement] [--stage implement] [--depth 3] [--budget 12000] [--format json|md|canvas] [--out context-pack.json] + gsp links [--root .] [--depth -1] [--format json|md] [--out links.json] gsp impact [--root .] [--depth -1] [--format json|md|canvas] [--out impact.json] gsp message validate [--root .] [--out message-report.json] gsp graph [id] [--root .] [--depth 3] [--format json|mermaid|md|canvas] [--out graph.json] @@ -85,6 +88,33 @@ Usage: `) } +func runLinks(args []string) error { + fs := flag.NewFlagSet("links", flag.ContinueOnError) + root := commonRoot(fs) + out := commonOut(fs) + depth := fs.Int("depth", -1, "maximum relation depth; -1 means unlimited") + format := fs.String("format", "json", "json or md") + if err := fs.Parse(normalizeFlagArgs(args)); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("links requires one GSP id") + } + project, err := gsp.LoadProject(*root) + if err != nil { + return err + } + result := project.Links(fs.Arg(0), *depth) + switch *format { + case "json": + return writeJSON(*out, result) + case "md": + return writeText(*out, result.Markdown()) + default: + return fmt.Errorf("unsupported links format %q", *format) + } +} + func runCompletion(args []string) error { if len(args) == 0 { return fmt.Errorf("completion requires powershell, bash, zsh, fish, or install powershell") diff --git a/toolkit/internal/gsp/ai_init.go b/toolkit/internal/gsp/ai_init.go index dec65c4..08b4b42 100644 --- a/toolkit/internal/gsp/ai_init.go +++ b/toolkit/internal/gsp/ai_init.go @@ -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 `"+` to inspect relations. - Use `+"`gsp flatten `"+` before implementation or task splitting. - Use `+"`gsp pack `"+` when a compact AI context is needed. +- Use `+"`gsp links `"+` to inspect associated files, folders, URLs, or addresses. - Use `+"`gsp impact `"+` before changing shared GSP units. - Use `+"`gsp message validate `"+` for agent communication messages. - Use `+"`gsp stage-check --stage `"+` before stage handoff. diff --git a/toolkit/internal/gsp/format.go b/toolkit/internal/gsp/format.go index 23dccdc..4458c9f 100644 --- a/toolkit/internal/gsp/format.go +++ b/toolkit/internal/gsp/format.go @@ -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 { diff --git a/toolkit/internal/gsp/links.go b/toolkit/internal/gsp/links.go new file mode 100644 index 0000000..6b4773f --- /dev/null +++ b/toolkit/internal/gsp/links.go @@ -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() +} diff --git a/toolkit/internal/gsp/model.go b/toolkit/internal/gsp/model.go index 66a36e3..1306067 100644 --- a/toolkit/internal/gsp/model.go +++ b/toolkit/internal/gsp/model.go @@ -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 { diff --git a/toolkit/internal/gsp/project.go b/toolkit/internal/gsp/project.go index 1777428..096033f 100644 --- a/toolkit/internal/gsp/project.go +++ b/toolkit/internal/gsp/project.go @@ -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 diff --git a/toolkit/internal/gsp/project_test.go b/toolkit/internal/gsp/project_test.go index aa0c25b..482f929 100644 --- a/toolkit/internal/gsp/project_test.go +++ b/toolkit/internal/gsp/project_test.go @@ -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 {