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

@@ -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 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 --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 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 md --out .gsp\impact.md
.\bin\gsp.exe impact feedback.positive --root examples\lottery --format canvas --out .gsp\impact.canvas .\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 .\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/flattened.json
.gsp/context-pack.json .gsp/context-pack.json
.gsp/context-pack.md .gsp/context-pack.md
.gsp/links.md
.gsp/impact.md .gsp/impact.md
.gsp/impact.canvas .gsp/impact.canvas
.gsp/message-report.json .gsp/message-report.json
@@ -155,6 +157,7 @@ gsp validate
gsp index gsp index
gsp flatten <id> gsp flatten <id>
gsp pack <id> --for implement --format md gsp pack <id> --for implement --format md
gsp links <id> --format md
gsp impact <id> --format md gsp impact <id> --format md
gsp message validate message.gspmsg gsp message validate message.gspmsg
gsp graph <id> gsp graph <id>

View File

@@ -25,6 +25,7 @@ page.lottery.main
.\bin\gsp.exe validate --root examples\lottery .\bin\gsp.exe validate --root examples\lottery
.\bin\gsp.exe flatten page.lottery.main --root examples\lottery --depth -1 .\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 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 impact feedback.positive --root examples\lottery --format md
.\bin\gsp.exe message validate messages\implement-page.gspmsg --root examples\lottery .\bin\gsp.exe message validate messages\implement-page.gspmsg --root examples\lottery
.\bin\gsp.exe graph page.lottery.main --root examples\lottery --format mermaid .\bin\gsp.exe graph page.lottery.main --root examples\lottery --format mermaid

View File

@@ -0,0 +1 @@
Primary button visual reference placeholder.

View File

@@ -3,6 +3,10 @@ title: 抽奖页面
type: page type: page
resolution: L3 resolution: L3
context: 抽奖页面,需要表达奖励期待、抽取行为和结果反馈。玩家应快速理解奖池价值、抽奖入口和结果反馈。 context: 抽奖页面,需要表达奖励期待、抽取行为和结果反馈。玩家应快速理解奖池价值、抽奖入口和结果反馈。
links:
- path: assets/ui
role: reference
- https://example.com/lottery-reference
with: with:
- ui.button.reward_primary - ui.button.reward_primary
- feedback.positive - feedback.positive

View File

@@ -3,3 +3,4 @@ title: 通用主按钮
type: ui type: ui
resolution: L3 resolution: L3
context: 通用主按钮,用于明确、正向、优先级最高的玩家操作。 context: 通用主按钮,用于明确、正向、优先级最高的玩家操作。
links: assets/ui/button_primary.txt

View File

@@ -84,6 +84,7 @@ context: 积极反馈。用于让玩家在操作后获得明确、正向、值
| `with` | 否 | 通用设计语境关系。 | | `with` | 否 | 通用设计语境关系。 |
| `refines` | 否 | 单一细化来源。 | | `refines` | 否 | 单一细化来源。 |
| `type` | 否 | 辅助分类。 | | `type` | 否 | 辅助分类。 |
| `links` | 否 | 关联路径、文件夹、URL 或外部地址。 |
## 核心边界 ## 核心边界
@@ -95,6 +96,7 @@ context: 积极反馈。用于让玩家在操作后获得明确、正向、值
- `with` 是通用设计语境关系。 - `with` 是通用设计语境关系。
- `refines` 是单一细化来源。 - `refines` 是单一细化来源。
- `type` 是辅助分类字段。 - `type` 是辅助分类字段。
- `links` 是外部对象关联字段。
- GSP 不预设抽象和实体的硬边界。 - GSP 不预设抽象和实体的硬边界。
- 置信度、自我纠错、模块信誉和历史归因属于外部模块,不进入 GSP 核心协议。 - 置信度、自我纠错、模块信誉和历史归因属于外部模块,不进入 GSP 核心协议。
@@ -124,6 +126,40 @@ title: 抽奖页面
工具在图形、索引和 AI 入口中优先使用 `title` 展示。没有 `title` 时使用 `id` 工具在图形、索引和 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
`with` 表示当前 GSP 需要与哪些 GSP 一起进入设计语境。 `with` 表示当前 GSP 需要与哪些 GSP 一起进入设计语境。

View File

