chore: initial release v0.1.0
ci / test (push) Failing after 5s
ci / lint (push) Failing after 4s

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:
Maksim Totmin
2026-09-14 00:23:08 +07:00
commit c398045664
43 changed files with 2581 additions and 0 deletions
+324
View File
@@ -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 ""
}
+78
View File
@@ -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)
}
}