This commit is contained in:
h z
2026-02-15 02:28:28 +00:00
commit 95b22c595d
36 changed files with 1884 additions and 0 deletions

9
.idea/Marshmallow.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

21
AbstractWizard/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /abstract-wizard .
FROM gcr.io/distroless/static-debian12
COPY --from=build /abstract-wizard /abstract-wizard
USER nonroot:nonroot
ENV CONFIG_DIR=/config
ENV LISTEN_ADDR=0.0.0.0:8080
EXPOSE 8080
VOLUME /config
ENTRYPOINT ["/abstract-wizard"]

287
AbstractWizard/README.md Normal file
View File

@@ -0,0 +1,287 @@
# Abstract Wizard
[English](#english) | [中文](#中文)
---
## English
Secure configuration file management service. Read, modify, and version-control JSON/YAML config files via REST API. Listens on localhost only — access remotely via SSH tunnel.
### Quick Start
#### Docker Compose (Recommended)
```bash
docker compose up -d
```
The service listens on `127.0.0.1:18080`.
#### Run Locally
```bash
go build -o abstract-wizard .
CONFIG_DIR=./configs ./abstract-wizard
```
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `CONFIG_DIR` | `/config` | Base directory for config files |
| `LISTEN_ADDR` | `127.0.0.1:8080` | HTTP listen address |
| `MAX_BACKUPS` | `10` | Max backup versions per file |
### API
#### Health Check
```bash
curl http://127.0.0.1:18080/health
```
#### Read Config
```bash
curl http://127.0.0.1:18080/api/v1/config/app.json
```
#### Full Replace
```bash
curl -X PUT http://127.0.0.1:18080/api/v1/config/app.json \
-d '{"database": {"host": "localhost", "port": 5432}}'
```
A backup is created automatically before each write. The response includes `backup_version`.
#### Partial Update (Deep Merge)
```bash
curl -X PATCH http://127.0.0.1:18080/api/v1/config/app.json \
-d '{"database": {"port": 3306}}'
```
Only the specified fields are updated; nested objects are merged recursively.
#### List Backups
```bash
curl http://127.0.0.1:18080/api/v1/backups/app.json
```
#### Rollback
```bash
# Get the version from the backup list, then:
curl -X POST http://127.0.0.1:18080/api/v1/rollback/app.json \
-d '{"version": "20260215T120000Z"}'
```
#### Mode Switching
Get current mode:
```bash
curl http://127.0.0.1:18080/api/v1/mode
```
Switch to readonly (rejects all writes):
```bash
curl -X PUT http://127.0.0.1:18080/api/v1/mode \
-d '{"mode": "readonly"}'
```
Switch back to init (read/write):
```bash
curl -X PUT http://127.0.0.1:18080/api/v1/mode \
-d '{"mode": "init"}'
```
### YAML Support
All endpoints support both JSON and YAML. The format is detected by file extension:
```bash
curl -X PUT http://127.0.0.1:18080/api/v1/config/app.yaml \
-H "Content-Type: text/yaml" \
-d '
database:
host: localhost
port: 5432
'
```
### Security Model
The service binds to `127.0.0.1` by default and is not exposed externally. Use an SSH tunnel for remote access:
```bash
ssh -L 18080:127.0.0.1:18080 user@server
```
Then access via `http://127.0.0.1:18080` locally.
### Project Structure
```
├── main.go # Entry point, env config, graceful shutdown
├── config/
│ ├── validation.go # Path validation, traversal prevention
│ ├── parser.go # JSON/YAML parse, serialize, deep merge
│ ├── atomic.go # Atomic write (temp → fsync → rename)
│ └── backup.go # Timestamped backups, pruning, rollback
├── audit/
│ └── logger.go # Structured JSON audit log
├── server/
│ ├── server.go # HTTP server, routing, mode state machine
│ ├── middleware.go # Request logging middleware
│ └── handlers.go # API handlers
├── Dockerfile # Multi-stage build
└── docker-compose.yaml # Example deployment
```
---
## 中文
安全的配置文件管理服务。通过 REST API 对 JSON/YAML 配置文件进行读取、修改和版本管理,仅监听 localhost通过 SSH 隧道访问。
### 快速开始
#### Docker Compose推荐
```bash
docker compose up -d
```
服务启动后监听 `127.0.0.1:18080`
#### 本地运行
```bash
go build -o abstract-wizard .
CONFIG_DIR=./configs ./abstract-wizard
```
### 环境变量
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `CONFIG_DIR` | `/config` | 配置文件存放目录 |
| `LISTEN_ADDR` | `127.0.0.1:8080` | 监听地址 |
| `MAX_BACKUPS` | `10` | 每个文件保留的最大备份数 |
### API
#### 健康检查
```bash
curl http://127.0.0.1:18080/health
```
#### 读取配置
```bash
curl http://127.0.0.1:18080/api/v1/config/app.json
```
#### 完整替换配置
```bash
curl -X PUT http://127.0.0.1:18080/api/v1/config/app.json \
-d '{"database": {"host": "localhost", "port": 5432}}'
```
写入前会自动创建备份,响应中包含 `backup_version`
#### 局部更新(深度合并)
```bash
curl -X PATCH http://127.0.0.1:18080/api/v1/config/app.json \
-d '{"database": {"port": 3306}}'
```
仅更新指定字段,嵌套对象递归合并。
#### 查看备份列表
```bash
curl http://127.0.0.1:18080/api/v1/backups/app.json
```
#### 回滚到指定版本
```bash
# 先从备份列表获取版本号,然后:
curl -X POST http://127.0.0.1:18080/api/v1/rollback/app.json \
-d '{"version": "20260215T120000Z"}'
```
#### 模式切换
查看当前模式:
```bash
curl http://127.0.0.1:18080/api/v1/mode
```
切换为只读模式(拒绝所有写操作):
```bash
curl -X PUT http://127.0.0.1:18080/api/v1/mode \
-d '{"mode": "readonly"}'
```
切换回初始化模式(允许读写):
```bash
curl -X PUT http://127.0.0.1:18080/api/v1/mode \
-d '{"mode": "init"}'
```
### YAML 支持
所有端点同时支持 JSON 和 YAML格式由文件扩展名自动判断
```bash
curl -X PUT http://127.0.0.1:18080/api/v1/config/app.yaml \
-H "Content-Type: text/yaml" \
-d '
database:
host: localhost
port: 5432
'
```
### 安全模型
服务默认仅监听 `127.0.0.1`,不暴露到外部网络。远程访问通过 SSH 隧道实现:
```bash
ssh -L 18080:127.0.0.1:18080 user@server
```
之后本地即可通过 `http://127.0.0.1:18080` 访问。
### 项目结构
```
├── main.go # 入口:环境变量、初始化、优雅关闭
├── config/
│ ├── validation.go # 路径校验,防止目录穿越
│ ├── parser.go # JSON/YAML 解析、序列化、深度合并
│ ├── atomic.go # 原子写入temp → fsync → rename
│ └── backup.go # 时间戳备份、清理、回滚
├── audit/
│ └── logger.go # 结构化 JSON 审计日志
├── server/
│ ├── server.go # HTTP 服务、路由、模式状态机
│ ├── middleware.go # 请求日志中间件
│ └── handlers.go # API 处理函数
├── Dockerfile # 多阶段构建
└── docker-compose.yaml # 示例部署配置
```

View File

@@ -0,0 +1,46 @@
package audit
import (
"encoding/json"
"log"
"os"
"time"
)
// Entry represents a single audit log record.
type Entry struct {
Timestamp string `json:"timestamp"`
RemoteIP string `json:"remote_ip"`
Action string `json:"action"`
Path string `json:"path"`
Summary string `json:"summary"`
}
// Logger writes structured audit entries as JSON to stdout.
type Logger struct {
out *log.Logger
}
// NewLogger creates a new audit logger writing to stdout.
func NewLogger() *Logger {
return &Logger{
out: log.New(os.Stdout, "", 0),
}
}
// Log writes a structured audit entry.
func (l *Logger) Log(remoteIP, action, path, summary string) {
entry := Entry{
Timestamp: time.Now().UTC().Format(time.RFC3339),
RemoteIP: remoteIP,
Action: action,
Path: path,
Summary: summary,
}
data, err := json.Marshal(entry)
if err != nil {
l.out.Printf(`{"error":"failed to marshal audit entry: %v"}`, err)
return
}
l.out.Print(string(data))
}

View File

@@ -0,0 +1,55 @@
package config
import (
"fmt"
"os"
"path/filepath"
)
// AtomicWrite writes data to the target path atomically by writing to a temp file
// in the same directory, fsyncing, then renaming over the target.
func AtomicWrite(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create directory: %w", err)
}
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpName := tmp.Name()
cleanup := func() {
tmp.Close()
os.Remove(tmpName)
}
if _, err := tmp.Write(data); err != nil {
cleanup()
return fmt.Errorf("write temp file: %w", err)
}
if err := tmp.Sync(); err != nil {
cleanup()
return fmt.Errorf("fsync temp file: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return fmt.Errorf("close temp file: %w", err)
}
if err := os.Chmod(tmpName, perm); err != nil {
os.Remove(tmpName)
return fmt.Errorf("chmod temp file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
os.Remove(tmpName)
return fmt.Errorf("rename temp to target: %w", err)
}
return nil
}

View File

@@ -0,0 +1,156 @@
package config
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
const backupDirName = ".backups"
// BackupInfo holds metadata about a single backup.
type BackupInfo struct {
Version string `json:"version"`
Timestamp string `json:"timestamp"`
Path string `json:"path"`
}
// CreateBackup copies the current file into a timestamped backup before modification.
// Returns the backup version string. If the source file doesn't exist, returns "" with no error.
func CreateBackup(baseDir, relPath string, maxBackups int) (string, error) {
srcPath, err := FullPath(baseDir, relPath)
if err != nil {
return "", err
}
if _, err := os.Stat(srcPath); os.IsNotExist(err) {
return "", nil
}
data, err := os.ReadFile(srcPath)
if err != nil {
return "", fmt.Errorf("read source for backup: %w", err)
}
backupDir := filepath.Join(baseDir, backupDirName, filepath.Dir(filepath.Clean(relPath)))
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return "", fmt.Errorf("create backup dir: %w", err)
}
version := time.Now().UTC().Format("20060102T150405Z")
base := filepath.Base(relPath)
ext := filepath.Ext(base)
name := strings.TrimSuffix(base, ext)
backupName := fmt.Sprintf("%s.%s%s", name, version, ext)
backupPath := filepath.Join(backupDir, backupName)
if err := AtomicWrite(backupPath, data, 0o644); err != nil {
return "", fmt.Errorf("write backup: %w", err)
}
if err := pruneBackups(backupDir, name, ext, maxBackups); err != nil {
return "", fmt.Errorf("prune backups: %w", err)
}
return version, nil
}
// ListBackups returns all backup versions for a given config file, newest first.
func ListBackups(baseDir, relPath string) ([]BackupInfo, error) {
if err := ValidatePath(relPath); err != nil {
return nil, err
}
backupDir := filepath.Join(baseDir, backupDirName, filepath.Dir(filepath.Clean(relPath)))
base := filepath.Base(relPath)
ext := filepath.Ext(base)
name := strings.TrimSuffix(base, ext)
prefix := name + "."
entries, err := os.ReadDir(backupDir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read backup dir: %w", err)
}
var backups []BackupInfo
for _, e := range entries {
fname := e.Name()
if !strings.HasPrefix(fname, prefix) || !strings.HasSuffix(fname, ext) {
continue
}
version := strings.TrimSuffix(strings.TrimPrefix(fname, prefix), ext)
backups = append(backups, BackupInfo{
Version: version,
Timestamp: version,
Path: filepath.Join(backupDir, fname),
})
}
sort.Slice(backups, func(i, j int) bool {
return backups[i].Version > backups[j].Version
})
return backups, nil
}
// Rollback restores a config file to the given backup version.
func Rollback(baseDir, relPath, version string) error {
if err := ValidatePath(relPath); err != nil {
return err
}
backupDir := filepath.Join(baseDir, backupDirName, filepath.Dir(filepath.Clean(relPath)))
base := filepath.Base(relPath)
ext := filepath.Ext(base)
name := strings.TrimSuffix(base, ext)
backupName := fmt.Sprintf("%s.%s%s", name, version, ext)
backupPath := filepath.Join(backupDir, backupName)
data, err := os.ReadFile(backupPath)
if err != nil {
return fmt.Errorf("read backup %q: %w", version, err)
}
targetPath, err := FullPath(baseDir, relPath)
if err != nil {
return err
}
return AtomicWrite(targetPath, data, 0o644)
}
// pruneBackups removes oldest backups exceeding maxBackups.
func pruneBackups(backupDir, name, ext string, maxBackups int) error {
if maxBackups <= 0 {
return nil
}
prefix := name + "."
entries, err := os.ReadDir(backupDir)
if err != nil {
return err
}
var matching []string
for _, e := range entries {
if strings.HasPrefix(e.Name(), prefix) && strings.HasSuffix(e.Name(), ext) {
matching = append(matching, e.Name())
}
}
sort.Strings(matching)
if len(matching) > maxBackups {
for _, old := range matching[:len(matching)-maxBackups] {
os.Remove(filepath.Join(backupDir, old))
}
}
return nil
}

View File

@@ -0,0 +1,83 @@
package config
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// Format represents a config file format.
type Format int
const (
FormatJSON Format = iota
FormatYAML
)
// DetectFormat returns the format based on file extension.
func DetectFormat(path string) (Format, error) {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".json":
return FormatJSON, nil
case ".yaml", ".yml":
return FormatYAML, nil
default:
return 0, fmt.Errorf("unsupported format: %s", ext)
}
}
// Parse deserializes raw bytes into a map based on the given format.
func Parse(data []byte, format Format) (map[string]any, error) {
var result map[string]any
switch format {
case FormatJSON:
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
case FormatYAML:
if err := yaml.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("invalid YAML: %w", err)
}
default:
return nil, fmt.Errorf("unknown format")
}
if result == nil {
result = make(map[string]any)
}
return result, nil
}
// Serialize converts a map back to bytes in the given format.
func Serialize(data map[string]any, format Format) ([]byte, error) {
switch format {
case FormatJSON:
return json.MarshalIndent(data, "", " ")
case FormatYAML:
return yaml.Marshal(data)
default:
return nil, fmt.Errorf("unknown format")
}
}
// DeepMerge merges src into dst recursively. Values in src override those in dst.
// Nested maps are merged; all other types are replaced.
func DeepMerge(dst, src map[string]any) map[string]any {
out := make(map[string]any, len(dst))
for k, v := range dst {
out[k] = v
}
for k, v := range src {
if srcMap, ok := v.(map[string]any); ok {
if dstMap, ok := out[k].(map[string]any); ok {
out[k] = DeepMerge(dstMap, srcMap)
continue
}
}
out[k] = v
}
return out
}

