Add context packs impact analysis and message validation

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

View File

@@ -49,6 +49,10 @@ bin/gsp.exe
.\bin\gsp.exe index --root examples\lottery --out .gsp\index.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 --for implement --stage implement --format md --out .gsp\context-pack.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
.\bin\gsp.exe graph page.lottery.main --root examples\lottery --format mermaid --out .gsp\graph.mmd
.\bin\gsp.exe graph page.lottery.main --root examples\lottery --format md --out .gsp\graph.md
.\bin\gsp.exe graph page.lottery.main --root examples\lottery --format canvas --out .gsp\graph.canvas
@@ -63,6 +67,10 @@ bin/gsp.exe
.gsp/index.json
.gsp/flattened.json
.gsp/context-pack.json
.gsp/context-pack.md
.gsp/impact.md
.gsp/impact.canvas
.gsp/message-report.json
.gsp/graph.mmd
.gsp/graph.md
.gsp/graph.canvas
@@ -77,8 +85,10 @@ bin/gsp.exe
| `specs/versions/0.1/README.md` | GSP 0.1 语言说明。 |
| `specs/versions/0.1/gsp.schema.json` | GSP 0.1 核心字段 schema。 |
| `specs/versions/0.1/gsp.manifest.schema.json` | GSP 0.1 工程 manifest schema。 |
| `specs/versions/0.1/gsp.message.schema.json` | GSP 0.1 agent 通信消息 schema。 |
| `specs/versions/0.1/commands.md` | GSP 0.1 命令规范。 |
| `specs/versions/0.1/ai-usage.md` | GSP 0.1 AI 使用规则。 |
| `specs/versions/0.1/message.md` | GSP Message 说明。 |
| `toolkit/README.md` | GSP Toolkit 命令与实现说明。 |
## GSP 工程结构
@@ -144,6 +154,9 @@ gsp completion install powershell
gsp validate
gsp index
gsp flatten <id>
gsp pack <id> --for implement --format md
gsp impact <id> --format md
gsp message validate message.gspmsg
gsp graph <id>
gsp stage-check --stage implement
```

View File

@@ -24,6 +24,9 @@ page.lottery.main
```powershell
.\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 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
.\bin\gsp.exe graph page.lottery.main --root examples\lottery --format md --out .gsp\lottery-graph.md
.\bin\gsp.exe graph page.lottery.main --root examples\lottery --format canvas --out .gsp\lottery-graph.canvas

View File

@@ -0,0 +1,14 @@
gspMessageVersion: 0.1
id: msg.implement.lottery_page
from: planner
to: implementer
intent: implement
entry: page.lottery.main
stage: implement
requires:
- ui.button.reward_primary
- feedback.positive
contextPack:
mode: implement
depth: -1
budget: 12000

View File

@@ -13,8 +13,10 @@ README 面向准备使用 GSP 语言的人类和 AI。它说明 GSP 的用途、
| `README.md` | GSP 语言使用前说明。给人类和 AI 阅读。 |
| `gsp.schema.json` | GSP 第一版核心字段规范。使用 JSON Schema 表达,便于 AI、工具、编译器和实现模块识别。 |
| `gsp.manifest.schema.json` | GSP 工程 manifest 字段规范。 |
| `gsp.message.schema.json` | GSP agent 通信消息字段规范。 |
| `commands.md` | GSP Toolkit 命令规范。 |
| `ai-usage.md` | GSP 项目 AI 使用规则。 |
| `message.md` | GSP Message 说明。 |
## GSP 是什么

View File

@@ -16,4 +16,6 @@
- Use `gsp trace <id>` to inspect relations.
- Use `gsp flatten <id>` before implementation or task splitting.
- Use `gsp pack <id>` when a compact AI context is needed.
- Use `gsp impact <id>` before changing shared GSP units.
- Use `gsp message validate <file>` for agent communication messages.
- Use `gsp stage-check --stage <stage>` before stage handoff.

View File