@@ -6,6 +6,7 @@
- Preserve `id`; do not rename it unless explicitly requested. - Preserve `id`; do not rename it unless explicitly requested.
- `id` is the unique identity of a GSP unit. - `id` is the unique identity of a GSP unit.
- `title` is display text; use `id` when `title` is missing. - `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. - Use only fields valid for the declared GSP version.
- `with` means related design context. - `with` means related design context.
- `refines` means single-source refinement. - `refines` means single-source refinement.
@@ -16,6 +17,7 @@
- Use `gsp trace <id>` to inspect relations. - Use `gsp trace <id>` to inspect relations.
- Use `gsp flatten <id>` before implementation or task splitting. - Use `gsp flatten <id>` before implementation or task splitting.
- Use `gsp pack <id>` when a compact AI context is needed. - 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 impact <id>` before changing shared GSP units.
- Use `gsp message validate <file>` for agent communication messages. - Use `gsp message validate <file>` for agent communication messages.
- Use `gsp stage-check --stage <stage>` before stage handoff. - Use `gsp stage-check --stage <stage>` before stage handoff.

View File

@@ -139,6 +139,21 @@ md Human-readable impact report.
canvas Obsidian JSON Canvas for affected relations. 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 ## message
Validate GSP agent communication messages. Validate GSP agent communication messages.

View File

@@ -59,6 +59,44 @@
"type": { "type": {
"type": "string", "type": "string",
"description": "Optional helper category for search, display, compiler hints and context pruning." "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"
}
}
}
]
}
}
]
} }
} }
} }

View File

@@ -177,6 +177,7 @@ gsp index
gsp trace <id> gsp trace <id>
gsp flatten <id> gsp flatten <id>
gsp pack <id> gsp pack <id>
gsp links <id>
gsp impact <id> gsp impact <id>
gsp message validate <file> gsp message validate <file>
gsp graph <id> gsp graph <id>
@@ -193,6 +194,7 @@ go build -o ../bin/gsp ./cmd/gsp
../bin/gsp validate --root ../examples/lottery ../bin/gsp validate --root ../examples/lottery
../bin/gsp flatten page.lottery.main --root ../examples/lottery --depth -1 --out ../.gsp/flattened.json ../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 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 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 mermaid --out ../.gsp/graph.mmd
../bin/gsp graph page.lottery.main --root ../examples/lottery --format md --out ../.gsp/graph.md ../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/graph.canvas
.gsp/context-pack.json .gsp/context-pack.json
.gsp/context-pack.md .gsp/context-pack.md
.gsp/links.md
.gsp/flattened.json .gsp/flattened.json
.gsp/impact.md .gsp/impact.md
``` ```

View File