View File

@@ -0,0 +1,49 @@
package config
import (
"fmt"
"path/filepath"
"strings"
)
var allowedExtensions = map[string]bool{
".json": true,
".yaml": true,
".yml": true,
}
// ValidatePath checks that the given relative path is safe and points to a supported file type.
// It prevents directory traversal and rejects unsupported extensions.
func ValidatePath(relPath string) error {
if relPath == "" {
return fmt.Errorf("empty path")
}
cleaned := filepath.Clean(relPath)
if filepath.IsAbs(cleaned) {
return fmt.Errorf("absolute paths not allowed")
}
for _, part := range strings.Split(cleaned, string(filepath.Separator)) {
if part == ".." {
return fmt.Errorf("path traversal not allowed")
}
}
ext := strings.ToLower(filepath.Ext(cleaned))
if !allowedExtensions[ext] {
return fmt.Errorf("unsupported file extension %q; allowed: .json, .yaml, .yml", ext)
}
return nil
}
// FullPath joins the base config directory with the validated relative path.
func FullPath(baseDir, relPath string) (string, error) {
if err := ValidatePath(relPath); err != nil {
return "", err
}
full := filepath.Join(baseDir, filepath.Clean(relPath))
return full, nil
}

View File

@@ -0,0 +1,14 @@
services:
abstract-wizard:
build: .
ports:
- "127.0.0.1:18080:8080"
volumes:
- app_config:/config
environment:
CONFIG_DIR: /config
LISTEN_ADDR: "0.0.0.0:8080"
MAX_BACKUPS: "10"
volumes:
app_config:

