Files
Maksim Totmin c398045664
ci / test (push) Failing after 5s
ci / lint (push) Failing after 4s
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
2026-09-14 00:23:08 +07:00

325 lines
8.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 ""
}