@@ -12,6 +12,7 @@ $script:GspSubcommands = @(
'trace', 'trace',
'flatten', 'flatten',
'pack', 'pack',
'links',
'impact', 'impact',
'message', 'message',
'graph', 'graph',
@@ -29,6 +30,7 @@ $script:GspFlags = @{
'trace' = @('--root', '--depth', '--out') 'trace' = @('--root', '--depth', '--out')
'flatten' = @('--root', '--depth', '--include-type', '--exclude-type', '--out') 'flatten' = @('--root', '--depth', '--include-type', '--exclude-type', '--out')
'pack' = @('--root', '--for', '--stage', '--depth', '--budget', '--format', '--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') 'impact' = @('--root', '--depth', '--format', '--out')
'message' = @('validate') 'message' = @('validate')
'graph' = @('--root', '--depth', '--format', '--out') 'graph' = @('--root', '--depth', '--format', '--out')
@@ -80,6 +82,8 @@ Register-ArgumentCompleter -Native -CommandName gsp -ScriptBlock {
$formats = @('json', 'md', 'canvas') $formats = @('json', 'md', 'canvas')
if ($command -eq 'graph') { if ($command -eq 'graph') {
$formats = @('json', 'mermaid', 'md', 'canvas') $formats = @('json', 'mermaid', 'md', 'canvas')
} elseif ($command -eq 'links') {
$formats = @('json', 'md')
} }
return $formats | return $formats |
Where-Object { $_ -like "$wordToComplete*" } | Where-Object { $_ -like "$wordToComplete*" } |
@@ -125,7 +129,7 @@ Register-ArgumentCompleter -Native -CommandName gsp -ScriptBlock {
ForEach-Object { New-GspCompletion $_ } ForEach-Object { New-GspCompletion $_ }
} }
if (@('trace', 'flatten', 'pack', 'impact', 'graph') -contains $command) { if (@('trace', 'flatten', 'pack', 'links', 'impact', 'graph') -contains $command) {
return Get-GspIds | return Get-GspIds |
Where-Object { $_ -like "$wordToComplete*" } | Where-Object { $_ -like "$wordToComplete*" } |
ForEach-Object { New-GspCompletion $_ } ForEach-Object { New-GspCompletion $_ }
@@ -144,11 +148,13 @@ _gsp_completion() {
cur="${COMP_WORDS[COMP_CWORD]}" cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}" prev="${COMP_WORDS[COMP_CWORD-1]}"
cmd="${COMP_WORDS[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 case "$prev" in
--format) --format)
if [[ "$cmd" == "graph" ]]; then if [[ "$cmd" == "graph" ]]; then
COMPREPLY=( $(compgen -W "json mermaid md canvas" -- "$cur") ) COMPREPLY=( $(compgen -W "json mermaid md canvas" -- "$cur") )
elif [[ "$cmd" == "links" ]]; then
COMPREPLY=( $(compgen -W "json md" -- "$cur") )
else else
COMPREPLY=( $(compgen -W "json md canvas" -- "$cur") ) COMPREPLY=( $(compgen -W "json md canvas" -- "$cur") )
fi fi
@@ -171,6 +177,7 @@ _gsp_completion() {
trace) COMPREPLY=( $(compgen -W "--root --depth --out" -- "$cur") ) ;; trace) COMPREPLY=( $(compgen -W "--root --depth --out" -- "$cur") ) ;;
flatten) COMPREPLY=( $(compgen -W "--root --depth --include-type --exclude-type --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") ) ;; 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") ) ;; impact) COMPREPLY=( $(compgen -W "--root --depth --format --out" -- "$cur") ) ;;
message) COMPREPLY=( $(compgen -W "validate" -- "$cur") ) ;; message) COMPREPLY=( $(compgen -W "validate" -- "$cur") ) ;;
graph) COMPREPLY=( $(compgen -W "--root --depth --format --out" -- "$cur") ) ;; graph) COMPREPLY=( $(compgen -W "--root --depth --format --out" -- "$cur") ) ;;
@@ -180,7 +187,7 @@ _gsp_completion() {
return return
fi fi
case "$cmd" in case "$cmd" in
trace|flatten|pack|impact|graph) trace|flatten|pack|links|impact|graph)
local ids local ids
ids=$(gsp index --root . 2>/dev/null | sed -n 's/.*"id": "\([^"]*\)".*/\1/p') ids=$(gsp index --root . 2>/dev/null | sed -n 's/.*"id": "\([^"]*\)".*/\1/p')
COMPREPLY=( $(compgen -W "$ids" -- "$cur") ) COMPREPLY=( $(compgen -W "$ids" -- "$cur") )
@@ -206,6 +213,7 @@ _gsp() {
'trace' 'trace'
'flatten' 'flatten'
'pack' 'pack'
'links'
'impact' 'impact'
'message' 'message'
'graph' 'graph'
@@ -220,12 +228,13 @@ _gsp "$@"
func fishCompletionScript() string { func fishCompletionScript() string {
return `# GSP fish completion 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 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 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 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 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 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'')'
` `
} }

View File

@@ -48,6 +48,8 @@ func run(args []string) error {
return runFlatten(args[1:]) return runFlatten(args[1:])
case "pack": case "pack":
return runPack(args[1:]) return runPack(args[1:])
case "links":
return runLinks(args[1:])
case "impact": case "impact":
return runImpact(args[1:]) return runImpact(args[1:])
case "message": case "message":
@@ -78,6 +80,7 @@ Usage:
gsp trace <id> [--root .] [--depth 3] [--out trace.json] 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 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 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 impact <id> [--root .] [--depth -1] [--format json|md|canvas] [--out impact.json]
gsp message validate <file> [--root .] [--out message-report.json] gsp message validate <file> [--root .] [--out message-report.json]
gsp graph [id] [--root .] [--depth 3] [--format json|mermaid|md|canvas] [--out graph.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 { func runCompletion(args []string) error {
if len(args) == 0 { if len(args) == 0 {
return fmt.Errorf("completion requires powershell, bash, zsh, fish, or install powershell") return fmt.Errorf("completion requires powershell, bash, zsh, fish, or install powershell")

View File

@@ -106,6 +106,7 @@ func aiUsage(gspVersion, scan string) string {
- Preserve `+"`id`"+`; do not rename it unless explicitly requested. - Preserve `+"`id`"+`; do not rename it unless explicitly requested.
- `+"`id`"+` is the unique identity of a GSP unit. - `+"`id`"+` is the unique identity of a GSP unit.
- `+"`title`"+` is display text; use `+"`id`"+` when `+"`title`"+` is missing. - `+"`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. - Use only fields valid for the declared GSP version.
- `+"`with`"+` means related design context. - `+"`with`"+` means related design context.
- `+"`refines`"+` means single-source refinement. - `+"`refines`"+` means single-source refinement.
@@ -116,6 +117,7 @@ func aiUsage(gspVersion, scan string) string {
- Use `+"`gsp trace <id>`"+` to inspect relations. - Use `+"`gsp trace <id>`"+` to inspect relations.
- Use `+"`gsp flatten <id>`"+` before implementation or task splitting. - Use `+"`gsp flatten <id>`"+` before implementation or task splitting.
- Use `+"`gsp pack <id>`"+` when a compact AI context is needed. - 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 impact <id>`"+` before changing shared GSP units.
- Use `+"`gsp message validate <file>`"+` for agent communication messages. - Use `+"`gsp message validate <file>`"+` for agent communication messages.
- Use `+"`gsp stage-check --stage <stage>`"+` before stage handoff. - 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("- Stage: `%s`\n", p.Stage))
} }
builder.WriteString(fmt.Sprintf("- Units: `%d`\n", p.Summary.UnitCount)) 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 != "" { if p.Summary.MinResolution != "" {
builder.WriteString(fmt.Sprintf("- Resolution: `%s` to `%s`\n", p.Summary.MinResolution, p.Summary.MaxResolution)) 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") 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 { if len(p.Warnings) > 0 {
builder.WriteString("## Warnings\n\n") builder.WriteString("## Warnings\n\n")
for _, warning := range p.Warnings { 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"` With Relations `json:"with,omitempty" yaml:"with"`
Refines string `json:"refines,omitempty" yaml:"refines"` Refines string `json:"refines,omitempty" yaml:"refines"`
Type string `json:"type,omitempty" yaml:"type"` Type string `json:"type,omitempty" yaml:"type"`
Links Links `json:"links,omitempty" yaml:"links"`
File string `json:"file,omitempty" yaml:"-"` File string `json:"file,omitempty" yaml:"-"`
} }
@@ -67,6 +68,55 @@ func (r *Relations) UnmarshalYAML(value *yaml.Node) error {
return nil 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 { type Issue struct {
Level string `json:"level"` Level string `json:"level"`
Code string `json:"code"` Code string `json:"code"`
@@ -112,6 +162,7 @@ type IndexEntry struct {
Resolution string `json:"resolution,omitempty"` Resolution string `json:"resolution,omitempty"`
With []string `json:"with,omitempty"` With []string `json:"with,omitempty"`
Refines string `json:"refines,omitempty"` Refines string `json:"refines,omitempty"`
Links []string `json:"links,omitempty"`
} }
type FlattenResult struct { type FlattenResult struct {
@@ -130,16 +181,17 @@ type TraceResult struct {
} }
type PackResult struct { type PackResult struct {
Entry string `json:"entry"` Entry string `json:"entry"`
Intent string `json:"intent,omitempty"` Intent string `json:"intent,omitempty"`
Stage string `json:"stage,omitempty"` Stage string `json:"stage,omitempty"`
Depth int `json:"depth"` Depth int `json:"depth"`
Budget int `json:"budget,omitempty"` Budget int `json:"budget,omitempty"`
Truncated bool `json:"truncated"` Truncated bool `json:"truncated"`
Units []*Unit `json:"units"` Units []*Unit `json:"units"`
Summary Summary `json:"summary"` Links []ResolvedLink `json:"links,omitempty"`
ApproxChars int `json:"approxChars"` Summary Summary `json:"summary"`
Warnings []Issue `json:"warnings,omitempty"` ApproxChars int `json:"approxChars"`
Warnings []Issue `json:"warnings,omitempty"`
} }
type Summary struct { type Summary struct {
@@ -148,6 +200,34 @@ type Summary struct {
MaxResolution string `json:"maxResolution,omitempty"` MaxResolution string `json:"maxResolution,omitempty"`
TypeCounts map[string]int `json:"typeCounts,omitempty"` TypeCounts map[string]int `json:"typeCounts,omitempty"`
MissingCount int `json:"missingCount,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 { type ImpactResult struct {

View File

@@ -3,6 +3,7 @@ package gsp
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"path/filepath"
"sort" "sort"
"strings" "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)) 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 unit.Refines != "" {
if _, ok := p.ByID[unit.Refines]; !ok { if _, ok := p.ByID[unit.Refines]; !ok {
report.addError("missing_refines", unit.ID, unit.File, fmt.Sprintf("refines references missing GSP %q", unit.Refines)) 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 { for _, rel := range unit.With {
with = append(with, rel.ID) 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(with)
sort.Strings(links)
entries = append(entries, IndexEntry{ entries = append(entries, IndexEntry{
ID: unit.ID, ID: unit.ID,
Title: unit.Title, Title: unit.Title,
@@ -89,6 +111,7 @@ func (p *Project) Index() []IndexEntry {
Resolution: unit.Resolution, Resolution: unit.Resolution,
With: with, With: with,
Refines: unit.Refines, Refines: unit.Refines,
Links: links,
}) })
} }
sort.Slice(entries, func(i, j int) bool { 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 units = candidate
approx = len(data) approx = len(data)
} }
links := p.resolveLinks(units)
return PackResult{ return PackResult{
Entry: id, Entry: id,
Intent: intent, Intent: intent,
@@ -192,17 +216,19 @@ func (p *Project) PackFor(id, intent, stage string, depth, budget int, filter Fi
Budget: budget, Budget: budget,
Truncated: truncated, Truncated: truncated,
Units: units, Units: units,
Summary: summarizeUnits(units, len(flattened.Warnings)), Links: links,
Summary: summarizeUnits(units, len(flattened.Warnings), len(links)),
ApproxChars: approx, ApproxChars: approx,
Warnings: flattened.Warnings, Warnings: flattened.Warnings,
} }
} }
func summarizeUnits(units []*Unit, missingCount int) Summary { func summarizeUnits(units []*Unit, missingCount, linkCount int) Summary {
summary := Summary{ summary := Summary{
UnitCount: len(units), UnitCount: len(units),
TypeCounts: map[string]int{}, TypeCounts: map[string]int{},
MissingCount: missingCount, MissingCount: missingCount,
LinkCount: linkCount,
} }
min := 99 min := 99
max := -1 max := -1

View File

@@ -19,6 +19,10 @@ title: Lottery Page
type: page type: page
resolution: L3 resolution: L3
context: Lottery page. context: Lottery page.
links:
- assets/ui
- path: https://example.com/lottery
role: reference
with: with:
- id: ui.button.primary - id: ui.button.primary
context: Main action. context: Main action.
@@ -29,6 +33,7 @@ refines: page.lottery.base
type: ui type: ui
resolution: L3 resolution: L3
context: Primary button. context: Primary button.
links: assets/ui/button.txt
`) `)
writeTestFile(t, design, "feedback.gsp", `id: feedback.positive writeTestFile(t, design, "feedback.gsp", `id: feedback.positive
type: feedback type: feedback
@@ -39,6 +44,11 @@ context: Positive feedback.
resolution: L2 resolution: L2
context: Base lottery page. 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) project, err := LoadProject(root)
if err != nil { if err != nil {
@@ -99,6 +109,20 @@ context: Base lottery page.
if !strings.Contains(pack.Markdown(), "GSP Context Pack") { if !strings.Contains(pack.Markdown(), "GSP Context Pack") {
t.Fatal("expected pack markdown") 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) impact := project.Impact("feedback.positive", -1)
if impact.Summary.DirectCount != 1 { if impact.Summary.DirectCount != 1 {