5
AbstractWizard/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module AbstractWizard
go 1.24
require gopkg.in/yaml.v3 v3.0.1

4
AbstractWizard/go.sum Normal file
View File

@@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

51
AbstractWizard/main.go Normal file
View File

@@ -0,0 +1,51 @@
package main
import (
"log"
"os"
"strconv"
"AbstractWizard/audit"
"AbstractWizard/server"
)
func main() {
cfg := server.AppConfig{
ConfigDir: envOrDefault("CONFIG_DIR", "/config"),
ListenAddr: envOrDefault("LISTEN_ADDR", "127.0.0.1:8080"),
MaxBackups: envOrDefaultInt("MAX_BACKUPS", 10),
}
if err := os.MkdirAll(cfg.ConfigDir, 0o755); err != nil {
log.Fatalf("failed to create config directory %s: %v", cfg.ConfigDir, err)
}
auditLog := audit.NewLogger()
srv := server.New(cfg, auditLog)
log.Printf("config_dir=%s listen_addr=%s max_backups=%d", cfg.ConfigDir, cfg.ListenAddr, cfg.MaxBackups)
if err := srv.ListenAndServe(); err != nil {
log.Fatalf("server error: %v", err)
}
}
func envOrDefault(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}
func envOrDefaultInt(key string, defaultVal int) int {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
n, err := strconv.Atoi(v)
if err != nil {
log.Printf("warning: invalid %s=%q, using default %d", key, v, defaultVal)
return defaultVal
}
return n
}

