Add GSP links field and link inspection
This commit is contained in:
@@ -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 <id>
|
||||
gsp pack <id> --for implement --format md
|
||||
gsp links <id> --format md
|
||||
gsp impact <id> --format md
|
||||
gsp message validate message.gspmsg
|
||||
gsp graph <id>
|
||||
|
||||
@@ -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
|
||||
|
||||
1
examples/lottery/assets/ui/button_primary.txt
Normal file
1
examples/lottery/assets/ui/button_primary.txt
Normal file
@@ -0,0 +1 @@
|
||||
Primary button visual reference placeholder.
|
||||
@@ -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
|
||||
|
||||
@@ -3,3 +3,4 @@ title: 通用主按钮
|
||||
type: ui
|
||||
resolution: L3
|
||||
context: 通用主按钮,用于明确、正向、优先级最高的玩家操作。
|
||||
links: assets/ui/button_primary.txt
|
||||
|
||||
@@ -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 一起进入设计语境。
|
||||
|
||||
@@ -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 <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.
|
||||
|
||||
@@ -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 <id> [--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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@ gsp index
|
||||
gsp trace <id>
|
||||
gsp flatten <id>
|
||||
gsp pack <id>
|
||||
gsp links <id>
|
||||
gsp impact <id>
|
||||
gsp message validate <file>
|
||||
gsp graph <id>
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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'')'
|
||||
`
|
||||
}
|
||||
|
||||
@@ -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 <id> [--root .] [--depth 3] [--out trace.json]
|
||||
gsp flatten <id> [--root .] [--depth 3] [--include-type a,b] [--exclude-type a,b] [--out flattened.json]
|
||||
gsp pack <id> [--root .] [--for implement] [--stage implement] [--depth 3] [--budget 12000] [--format json|md|canvas] [--out context-pack.json]
|
||||
gsp links <id> [--root .] [--depth -1] [--format json|md] [--out links.json]
|
||||
gsp impact <id> [--root .] [--depth -1] [--format json|md|canvas] [--out impact.json]
|
||||
gsp message validate <file> [--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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
143
toolkit/internal/gsp/links.go
Normal file
143
toolkit/internal/gsp/links.go
Normal 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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user