@@ -100,9 +100,55 @@ gsp flatten <id> [--root .] [--depth 3] [--include-type a,b] [--exclude-type a,b
Create a compact AI context pack.
```bash
gsp pack <id> [--root .] [--depth 3] [--budget 12000] [--out context-pack.json]
gsp pack <id> [--root .] [--for implement] [--stage implement] [--depth 3] [--budget 12000] [--format json|md|canvas] [--out context-pack.json]
```
The `--for` value describes the task intent:
```text
design
implement
review
test
acceptance
handoff
inspect
```
Formats:
```text
json Machine-readable context pack.
md Human-readable and AI-readable context pack.
canvas Obsidian JSON Canvas for included GSP relations.
```
## impact
Find direct and indirect GSPs affected by a GSP id.
```bash
gsp impact <id> [--root .] [--depth -1] [--format json|md|canvas] [--out impact.json]
```
Formats:
```text
json Machine-readable impact result.
md Human-readable impact report.
canvas Obsidian JSON Canvas for affected relations.
```
## message
Validate GSP agent communication messages.
```bash
gsp message validate <file> [--root .] [--out message-report.json]
```
Message files use YAML and reference GSP ids from the project.
## graph
Generate a relation graph.

View File

@@ -0,0 +1,63 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://gsp.local/schema/gsp.message.schema.json",
"title": "GSP Message",
"description": "GSP agent communication message schema.",
"type": "object",
"additionalProperties": true,
"required": ["gspMessageVersion", "id", "from", "to", "intent", "entry"],
"properties": {
"gspMessageVersion": {
"type": "string",
"enum": ["0.1"]
},
"id": {
"type": "string",
"minLength": 1
},
"from": {
"type": "string",
"minLength": 1
},
"to": {
"type": "string",
"minLength": 1
},
"intent": {
"type": "string",
"enum": ["design", "implement", "review", "test", "acceptance", "handoff", "inspect"]
},
"entry": {
"type": "string",
"minLength": 1
},
"stage": {
"type": "string",
"enum": ["design", "integrate", "implement", "bind", "release"]
},
"requires": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"contextPack": {
"type": "object",
"additionalProperties": true,
"properties": {
"mode": {
"type": "string",
"enum": ["design", "implement", "review", "test", "acceptance", "handoff", "inspect"]
},
"depth": {
"type": "integer"
},
"budget": {
"type": "integer",
"minimum": 0
}
}
}
}
}

View File

@@ -0,0 +1,41 @@
# GSP Message 0.1
GSP Message is a lightweight agent communication format.
It references GSP ids and does not replace `.gsp` files.
```yaml
gspMessageVersion: 0.1
id: msg.implement.lottery_page
from: planner
to: implementer
intent: implement
entry: page.lottery.main
stage: implement
requires:
- ui.button.reward_primary
- feedback.positive
contextPack:
mode: implement
depth: -1
budget: 12000
```
Required fields:
| Field | Meaning |
|---|---|
| `gspMessageVersion` | Message protocol version. |
| `id` | Message id. |
| `from` | Sender agent or module id. |
| `to` | Receiver agent or module id. |
| `intent` | Task intent. |
| `entry` | Entry GSP id. |
Validation:
```bash
gsp message validate message.gspmsg --root .
```
The validator checks message version, required fields, intent, stage, entry GSP, and required GSP references.

View File

@@ -177,6 +177,8 @@ gsp index
gsp trace <id>
gsp flatten <id>
gsp pack <id>
gsp impact <id>
gsp message validate <file>
gsp graph <id>
gsp stage-check --stage implement
```
@@ -190,6 +192,8 @@ go build -o ../bin/gsp ./cmd/gsp
../bin/gsp version
../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 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
../bin/gsp graph page.lottery.main --root ../examples/lottery --format canvas --out ../.gsp/graph.canvas
@@ -216,7 +220,9 @@ go build -o ../bin/gsp ./cmd/gsp
.gsp/graph.md
.gsp/graph.canvas
.gsp/context-pack.json
.gsp/context-pack.md
.gsp/flattened.json
.gsp/impact.md
```
## 6. 必须考虑的问题

View File