View File

@@ -0,0 +1,265 @@
package server
import (
"encoding/json"
"io"
"net/http"
"os"
"AbstractWizard/config"
)
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
relPath := r.PathValue("path")
fullPath, err := config.FullPath(s.cfg.ConfigDir, relPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
data, err := os.ReadFile(fullPath)
if os.IsNotExist(err) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read file"})
return
}
format, err := config.DetectFormat(fullPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
parsed, err := config.Parse(data, format)
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
s.audit.Log(r.RemoteAddr, "read", relPath, "config read")
writeJSON(w, http.StatusOK, parsed)
}
func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) {
if s.GetMode() == ModeReadOnly {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "server is in readonly mode"})
return
}
relPath := r.PathValue("path")
fullPath, err := config.FullPath(s.cfg.ConfigDir, relPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20)) // 10 MB limit
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read request body"})
return
}
format, err := config.DetectFormat(fullPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
// Validate that the body is valid config
if _, err := config.Parse(body, format); err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
version, err := config.CreateBackup(s.cfg.ConfigDir, relPath, s.cfg.MaxBackups)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "backup failed: " + err.Error()})
return
}
if err := config.AtomicWrite(fullPath, body, 0o644); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "write failed: " + err.Error()})
return
}
s.audit.Log(r.RemoteAddr, "replace", relPath, "full replace")
resp := map[string]string{"status": "ok"}
if version != "" {
resp["backup_version"] = version
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
if s.GetMode() == ModeReadOnly {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "server is in readonly mode"})
return
}
relPath := r.PathValue("path")
fullPath, err := config.FullPath(s.cfg.ConfigDir, relPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
format, err := config.DetectFormat(fullPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
// Read existing config
existing := make(map[string]any)
data, err := os.ReadFile(fullPath)
if err != nil && !os.IsNotExist(err) {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read existing file"})
return
}
if err == nil {
existing, err = config.Parse(data, format)
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "existing file is invalid: " + err.Error()})
return
}
}
// Parse patch body
body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read request body"})
return
}
patch, err := config.Parse(body, format)
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
merged := config.DeepMerge(existing, patch)
out, err := config.Serialize(merged, format)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "serialization failed"})
return
}
version, err := config.CreateBackup(s.cfg.ConfigDir, relPath, s.cfg.MaxBackups)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "backup failed: " + err.Error()})
return
}
if err := config.AtomicWrite(fullPath, out, 0o644); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "write failed"})
return
}
s.audit.Log(r.RemoteAddr, "patch", relPath, "deep merge update")
resp := map[string]string{"status": "ok"}
if version != "" {
resp["backup_version"] = version
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) {
relPath := r.PathValue("path")
backups, err := config.ListBackups(s.cfg.ConfigDir, relPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
type backupResponse struct {
Version string `json:"version"`
Timestamp string `json:"timestamp"`
}
var resp []backupResponse
for _, b := range backups {
resp = append(resp, backupResponse{Version: b.Version, Timestamp: b.Timestamp})
}
if resp == nil {
resp = []backupResponse{}
}
s.audit.Log(r.RemoteAddr, "list_backups", relPath, "listed backups")
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
if s.GetMode() == ModeReadOnly {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "server is in readonly mode"})
return
}
relPath := r.PathValue("path")
var body struct {
Version string `json:"version"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if body.Version == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "version is required"})
return
}
// Backup current state before rollback
if _, err := config.CreateBackup(s.cfg.ConfigDir, relPath, s.cfg.MaxBackups); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "pre-rollback backup failed: " + err.Error()})
return
}
if err := config.Rollback(s.cfg.ConfigDir, relPath, body.Version); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rollback failed: " + err.Error()})
return
}
s.audit.Log(r.RemoteAddr, "rollback", relPath, "rolled back to "+body.Version)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "restored_version": body.Version})
}
func (s *Server) handleGetMode(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"mode": s.GetMode().String()})
}
func (s *Server) handleSetMode(w http.ResponseWriter, r *http.Request) {
var body struct {
Mode string `json:"mode"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
mode, ok := ParseMode(body.Mode)
if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid mode; must be 'init' or 'readonly'"})
return
}
s.SetMode(mode)
s.audit.Log(r.RemoteAddr, "set_mode", "", "mode changed to "+body.Mode)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "mode": mode.String()})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}

