chore: initial release v0.1.0
forge-toolkit: хелперы для разработки MCP-серверов на Go. - toolkit: args/schema/result/env/probe/server - toolkit/validate: валидаторы синтаксиса - configreload: live-reload конфига по контент-хэшу - docs: ARCHITECTURE, quickstart, mcp-contract - template/ и examples/hello-tool/ - CI (Gitea Actions), лицензия Apache-2.0
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
// Package validate — серверные (Go-native) валидаторы синтаксиса и детект
|
||||
// типа файла по расширению. Все парсеры выполняются на MCP-сервере (ноль
|
||||
// зависимостей от удалённого хоста). Вынесено из нескольких MCP-серверов,
|
||||
// где код дублировался почти дословно.
|
||||
package validate
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Result — итог проверки синтаксиса.
|
||||
type Result struct {
|
||||
Valid bool
|
||||
FileType string
|
||||
Errors []string
|
||||
}
|
||||
|
||||
// FormatResult возвращает человекочитаемую сводку.
|
||||
func (v *Result) FormatResult(path string) string {
|
||||
if v.Valid {
|
||||
return fmt.Sprintf("✓ Valid %s — %s", strings.ToUpper(v.FileType), path)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "✗ INVALID %s — %s\n", strings.ToUpper(v.FileType), path)
|
||||
for _, e := range v.Errors {
|
||||
b.WriteString(" " + e + "\n")
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// Content валидирует сырой контент по типу файла. nil — тип не распознан
|
||||
// (валидация невозможна).
|
||||
func Content(content, fileType string) *Result {
|
||||
switch fileType {
|
||||
case "json":
|
||||
return validateJSON(content)
|
||||
case "yaml":
|
||||
return validateYAML(content)
|
||||
case "toml":
|
||||
return validateTOML(content)
|
||||
case "xml":
|
||||
return validateXML(content)
|
||||
case "ini":
|
||||
return validateINI(content)
|
||||
case "env":
|
||||
return validateENV(content)
|
||||
case "dockerfile":
|
||||
return validateDockerfile(content)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// --- JSON ---
|
||||
|
||||
func validateJSON(content string) *Result {
|
||||
r := &Result{FileType: "json"}
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(content), &v); err != nil {
|
||||
r.Errors = append(r.Errors, err.Error())
|
||||
return r
|
||||
}
|
||||
r.Valid = true
|
||||
return r
|
||||
}
|
||||
|
||||
// --- YAML ---
|
||||
|
||||
func validateYAML(content string) *Result {
|
||||
r := &Result{FileType: "yaml"}
|
||||
// Decode all documents (multi-doc YAML support)
|
||||
dec := yaml.NewDecoder(strings.NewReader(content))
|
||||
for {
|
||||
var v interface{}
|
||||
err := dec.Decode(&v)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
r.Errors = append(r.Errors, err.Error())
|
||||
return r
|
||||
}
|
||||
}
|
||||
r.Valid = true
|
||||
return r
|
||||
}
|
||||
|
||||
// --- TOML ---
|
||||
|
||||
func validateTOML(content string) *Result {
|
||||
r := &Result{FileType: "toml"}
|
||||
var v interface{}
|
||||
if _, err := toml.Decode(content, &v); err != nil {
|
||||
r.Errors = append(r.Errors, err.Error())
|
||||
return r
|
||||
}
|
||||
r.Valid = true
|
||||
return r
|
||||
}
|
||||
|
||||
// --- XML ---
|
||||
|
||||
func validateXML(content string) *Result {
|
||||
r := &Result{FileType: "xml"}
|
||||
dec := xml.NewDecoder(strings.NewReader(content))
|
||||
for {
|
||||
_, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
r.Errors = append(r.Errors, err.Error())
|
||||
return r
|
||||
}
|
||||
}
|
||||
r.Valid = true
|
||||
return r
|
||||
}
|
||||
|
||||
// --- INI / .conf / .cfg ---
|
||||
// Simple validator: checks section headers [section] and key=value pairs.
|
||||
// Allows comments (# and ;) and blank lines.
|
||||
|
||||
func validateINI(content string) *Result {
|
||||
r := &Result{FileType: "ini"}
|
||||
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
// Blank or comment
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Section header
|
||||
if strings.HasPrefix(line, "[") {
|
||||
if !strings.HasSuffix(line, "]") {
|
||||
r.Errors = append(r.Errors, fmt.Sprintf("line %d: unclosed section header: %s", lineNum, line))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Key=value (allow key = value, key: value)
|
||||
if strings.ContainsAny(line, "=:") {
|
||||
continue
|
||||
}
|
||||
|
||||
r.Errors = append(r.Errors, fmt.Sprintf("line %d: invalid syntax: %s", lineNum, line))
|
||||
}
|
||||
|
||||
r.Valid = len(r.Errors) == 0
|
||||
return r
|
||||
}
|
||||
|
||||
// --- .env / dotenv ---
|
||||
// Validates KEY=VALUE format. Allows comments (#) and blank lines.
|
||||
// Keys must start with a letter or underscore.
|
||||
|
||||
func validateENV(content string) *Result {
|
||||
r := &Result{FileType: "env"}
|
||||
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Must contain = and key must start with letter/underscore
|
||||
eqIdx := strings.Index(line, "=")
|
||||
if eqIdx <= 0 {
|
||||
r.Errors = append(r.Errors, fmt.Sprintf("line %d: missing KEY=VALUE format: %s", lineNum, line))
|
||||
continue
|
||||
}
|
||||
|
||||
key := line[:eqIdx]
|
||||
key = strings.TrimSpace(key)
|
||||
// Remove "export " prefix if present
|
||||
key = strings.TrimPrefix(key, "export ")
|
||||
key = strings.TrimSpace(key)
|
||||
|
||||
if key == "" {
|
||||
r.Errors = append(r.Errors, fmt.Sprintf("line %d: empty key", lineNum))
|
||||
continue
|
||||
}
|
||||
|
||||
firstChar := key[0]
|
||||
isLetter := firstChar >= 'A' && firstChar <= 'Z' || firstChar >= 'a' && firstChar <= 'z'
|
||||
if !isLetter && firstChar != '_' {
|
||||
r.Errors = append(r.Errors, fmt.Sprintf("line %d: key must start with letter or underscore: %s", lineNum, key))
|
||||
}
|
||||
}
|
||||
|
||||
r.Valid = len(r.Errors) == 0
|
||||
return r
|
||||
}
|
||||
|
||||
// --- Dockerfile ---
|
||||
// Validates that each non-comment, non-continuation line starts with a known instruction.
|
||||
|
||||
var dockerfileInstructions = map[string]bool{
|
||||
"FROM": true, "RUN": true, "CMD": true, "LABEL": true,
|
||||
"EXPOSE": true, "ENV": true, "ADD": true, "COPY": true,
|
||||
"ENTRYPOINT": true, "VOLUME": true, "USER": true, "WORKDIR": true,
|
||||
"ARG": true, "ONBUILD": true, "STOPSIGNAL": true, "HEALTHCHECK": true,
|
||||
"SHELL": true, "MAINTAINER": true,
|
||||
}
|
||||
|
||||
func validateDockerfile(content string) *Result {
|
||||
r := &Result{FileType: "dockerfile"}
|
||||
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||
lineNum := 0
|
||||
continuation := false
|
||||
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Handle line continuation from previous line
|
||||
if continuation {
|
||||
continuation = strings.HasSuffix(trimmed, "\\")
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip blank lines and comments
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if line continues
|
||||
continuation = strings.HasSuffix(trimmed, "\\")
|
||||
|
||||
// Extract instruction (first word)
|
||||
parts := strings.Fields(trimmed)
|
||||
if len(parts) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
instruction := strings.ToUpper(parts[0])
|
||||
// Handle parser directives (# syntax=..., # escape=...)
|
||||
if strings.HasPrefix(instruction, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
if !dockerfileInstructions[instruction] {
|
||||
r.Errors = append(r.Errors, fmt.Sprintf("line %d: unknown instruction: %s", lineNum, parts[0]))
|
||||
}
|
||||
}
|
||||
|
||||
// Check that FROM is present
|
||||
hasFrom := false
|
||||
scanner2 := bufio.NewScanner(strings.NewReader(content))
|
||||
for scanner2.Scan() {
|
||||
line := strings.TrimSpace(scanner2.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) > 0 && strings.ToUpper(parts[0]) == "FROM" {
|
||||
hasFrom = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasFrom && strings.TrimSpace(content) != "" {
|
||||
r.Errors = append(r.Errors, "missing FROM instruction")
|
||||
}
|
||||
|
||||
r.Valid = len(r.Errors) == 0
|
||||
return r
|
||||
}
|
||||
|
||||
// fileTypePatterns — сопоставление lowercase basename с типом файла.
|
||||
var fileTypePatterns = []struct {
|
||||
pattern string // matched against lowercase basename
|
||||
fileType string
|
||||
}{
|
||||
{"*.json", "json"},
|
||||
{"*.yaml", "yaml"},
|
||||
{"*.yml", "yaml"},
|
||||
{"*.toml", "toml"},
|
||||
{"*.xml", "xml"},
|
||||
{"*.xsl", "xml"},
|
||||
{"*.xslt", "xml"},
|
||||
{"*.svg", "xml"},
|
||||
{"*.xhtml", "xml"},
|
||||
{"*.plist", "xml"},
|
||||
{"*.ini", "ini"},
|
||||
{"*.cfg", "ini"},
|
||||
{"*.conf", "ini"},
|
||||
{"*.env", "env"},
|
||||
{"dockerfile*", "dockerfile"},
|
||||
{".env*", "env"},
|
||||
}
|
||||
|
||||
// DetectFileType определяет тип файла по расширению или имени. Пусто — тип
|
||||
// не распознан.
|
||||
func DetectFileType(path string) string {
|
||||
lower := strings.ToLower(path)
|
||||
base := lower
|
||||
if idx := strings.LastIndex(lower, "/"); idx >= 0 {
|
||||
base = lower[idx+1:]
|
||||
}
|
||||
for _, p := range fileTypePatterns {
|
||||
if matched, _ := filepath.Match(p.pattern, base); matched {
|
||||
return p.fileType
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestContentValid(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
fileType string
|
||||
valid bool
|
||||
}{
|
||||
{"json ok", `{"a":1}`, "json", true},
|
||||
{"json bad", `{`, "json", false},
|
||||
{"yaml ok", "a: 1\nb: 2\n", "yaml", true},
|
||||
{"yaml bad", "a: [1,\n", "yaml", false},
|
||||
{"toml ok", "a = 1\n", "toml", true},
|
||||
{"toml bad", "a = = 1\n", "toml", false},
|
||||
{"xml ok", "<a><b/></a>", "xml", true},
|
||||
{"xml bad", "<a><b></a>", "xml", false},
|
||||
{"ini ok", "[s]\nk = v\n", "ini", true},
|
||||
{"ini bad", "no separator line\n", "ini", false},
|
||||
{"env ok", "FOO=bar\nexport BAZ=1\n", "env", true},
|
||||
{"env bad", "1BAD=x\n", "env", false},
|
||||
{"dockerfile ok", "FROM alpine\nRUN echo hi\n", "dockerfile", true},
|
||||
{"dockerfile bad", "FROM alpine\nBOGUS x\n", "dockerfile", false},
|
||||
{"unknown type", "whatever", "nope", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := Content(tc.content, tc.fileType)
|
||||
if tc.fileType == "nope" {
|
||||
if r != nil {
|
||||
t.Fatalf("unknown type must return nil, got %+v", r)
|
||||
}
|
||||
return
|
||||
}
|
||||
if r == nil {
|
||||
t.Fatal("nil result")
|
||||
}
|
||||
if r.Valid != tc.valid {
|
||||
t.Errorf("Valid = %v, want %v (errors=%v)", r.Valid, tc.valid, r.Errors)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFileType(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"a.json": "json",
|
||||
"dir/b.yaml": "yaml",
|
||||
"c.YML": "yaml",
|
||||
"Dockerfile": "dockerfile",
|
||||
"path/to/Dockerfile": "dockerfile",
|
||||
".env": "env",
|
||||
".env.local": "env",
|
||||
"conf/app.conf": "ini",
|
||||
"x.txt": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := DetectFileType(in); got != want {
|
||||
t.Errorf("DetectFileType(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatResult(t *testing.T) {
|
||||
ok := &Result{Valid: true, FileType: "json"}
|
||||
if got := ok.FormatResult("/a.json"); !strings.HasPrefix(got, "✓") {
|
||||
t.Errorf("valid format = %q", got)
|
||||
}
|
||||
bad := &Result{FileType: "json", Errors: []string{"boom"}}
|
||||
if got := bad.FormatResult("/a.json"); !strings.HasPrefix(got, "✗") {
|
||||
t.Errorf("invalid format = %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user