From 21e6e9dbe91e9535b770000bb7cb30aa4c1757bd Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Sun, 1 Mar 2026 05:28:54 +0300 Subject: [PATCH 1/2] add mcp tool for config describe --- README.md | 8 + Taskfile.yml | 11 + cmd/easyp-schema-gen/main.go | 52 ++++ go.mod | 14 ++ go.sum | 27 +++ internal/api/temporaly_helper.go | 1 - internal/config/config.go | 1 - internal/config/validate_raw.go | 9 +- internal/config/validate_raw_test.go | 145 ++++++++++++ internal/config/yaml_validators.go | 20 +- internal/core/dom.go | 1 - internal/rules/builder.go | 14 ++ internal/rules/builder_test.go | 28 +++ mcp/easypconfig/README.md | 39 +++ mcp/easypconfig/describe.go | 338 ++++++++++++++++++++++++++ mcp/easypconfig/easypconfig_test.go | 320 +++++++++++++++++++++++++ mcp/easypconfig/generate.go | 3 + mcp/easypconfig/schema.go | 171 ++++++++++++++ mcp/easypconfig/schema_model.go | 178 ++++++++++++++ mcp/easypconfig/spec_docs.go | 281 ++++++++++++++++++++++ mcp/easypconfig/tool.go | 22 ++ mcp/easypconfig/tool_schemas.go | 149 ++++++++++++ schemas/easyp-config-v1.schema.json | 339 +++++++++++++++++++++++++++ schemas/easyp-config.schema.json | 339 +++++++++++++++++++++++++++ 24 files changed, 2502 insertions(+), 8 deletions(-) create mode 100644 cmd/easyp-schema-gen/main.go create mode 100644 mcp/easypconfig/README.md create mode 100644 mcp/easypconfig/describe.go create mode 100644 mcp/easypconfig/easypconfig_test.go create mode 100644 mcp/easypconfig/generate.go create mode 100644 mcp/easypconfig/schema.go create mode 100644 mcp/easypconfig/schema_model.go create mode 100644 mcp/easypconfig/spec_docs.go create mode 100644 mcp/easypconfig/tool.go create mode 100644 mcp/easypconfig/tool_schemas.go create mode 100644 schemas/easyp-config-v1.schema.json create mode 100644 schemas/easyp-config.schema.json diff --git a/README.md b/README.md index 7d8cd93a..a214f0fd 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,14 @@ easyp validate-config easyp --format text validate-config --config example.easyp.yaml ``` +### Config Schema Integration + +The source of truth for config schema + MCP tool metadata lives in [`mcp/easypconfig`](mcp/easypconfig/README.md). + +- Go integration: import `github.com/easyp-tech/easyp/mcp/easypconfig` and call `RegisterTool(...)` / `Describe(...)`. +- Cross-language integration: consume generated JSON Schema artifacts in `schemas/easyp-config-v1.schema.json` and `schemas/easyp-config.schema.json`. +- Regenerate artifacts: `task schema:generate` (or `go run ./cmd/easyp-schema-gen`). + ## Community For help and discussion around EasyP and Protocol Buffers best practices: diff --git a/Taskfile.yml b/Taskfile.yml index 40fddc13..2618548d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -36,6 +36,17 @@ tasks: cmds: - "go build -o easyp ./cmd/easyp" + schema:generate: + desc: Generate versioned and latest easyp config JSON Schema artifacts + cmds: + - "go run ./cmd/easyp-schema-gen" + + schema:check: + desc: Ensure schema artifacts are up to date + cmds: + - task: schema:generate + - "git diff --exit-code -- schemas/easyp-config-v1.schema.json schemas/easyp-config.schema.json" + test: cmds: - "{{.LOCAL_BIN}}/gotestsum --format pkgname -- -coverprofile=coverage.out -race -count=1 ./..." diff --git a/cmd/easyp-schema-gen/main.go b/cmd/easyp-schema-gen/main.go new file mode 100644 index 00000000..cf61636b --- /dev/null +++ b/cmd/easyp-schema-gen/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/easyp-tech/easyp/mcp/easypconfig" +) + +func main() { + var ( + versionedOut string + latestOut string + ) + + flag.StringVar(&versionedOut, "out-versioned", "schemas/easyp-config-v1.schema.json", "path to versioned schema file") + flag.StringVar(&latestOut, "out-latest", "schemas/easyp-config.schema.json", "path to latest schema alias file") + flag.Parse() + + data, err := easypconfig.MarshalConfigJSONSchema() + if err != nil { + exitf("marshal schema: %v", err) + } + data = append(data, '\n') + + if err := writeFile(versionedOut, data); err != nil { + exitf("write versioned schema: %v", err) + } + if err := writeFile(latestOut, data); err != nil { + exitf("write latest schema: %v", err) + } +} + +func writeFile(path string, data []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("os.WriteFile %s: %w", path, err) + } + + return nil +} + +func exitf(format string, args ...any) { + _, _ = fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/go.mod b/go.mod index 96eee3d7..1a8f26c3 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,9 @@ require ( github.com/codeclysm/extract/v3 v3.1.1 github.com/easyp-tech/service v0.2.0 github.com/go-git/go-git/v5 v5.16.3 + github.com/google/jsonschema-go v0.4.2 + github.com/invopop/jsonschema v0.13.0 + github.com/modelcontextprotocol/go-sdk v1.3.1 github.com/otiai10/copy v1.14.1 github.com/samber/lo v1.52.0 github.com/stretchr/testify v1.11.1 @@ -24,6 +27,17 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.3 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.30.0 // indirect +) + require ( dario.cat/mergo v1.0.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect diff --git a/go.sum b/go.sum index 96e733f6..0c5bb160 100644 --- a/go.sum +++ b/go.sum @@ -15,10 +15,14 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= @@ -64,18 +68,25 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/juju/errors v1.0.0 h1:yiq7kjCLll1BiaRuNY53MGI0+EQ3rF6GB+wvboZDefM= github.com/juju/errors v1.0.0/go.mod h1:B5x9thDqx0wIMH3+aLIMP9HjItInYWObRovoCFM5Qe8= github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= @@ -93,12 +104,16 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI= +github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -126,6 +141,10 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -146,6 +165,8 @@ github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/wasilibs/wazero-helpers v0.0.0-20250123031827-cd30c44769bb h1:gQ+ZV4wJke/EBKYciZ2MshEouEHFuinB85dY3f5s1q8= github.com/wasilibs/wazero-helpers v0.0.0-20250123031827-cd30c44769bb/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -156,6 +177,8 @@ github.com/yakwilikk/go-yamlvalidator v0.0.0-20260216223344-568790865548 h1:wKoh github.com/yakwilikk/go-yamlvalidator v0.0.0-20260216223344-568790865548/go.mod h1:JIBeXFmTJyghm8crrAhNGhVxsNpCFSmuPLjz2spSBVU= github.com/yoheimuta/go-protoparser/v4 v4.14.2 h1:/P/LlX1CF9NaTWEltGcIZVvNlPbhABuAnBtAWpb3+74= github.com/yoheimuta/go-protoparser/v4 v4.14.2/go.mod h1:AHNNnSWnb0UoL4QgHPiOAg2BniQceFscPI5X/BZNHl8= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= @@ -178,6 +201,8 @@ golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -197,6 +222,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8= diff --git a/internal/api/temporaly_helper.go b/internal/api/temporaly_helper.go index 24b0e8b9..bf992d53 100644 --- a/internal/api/temporaly_helper.go +++ b/internal/api/temporaly_helper.go @@ -127,7 +127,6 @@ func buildCore(_ context.Context, log logger.Logger, cfg config.Config, dirWalke return core.InputGitRepo{ URL: i.GitRepo.URL, SubDirectory: i.GitRepo.SubDirectory, - Out: i.GitRepo.Out, Root: i.GitRepo.Root, } }), func(i core.InputGitRepo, _ int) bool { diff --git a/internal/config/config.go b/internal/config/config.go index 8e7656ec..7154d3dd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -146,7 +146,6 @@ type Input struct { type InputGitRepo struct { URL string `yaml:"url"` SubDirectory string `yaml:"sub_directory"` - Out string `yaml:"out"` Root string `yaml:"root"` } diff --git a/internal/config/validate_raw.go b/internal/config/validate_raw.go index 8932b798..085f995b 100644 --- a/internal/config/validate_raw.go +++ b/internal/config/validate_raw.go @@ -49,8 +49,12 @@ func ValidateRaw(buf []byte) ([]ValidationIssue, error) { } msg := e.Message - if e.Expected != "" || e.Got != "" { + if e.Expected != "" && e.Got != "" { msg = fmt.Sprintf("%s (expected %s, got %s)", e.Message, e.Expected, e.Got) + } else if e.Expected != "" { + msg = fmt.Sprintf("%s (expected %s)", e.Message, e.Expected) + } else if e.Got != "" { + msg = fmt.Sprintf("%s (got %s)", e.Message, e.Got) } if e.Path != "" { msg = fmt.Sprintf("%s (path: %s)", msg, e.Path) @@ -111,7 +115,7 @@ func buildSchema() *v.FieldSchema { "path": {Type: v.TypeString}, "root": {Type: v.TypeString}, }, - UnknownKeyPolicy: v.UnknownKeyWarn, + UnknownKeyPolicy: v.UnknownKeyIgnore, Validators: []v.ValueValidator{directoryValidator{}}, } @@ -120,7 +124,6 @@ func buildSchema() *v.FieldSchema { AllowedKeys: map[string]*v.FieldSchema{ "url": {Type: v.TypeString, Required: true}, "sub_directory": {Type: v.TypeString}, - "out": {Type: v.TypeString}, "root": {Type: v.TypeString}, }, UnknownKeyPolicy: v.UnknownKeyWarn, diff --git a/internal/config/validate_raw_test.go b/internal/config/validate_raw_test.go index efd4e756..534af0e7 100644 --- a/internal/config/validate_raw_test.go +++ b/internal/config/validate_raw_test.go @@ -66,6 +66,7 @@ generate: } } require.True(t, found, "expected plugin opts validation error, got issues: %#v", issues) + assertNoEmptyExpectedGotArtifact(t, issues) } func containsAny(s string, candidates ...string) bool { @@ -77,6 +78,68 @@ func containsAny(s string, candidates ...string) bool { return false } +func issuesBySeverityAndPath(issues []ValidationIssue, severity string, path string) []ValidationIssue { + out := make([]ValidationIssue, 0) + pathMarker := "(path: " + path + ")" + for _, issue := range issues { + if issue.Severity == severity && strings.Contains(issue.Message, pathMarker) { + out = append(out, issue) + } + } + return out +} + +func assertNoEmptyExpectedGotArtifact(t *testing.T, issues []ValidationIssue) { + t.Helper() + for _, issue := range issues { + require.NotContains(t, issue.Message, "expected , got", "unexpected malformed message: %q", issue.Message) + } +} + +func TestValidateRaw_PluginOptsRejectsNestedMapValue(t *testing.T) { + content := `version: v1alpha +lint: + use: + - DIRECTORY_SAME_PACKAGE +generate: + inputs: + - directory: proto + plugins: + - command: + - go + - run + out: . + opts: + l: + L: +` + + issues, err := ValidateRaw([]byte(content)) + require.NoError(t, err) + require.True(t, HasErrors(issues)) + assertNoEmptyExpectedGotArtifact(t, issues) + + var hasTypedError bool + var hasNestedUnknown bool + for _, issue := range issues { + if issue.Severity == SeverityError && + strings.Contains(issue.Message, "opts value must be a scalar or sequence of scalars") && + strings.Contains(issue.Message, "expected scalar or sequence of scalars, got MappingNode") && + strings.Contains(issue.Message, "generate.plugins[0].opts.l") { + hasTypedError = true + } + + if issue.Severity == SeverityError && + strings.Contains(issue.Message, `unknown key "L"`) && + strings.Contains(issue.Message, "generate.plugins[0].opts.l.L") { + hasNestedUnknown = true + } + } + + require.True(t, hasTypedError, "expected typed opts error with MappingNode, got issues: %#v", issues) + require.True(t, hasNestedUnknown, "expected nested unknown key error for opts.l.L, got issues: %#v", issues) +} + func TestValidateRaw_BreakingSchemaValidKeys(t *testing.T) { content := `lint: use: @@ -115,3 +178,85 @@ breaking: } require.True(t, hasWarning, "expected warning for unknown key 'unknown_field' in breaking section, got: %v", issues) } + +func TestValidateRaw_DirectoryUnknownKey_NoDuplicate(t *testing.T) { + content := `version: v1alpha +lint: + use: + - DIRECTORY_SAME_PACKAGE +generate: + inputs: + - directory: + path: api + pal: value + plugins: + - name: go + out: . +` + + issues, err := ValidateRaw([]byte(content)) + require.NoError(t, err) + require.False(t, HasErrors(issues), "directory unknown key should be warning-only, got: %#v", issues) + assertNoEmptyExpectedGotArtifact(t, issues) + + warningsAtPath := issuesBySeverityAndPath(issues, SeverityWarn, "generate.inputs[0].directory.pal") + + require.Len(t, warningsAtPath, 1, "expected a single warning for directory unknown key, got: %#v", issues) + require.Contains(t, warningsAtPath[0].Message, "unknown field under directory") + require.NotContains(t, warningsAtPath[0].Message, `unknown key "pal"`) +} + +func TestValidateRaw_GitRepoOut_IsUnknownKey(t *testing.T) { + content := `version: v1alpha +lint: + use: + - DIRECTORY_SAME_PACKAGE +generate: + inputs: + - git_repo: + url: github.com/acme/common@v1.0.0 + out: gen + plugins: + - name: go + out: . +` + + issues, err := ValidateRaw([]byte(content)) + require.NoError(t, err) + require.False(t, HasErrors(issues), "git_repo.out should be treated as unknown warning only, got: %#v", issues) + assertNoEmptyExpectedGotArtifact(t, issues) + + warningsAtPath := issuesBySeverityAndPath(issues, SeverityWarn, "generate.inputs[0].git_repo.out") + + require.Len(t, warningsAtPath, 1, "expected one unknown warning for git_repo.out, got: %#v", issues) + require.Contains(t, warningsAtPath[0].Message, `unknown key "out"`) + require.Contains(t, warningsAtPath[0].Message, "(got ") + require.NotContains(t, warningsAtPath[0].Message, "unknown field under directory") +} + +func TestValidateRaw_MessageFormatting_GotOnly(t *testing.T) { + content := `lint: + use: + - DIRECTORY_SAME_PACKAGE +breaking: + ignore: + - proto/legacy + unknown_field: true +` + + issues, err := ValidateRaw([]byte(content)) + require.NoError(t, err) + assertNoEmptyExpectedGotArtifact(t, issues) + + var gotOnlyMessage string + for _, issue := range issues { + if issue.Severity == SeverityWarn && strings.Contains(issue.Message, `unknown key "unknown_field"`) { + gotOnlyMessage = issue.Message + break + } + } + + require.NotEmpty(t, gotOnlyMessage, "expected warning for unknown_field, got issues: %#v", issues) + require.Contains(t, gotOnlyMessage, "(got ") + require.NotContains(t, gotOnlyMessage, "(expected ") +} diff --git a/internal/config/yaml_validators.go b/internal/config/yaml_validators.go index f5552df7..44655720 100644 --- a/internal/config/yaml_validators.go +++ b/internal/config/yaml_validators.go @@ -7,6 +7,21 @@ import ( "gopkg.in/yaml.v3" ) +func yamlKindName(kind yaml.Kind) string { + switch kind { + case yaml.ScalarNode: + return "ScalarNode" + case yaml.SequenceNode: + return "SequenceNode" + case yaml.MappingNode: + return "MappingNode" + case yaml.AliasNode: + return "AliasNode" + default: + return fmt.Sprintf("%d", kind) + } +} + // directoryValidator allows directory to be either a string or a map with required path and optional root. type directoryValidator struct{} @@ -78,7 +93,7 @@ func (directoryValidator) Validate(node *yaml.Node, path string, ctx *v.Validati Line: node.Line, Column: node.Column, Message: "directory must be string or mapping", - Got: fmt.Sprintf("%v", node.Kind), + Got: yamlKindName(node.Kind), }) } } @@ -158,7 +173,8 @@ func (pluginOptsValidator) Validate(node *yaml.Node, path string, ctx *v.Validat Line: valNode.Line, Column: valNode.Column, Message: "opts value must be a scalar or sequence of scalars", - Got: fmt.Sprintf("%v", valNode.Kind), + Expected: "scalar or sequence of scalars", + Got: yamlKindName(valNode.Kind), }) } } diff --git a/internal/core/dom.go b/internal/core/dom.go index bf41b7de..2f3531fc 100644 --- a/internal/core/dom.go +++ b/internal/core/dom.go @@ -219,7 +219,6 @@ type ( InputGitRepo struct { URL string SubDirectory string - Out string Root string } // InputFilesDir is the configuration of the directory with additional functionality. diff --git a/internal/rules/builder.go b/internal/rules/builder.go index 20eb7edd..66dc9a38 100644 --- a/internal/rules/builder.go +++ b/internal/rules/builder.go @@ -44,6 +44,20 @@ func AllRuleNames() []string { return res } +// AllLintUseValues returns all valid values for lint.use: +// group keys, grouped rule names, and uncategorized rule names. +func AllLintUseValues() []string { + groups := AllGroups() + values := make([]string, 0, len(groups)+len(AllRuleNames())+1) + for _, group := range groups { + values = append(values, group.Key) + } + values = append(values, AllRuleNames()...) + values = append(values, core.GetRuleName(&PackageNoImportCycle{})) + + return lo.FindUniques(values) +} + // New returns a map of rules and a map of ignore only rules by configuration. func New(cfg config.LintConfig) ([]core.Rule, map[string][]string, error) { allRules := []core.Rule{ diff --git a/internal/rules/builder_test.go b/internal/rules/builder_test.go index 15a9a23a..42048f20 100644 --- a/internal/rules/builder_test.go +++ b/internal/rules/builder_test.go @@ -56,6 +56,34 @@ func TestAllRuleNames(t *testing.T) { require.Equal(t, expected, allRules) } +func TestAllLintUseValues(t *testing.T) { + values := rules.AllLintUseValues() + require.NotEmpty(t, values) + + for _, group := range rules.AllGroups() { + require.Contains(t, values, group.Key) + } + for _, ruleName := range rules.AllRuleNames() { + require.Contains(t, values, ruleName) + } + require.Contains(t, values, "PACKAGE_NO_IMPORT_CYCLE") + require.Equal(t, len(values), len(uniqueStrings(values))) +} + +func uniqueStrings(values []string) []string { + set := make(map[string]struct{}, len(values)) + for _, value := range values { + set[value] = struct{}{} + } + + out := make([]string, 0, len(set)) + for value := range set { + out = append(out, value) + } + + return out +} + func TestNew_ExceptExpandsGroups(t *testing.T) { // Use DEFAULT group and except COMMENTS group. // COMMENTS rules should not appear in result (but they aren't in DEFAULT anyway). diff --git a/mcp/easypconfig/README.md b/mcp/easypconfig/README.md new file mode 100644 index 00000000..f437a868 --- /dev/null +++ b/mcp/easypconfig/README.md @@ -0,0 +1,39 @@ +# easypconfig + +`mcp/easypconfig` is the source-of-truth package for `easyp.yaml` schema metadata and the MCP tool `easyp_config_describe`. + +## Go usage + +```go +import ( + "github.com/easyp-tech/easyp/mcp/easypconfig" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func register(server *mcp.Server) { + easypconfig.RegisterTool(server) +} +``` + +Programmatic access: + +- `easypconfig.Describe(...)` +- `easypconfig.SchemaByPath()` +- `easypconfig.MarshalConfigJSONSchema()` + +## Non-Go usage + +Versioned and latest JSON Schema artifacts are generated into: + +- `schemas/easyp-config-v1.schema.json` +- `schemas/easyp-config.schema.json` + +This allows external tooling (for example Kotlin/JetBrains plugins) to consume the same schema without linking Go code. + +Generate/update artifacts: + +```sh +go run ./cmd/easyp-schema-gen +# or +task schema:generate +``` diff --git a/mcp/easypconfig/describe.go b/mcp/easypconfig/describe.go new file mode 100644 index 00000000..2e90db4c --- /dev/null +++ b/mcp/easypconfig/describe.go @@ -0,0 +1,338 @@ +package easypconfig + +import ( + "fmt" + "regexp" + "sort" + "strings" + "sync" + + "github.com/easyp-tech/easyp/internal/rules" +) + +const ( + ToolName = "easyp_config_describe" + SchemaVersion = "easyp-config-v1" +) + +type DescribeInput struct { + Path string `json:"path,omitempty"` + IncludeSchema *bool `json:"include_schema,omitempty"` + IncludeFields *bool `json:"include_fields,omitempty"` + IncludeExamples *bool `json:"include_examples,omitempty"` + IncludeChildren *bool `json:"include_children,omitempty"` + ExamplesLimit *int `json:"examples_limit,omitempty"` +} + +type FieldDoc struct { + Path string `json:"path"` + Type string `json:"type"` + Required bool `json:"required"` + Description string `json:"description"` + AllowedValues []string `json:"allowed_values,omitempty"` + DefaultValue string `json:"default_value,omitempty"` + Examples []string `json:"examples,omitempty"` + Notes []string `json:"notes,omitempty"` +} + +type Example struct { + Title string `json:"title"` + Description string `json:"description,omitempty"` + YAML string `json:"yaml"` + Paths []string `json:"paths,omitempty"` +} + +type DescribeOutput struct { + SchemaVersion string `json:"schema_version"` + SelectedPath string `json:"selected_path"` + Schema map[string]any `json:"schema,omitempty"` + Fields []FieldDoc `json:"fields,omitempty"` + Examples []Example `json:"examples,omitempty"` + Notes []string `json:"notes,omitempty"` +} + +type nodeDoc struct { + Fields []FieldDoc + Examples []Example + Notes []string +} + +type spec struct { + SchemaVersion string + SchemaByPath map[string]map[string]any + DocsByPath map[string]nodeDoc +} + +var ( + specOnce sync.Once + specData spec + + arrayIndexPattern = regexp.MustCompile(`\[\d+\]`) +) + +func Describe(input DescribeInput) (DescribeOutput, error) { + s := getSpec() + return s.describe(input) +} + +func getSpec() spec { + specOnce.Do(func() { + specData = newSpec() + }) + return specData +} + +func (s spec) describe(input DescribeInput) (DescribeOutput, error) { + selectedPath, ok := s.resolvePath(input.Path) + if !ok { + return DescribeOutput{}, fmt.Errorf("unknown path %q", input.Path) + } + + includeSchema := boolOrDefault(input.IncludeSchema, true) + includeFields := boolOrDefault(input.IncludeFields, true) + includeExamples := boolOrDefault(input.IncludeExamples, true) + includeChildren := boolOrDefault(input.IncludeChildren, true) + examplesLimit := intOrDefault(input.ExamplesLimit, 10) + if examplesLimit < 1 { + examplesLimit = 1 + } + if examplesLimit > 50 { + examplesLimit = 50 + } + + paths := s.pathsFor(selectedPath, includeChildren) + + out := DescribeOutput{ + SchemaVersion: s.SchemaVersion, + SelectedPath: selectedPath, + } + + if includeSchema { + out.Schema = cloneSchemaMap(s.SchemaByPath[selectedPath]) + } + if includeFields { + out.Fields = s.collectFields(paths) + } + if includeExamples { + out.Examples = s.collectExamples(paths, examplesLimit) + } + out.Notes = s.collectNotes(paths) + + return out, nil +} + +func (s spec) resolvePath(rawPath string) (string, bool) { + path := normalizePath(rawPath) + if s.hasPath(path) { + return path, true + } + + normPath := removeArrayMarkers(path) + for _, candidate := range s.allPaths() { + if removeArrayMarkers(candidate) == normPath { + return candidate, true + } + } + + return "", false +} + +func (s spec) pathsFor(selectedPath string, includeChildren bool) []string { + if !includeChildren { + return []string{selectedPath} + } + + allPaths := s.allPaths() + paths := make([]string, 0, len(allPaths)) + for _, p := range allPaths { + if isPathWithin(selectedPath, p) { + paths = append(paths, p) + } + } + return paths +} + +func (s spec) collectFields(paths []string) []FieldDoc { + seen := make(map[string]struct{}) + out := make([]FieldDoc, 0) + for _, p := range paths { + doc, ok := s.DocsByPath[p] + if !ok { + continue + } + for _, f := range doc.Fields { + if _, exists := seen[f.Path]; exists { + continue + } + seen[f.Path] = struct{}{} + out = append(out, cloneFieldDoc(f)) + } + } + + sort.Slice(out, func(i, j int) bool { + return out[i].Path < out[j].Path + }) + return out +} + +func (s spec) collectExamples(paths []string, limit int) []Example { + out := make([]Example, 0, limit) + seen := make(map[string]struct{}) + for _, p := range paths { + doc, ok := s.DocsByPath[p] + if !ok { + continue + } + for _, ex := range doc.Examples { + key := exampleKey(ex) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + out = append(out, cloneExample(ex)) + if len(out) >= limit { + return out + } + } + } + return out +} + +func (s spec) collectNotes(paths []string) []string { + seen := make(map[string]struct{}) + out := make([]string, 0) + for _, p := range paths { + doc, ok := s.DocsByPath[p] + if !ok { + continue + } + for _, note := range doc.Notes { + if _, exists := seen[note]; exists { + continue + } + seen[note] = struct{}{} + out = append(out, note) + } + } + return out +} + +func (s spec) hasPath(path string) bool { + if _, ok := s.SchemaByPath[path]; ok { + return true + } + if _, ok := s.DocsByPath[path]; ok { + return true + } + return false +} + +func (s spec) allPaths() []string { + seen := make(map[string]struct{}) + paths := make([]string, 0, len(s.SchemaByPath)+len(s.DocsByPath)) + + for p := range s.SchemaByPath { + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + paths = append(paths, p) + } + for p := range s.DocsByPath { + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + paths = append(paths, p) + } + + sort.Strings(paths) + return paths +} + +func boolOrDefault(v *bool, def bool) bool { + if v == nil { + return def + } + return *v +} + +func intOrDefault(v *int, def int) int { + if v == nil { + return def + } + return *v +} + +func lintUseAllowedValues() []string { + return append([]string(nil), rules.AllLintUseValues()...) +} + +func normalizePath(path string) string { + path = strings.TrimSpace(path) + if path == "" || path == "$" || strings.EqualFold(path, "root") { + return "$" + } + + path = strings.TrimPrefix(path, "$.") + path = strings.TrimPrefix(path, ".") + path = strings.ReplaceAll(path, "[*]", "[]") + path = arrayIndexPattern.ReplaceAllString(path, "[]") + path = strings.TrimSuffix(path, ".") + + return path +} + +func removeArrayMarkers(path string) string { + return strings.ReplaceAll(path, "[]", "") +} + +func isPathWithin(base, candidate string) bool { + if base == "$" { + return true + } + if base == candidate { + return true + } + if strings.HasPrefix(candidate, base+".") { + return true + } + if strings.HasPrefix(candidate, base+"[].") { + return true + } + if candidate == base+"[]" { + return true + } + return false +} + +func cloneFieldDoc(in FieldDoc) FieldDoc { + out := in + out.AllowedValues = append([]string(nil), in.AllowedValues...) + out.Examples = append([]string(nil), in.Examples...) + out.Notes = append([]string(nil), in.Notes...) + return out +} + +func cloneExample(in Example) Example { + out := in + out.Paths = append([]string(nil), in.Paths...) + return out +} + +func exampleKey(ex Example) string { + return strings.Join([]string{ + ex.Title, + ex.Description, + ex.YAML, + strings.Join(ex.Paths, "\x1f"), + }, "\x1e") +} + +func newSpec() spec { + return spec{ + SchemaVersion: SchemaVersion, + SchemaByPath: SchemaByPath(), + DocsByPath: docsByPath(), + } +} diff --git a/mcp/easypconfig/easypconfig_test.go b/mcp/easypconfig/easypconfig_test.go new file mode 100644 index 00000000..688b6f91 --- /dev/null +++ b/mcp/easypconfig/easypconfig_test.go @@ -0,0 +1,320 @@ +package easypconfig + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/easyp-tech/easyp/internal/rules" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" +) + +func TestDescribe_GitRepoPath_NoOutField(t *testing.T) { + t.Parallel() + + out, err := Describe(DescribeInput{Path: "generate.inputs[*].git_repo"}) + require.NoError(t, err) + + require.Equal(t, SchemaVersion, out.SchemaVersion) + require.Equal(t, "generate.inputs[].git_repo", out.SelectedPath) + require.Contains(t, fieldPaths(out.Fields), "generate.inputs[].git_repo.url") + require.NotContains(t, fieldPaths(out.Fields), "generate.inputs[].git_repo.out") + + props, ok := nestedMap(out.Schema, "properties") + require.True(t, ok, "expected properties in schema fragment") + _, hasOut := props["out"] + require.False(t, hasOut, "git_repo.out must not exist in schema") +} + +func TestDescribe_UnknownPath(t *testing.T) { + t.Parallel() + + _, err := Describe(DescribeInput{Path: "unknown.section"}) + require.Error(t, err) + require.Contains(t, err.Error(), "unknown path") +} + +func TestDescribe_FlagsAndExamplesLimitClamp(t *testing.T) { + t.Parallel() + + f := false + out, err := Describe(DescribeInput{ + Path: "$", + IncludeSchema: &f, + IncludeFields: &f, + IncludeExamples: &f, + }) + require.NoError(t, err) + require.Nil(t, out.Schema) + require.Empty(t, out.Fields) + require.Empty(t, out.Examples) + + tv := true + zero := 0 + out, err = Describe(DescribeInput{ + Path: "$", + IncludeExamples: &tv, + ExamplesLimit: &zero, + }) + require.NoError(t, err) + require.Len(t, out.Examples, 1) +} + +func TestDescribe_IncludeChildrenToggle(t *testing.T) { + t.Parallel() + + f := false + withoutChildren, err := Describe(DescribeInput{ + Path: "generate", + IncludeChildren: &f, + }) + require.NoError(t, err) + require.NotContains(t, fieldPaths(withoutChildren.Fields), "generate.inputs[].directory.path") + + withChildren, err := Describe(DescribeInput{ + Path: "generate", + }) + require.NoError(t, err) + require.Contains(t, fieldPaths(withChildren.Fields), "generate.inputs[].directory.path") +} + +func TestDescribe_PathNormalization_GenericArrayIndex(t *testing.T) { + t.Parallel() + + out, err := Describe(DescribeInput{Path: "$.generate.inputs[12].git_repo"}) + require.NoError(t, err) + require.Equal(t, "generate.inputs[].git_repo", out.SelectedPath) + + out, err = Describe(DescribeInput{Path: "generate.plugins[99]"}) + require.NoError(t, err) + require.Equal(t, "generate.plugins[]", out.SelectedPath) +} + +func TestDescribe_LintAllowedValuesFromRules(t *testing.T) { + t.Parallel() + + out, err := Describe(DescribeInput{Path: "lint"}) + require.NoError(t, err) + + var lintUse FieldDoc + var found bool + for _, field := range out.Fields { + if field.Path == "lint.use" { + lintUse = field + found = true + break + } + } + require.True(t, found, "lint.use field must be present") + + require.ElementsMatch(t, rules.AllLintUseValues(), lintUse.AllowedValues) +} + +func TestDescribe_OutputSlicesAreIndependent(t *testing.T) { + t.Parallel() + + out, err := Describe(DescribeInput{Path: "lint"}) + require.NoError(t, err) + require.NotEmpty(t, out.Examples) + + lintFieldIdx := -1 + for i := range out.Fields { + if out.Fields[i].Path == "lint.use" { + lintFieldIdx = i + break + } + } + require.NotEqual(t, -1, lintFieldIdx) + require.NotEmpty(t, out.Fields[lintFieldIdx].AllowedValues) + require.NotEmpty(t, out.Examples[0].Paths) + + firstAllowed := out.Fields[lintFieldIdx].AllowedValues[0] + firstExampleTitle := out.Examples[0].Title + firstExamplePath := out.Examples[0].Paths[0] + + out.Fields[lintFieldIdx].AllowedValues[0] = "__MUTATED__" + out.Examples[0].Paths[0] = "__MUTATED__" + + outAfterMutation, err := Describe(DescribeInput{Path: "lint"}) + require.NoError(t, err) + + var lintUseAfter FieldDoc + found := false + for _, field := range outAfterMutation.Fields { + if field.Path == "lint.use" { + lintUseAfter = field + found = true + break + } + } + require.True(t, found) + require.Equal(t, firstAllowed, lintUseAfter.AllowedValues[0]) + require.NotEqual(t, "__MUTATED__", lintUseAfter.AllowedValues[0]) + + var exampleAfter Example + found = false + for _, example := range outAfterMutation.Examples { + if example.Title == firstExampleTitle { + exampleAfter = example + found = true + break + } + } + require.True(t, found) + require.Equal(t, firstExamplePath, exampleAfter.Paths[0]) + require.NotEqual(t, "__MUTATED__", exampleAfter.Paths[0]) +} + +func TestCollectExamples_DeduplicatesByFullExampleContent(t *testing.T) { + t.Parallel() + + s := spec{ + DocsByPath: map[string]nodeDoc{ + "a": { + Examples: []Example{ + {Title: "same_title", YAML: "x: 1", Paths: []string{"a"}}, + {Title: "same_title", YAML: "x: 2", Paths: []string{"a"}}, + {Title: "same_title", YAML: "x: 1", Paths: []string{"a"}}, + }, + }, + }, + } + + got := s.collectExamples([]string{"a"}, 10) + require.Len(t, got, 2) + require.Equal(t, "x: 1", got[0].YAML) + require.Equal(t, "x: 2", got[1].YAML) +} + +func TestSchemaByPath_GitRepoOutAbsent(t *testing.T) { + t.Parallel() + + index := SchemaByPath() + node, ok := index["generate.inputs[].git_repo"] + require.True(t, ok, "expected schema path generate.inputs[].git_repo") + + props, ok := nestedMap(node, "properties") + require.True(t, ok) + _, hasOut := props["out"] + require.False(t, hasOut) +} + +func TestMarshalConfigJSONSchema_Golden(t *testing.T) { + t.Parallel() + + got, err := MarshalConfigJSONSchema() + require.NoError(t, err) + + goldenPath := filepath.Join("..", "..", "schemas", "easyp-config-v1.schema.json") + want, err := os.ReadFile(goldenPath) + require.NoError(t, err) + + var gotObj any + var wantObj any + require.NoError(t, json.Unmarshal(got, &gotObj)) + require.NoError(t, json.Unmarshal(want, &wantObj)) + require.Equal(t, wantObj, gotObj) +} + +func TestRegisterTool(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + srv := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v1.0.0"}, nil) + RegisterTool(srv) + + mux := http.NewServeMux() + mux.Handle("/mcp", mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return srv + }, &mcp.StreamableHTTPOptions{})) + + httpSrv := httptest.NewServer(mux) + defer httpSrv.Close() + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v1.0.0"}, nil) + session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: httpSrv.URL + "/mcp"}, nil) + require.NoError(t, err) + defer session.Close() + + tools, err := session.ListTools(ctx, nil) + require.NoError(t, err) + require.Contains(t, toolNames(tools.Tools), ToolName) + + res, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: ToolName, + Arguments: map[string]any{ + "path": "generate.plugins[]", + }, + }) + require.NoError(t, err) + require.False(t, res.IsError) + + var out DescribeOutput + decodeStructured(t, res, &out) + require.Equal(t, "generate.plugins[]", out.SelectedPath) + + errRes, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: ToolName, + Arguments: map[string]any{ + "path": "unknown.section", + }, + }) + require.NoError(t, err) + require.True(t, errRes.IsError) + require.Contains(t, toolText(errRes), "unknown path") +} + +func nestedMap(schema map[string]any, key string) (map[string]any, bool) { + v, ok := schema[key] + if !ok { + return nil, false + } + m, ok := v.(map[string]any) + if !ok { + return nil, false + } + return m, true +} + +func fieldPaths(fields []FieldDoc) []string { + out := make([]string, 0, len(fields)) + for _, f := range fields { + out = append(out, f.Path) + } + return out +} + +func toolNames(tools []*mcp.Tool) []string { + out := make([]string, 0, len(tools)) + for _, tool := range tools { + out = append(out, tool.Name) + } + return out +} + +func decodeStructured(t *testing.T, res *mcp.CallToolResult, dst any) { + t.Helper() + + data, err := json.Marshal(res.StructuredContent) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(data, dst)) +} + +func toolText(res *mcp.CallToolResult) string { + parts := make([]string, 0, len(res.Content)) + for _, c := range res.Content { + if text, ok := c.(*mcp.TextContent); ok { + parts = append(parts, text.Text) + } + } + return strings.Join(parts, "\n") +} diff --git a/mcp/easypconfig/generate.go b/mcp/easypconfig/generate.go new file mode 100644 index 00000000..bfa02250 --- /dev/null +++ b/mcp/easypconfig/generate.go @@ -0,0 +1,3 @@ +package easypconfig + +//go:generate go run ../../cmd/easyp-schema-gen diff --git a/mcp/easypconfig/schema.go b/mcp/easypconfig/schema.go new file mode 100644 index 00000000..24e49ea8 --- /dev/null +++ b/mcp/easypconfig/schema.go @@ -0,0 +1,171 @@ +package easypconfig + +import ( + "encoding/json" + "sort" + "sync" + + invjsonschema "github.com/invopop/jsonschema" +) + +var ( + schemaCacheOnce sync.Once + cachedByPath map[string]map[string]any +) + +func SchemaByPath() map[string]map[string]any { + ensureSchemaCache() + return cloneSchemaByPath(cachedByPath) +} + +func MarshalConfigJSONSchema() ([]byte, error) { + schema := reflectConfigSchema() + return json.MarshalIndent(schema, "", " ") +} + +func ensureSchemaCache() { + schemaCacheOnce.Do(func() { + root := buildRootSchemaMap() + cachedByPath = buildSchemaByPath(root) + }) +} + +func buildRootSchemaMap() map[string]any { + root := invSchemaToMap(reflectConfigSchema()) + if len(root) == 0 { + return map[string]any{} + } + return root +} + +func reflectConfigSchema() *invjsonschema.Schema { + reflector := &invjsonschema.Reflector{ + Anonymous: true, + DoNotReference: true, + } + return reflector.Reflect(configSchemaRoot{}) +} + +func buildSchemaByPath(root map[string]any) map[string]map[string]any { + if len(root) == 0 { + return map[string]map[string]any{} + } + + index := map[string]map[string]any{ + "$": root, + } + walkSchemaPaths(index, "$", root) + return index +} + +func walkSchemaPaths(index map[string]map[string]any, basePath string, schema map[string]any) { + for _, key := range []string{"allOf", "anyOf", "oneOf"} { + branches, ok := asSchemaArray(schema[key]) + if !ok { + continue + } + for _, branch := range branches { + walkSchemaPaths(index, basePath, branch) + } + } + + props, ok := asSchemaMap(schema["properties"]) + if ok { + names := make([]string, 0, len(props)) + for name := range props { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + child, ok := asSchemaMap(props[name]) + if !ok { + continue + } + childPath := joinSchemaPath(basePath, name) + if _, exists := index[childPath]; !exists { + index[childPath] = child + } + walkSchemaPaths(index, childPath, child) + } + } + + if items, ok := asSchemaMap(schema["items"]); ok { + arrayPath := basePath + "[]" + if _, exists := index[arrayPath]; !exists { + index[arrayPath] = items + } + walkSchemaPaths(index, arrayPath, items) + } +} + +func joinSchemaPath(base, child string) string { + if base == "$" { + return child + } + return base + "." + child +} + +func asSchemaArray(v any) ([]map[string]any, bool) { + arr, ok := v.([]any) + if !ok { + return nil, false + } + out := make([]map[string]any, 0, len(arr)) + for _, item := range arr { + m, ok := asSchemaMap(item) + if !ok { + continue + } + out = append(out, m) + } + if len(out) == 0 { + return nil, false + } + return out, true +} + +func asSchemaMap(v any) (map[string]any, bool) { + m, ok := v.(map[string]any) + if !ok { + return nil, false + } + return m, true +} + +func invSchemaToMap(schema *invjsonschema.Schema) map[string]any { + if schema == nil { + return nil + } + + data, err := json.Marshal(schema) + if err != nil { + return map[string]any{} + } + + var out map[string]any + if err := json.Unmarshal(data, &out); err != nil { + return map[string]any{} + } + return out +} + +func cloneSchemaMap(in map[string]any) map[string]any { + return cloneJSON(in, map[string]any{}) +} + +func cloneSchemaByPath(in map[string]map[string]any) map[string]map[string]any { + return cloneJSON(in, map[string]map[string]any{}) +} + +func cloneJSON[T any](in T, fallback T) T { + data, err := json.Marshal(in) + if err != nil { + return fallback + } + var out T + if err := json.Unmarshal(data, &out); err != nil { + return fallback + } + return out +} diff --git a/mcp/easypconfig/schema_model.go b/mcp/easypconfig/schema_model.go new file mode 100644 index 00000000..bbb86725 --- /dev/null +++ b/mcp/easypconfig/schema_model.go @@ -0,0 +1,178 @@ +package easypconfig + +import invjsonschema "github.com/invopop/jsonschema" + +type configSchemaRoot struct { + Version string `json:"version,omitempty"` + Lint *configSchemaLint `json:"lint,omitempty"` + Deps []string `json:"deps,omitempty"` + Generate *configSchemaGenerate `json:"generate,omitempty"` + Breaking *configSchemaBreaking `json:"breaking,omitempty"` +} + +type configSchemaLint struct { + Use []string `json:"use,omitempty"` + EnumZeroValueSuffix string `json:"enum_zero_value_suffix,omitempty"` + ServiceSuffix string `json:"service_suffix,omitempty"` + Ignore []string `json:"ignore,omitempty"` + Except []string `json:"except,omitempty"` + AllowCommentIgnores bool `json:"allow_comment_ignores,omitempty"` + IgnoreOnly map[string][]string `json:"ignore_only,omitempty"` +} + +type configSchemaGenerate struct { + Inputs []configSchemaInput `json:"inputs"` + Plugins []configSchemaPlugin `json:"plugins"` + Managed *configSchemaManaged `json:"managed,omitempty"` +} + +func (configSchemaGenerate) JSONSchemaExtend(schema *invjsonschema.Schema) { + setMinItems(schema, "inputs", 1) + setMinItems(schema, "plugins", 1) +} + +type configSchemaInput struct { + Directory configSchemaInputDirectory `json:"directory,omitempty"` + GitRepo *configSchemaInputGitRepo `json:"git_repo,omitempty"` +} + +func (configSchemaInput) JSONSchemaExtend(schema *invjsonschema.Schema) { + schema.OneOf = []*invjsonschema.Schema{ + {Required: []string{"directory"}}, + {Required: []string{"git_repo"}}, + } +} + +type configSchemaInputDirectory struct{} + +func (configSchemaInputDirectory) JSONSchema() *invjsonschema.Schema { + reflector := &invjsonschema.Reflector{ + Anonymous: true, + DoNotReference: true, + } + objectSchema := reflector.Reflect(configSchemaInputDirectoryObject{}) + objectSchema.Version = "" + objectSchema.ID = "" + objectSchema.Definitions = nil + objectSchema.Title = "" + + return &invjsonschema.Schema{ + OneOf: []*invjsonschema.Schema{ + {Type: "string"}, + objectSchema, + }, + } +} + +type configSchemaInputDirectoryObject struct { + Path string `json:"path"` + Root string `json:"root,omitempty"` +} + +type configSchemaInputGitRepo struct { + URL string `json:"url"` + SubDirectory string `json:"sub_directory,omitempty"` + Root string `json:"root,omitempty"` +} + +type configSchemaPlugin struct { + Name string `json:"name,omitempty"` + Remote string `json:"remote,omitempty"` + Path string `json:"path,omitempty"` + Command []string `json:"command,omitempty"` + Out string `json:"out"` + Opts configSchemaPluginOpts `json:"opts,omitempty"` + WithImports bool `json:"with_imports,omitempty"` +} + +func (configSchemaPlugin) JSONSchemaExtend(schema *invjsonschema.Schema) { + schema.OneOf = []*invjsonschema.Schema{ + {Required: []string{"name"}}, + {Required: []string{"remote"}}, + {Required: []string{"path"}}, + {Required: []string{"command"}}, + } +} + +type configSchemaPluginOpts map[string]any + +func (configSchemaPluginOpts) JSONSchema() *invjsonschema.Schema { + return &invjsonschema.Schema{ + Type: "object", + AdditionalProperties: &invjsonschema.Schema{ + OneOf: []*invjsonschema.Schema{ + {Type: "string"}, + { + Type: "array", + Items: &invjsonschema.Schema{Type: "string"}, + }, + }, + }, + } +} + +type configSchemaManaged struct { + Enabled bool `json:"enabled,omitempty"` + Disable []configSchemaManagedDisableRule `json:"disable,omitempty"` + Override []configSchemaManagedOverrideRule `json:"override,omitempty"` +} + +type configSchemaManagedDisableRule struct { + Module string `json:"module,omitempty"` + Path string `json:"path,omitempty"` + FileOption string `json:"file_option,omitempty"` + FieldOption string `json:"field_option,omitempty"` + Field string `json:"field,omitempty"` +} + +func (configSchemaManagedDisableRule) JSONSchemaExtend(schema *invjsonschema.Schema) { + schema.AnyOf = []*invjsonschema.Schema{ + {Required: []string{"module"}}, + {Required: []string{"path"}}, + {Required: []string{"file_option"}}, + {Required: []string{"field_option"}}, + {Required: []string{"field"}}, + } + schema.Not = &invjsonschema.Schema{Required: []string{"file_option", "field_option"}} + schema.DependentRequired = map[string][]string{ + "field": {"field_option"}, + } +} + +type configSchemaManagedOverrideRule struct { + FileOption string `json:"file_option,omitempty"` + FieldOption string `json:"field_option,omitempty"` + Value any `json:"value"` + Module string `json:"module,omitempty"` + Path string `json:"path,omitempty"` + Field string `json:"field,omitempty"` +} + +func (configSchemaManagedOverrideRule) JSONSchemaExtend(schema *invjsonschema.Schema) { + schema.AnyOf = []*invjsonschema.Schema{ + {Required: []string{"file_option"}}, + {Required: []string{"field_option"}}, + } + schema.Not = &invjsonschema.Schema{Required: []string{"file_option", "field_option"}} + schema.DependentRequired = map[string][]string{ + "field": {"field_option"}, + } +} + +type configSchemaBreaking struct { + Ignore []string `json:"ignore,omitempty"` + AgainstGitRef string `json:"against_git_ref,omitempty"` +} + +func setMinItems(schema *invjsonschema.Schema, fieldName string, min uint64) { + if schema == nil || schema.Properties == nil { + return + } + + itemSchema, ok := schema.Properties.Get(fieldName) + if !ok || itemSchema == nil { + return + } + + itemSchema.MinItems = &min +} diff --git a/mcp/easypconfig/spec_docs.go b/mcp/easypconfig/spec_docs.go new file mode 100644 index 00000000..f894d7f2 --- /dev/null +++ b/mcp/easypconfig/spec_docs.go @@ -0,0 +1,281 @@ +package easypconfig + +func docsByPath() map[string]nodeDoc { + return map[string]nodeDoc{ + "$": { + Fields: []FieldDoc{ + {Path: "version", Type: "string", Required: false, Description: "Legacy compatibility field.", DefaultValue: "omitted", Examples: []string{"v1alpha"}}, + {Path: "lint", Type: "object", Required: false, Description: "Linter configuration and rule selection."}, + {Path: "deps", Type: "array", Required: false, Description: "Dependency repositories in format @."}, + {Path: "generate", Type: "object", Required: false, Description: "Code generation configuration."}, + {Path: "breaking", Type: "object", Required: false, Description: "Breaking changes check configuration."}, + }, + Examples: []Example{ + { + Title: "minimal_config", + Description: "Small valid configuration with local input and one plugin.", + YAML: "lint:\n use:\n - DIRECTORY_SAME_PACKAGE\ngenerate:\n inputs:\n - directory: proto\n plugins:\n - name: go\n out: .\n opts:\n paths: source_relative\n", + Paths: []string{"$", "lint", "generate"}, + }, + { + Title: "full_config_reference", + Description: "Reference config touching all top-level sections.", + YAML: "version: v1alpha\nlint:\n use:\n - DEFAULT\n enum_zero_value_suffix: _UNSPECIFIED\n service_suffix: Service\n ignore:\n - vendor\n ignore_only:\n RPC_REQUEST_STANDARD_NAME:\n - proto/legacy\ndeps:\n - github.com/googleapis/googleapis@common-protos-1_3_1\ngenerate:\n inputs:\n - directory:\n path: api\n root: .\n - git_repo:\n url: github.com/acme/contracts@v1.2.3\n sub_directory: proto\n root: .\n plugins:\n - name: go\n out: gen/go\n opts:\n paths: source_relative\n - remote: api.easyp.tech/grpc/go:v1.5.1\n out: gen/go\n with_imports: true\n managed:\n enabled: true\n disable:\n - module: buf.build/googleapis/googleapis\n override:\n - file_option: go_package_prefix\n value: github.com/acme/contracts/gen/go\nbreaking:\n against_git_ref: main\n ignore:\n - proto/legacy\n", + Paths: []string{"$", "lint", "deps", "generate", "breaking"}, + }, + }, + }, + "lint": { + Fields: []FieldDoc{ + {Path: "lint.use", Type: "array", Required: false, Description: "Rule groups and/or individual lint rule names.", AllowedValues: lintUseAllowedValues(), DefaultValue: "[]"}, + {Path: "lint.enum_zero_value_suffix", Type: "string", Required: false, Description: "Required suffix for enum zero value.", DefaultValue: "UNSPECIFIED (runtime default)"}, + {Path: "lint.service_suffix", Type: "string", Required: false, Description: "Required suffix for service names.", DefaultValue: "Service (runtime default)"}, + {Path: "lint.ignore", Type: "array", Required: false, Description: "Paths to exclude from linting.", DefaultValue: "[]"}, + {Path: "lint.except", Type: "array", Required: false, Description: "Rules to disable globally.", DefaultValue: "[]"}, + {Path: "lint.allow_comment_ignores", Type: "boolean", Required: false, Description: "Allow inline ignore comments in proto files.", DefaultValue: "false"}, + {Path: "lint.ignore_only", Type: "map>", Required: false, Description: "Disable specific rules only for selected paths.", DefaultValue: "{}"}, + }, + Examples: []Example{ + { + Title: "lint_groups_and_exceptions", + Description: "Rule groups with selected exceptions and comment ignores enabled.", + YAML: "lint:\n use:\n - DEFAULT\n - RPC_NO_CLIENT_STREAMING\n except:\n - COMMENT_RPC\n - COMMENT_SERVICE\n allow_comment_ignores: true\n", + Paths: []string{"lint"}, + }, + { + Title: "lint_ignore_only_rules", + Description: "Disable specific rules only for selected paths.", + YAML: "lint:\n use:\n - DEFAULT\n ignore_only:\n PACKAGE_VERSION_SUFFIX:\n - proto/legacy\n RPC_REQUEST_STANDARD_NAME:\n - proto/public\n", + Paths: []string{"lint"}, + }, + }, + }, + "deps": { + Fields: []FieldDoc{ + {Path: "deps[]", Type: "string", Required: false, Description: "Dependency in format @.", Examples: []string{"github.com/googleapis/googleapis@v1.0.0", "github.com/bufbuild/protoc-gen-validate"}}, + }, + Examples: []Example{ + { + Title: "deps_with_and_without_revision", + Description: "Dependencies can be pinned or float to repository default revision.", + YAML: "deps:\n - github.com/googleapis/googleapis@common-protos-1_3_1\n - github.com/bufbuild/protoc-gen-validate\n", + Paths: []string{"deps"}, + }, + }, + }, + "generate": { + Fields: []FieldDoc{ + {Path: "generate.inputs", Type: "array", Required: true, Description: "Input sources for proto files.", DefaultValue: "must be provided"}, + {Path: "generate.plugins", Type: "array", Required: true, Description: "Plugin definitions for generation.", DefaultValue: "must be provided"}, + {Path: "generate.managed", Type: "object", Required: false, Description: "Managed mode rules for file/field options.", DefaultValue: "{}"}, + }, + Examples: []Example{ + { + Title: "generate_local_and_remote_plugin", + Description: "Local directory input with remote plugin execution.", + YAML: "generate:\n inputs:\n - directory:\n path: api\n root: .\n plugins:\n - remote: api.easyp.tech/protobuf/go:v1.36.10\n out: .\n opts:\n paths: source_relative\n", + Paths: []string{"generate", "generate.inputs", "generate.plugins"}, + }, + { + Title: "generate_all_sections", + Description: "Generate section with local+git inputs, multiple plugin styles, and managed mode.", + YAML: "generate:\n inputs:\n - directory: proto\n - git_repo:\n url: github.com/acme/contracts@v1.2.3\n sub_directory: api\n plugins:\n - name: go\n out: gen/go\n - command: [\"go\", \"run\", \"example.com/protoc-gen-custom@latest\"]\n out: gen/custom\n with_imports: true\n managed:\n enabled: true\n disable:\n - module: buf.build/googleapis/googleapis\n override:\n - file_option: go_package_prefix\n value: github.com/acme/contracts/gen/go\n", + Paths: []string{"generate", "generate.inputs", "generate.plugins", "generate.managed"}, + }, + }, + }, + "generate.inputs": { + Fields: []FieldDoc{ + {Path: "generate.inputs[].directory", Type: "string | object", Required: false, Description: "Local input directory. Shorthand string or object with path/root."}, + {Path: "generate.inputs[].git_repo", Type: "object", Required: false, Description: "Remote git repository input."}, + }, + Examples: []Example{ + { + Title: "inputs_directory_and_git_repo", + Description: "Each item uses exactly one source: directory or git_repo.", + YAML: "generate:\n inputs:\n - directory: proto\n - git_repo:\n url: github.com/acme/contracts@v1.0.0\n sub_directory: api\n plugins:\n - name: go\n out: .\n", + Paths: []string{"generate.inputs"}, + }, + }, + Notes: []string{ + "Each input item must contain exactly one of `directory` or `git_repo`.", + }, + }, + "generate.inputs[].directory": { + Fields: []FieldDoc{ + {Path: "generate.inputs[].directory.path", Type: "string", Required: true, Description: "Directory with .proto files (relative to config root unless absolute).", Examples: []string{"proto", "api/proto"}}, + {Path: "generate.inputs[].directory.root", Type: "string", Required: false, Description: "Import root for path normalization.", DefaultValue: "."}, + }, + Examples: []Example{ + { + Title: "input_directory_shorthand", + Description: "Shorthand string form for directory input.", + YAML: "generate:\n inputs:\n - directory: proto\n plugins:\n - name: go\n out: .\n", + Paths: []string{"generate.inputs[].directory"}, + }, + { + Title: "input_directory_object_with_root", + Description: "Object form with explicit path and root.", + YAML: "generate:\n inputs:\n - directory:\n path: api/proto\n root: api\n plugins:\n - name: go\n out: .\n", + Paths: []string{"generate.inputs[].directory"}, + }, + }, + }, + "generate.inputs[].git_repo": { + Fields: []FieldDoc{ + {Path: "generate.inputs[].git_repo.url", Type: "string", Required: true, Description: "Git repo URL with optional revision.", Examples: []string{"github.com/acme/common@v1.0.0"}}, + {Path: "generate.inputs[].git_repo.sub_directory", Type: "string", Required: false, Description: "Subdirectory inside checked-out repository."}, + {Path: "generate.inputs[].git_repo.root", Type: "string", Required: false, Description: "Import root under repository contents.", DefaultValue: "\"\""}, + }, + Examples: []Example{ + { + Title: "input_git_repo_full", + Description: "Git input with revision, subdirectory and import root.", + YAML: "generate:\n inputs:\n - git_repo:\n url: github.com/acme/contracts@v1.3.0\n sub_directory: proto/public\n root: proto\n plugins:\n - name: go\n out: .\n", + Paths: []string{"generate.inputs[].git_repo"}, + }, + }, + }, + "generate.plugins": { + Fields: []FieldDoc{ + {Path: "generate.plugins[]", Type: "object", Required: true, Description: "Plugin item with exactly one source and required output directory."}, + }, + Examples: []Example{ + { + Title: "plugins_all_source_variants", + Description: "Plugins can use name, remote, path, or command as a source.", + YAML: "generate:\n plugins:\n - name: go\n out: gen/go\n - remote: api.easyp.tech/protobuf/go:v1.36.10\n out: gen/go\n - path: ./bin/protoc-gen-custom\n out: gen/custom\n - command: [\"go\", \"run\", \"example.com/protoc-gen-alt@latest\"]\n out: gen/alt\n", + Paths: []string{"generate.plugins"}, + }, + }, + }, + "generate.plugins[]": { + Fields: []FieldDoc{ + {Path: "generate.plugins[].name", Type: "string", Required: false, Description: "Built-in/local plugin name (one source option).", Examples: []string{"go", "go-grpc"}}, + {Path: "generate.plugins[].remote", Type: "string", Required: false, Description: "Remote plugin endpoint (one source option).", Examples: []string{"api.easyp.tech/protobuf/go:v1.36.10"}}, + {Path: "generate.plugins[].path", Type: "string", Required: false, Description: "Explicit path to plugin binary (one source option)."}, + {Path: "generate.plugins[].command", Type: "array", Required: false, Description: "Command invocation for plugin (one source option).", Examples: []string{`["go","run","github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@v2.25.1"]`}}, + {Path: "generate.plugins[].out", Type: "string", Required: true, Description: "Output directory for generated files.", Examples: []string{".", "gen/go"}}, + {Path: "generate.plugins[].opts", Type: "map", Required: false, Description: "Plugin options; value can be scalar or array of scalars."}, + {Path: "generate.plugins[].with_imports", Type: "boolean", Required: false, Description: "Include dependency protos in generation.", DefaultValue: "false"}, + }, + Examples: []Example{ + { + Title: "plugin_remote", + Description: "Remote plugin source.", + YAML: "generate:\n plugins:\n - remote: api.easyp.tech/grpc/go:v1.5.1\n out: .\n opts:\n paths: source_relative\n", + Paths: []string{"generate.plugins[]"}, + }, + { + Title: "plugin_command", + Description: "Command-based plugin source.", + YAML: "generate:\n plugins:\n - command: [\"go\", \"run\", \"github.com/bufbuild/protoc-gen-validate@v0.10.1\"]\n out: gen/go\n", + Paths: []string{"generate.plugins[]"}, + }, + { + Title: "plugin_name", + Description: "Built-in plugin selected by name.", + YAML: "generate:\n plugins:\n - name: go-grpc\n out: gen/go\n", + Paths: []string{"generate.plugins[]"}, + }, + { + Title: "plugin_path", + Description: "Plugin binary loaded from explicit local path.", + YAML: "generate:\n plugins:\n - path: ./bin/protoc-gen-openapi\n out: gen/openapi\n", + Paths: []string{"generate.plugins[]"}, + }, + { + Title: "plugin_opts_scalar_and_array", + Description: "Plugin opts values can be scalar or arrays of scalars.", + YAML: "generate:\n plugins:\n - remote: api.easyp.tech/community/stephenh-ts-proto:v1.178.0\n out: gen/ts\n opts:\n env: node\n outputServices:\n - grpc-js\n - generic-definitions\n useExactTypes: false\n", + Paths: []string{"generate.plugins[]"}, + }, + { + Title: "plugin_with_imports_enabled", + Description: "Enable generation for dependency protos as well.", + YAML: "generate:\n plugins:\n - name: go\n out: gen/go\n with_imports: true\n", + Paths: []string{"generate.plugins[]"}, + }, + }, + }, + "generate.managed": { + Fields: []FieldDoc{ + {Path: "generate.managed.enabled", Type: "boolean", Required: false, Description: "Enable managed mode option rewriting.", DefaultValue: "false"}, + {Path: "generate.managed.disable", Type: "array", Required: false, Description: "Disable managed mode per module/path/option."}, + {Path: "generate.managed.override", Type: "array", Required: false, Description: "Override file/field options with values."}, + }, + Examples: []Example{ + { + Title: "managed_mode_full", + Description: "Managed mode with both disable and override rules.", + YAML: "generate:\n managed:\n enabled: true\n disable:\n - module: buf.build/googleapis/googleapis\n - field_option: jstype\n field: acme.v1.Message.count\n override:\n - file_option: go_package_prefix\n value: github.com/acme/contracts/gen/go\n - field_option: jstype\n field: acme.v1.Message.count\n value: JS_STRING\n", + Paths: []string{"generate.managed"}, + }, + }, + }, + "generate.managed.disable": { + Fields: []FieldDoc{ + {Path: "generate.managed.disable[].module", Type: "string", Required: false, Description: "Apply disable to module."}, + {Path: "generate.managed.disable[].path", Type: "string", Required: false, Description: "Apply disable to path."}, + {Path: "generate.managed.disable[].file_option", Type: "string", Required: false, Description: "Disable this file option."}, + {Path: "generate.managed.disable[].field_option", Type: "string", Required: false, Description: "Disable this field option."}, + {Path: "generate.managed.disable[].field", Type: "string", Required: false, Description: "Field selector for field_option."}, + }, + Examples: []Example{ + { + Title: "managed_disable_variants", + Description: "Disable rules by path, file option, and field option.", + YAML: "generate:\n managed:\n disable:\n - path: proto/third_party\n - file_option: java_package\n - field_option: jstype\n field: acme.v1.Message.count\n", + Paths: []string{"generate.managed.disable"}, + }, + }, + Notes: []string{ + "At least one key in each disable item is required.", + "`file_option` and `field_option` cannot be used together.", + "`field` requires `field_option`.", + }, + }, + "generate.managed.override": { + Fields: []FieldDoc{ + {Path: "generate.managed.override[].file_option", Type: "string", Required: false, Description: "Target file option to override."}, + {Path: "generate.managed.override[].field_option", Type: "string", Required: false, Description: "Target field option to override."}, + {Path: "generate.managed.override[].value", Type: "any", Required: true, Description: "Override value."}, + {Path: "generate.managed.override[].module", Type: "string", Required: false, Description: "Optional module selector."}, + {Path: "generate.managed.override[].path", Type: "string", Required: false, Description: "Optional path selector."}, + {Path: "generate.managed.override[].field", Type: "string", Required: false, Description: "Optional field selector (for field_option)."}, + }, + Examples: []Example{ + { + Title: "managed_override_file_option", + Description: "Override a file option with a custom value.", + YAML: "generate:\n managed:\n override:\n - file_option: java_package_prefix\n value: com.acme.generated\n", + Paths: []string{"generate.managed.override"}, + }, + { + Title: "managed_override_field_option", + Description: "Override a field option for a selected field.", + YAML: "generate:\n managed:\n override:\n - field_option: jstype\n field: acme.v1.Message.count\n value: JS_NUMBER\n", + Paths: []string{"generate.managed.override"}, + }, + }, + Notes: []string{ + "Each override item requires exactly one of file_option or field_option.", + "`field` can only be used with `field_option`.", + }, + }, + "breaking": { + Fields: []FieldDoc{ + {Path: "breaking.ignore", Type: "array", Required: false, Description: "Paths excluded from breaking-change checks.", DefaultValue: "[]"}, + {Path: "breaking.against_git_ref", Type: "string", Required: false, Description: "Branch/tag/commit used for comparison."}, + }, + Examples: []Example{ + { + Title: "breaking_with_ref_and_ignore", + Description: "Compare against selected git ref and ignore selected directories.", + YAML: "breaking:\n against_git_ref: origin/main\n ignore:\n - proto/experimental\n", + Paths: []string{"breaking"}, + }, + }, + }, + } +} diff --git a/mcp/easypconfig/tool.go b/mcp/easypconfig/tool.go new file mode 100644 index 00000000..c6d4f8cb --- /dev/null +++ b/mcp/easypconfig/tool.go @@ -0,0 +1,22 @@ +package easypconfig + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func RegisterTool(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: ToolName, + Description: "Describe easyp.yaml schema and field usage. Supports full schema or a specific path with examples.", + InputSchema: describeInputSchema(), + OutputSchema: describeOutputSchema(), + }, func(_ context.Context, _ *mcp.CallToolRequest, input DescribeInput) (*mcp.CallToolResult, DescribeOutput, error) { + out, err := Describe(input) + if err != nil { + return nil, DescribeOutput{}, err + } + return nil, out, nil + }) +} diff --git a/mcp/easypconfig/tool_schemas.go b/mcp/easypconfig/tool_schemas.go new file mode 100644 index 00000000..11ed5e1f --- /dev/null +++ b/mcp/easypconfig/tool_schemas.go @@ -0,0 +1,149 @@ +package easypconfig + +import "github.com/google/jsonschema-go/jsonschema" + +func describeInputSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "path": { + Type: "string", + Description: "Dot path to a section of the schema. Empty means full schema", + }, + "include_schema": { + Type: "boolean", + Description: "Include JSON schema fragment in output. Default: true", + }, + "include_fields": { + Type: "boolean", + Description: "Include field documentation in output. Default: true", + }, + "include_examples": { + Type: "boolean", + Description: "Include examples in output. Default: true", + }, + "include_children": { + Type: "boolean", + Description: "Include descendants of selected path. Default: true", + }, + "examples_limit": { + Type: "integer", + Description: "Maximum number of examples to return. Default: 10, range 1..50", + Minimum: jsonschema.Ptr(1.0), + Maximum: jsonschema.Ptr(50.0), + }, + }, + } +} + +func describeOutputSchema() *jsonschema.Schema { + fieldDocSchema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "path": { + Type: "string", + Description: "Field path", + }, + "type": { + Type: "string", + Description: "Value type", + }, + "required": { + Type: "boolean", + Description: "Whether field is required", + }, + "description": { + Type: "string", + Description: "Field purpose", + }, + "allowed_values": { + Type: "array", + Description: "Allowed values or enum options", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "default_value": { + Type: "string", + Description: "Default value if omitted", + }, + "examples": { + Type: "array", + Description: "Value examples", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "notes": { + Type: "array", + Description: "Extra constraints or caveats", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + }, + Required: []string{"path", "type", "required", "description"}, + } + + exampleSchema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "title": { + Type: "string", + Description: "Short example title", + }, + "description": { + Type: "string", + Description: "Example purpose", + }, + "yaml": { + Type: "string", + Description: "YAML snippet", + }, + "paths": { + Type: "array", + Description: "Schema paths covered by this example", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + }, + Required: []string{"title", "yaml"}, + } + + return &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "schema_version": { + Type: "string", + Description: "Schema metadata version", + }, + "selected_path": { + Type: "string", + Description: "Resolved path used for this response", + }, + "schema": { + Type: "object", + Description: "JSON schema fragment for selected path", + }, + "fields": { + Type: "array", + Description: "Field documentation", + Items: fieldDocSchema, + }, + "examples": { + Type: "array", + Description: "YAML examples", + Items: exampleSchema, + }, + "notes": { + Type: "array", + Description: "General notes and caveats", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + }, + Required: []string{"schema_version", "selected_path"}, + } +} diff --git a/schemas/easyp-config-v1.schema.json b/schemas/easyp-config-v1.schema.json new file mode 100644 index 00000000..64f7b5a9 --- /dev/null +++ b/schemas/easyp-config-v1.schema.json @@ -0,0 +1,339 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "version": { + "type": "string" + }, + "lint": { + "properties": { + "use": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enum_zero_value_suffix": { + "type": "string" + }, + "service_suffix": { + "type": "string" + }, + "ignore": { + "items": { + "type": "string" + }, + "type": "array" + }, + "except": { + "items": { + "type": "string" + }, + "type": "array" + }, + "allow_comment_ignores": { + "type": "boolean" + }, + "ignore_only": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + } + }, + "additionalProperties": false, + "type": "object" + }, + "deps": { + "items": { + "type": "string" + }, + "type": "array" + }, + "generate": { + "properties": { + "inputs": { + "items": { + "oneOf": [ + { + "required": [ + "directory" + ] + }, + { + "required": [ + "git_repo" + ] + } + ], + "properties": { + "directory": { + "oneOf": [ + { + "type": "string" + }, + { + "properties": { + "path": { + "type": "string" + }, + "root": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "path" + ] + } + ] + }, + "git_repo": { + "properties": { + "url": { + "type": "string" + }, + "sub_directory": { + "type": "string" + }, + "root": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "url" + ] + } + }, + "additionalProperties": false, + "type": "object" + }, + "type": "array", + "minItems": 1 + }, + "plugins": { + "items": { + "oneOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "remote" + ] + }, + { + "required": [ + "path" + ] + }, + { + "required": [ + "command" + ] + } + ], + "properties": { + "name": { + "type": "string" + }, + "remote": { + "type": "string" + }, + "path": { + "type": "string" + }, + "command": { + "items": { + "type": "string" + }, + "type": "array" + }, + "out": { + "type": "string" + }, + "opts": { + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "type": "object" + }, + "with_imports": { + "type": "boolean" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "out" + ] + }, + "type": "array", + "minItems": 1 + }, + "managed": { + "properties": { + "enabled": { + "type": "boolean" + }, + "disable": { + "items": { + "anyOf": [ + { + "required": [ + "module" + ] + }, + { + "required": [ + "path" + ] + }, + { + "required": [ + "file_option" + ] + }, + { + "required": [ + "field_option" + ] + }, + { + "required": [ + "field" + ] + } + ], + "not": { + "required": [ + "file_option", + "field_option" + ] + }, + "properties": { + "module": { + "type": "string" + }, + "path": { + "type": "string" + }, + "file_option": { + "type": "string" + }, + "field_option": { + "type": "string" + }, + "field": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "dependentRequired": { + "field": [ + "field_option" + ] + } + }, + "type": "array" + }, + "override": { + "items": { + "anyOf": [ + { + "required": [ + "file_option" + ] + }, + { + "required": [ + "field_option" + ] + } + ], + "not": { + "required": [ + "file_option", + "field_option" + ] + }, + "properties": { + "file_option": { + "type": "string" + }, + "field_option": { + "type": "string" + }, + "value": true, + "module": { + "type": "string" + }, + "path": { + "type": "string" + }, + "field": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "value" + ], + "dependentRequired": { + "field": [ + "field_option" + ] + } + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "inputs", + "plugins" + ] + }, + "breaking": { + "properties": { + "ignore": { + "items": { + "type": "string" + }, + "type": "array" + }, + "against_git_ref": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" + } + }, + "additionalProperties": false, + "type": "object" +} diff --git a/schemas/easyp-config.schema.json b/schemas/easyp-config.schema.json new file mode 100644 index 00000000..64f7b5a9 --- /dev/null +++ b/schemas/easyp-config.schema.json @@ -0,0 +1,339 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "version": { + "type": "string" + }, + "lint": { + "properties": { + "use": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enum_zero_value_suffix": { + "type": "string" + }, + "service_suffix": { + "type": "string" + }, + "ignore": { + "items": { + "type": "string" + }, + "type": "array" + }, + "except": { + "items": { + "type": "string" + }, + "type": "array" + }, + "allow_comment_ignores": { + "type": "boolean" + }, + "ignore_only": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + } + }, + "additionalProperties": false, + "type": "object" + }, + "deps": { + "items": { + "type": "string" + }, + "type": "array" + }, + "generate": { + "properties": { + "inputs": { + "items": { + "oneOf": [ + { + "required": [ + "directory" + ] + }, + { + "required": [ + "git_repo" + ] + } + ], + "properties": { + "directory": { + "oneOf": [ + { + "type": "string" + }, + { + "properties": { + "path": { + "type": "string" + }, + "root": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "path" + ] + } + ] + }, + "git_repo": { + "properties": { + "url": { + "type": "string" + }, + "sub_directory": { + "type": "string" + }, + "root": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "url" + ] + } + }, + "additionalProperties": false, + "type": "object" + }, + "type": "array", + "minItems": 1 + }, + "plugins": { + "items": { + "oneOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "remote" + ] + }, + { + "required": [ + "path" + ] + }, + { + "required": [ + "command" + ] + } + ], + "properties": { + "name": { + "type": "string" + }, + "remote": { + "type": "string" + }, + "path": { + "type": "string" + }, + "command": { + "items": { + "type": "string" + }, + "type": "array" + }, + "out": { + "type": "string" + }, + "opts": { + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "type": "object" + }, + "with_imports": { + "type": "boolean" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "out" + ] + }, + "type": "array", + "minItems": 1 + }, + "managed": { + "properties": { + "enabled": { + "type": "boolean" + }, + "disable": { + "items": { + "anyOf": [ + { + "required": [ + "module" + ] + }, + { + "required": [ + "path" + ] + }, + { + "required": [ + "file_option" + ] + }, + { + "required": [ + "field_option" + ] + }, + { + "required": [ + "field" + ] + } + ], + "not": { + "required": [ + "file_option", + "field_option" + ] + }, + "properties": { + "module": { + "type": "string" + }, + "path": { + "type": "string" + }, + "file_option": { + "type": "string" + }, + "field_option": { + "type": "string" + }, + "field": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "dependentRequired": { + "field": [ + "field_option" + ] + } + }, + "type": "array" + }, + "override": { + "items": { + "anyOf": [ + { + "required": [ + "file_option" + ] + }, + { + "required": [ + "field_option" + ] + } + ], + "not": { + "required": [ + "file_option", + "field_option" + ] + }, + "properties": { + "file_option": { + "type": "string" + }, + "field_option": { + "type": "string" + }, + "value": true, + "module": { + "type": "string" + }, + "path": { + "type": "string" + }, + "field": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "value" + ], + "dependentRequired": { + "field": [ + "field_option" + ] + } + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "inputs", + "plugins" + ] + }, + "breaking": { + "properties": { + "ignore": { + "items": { + "type": "string" + }, + "type": "array" + }, + "against_git_ref": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" + } + }, + "additionalProperties": false, + "type": "object" +} From ebac8b6542091a3383295cff22df5227498133a0 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Mon, 2 Mar 2026 20:13:06 +0300 Subject: [PATCH 2/2] move shema-gen into existing main as subcommand --- README.md | 2 +- Taskfile.yml | 2 +- cmd/easyp/main.go | 1 + internal/api/schema_gen.go | 56 +++++++++++++++++++ .../schemagen/schemagen.go | 44 +++++++++------ internal/schemagen/schemagen_test.go | 42 ++++++++++++++ mcp/easypconfig/README.md | 2 +- mcp/easypconfig/generate.go | 2 +- 8 files changed, 129 insertions(+), 22 deletions(-) create mode 100644 internal/api/schema_gen.go rename cmd/easyp-schema-gen/main.go => internal/schemagen/schemagen.go (50%) create mode 100644 internal/schemagen/schemagen_test.go diff --git a/README.md b/README.md index a214f0fd..541de5c0 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ The source of truth for config schema + MCP tool metadata lives in [`mcp/easypco - Go integration: import `github.com/easyp-tech/easyp/mcp/easypconfig` and call `RegisterTool(...)` / `Describe(...)`. - Cross-language integration: consume generated JSON Schema artifacts in `schemas/easyp-config-v1.schema.json` and `schemas/easyp-config.schema.json`. -- Regenerate artifacts: `task schema:generate` (or `go run ./cmd/easyp-schema-gen`). +- Regenerate artifacts: `task schema:generate` (or `go run ./cmd/easyp schema-gen`). ## Community diff --git a/Taskfile.yml b/Taskfile.yml index 2618548d..1980ce47 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -39,7 +39,7 @@ tasks: schema:generate: desc: Generate versioned and latest easyp config JSON Schema artifacts cmds: - - "go run ./cmd/easyp-schema-gen" + - "go run ./cmd/easyp schema-gen" schema:check: desc: Ensure schema artifacts are up to date diff --git a/cmd/easyp/main.go b/cmd/easyp/main.go index cba760cb..45ac3dd6 100644 --- a/cmd/easyp/main.go +++ b/cmd/easyp/main.go @@ -48,6 +48,7 @@ func main() { api.Completion{}, api.Init{}, api.Generate{}, + api.SchemaGen{}, api.LsFiles{}, api.Validate{}, api.BreakingCheck{}, diff --git a/internal/api/schema_gen.go b/internal/api/schema_gen.go new file mode 100644 index 00000000..b91b847e --- /dev/null +++ b/internal/api/schema_gen.go @@ -0,0 +1,56 @@ +package api + +import ( + "fmt" + + "github.com/urfave/cli/v2" + + "github.com/easyp-tech/easyp/internal/schemagen" +) + +var _ Handler = (*SchemaGen)(nil) + +type SchemaGen struct{} + +var ( + flagSchemaGenOutVersioned = &cli.StringFlag{ + Name: "out-versioned", + Usage: "path to versioned schema file", + Required: false, + HasBeenSet: true, + Value: schemagen.DefaultVersionedOut, + } + + flagSchemaGenOutLatest = &cli.StringFlag{ + Name: "out-latest", + Usage: "path to latest schema alias file", + Required: false, + HasBeenSet: true, + Value: schemagen.DefaultLatestOut, + } +) + +func (s SchemaGen) Command() *cli.Command { + return &cli.Command{ + Name: "schema-gen", + Usage: "generate easyp config JSON Schema artifacts", + UsageText: "schema-gen [--out-versioned path] [--out-latest path]", + Description: "generate versioned and latest easyp config JSON Schema artifacts", + Action: s.Action, + Flags: []cli.Flag{ + flagSchemaGenOutVersioned, + flagSchemaGenOutLatest, + }, + } +} + +func (s SchemaGen) Action(ctx *cli.Context) error { + if err := schemagen.Run(schemagen.Options{ + VersionedOut: ctx.String(flagSchemaGenOutVersioned.Name), + LatestOut: ctx.String(flagSchemaGenOutLatest.Name), + }); err != nil { + return fmt.Errorf("schemagen.Run: %w", err) + } + + return nil +} diff --git a/cmd/easyp-schema-gen/main.go b/internal/schemagen/schemagen.go similarity index 50% rename from cmd/easyp-schema-gen/main.go rename to internal/schemagen/schemagen.go index cf61636b..b8ed076e 100644 --- a/cmd/easyp-schema-gen/main.go +++ b/internal/schemagen/schemagen.go @@ -1,7 +1,6 @@ -package main +package schemagen import ( - "flag" "fmt" "os" "path/filepath" @@ -9,28 +8,42 @@ import ( "github.com/easyp-tech/easyp/mcp/easypconfig" ) -func main() { - var ( - versionedOut string - latestOut string - ) +const ( + DefaultVersionedOut = "schemas/easyp-config-v1.schema.json" + DefaultLatestOut = "schemas/easyp-config.schema.json" +) + +type Options struct { + VersionedOut string + LatestOut string +} - flag.StringVar(&versionedOut, "out-versioned", "schemas/easyp-config-v1.schema.json", "path to versioned schema file") - flag.StringVar(&latestOut, "out-latest", "schemas/easyp-config.schema.json", "path to latest schema alias file") - flag.Parse() +func Run(opts Options) error { + versionedOut := opts.VersionedOut + if versionedOut == "" { + versionedOut = DefaultVersionedOut + } + + latestOut := opts.LatestOut + if latestOut == "" { + latestOut = DefaultLatestOut + } data, err := easypconfig.MarshalConfigJSONSchema() if err != nil { - exitf("marshal schema: %v", err) + return fmt.Errorf("marshal schema: %w", err) } data = append(data, '\n') if err := writeFile(versionedOut, data); err != nil { - exitf("write versioned schema: %v", err) + return fmt.Errorf("write versioned schema: %w", err) } + if err := writeFile(latestOut, data); err != nil { - exitf("write latest schema: %v", err) + return fmt.Errorf("write latest schema: %w", err) } + + return nil } func writeFile(path string, data []byte) error { @@ -45,8 +58,3 @@ func writeFile(path string, data []byte) error { return nil } - -func exitf(format string, args ...any) { - _, _ = fmt.Fprintf(os.Stderr, format+"\n", args...) - os.Exit(1) -} diff --git a/internal/schemagen/schemagen_test.go b/internal/schemagen/schemagen_test.go new file mode 100644 index 00000000..4dcd5604 --- /dev/null +++ b/internal/schemagen/schemagen_test.go @@ -0,0 +1,42 @@ +package schemagen + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestRun(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + versionedPath := filepath.Join(tmp, "nested", "easyp-config-v1.schema.json") + latestPath := filepath.Join(tmp, "nested", "easyp-config.schema.json") + + err := Run(Options{ + VersionedOut: versionedPath, + LatestOut: latestPath, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + + versionedData, err := os.ReadFile(versionedPath) + if err != nil { + t.Fatalf("os.ReadFile(versionedPath) error = %v", err) + } + + latestData, err := os.ReadFile(latestPath) + if err != nil { + t.Fatalf("os.ReadFile(latestPath) error = %v", err) + } + + if !bytes.Equal(versionedData, latestData) { + t.Fatalf("schema files differ") + } + + if len(versionedData) == 0 { + t.Fatalf("schema file is empty") + } +} diff --git a/mcp/easypconfig/README.md b/mcp/easypconfig/README.md index f437a868..ac4b17be 100644 --- a/mcp/easypconfig/README.md +++ b/mcp/easypconfig/README.md @@ -33,7 +33,7 @@ This allows external tooling (for example Kotlin/JetBrains plugins) to consume t Generate/update artifacts: ```sh -go run ./cmd/easyp-schema-gen +go run ./cmd/easyp schema-gen # or task schema:generate ``` diff --git a/mcp/easypconfig/generate.go b/mcp/easypconfig/generate.go index bfa02250..37c886d3 100644 --- a/mcp/easypconfig/generate.go +++ b/mcp/easypconfig/generate.go @@ -1,3 +1,3 @@ package easypconfig -//go:generate go run ../../cmd/easyp-schema-gen +//go:generate go run ../../cmd/easyp schema-gen --out-versioned ../../schemas/easyp-config-v1.schema.json --out-latest ../../schemas/easyp-config.schema.json