View File

@@ -0,0 +1,27 @@
package server
import (
"log"
"net/http"
"time"
)
type responseRecorder struct {
http.ResponseWriter
statusCode int
}
func (r *responseRecorder) WriteHeader(code int) {
r.statusCode = code
r.ResponseWriter.WriteHeader(code)
}
// LoggingMiddleware logs each incoming request with method, path, status, and duration.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &responseRecorder{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rec, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rec.statusCode, time.Since(start).Round(time.Millisecond))
})
}

View File

@@ -0,0 +1,137 @@
package server
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"AbstractWizard/audit"
)
// Mode represents the server operating mode.
type Mode int
const (
ModeInit Mode = iota // init: read/write allowed
ModeReadOnly // readonly: only reads allowed
)
func (m Mode) String() string {
switch m {
case ModeInit:
return "init"
case ModeReadOnly:
return "readonly"
default:
return "unknown"
}
}
// ParseMode converts a string to a Mode.
func ParseMode(s string) (Mode, bool) {
switch s {
case "init":
return ModeInit, true
case "readonly":
return ModeReadOnly, true
default:
return 0, false
}
}
// AppConfig holds environment-based configuration.
type AppConfig struct {
ConfigDir string
ListenAddr string
MaxBackups int
}
// Server is the main HTTP server.
type Server struct {
cfg AppConfig
audit *audit.Logger
mode Mode
modeMu sync.RWMutex
srv *http.Server
}
// New creates a new Server.
func New(cfg AppConfig, auditLog *audit.Logger) *Server {
s := &Server{
cfg: cfg,
audit: auditLog,
mode: ModeInit,
}
mux := http.NewServeMux()
s.registerRoutes(mux)
s.srv = &http.Server{
Addr: cfg.ListenAddr,
Handler: LoggingMiddleware(mux),
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
return s
}
// registerRoutes sets up all API routes.
func (s *Server) registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /api/v1/config/{path...}", s.handleGetConfig)
mux.HandleFunc("PUT /api/v1/config/{path...}", s.handlePutConfig)
mux.HandleFunc("PATCH /api/v1/config/{path...}", s.handlePatchConfig)
mux.HandleFunc("GET /api/v1/backups/{path...}", s.handleListBackups)
mux.HandleFunc("POST /api/v1/rollback/{path...}", s.handleRollback)
mux.HandleFunc("GET /api/v1/mode", s.handleGetMode)
mux.HandleFunc("PUT /api/v1/mode", s.handleSetMode)
}
// GetMode returns the current server mode.
func (s *Server) GetMode() Mode {
s.modeMu.RLock()
defer s.modeMu.RUnlock()
return s.mode
}
// SetMode changes the server mode.
func (s *Server) SetMode(m Mode) {
s.modeMu.Lock()
defer s.modeMu.Unlock()
s.mode = m
}
// ListenAndServe starts the HTTP server and blocks until shutdown.
func (s *Server) ListenAndServe() error {
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
errCh := make(chan error, 1)
go func() {
log.Printf("listening on %s", s.cfg.ListenAddr)
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- err
}
close(errCh)
}()
select {
case sig := <-stop:
log.Printf("received signal %v, shutting down", sig)
case err := <-errCh:
if err != nil {
return err
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return s.srv.Shutdown(ctx)
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12 (DependencyExtractor)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -0,0 +1,57 @@
services:
frontend:
build:
context: ./Dialectic.Frontend
dockerfile: Dockerfile
ports:
- "${DIALECTIC_FRONTEND_PORT:-3000}:3000"
environment:
- REACT_APP_DIALECTIC_BACKEND_HOST=${DIALECTIC_BACKEND_URL:-http://localhost:8000}
networks:
- app-network
backend:
build:
context: ./Dialectic.Backend
dockerfile: Dockerfile
ports:
- "${DIALECTIC_BACKEND_PORT:-8000}:8000"
- "127.0.0.1:${DIALECTIC_CONFIG_PORT:-9090}:9090"
volumes:
- dialectic_config:/app/config
environment:
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-BgS9_9K2UMYxiYnP-BE63UEdi7a6PHaaZ6rQZQnSx54=}
- CONFIG_PATH=/app/config/dialectic.yaml
- CONFIG_PORT=${DIALECTIC_CONFIG_PORT:-9090}
depends_on:
db:
condition: service_healthy
networks:
- app-network
db:
image: mysql:8.0
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword}
MYSQL_DATABASE: ${MYSQL_DATABASE:-dialectic}
MYSQL_USER: ${MYSQL_USER:-dialectic}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-dialectic}
ports:
- "${DB_PORT:-3306}:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 20s
retries: 10
networks:
- app-network
volumes:
mysql_data:
dialectic_config:
networks:
app-network:
driver: bridge

47
Dialectic/init.sql Normal file
View File

