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
325 lines
8.0 KiB
Go
325 lines
8.0 KiB
Go
// 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 ""
|
||
}
|