@@ -12,6 +12,8 @@ $script:GspSubcommands = @(
'trace',
'flatten',
'pack',
'impact',
'message',
'graph',
'stage-check',
'help'
@@ -26,7 +28,9 @@ $script:GspFlags = @{
'index' = @('--root', '--out')
'trace' = @('--root', '--depth', '--out')
'flatten' = @('--root', '--depth', '--include-type', '--exclude-type', '--out')
'pack' = @('--root', '--depth', '--budget', '--include-type', '--exclude-type', '--out')
'pack' = @('--root', '--for', '--stage', '--depth', '--budget', '--format', '--include-type', '--exclude-type', '--out')
'impact' = @('--root', '--depth', '--format', '--out')
'message' = @('validate')
'graph' = @('--root', '--depth', '--format', '--out')
'stage-check' = @('--stage', '--root', '--out')
}
@@ -73,7 +77,11 @@ Register-ArgumentCompleter -Native -CommandName gsp -ScriptBlock {
switch ($previous) {
'--format' {
return @('json', 'mermaid', 'md', 'canvas') |
$formats = @('json', 'md', 'canvas')
if ($command -eq 'graph') {
$formats = @('json', 'mermaid', 'md', 'canvas')
}
return $formats |
Where-Object { $_ -like "$wordToComplete*" } |
ForEach-Object { New-GspCompletion $_ }
}
@@ -87,6 +95,11 @@ Register-ArgumentCompleter -Native -CommandName gsp -ScriptBlock {
Where-Object { $_ -like "$wordToComplete*" } |
ForEach-Object { New-GspCompletion $_ }
}
'--for' {
return @('design', 'implement', 'review', 'test', 'acceptance', 'handoff', 'inspect') |
Where-Object { $_ -like "$wordToComplete*" } |
ForEach-Object { New-GspCompletion $_ }
}
}
if ($command -eq 'completion') {
@@ -106,7 +119,13 @@ Register-ArgumentCompleter -Native -CommandName gsp -ScriptBlock {
ForEach-Object { New-GspCompletion $_ }
}
if (@('trace', 'flatten', 'pack', 'graph') -contains $command) {
if ($command -eq 'message') {
return @($script:GspFlags[$command]) |
Where-Object { $_ -like "$wordToComplete*" } |
ForEach-Object { New-GspCompletion $_ }
}
if (@('trace', 'flatten', 'pack', 'impact', 'graph') -contains $command) {
return Get-GspIds |
Where-Object { $_ -like "$wordToComplete*" } |
ForEach-Object { New-GspCompletion $_ }
@@ -125,11 +144,19 @@ _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 graph stage-check help"
local commands="init ai-init version completion validate index trace flatten pack impact message graph stage-check help"
case "$prev" in
--format) COMPREPLY=( $(compgen -W "json mermaid md canvas" -- "$cur") ); return ;;
--format)
if [[ "$cmd" == "graph" ]]; then
COMPREPLY=( $(compgen -W "json mermaid md canvas" -- "$cur") )
else
COMPREPLY=( $(compgen -W "json md canvas" -- "$cur") )
fi
return
;;
--stage) COMPREPLY=( $(compgen -W "design integrate implement bind release" -- "$cur") ); return ;;
--skill) COMPREPLY=( $(compgen -W "generic codex" -- "$cur") ); return ;;
--for) COMPREPLY=( $(compgen -W "design implement review test acceptance handoff inspect" -- "$cur") ); return ;;
esac
if [[ $COMP_CWORD -eq 1 ]]; then
COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
@@ -143,7 +170,9 @@ _gsp_completion() {
validate|index) COMPREPLY=( $(compgen -W "--root --out" -- "$cur") ) ;;
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 --depth --budget --include-type --exclude-type --out" -- "$cur") ) ;;
pack) COMPREPLY=( $(compgen -W "--root --for --stage --depth --budget --format --include-type --exclude-type --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") ) ;;
stage-check) COMPREPLY=( $(compgen -W "--stage --root --out" -- "$cur") ) ;;
completion) COMPREPLY=( $(compgen -W "powershell bash zsh fish install" -- "$cur") ) ;;
@@ -151,7 +180,7 @@ _gsp_completion() {
return
fi
case "$cmd" in
trace|flatten|pack|graph)
trace|flatten|pack|impact|graph)
local ids
ids=$(gsp index --root . 2>/dev/null | sed -n 's/.*"id": "\([^"]*\)".*/\1/p')
COMPREPLY=( $(compgen -W "$ids" -- "$cur") )
@@ -177,6 +206,8 @@ _gsp() {
'trace'
'flatten'
'pack'
'impact'
'message'
'graph'
'stage-check'
'help'
@@ -189,10 +220,12 @@ _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 graph stage-check help'
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 -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 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 trace flatten pack 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 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'')'
`
}

View File

@@ -48,6 +48,10 @@ func run(args []string) error {
return runFlatten(args[1:])
case "pack":
return runPack(args[1:])
case "impact":
return runImpact(args[1:])
case "message":
return runMessage(args[1:])
case "graph":
return runGraph(args[1:])
case "stage-check":
@@ -73,7 +77,9 @@ Usage:
gsp index [--root .] [--out index.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 pack <id> [--root .] [--depth 3] [--budget 12000] [--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 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]
gsp stage-check --stage implement [--root .] [--out stage-report.json]
`)
@@ -320,6 +326,9 @@ func runPack(args []string) error {
out := commonOut(fs)
depth := fs.Int("depth", 3, "maximum relation depth; -1 means unlimited")
budget := fs.Int("budget", 0, "approximate JSON character budget; 0 means unlimited")
intent := fs.String("for", "", "context purpose: design, implement, review, test, acceptance, handoff, or inspect")
stage := fs.String("stage", "", "optional project stage")
format := fs.String("format", "json", "json, md, or canvas")
includeType := fs.String("include-type", "", "comma-separated type allow-list")
excludeType := fs.String("exclude-type", "", "comma-separated type deny-list")
if err := fs.Parse(normalizeFlagArgs(args)); err != nil {
@@ -332,11 +341,91 @@ func runPack(args []string) error {
if err != nil {
return err
}
result := project.Pack(fs.Arg(0), *depth, *budget, gsp.Filter{
result := project.PackFor(fs.Arg(0), *intent, *stage, *depth, *budget, gsp.Filter{
IncludeTypes: splitCSV(*includeType),
ExcludeTypes: splitCSV(*excludeType),
})
return writeJSON(*out, result)
switch *format {
case "json":
return writeJSON(*out, result)
case "md":
return writeText(*out, result.Markdown())
case "canvas":
data, err := project.Graph(fs.Arg(0), *depth).Canvas()
if err != nil {
return err
}
return writeBytes(*out, data)
default:
return fmt.Errorf("unsupported pack format %q", *format)
}
}
func runImpact(args []string) error {
fs := flag.NewFlagSet("impact", flag.ContinueOnError)
root := commonRoot(fs)
out := commonOut(fs)
depth := fs.Int("depth", -1, "maximum reverse relation depth; -1 means unlimited")
format := fs.String("format", "json", "json, md, or canvas")
if err := fs.Parse(normalizeFlagArgs(args)); err != nil {
return err
}
if fs.NArg() != 1 {
return fmt.Errorf("impact requires one GSP id")
}
project, err := gsp.LoadProject(*root)
if err != nil {
return err
}
result := project.Impact(fs.Arg(0), *depth)
switch *format {
case "json":
return writeJSON(*out, result)
case "md":
return writeText(*out, result.Markdown())
case "canvas":
data, err := project.ImpactGraph(result).Canvas()
if err != nil {
return err
}
return writeBytes(*out, data)
default:
return fmt.Errorf("unsupported impact format %q", *format)
}
}
func runMessage(args []string) error {
if len(args) == 0 {
return fmt.Errorf("message requires validate")
}
switch args[0] {
case "validate":
return runMessageValidate(args[1:])
default:
return fmt.Errorf("unknown message command %q", args[0])
}
}
func runMessageValidate(args []string) error {
fs := flag.NewFlagSet("message validate", flag.ContinueOnError)
root := commonRoot(fs)
out := commonOut(fs)
if err := fs.Parse(normalizeFlagArgs(args)); err != nil {
return err
}
if fs.NArg() != 1 {
return fmt.Errorf("message validate requires one file")
}
project, err := gsp.LoadProject(*root)
if err != nil {
return err
}
message, err := gsp.ReadMessage(project.Root, fs.Arg(0))
if err != nil {
return err
}
report := project.ValidateMessage(message)
return writeReport(*out, report)
}
func runGraph(args []string) error {
@@ -418,6 +507,7 @@ func normalizeFlagArgs(args []string) []string {
"-budget": true, "--budget": true,
"-format": true, "--format": true,
"-stage": true, "--stage": true,
"-for": true, "--for": true,
"-name": true, "--name": true,
"-entry": true, "--entry": true,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"sort"
"strings"
)
func (p *Project) Validate(stage string) Report {
@@ -165,6 +166,10 @@ func (p *Project) Flatten(id string, depth int, filter Filter) FlattenResult {
}
func (p *Project) Pack(id string, depth, budget int, filter Filter) PackResult {
return p.PackFor(id, "", "", depth, budget, filter)
}
func (p *Project) PackFor(id, intent, stage string, depth, budget int, filter Filter) PackResult {
flattened := p.Flatten(id, depth, filter)
units := make([]*Unit, 0, len(flattened.Units))
approx := 0
@@ -181,15 +186,131 @@ func (p *Project) Pack(id string, depth, budget int, filter Filter) PackResult {
}
return PackResult{
Entry: id,
Intent: intent,
Stage: stage,
Depth: depth,
Budget: budget,
Truncated: truncated,
Units: units,
Summary: summarizeUnits(units, len(flattened.Warnings)),
ApproxChars: approx,
Warnings: flattened.Warnings,
}
}
func summarizeUnits(units []*Unit, missingCount int) Summary {
summary := Summary{
UnitCount: len(units),
TypeCounts: map[string]int{},
MissingCount: missingCount,
}
min := 99
max := -1
for _, unit := range units {
if unit.Type != "" {
summary.TypeCounts[unit.Type]++
}
rank, ok := resolutionValue(unit.Resolution)
if !ok {
continue
}
if rank < min {
min = rank
summary.MinResolution = displayResolution(unit.Resolution)
}
if rank > max {
max = rank
summary.MaxResolution = displayResolution(unit.Resolution)
}
}
if len(summary.TypeCounts) == 0 {
summary.TypeCounts = nil
}
return summary
}
func (p *Project) Impact(id string, depth int) ImpactResult {
result := ImpactResult{Entry: id, Depth: depth}
if _, ok := p.ByID[id]; !ok {
result.Warnings = append(result.Warnings, Issue{Level: "warning", Code: "missing", ID: id, Message: fmt.Sprintf("missing GSP %q", id)})
return result
}
reverse := map[string][]ImpactEntry{}
for _, unit := range p.Units {
if unit.Refines != "" {
reverse[unit.Refines] = append(reverse[unit.Refines], impactEntry(unit, 0, unit.Refines, "refines"))
}
for _, rel := range unit.With {
reverse[rel.ID] = append(reverse[rel.ID], impactEntry(unit, 0, rel.ID, "with"))
}
}
for key := range reverse {
sort.Slice(reverse[key], func(i, j int) bool {
return reverse[key][i].ID < reverse[key][j].ID
})
}
seen := map[string]bool{id: true}
queue := []ImpactEntry{{ID: id, Depth: 0}}
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
if depth >= 0 && current.Depth >= depth {
continue
}
for _, affected := range reverse[current.ID] {
if seen[affected.ID] {
continue
}
seen[affected.ID] = true
affected.Depth = current.Depth + 1
affected.Via = current.ID
if affected.Depth == 1 {
result.Direct = append(result.Direct, affected)
} else {
result.Indirect = append(result.Indirect, affected)
}
result.Edges = append(result.Edges, GraphEdge{From: affected.ID, To: current.ID, Kind: affected.Kind})
queue = append(queue, affected)
if affected.Depth > result.Summary.MaxDepth {
result.Summary.MaxDepth = affected.Depth
}
}
}
sortImpactEntries(result.Direct)
sortImpactEntries(result.Indirect)
sort.Slice(result.Edges, func(i, j int) bool {
return strings.Join([]string{result.Edges[i].From, result.Edges[i].To, result.Edges[i].Kind}, "\x00") < strings.Join([]string{result.Edges[j].From, result.Edges[j].To, result.Edges[j].Kind}, "\x00")
})
result.Summary.DirectCount = len(result.Direct)
result.Summary.IndirectCount = len(result.Indirect)
result.Summary.TotalCount = len(result.Direct) + len(result.Indirect)
return result
}
func impactEntry(unit *Unit, depth int, via, kind string) ImpactEntry {
return ImpactEntry{
ID: unit.ID,
Title: unit.Title,
Type: unit.Type,
Resolution: unit.Resolution,
File: unit.File,
Depth: depth,
Via: via,
Kind: kind,
}
}
func sortImpactEntries(entries []ImpactEntry) {
sort.Slice(entries, func(i, j int) bool {
if entries[i].Depth == entries[j].Depth {
return entries[i].ID < entries[j].ID
}
return entries[i].Depth < entries[j].Depth
})
}
type walker struct {
project *Project
depth int

View File

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