@@ -0,0 +1,47 @@
-- MySQL initialization script for Dialectic
CREATE DATABASE IF NOT EXISTS dialectic;
USE dialectic;
-- Table for storing API keys for different providers
CREATE TABLE IF NOT EXISTS api_keys (
id INT AUTO_INCREMENT PRIMARY KEY,
provider VARCHAR(50) NOT NULL UNIQUE,
api_key_encrypted TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- Table for storing model configurations
CREATE TABLE IF NOT EXISTS model_configs (
id INT AUTO_INCREMENT PRIMARY KEY,
provider VARCHAR(50) NOT NULL,
model_name VARCHAR(100) NOT NULL,
display_name VARCHAR(100),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY unique_provider_model (provider, model_name)
);
-- Insert default API key records (encrypted field will be empty initially)
INSERT IGNORE INTO api_keys (provider, api_key_encrypted) VALUES
('openai', ''),
('claude', ''),
('qwen', ''),
('deepseek', '');
-- Insert default model configurations
INSERT IGNORE INTO model_configs (provider, model_name, display_name) VALUES
('openai', 'gpt-4', 'GPT-4'),
('openai', 'gpt-3.5-turbo', 'GPT-3.5 Turbo'),
('claude', 'claude-3-opus', 'Claude 3 Opus'),
('claude', 'claude-3-sonnet', 'Claude 3 Sonnet'),
('claude', 'claude-3-haiku', 'Claude 3 Haiku'),
('qwen', 'qwen-max', 'Qwen Max'),
('qwen', 'qwen-plus', 'Qwen Plus'),
('deepseek', 'deepseek-chat', 'DeepSeek Chat'),
('deepseek', 'deepseek-coder', 'DeepSeek Coder');
-- Add evidence_library column to debate_sessions (migration for existing databases)
ALTER TABLE debate_sessions ADD COLUMN IF NOT EXISTS evidence_library TEXT NULL;

View File

@@ -0,0 +1,115 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Workspace Architecture
**DungeonSolver.Workspace** is a multi-project C# solution (.NET 8.0) organized as a modular game development workspace. Each project serves a specific purpose and can be built independently.
### Project Structure
- **DungeonSolver/**: Main Godot 4.5 game project with Python integration (see `DungeonSolver/CLAUDE.md` for detailed game-specific guidance)
- **DungeonSolver.Global/**: Core game systems and global providers, outputs to `DungeonSolver/Data/` for game consumption
- **DungeonSolver.PythonBridge/**: Python integration layer using IronPython/pythonnet
- **DungeonSolver.Game/**: Game-specific logic and systems
- **DungeonSolver.Generators/**: Roslyn source generators for automated code generation
- **DungeonSolver.Global.Generators/**: Source generators specifically for the Global project
- **DungeonSolver.Tasks/**: Build tasks and utilities
### Key Architectural Patterns
**Modular Project Design**: Each component is isolated in its own project with specific responsibilities:
- Global project compiles to `DungeonSolver/Data/` for runtime access
- Source generators use `OutputItemType="Analyzer" ReferenceOutputAssembly="false"` pattern
- Python bridge allows C# game systems to be accessed from Python via `clr.AddReference()`
**Source Generator Integration**: Projects use Roslyn source generators for code generation:
- Generators project contains sample incremental source generators
- Target classes use `[Generators.ReportAttribute]` for report generation
- Build generators to see generated code in IDE
**Python-C# Interop**:
- **Python Package Generator**: Automatically generates Python wrapper classes for C# types marked with `[PythonSharedType]` attributes
- Generates type-safe Python interfaces from C# classes, interfaces, and enums
- Handles complex generic types and nested class hierarchies
- Supports inheritance, member access, and method calls with proper type hints
- Generated Python packages are output to `user://dungeon_solver/types/`
- **Type Registration System**: Uses `PythonSharedTypeRegister` to map C# types to Python equivalents
- **Shared Memory Communication**: Advanced IPC system for real-time C#-Python communication (see `Documents/SharedMemoryCommunicationProtocol.md`)
- **Runtime Access**: Python scripts access C# via `DungeonSolver.Global.dll` using IronPython/pythonnet
- **Bridge Pattern**: Example implementation in `DungeonSolver/Data/Solver.py`
## Build and Development Commands
**Building Individual Projects**:
```bash
# Build specific project
dotnet build DungeonSolver.Global/
dotnet build DungeonSolver.Generators/
dotnet build DungeonSolver.PythonBridge/
# Build main game project (includes all dependencies)
cd DungeonSolver && dotnet build
```
**Building the Entire Workspace**:
```bash
# Build all projects from workspace root
dotnet build DungeonSolver/DungeonSolver.sln
```
**Source Generator Development**:
```bash
# Build generators to see generated code
dotnet build DungeonSolver.Generators/
dotnet build DungeonSolver.Global.Generators/
```
**Testing**:
```bash
# Run generator unit tests
cd DungeonSolver.Generators && dotnet test
cd DungeonSolver.Global.Generators && dotnet test
```
## Code Style Guidelines
**C# Coding Standards**:
- **No `var` keyword**: Always use explicit types instead of `var`
```csharp
string name = "example"; // Correct
List<int> numbers = new List<int>(); // Correct
// var name = "example"; // Wrong
```
- **No comments**: Do not write any comments in code (XML documentation, inline comments, or block comments)
- Write self-documenting code with clear variable and method names
## Development Workflow Notes
**Project Dependencies**: The main solution (`DungeonSolver/DungeonSolver.sln`) references all other projects, creating a unified build process while maintaining modular development.
**Global System Access**: Python scripts can access the Global project's compiled output via the established bridge pattern in `Data/Solver.py`.
**Source Generator Workflow**:
1. Modify generator code in `*.Generators` projects
2. Build generator project to compile changes
3. Target projects automatically regenerate code during their build
4. Use Rossynt plugin for syntax tree analysis during development
**Cross-Project Development**: Each project can be developed and tested independently, with the main game project serving as the integration point.
**Python Type System Development**:
1. Mark C# types with appropriate `[PythonSharedType]` attributes (`[PythonSharedClass]`, `[PythonSharedInterface]`, `[PythonSharedEnum]`)
2. Add `[PythonSharedMember]` and `[PythonSharedMethod]` attributes to expose specific members
3. Build `DungeonSolver.Global` project to register types in `PythonSharedTypeRegister`
4. Generated Python types automatically handle complex scenarios like:
- Generic type parameters and constraints
- Nested class inheritance hierarchies
- Interface implementation and multiple inheritance
- Enum value mapping and type safety
**Important Development Notes**:
- Always build `DungeonSolver.Global` before the main game project to ensure Python bindings are up-to-date
- When modifying Python-shared types, rebuild is required to regenerate Python wrapper classes
- Use `PythonPackageGenerator.Generate()` to manually trigger Python code generation during development
- Nested classes inherit sharing attributes from parent classes automatically via `NestedClassWrapper` pattern

Submodule DungeonSolver.Workspace/DungeonSolver added at 0d81d98a35

Submodule DungeonSolver.Workspace/DungeonSolver.Analyzer added at b82ca1657e

Submodule DungeonSolver.Workspace/DungeonSolver.Core added at 9de186f41a

Submodule DungeonSolver.Workspace/DungeonSolver.Core.Analyzer added at d7d39d2332

Submodule DungeonSolver.Workspace/DungeonSolver.Core.Generators added at 056e924943

Submodule DungeonSolver.Workspace/DungeonSolver.Generator.Core added at 8c66583d23

Submodule DungeonSolver.Workspace/DungeonSolver.Generators added at 57afa2cc50

View File

@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoGeneratedRunConfigurationManager">
<projectFile profileName="DebugRoslynAnalyzers">DungeonSolver.Global.Analyzer/DungeonSolver.Global.Analyzer.csproj</projectFile>
</component>
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="e8a473e9-efa8-4c17-9674-1989a6c91473" name="Changes" comment="" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" />
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 6
}</component>
<component name="ProjectId" id="34mRHwSVMSZy66teK5Ol8c3JLUl" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultAutoModeForALLUsers.v1&quot;: &quot;true&quot;,
&quot;com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;master&quot;,
&quot;junie.onboarding.icon.badge.shown&quot;: &quot;true&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;to.speed.mode.migration.done&quot;: &quot;true&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}</component>
<component name="RunManager">
<configuration name="DungeonSolver.Global.Analyzer: DebugRoslynAnalyzers" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/DungeonSolver.Global.Analyzer/DungeonSolver.Global.Analyzer.csproj" />
<option name="LAUNCH_PROFILE_TFM" value=".NETStandard,Version=v2.0" />
<option name="LAUNCH_PROFILE_NAME" value="DebugRoslynAnalyzers" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="e8a473e9-efa8-4c17-9674-1989a6c91473" name="Changes" comment="" />
<created>1761816676447</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1761816676447</updated>
<workItem from="1761816677692" duration="695000" />
<workItem from="1762861373046" duration="167000" />
</task>
<task id="LOCAL-00001" summary="init">
<option name="closed" value="true" />
<created>1761816967095</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1761816967095</updated>
</task>
<task id="LOCAL-00002" summary="clean">
<option name="closed" value="true" />
<created>1761817160460</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1761817160460</updated>
</task>
<task id="LOCAL-00003" summary="re init">
<option name="closed" value="true" />
<created>1761817211100</created>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1761817211100</updated>
</task>
<option name="localTasksCounter" value="4" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="UnityCheckinConfiguration" checkUnsavedScenes="true" />
<component name="UnityProjectConfiguration" hasMinimizedUI="false" />
<component name="VcsManagerConfiguration">
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
<MESSAGE value="init" />
<MESSAGE value="clean" />
<MESSAGE value="re init" />
<option name="LAST_COMMIT_MESSAGE" value="re init" />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>
<breakpoints>
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
<properties exception="System.OperationCanceledException" breakIfHandledByOtherCode="false" displayValue="System.OperationCanceledException" />
<option name="timeStamp" value="1" />
</breakpoint>
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
<properties exception="System.Threading.Tasks.TaskCanceledException" breakIfHandledByOtherCode="false" displayValue="System.Threading.Tasks.TaskCanceledException" />
<option name="timeStamp" value="2" />
</breakpoint>
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
<properties exception="System.Threading.ThreadAbortException" breakIfHandledByOtherCode="false" displayValue="System.Threading.ThreadAbortException" />
<option name="timeStamp" value="3" />
</breakpoint>
</breakpoints>
</breakpoint-manager>
</component>
</project>

View File

@@ -0,0 +1,2 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DungeonSolver_002ECore_002EAnalyzer_002FResources/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoGeneratedRunConfigurationManager">
<projectFile profileName="DebugRoslynSourceGenerator">../DungeonSolver.Core.Generators/DungeonSolver.Core.Generators.csproj</projectFile>
<projectFile profileName="DebugRoslynSourceGenerator">DungeonSolver.Global.Generators/DungeonSolver.Global.Generators/DungeonSolver.Global.Generators.csproj</projectFile>
</component>
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="a079404d-2a75-4a6c-acbc-be2886fe3cec" name="Changes" comment="" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$/../DungeonSolver.Core.Generators" />
</component>
<component name="ProblemsViewState">
<option name="selectedTabId" value="Toolset" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 7
}</component>
<component name="ProjectId" id="2okB8CsMteI0pzMz1Ui1IJ6NbpA" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultAutoModeForALLUsers.v1": "true",
"com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true",
"git-widget-placeholder": "master",
"junie.onboarding.icon.badge.shown": "true",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"to.speed.mode.migration.done": "true",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="RunManager" selected=".NET Launch Settings Profile.DungeonSolver.Global.Generators: DebugRoslynSourceGenerator">
<configuration name="DungeonSolver.Core.Generators: DebugRoslynSourceGenerator" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/../DungeonSolver.Core.Generators/DungeonSolver.Core.Generators.csproj" />
<option name="LAUNCH_PROFILE_TFM" value=".NETStandard,Version=v2.0" />
<option name="LAUNCH_PROFILE_NAME" value="DebugRoslynSourceGenerator" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="DungeonSolver.Global.Generators: DebugRoslynSourceGenerator" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/DungeonSolver.Global.Generators/DungeonSolver.Global.Generators/DungeonSolver.Global.Generators.csproj" />
<option name="LAUNCH_PROFILE_TFM" value=".NETStandard,Version=v2.0" />
<option name="LAUNCH_PROFILE_NAME" value="DebugRoslynSourceGenerator" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="a079404d-2a75-4a6c-acbc-be2886fe3cec" name="Changes" comment="" />
<created>1731403531514</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1731403531514</updated>
<workItem from="1731403532630" duration="194000" />
<workItem from="1762860510814" duration="327000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="UnityProjectConfiguration" hasMinimizedUI="false" />
<component name="VcsManagerConfiguration">
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>
<breakpoints>
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
<properties exception="System.OperationCanceledException" breakIfHandledByOtherCode="false" displayValue="System.OperationCanceledException" />
<option name="timeStamp" value="1" />
</breakpoint>
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
<properties exception="System.Threading.Tasks.TaskCanceledException" breakIfHandledByOtherCode="false" displayValue="System.Threading.Tasks.TaskCanceledException" />
<option name="timeStamp" value="2" />
</breakpoint>
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
<properties exception="System.Threading.ThreadAbortException" breakIfHandledByOtherCode="false" displayValue="System.Threading.ThreadAbortException" />
<option name="timeStamp" value="3" />
</breakpoint>
</breakpoints>
</breakpoint-manager>
</component>
</project>

View File

@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="da8668bf-f36a-441e-9a5c-e37101dd3fe9" name="Changes" comment="" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$/../DungeonSolver.Core" />
</component>
<component name="HighlightingSettingsPerFile">
<setting file="file://$PROJECT_DIR$/Class1.cs" root0="FORCE_HIGHLIGHTING" />
</component>
<component name="ProblemsViewState">
<option name="selectedTabId" value="Toolset" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 7
}</component>
<component name="ProjectId" id="2mmtyf0EpF2l5xWX0Aory29SVMk" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultAutoModeForALLUsers.v1": "true",
"com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true",
"git-widget-placeholder": "master",
"junie.onboarding.icon.badge.shown": "true",
"last_opened_file_path": "/home/hzhang/RiderProjects/Marshmallow/DungeonSolver.Workspace/DungeonSolver.Global.Generators/DungeonSolver.Global.Generators.sln",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"to.speed.mode.migration.done": "true",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="da8668bf-f36a-441e-9a5c-e37101dd3fe9" name="Changes" comment="" />
<created>1727693834377</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1727693834377</updated>
<workItem from="1727693835415" duration="220000" />
<workItem from="1732397814070" duration="338000" />
<workItem from="1762860049139" duration="457000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="UnityProjectConfiguration" hasMinimizedUI="false" />
<component name="VcsManagerConfiguration">
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>
<breakpoints>
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
<properties exception="System.OperationCanceledException" breakIfHandledByOtherCode="false" displayValue="System.OperationCanceledException" />
<option name="timeStamp" value="1" />
</breakpoint>
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
<properties exception="System.Threading.Tasks.TaskCanceledException" breakIfHandledByOtherCode="false" displayValue="System.Threading.Tasks.TaskCanceledException" />
<option name="timeStamp" value="2" />
</breakpoint>
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
<properties exception="System.Threading.ThreadAbortException" breakIfHandledByOtherCode="false" displayValue="System.Threading.ThreadAbortException" />
<option name="timeStamp" value="3" />
</breakpoint>
</breakpoints>
</breakpoint-manager>
</component>
</project>

Submodule DungeonSolver.Workspace/Skeleton added at 7299a2dba3

View File

@@ -0,0 +1,20 @@
import os
def count_cs_lines(root_dir: str):
total = 0
for dirpath, _, filenames in os.walk(root_dir):
for filename in filenames:
if filename.endswith(".cs"):
filepath = os.path.join(dirpath, filename)
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
lines = [
line for line in f
if line.strip() and not line.strip().startswith("//")
]
total += len(lines)
print(f"Total non-empty lines in .cs files: {total}")
if __name__ == "__main__":
os.chdir(os.path.dirname(os.path.abspath(__file__)))
count_cs_lines(".")

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12 (SkeletonTest)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

10
JupyterProject/.idea/JupyterProject.iml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.13 (JupyterProject)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

47
JupyterProject/s.ipynb Normal file
View File

@@ -0,0 +1,47 @@
{
"cells": [
{
"cell_type": "code",
"id": "initial_id",
"metadata": {
"collapsed": true,
"ExecuteTime": {
"end_time": "2025-09-29T14:08:48.168337Z",
"start_time": "2025-09-29T14:08:48.157155Z"
}
},
"source": "import sys",
"outputs": [],
"execution_count": 2
},
{
"metadata": {},
"cell_type": "code",
"outputs": [],
"execution_count": null,
"source": "",
"id": "652c2222ded0ab4f"
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 2
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython2",
"version": "2.7.6"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="uv (structured-output-parser-mcp)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>